Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apps/web/src/lib/session-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createServerOnlyFn } from '@tanstack/react-start';
import { getCookie, getRequestHeader } from '@tanstack/react-start/server';

import type { ServerSession } from './session-server';

// Server-only env: absolute URL to the API. The browser-facing VITE_API_URL
// is a relative path that flows through the Nitro proxy.
const API_BASE_URL = process.env.SERVER_API_URL ?? 'http://localhost:4000/api/v1';
const REFRESH_COOKIE_NAME = import.meta.env.VITE_REFRESH_COOKIE_NAME ?? 'refresh_token';

const EMPTY: ServerSession = { user: null };

export const resolveServerSession = createServerOnlyFn(async (): Promise<ServerSession> => {
const cookieHeader = getRequestHeader('cookie') ?? '';
const refreshToken =
getCookie(REFRESH_COOKIE_NAME) ?? getCookieValue(cookieHeader, REFRESH_COOKIE_NAME);
if (!refreshToken) {
return EMPTY;
}

// Forward the entire incoming Cookie header so the API receives the
// refresh_token (HttpOnly) it needs to validate the session.
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/auth/session`, {
headers: { cookie: cookieHeader },
method: 'GET',
});
} catch {
return EMPTY;
}

if (!response.ok) {
return EMPTY;
}

let user: NonNullable<ServerSession['user']>;
try {
user = (await response.json()) as NonNullable<ServerSession['user']>;
} catch {
return EMPTY;
}
if (!user?.id) {
return EMPTY;
}

return { user };
});

function getCookieValue(cookieHeader: string, name: string) {
const cookies = cookieHeader.split(';');
for (const cookie of cookies) {
const [rawName, ...rawValue] = cookie.trim().split('=');
if (rawName === name) {
return decodeURIComponent(rawValue.join('='));
}
}
return undefined;
}
12 changes: 12 additions & 0 deletions apps/web/src/lib/session-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock(
createServerFn: () => ({
handler: <Args extends unknown[], R>(fn: (...args: Args) => R) => fn,
}),
createServerOnlyFn: <Args extends unknown[], R>(fn: (...args: Args) => R) => fn,
}) as unknown as Partial<ReactStartModule>,
);

Expand Down Expand Up @@ -84,6 +85,17 @@ describe('getServerSession (SSR)', () => {
expect((init as RequestInit).method).toBe('GET');
});

it('falls back to the Cookie header when getCookie misses the refresh token', async () => {
getCookie.mockReturnValue(undefined);
getRequestHeader.mockReturnValue('csrf_token=csrf-xyz; refresh_token=rt-from-header');
fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'u1', email: 'a@b.c', name: 'Alice' }));

const result = await getServerSession();

expect(result).toStrictEqual({ user: { id: 'u1', email: 'a@b.c', name: 'Alice' } });
expect(fetchMock).toHaveBeenCalledOnce();
});

it('uses an empty cookie header when the incoming Cookie header is missing', async () => {
getCookie.mockReturnValue('rt-abc');
getRequestHeader.mockReturnValue(undefined);
Expand Down
48 changes: 2 additions & 46 deletions apps/web/src/lib/session-server.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { createServerFn } from '@tanstack/react-start';
import { getCookie, getRequestHeader } from '@tanstack/react-start/server';

// Server-only env: absolute URL to the API. The browser-facing VITE_API_URL
// is a relative path that flows through the Nitro proxy.
const API_BASE_URL =
process.env.SERVER_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4000/api/v1';
const REFRESH_COOKIE_NAME = import.meta.env.VITE_REFRESH_COOKIE_NAME ?? 'refresh_token';
import { resolveServerSession } from './session-resolver';

export interface ServerSession {
user: { id: string; email: string; name?: string } | null;
}

const EMPTY: ServerSession = { user: null };

/**
* Resolve the visitor's session on the server WITHOUT rotating the refresh
* token. Calls the backend's `GET /auth/session` endpoint, which only
Expand All @@ -27,41 +20,4 @@ const EMPTY: ServerSession = { user: null };
* - SSR (root route's `beforeLoad`): determines the chrome to render.
* - SPA navigation (RPC): re-validates the session before each route change.
*/
export const getServerSession = createServerFn({ method: 'GET' }).handler(
async (): Promise<ServerSession> => {
const refreshToken = getCookie(REFRESH_COOKIE_NAME);
if (!refreshToken) {
return EMPTY;
}

// Forward the entire incoming Cookie header so the API receives the
// refresh_token (HttpOnly) it needs to validate the session.
const cookieHeader = getRequestHeader('cookie') ?? '';

let response: Response;
try {
response = await fetch(`${API_BASE_URL}/auth/session`, {
headers: { cookie: cookieHeader },
method: 'GET',
});
} catch {
return EMPTY;
}

if (!response.ok) {
return EMPTY;
}

let user: NonNullable<ServerSession['user']>;
try {
user = (await response.json()) as NonNullable<ServerSession['user']>;
} catch {
return EMPTY;
}
if (!user?.id) {
return EMPTY;
}

return { user };
},
);
export const getServerSession = createServerFn({ method: 'GET' }).handler(resolveServerSession);
27 changes: 27 additions & 0 deletions apps/web/src/lib/site-settings-payloads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';

import { toAlertRuleUpdatePayload } from './site-settings-payloads';

describe(toAlertRuleUpdatePayload, () => {
it('keeps only fields accepted by the regression alert update API', () => {
expect(
toAlertRuleUpdatePayload({
id: 'rule-1',
siteId: 'site-1',
enabled: false,
notifyOnScoreDrop: true,
scoreDropThreshold: 7,
notifyOnNewCriticalIssues: false,
notifyOnIssueCountIncrease: true,
createdAt: '2026-06-23T00:00:00.000Z',
updatedAt: '2026-06-23T00:00:00.000Z',
}),
).toStrictEqual({
enabled: false,
notifyOnScoreDrop: true,
scoreDropThreshold: 7,
notifyOnNewCriticalIssues: false,
notifyOnIssueCountIncrease: true,
});
});
});
19 changes: 19 additions & 0 deletions apps/web/src/lib/site-settings-payloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type AlertRuleUpdatePayload = {
enabled: boolean;
notifyOnScoreDrop: boolean;
scoreDropThreshold: number;
notifyOnNewCriticalIssues: boolean;
notifyOnIssueCountIncrease: boolean;
};

export function toAlertRuleUpdatePayload(
rule: AlertRuleUpdatePayload & Record<string, unknown>,
): AlertRuleUpdatePayload {
return {
enabled: rule.enabled,
notifyOnScoreDrop: rule.notifyOnScoreDrop,
scoreDropThreshold: rule.scoreDropThreshold,
notifyOnNewCriticalIssues: rule.notifyOnNewCriticalIssues,
notifyOnIssueCountIncrease: rule.notifyOnIssueCountIncrease,
};
}
4 changes: 3 additions & 1 deletion apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ServiceWorkerRegistration } from '../components/service-worker-registra
import { ApiClientError } from '../lib/api-client';
import { AuthProvider } from '../lib/auth-context';
import { ProjectProvider } from '../lib/project-context';
import { resolveServerSession } from '../lib/session-resolver';
import { getServerSession } from '../lib/session-server';
import type { ServerSession } from '../lib/session-server';
import { toastBridge } from '../lib/toast-bridge';
Expand All @@ -29,7 +30,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
// beforeLoad and exposing it through the router context lets every child
// beforeLoad / loader read the same value with zero extra network calls.
beforeLoad: async () => {
const session = await getServerSession();
const session =
typeof window === 'undefined' ? await resolveServerSession() : await getServerSession();
return { session };
},
// Re-export the session as loader data so consumers using
Expand Down
31 changes: 22 additions & 9 deletions apps/web/src/routes/_authenticated/projects.$id.sites.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useForm } from '@tanstack/react-form';
import { Link, createFileRoute } from '@tanstack/react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Eye, Globe, Play, Plus, Search, Zap } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '#/components/button';
Expand Down Expand Up @@ -90,6 +90,9 @@ function ProjectProjectsPage() {
},
enabled: Boolean(auth.accessToken),
refetchInterval: pollWhileAnyLatestAuditActive,
// Keep the previous page visible while typing/filtering so the grid does
// not flash skeletons or empty-state on every keystroke.
placeholderData: keepPreviousData,
});

const projectItems = sites.data?.items ?? [];
Expand Down Expand Up @@ -133,7 +136,9 @@ function ProjectProjectsPage() {
});

const runAudit = useMutation({
mutationFn: (siteId: string) => auth.api.post(`/sites/${siteId}/audits/run`),
mutationFn: async (siteIds: string[]) => {
await Promise.all(siteIds.map((siteId) => auth.api.post(`/sites/${siteId}/audits/run`)));
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sites', id] });
await queryClient.invalidateQueries({
Expand Down Expand Up @@ -185,16 +190,20 @@ function ProjectProjectsPage() {
<Button
type="button"
onClick={() => {
const firstProject = projectItems[0];
if (firstProject) {
runAudit.mutate(firstProject.id);
const siteIds = projectItems.map((site) => site.id);
if (siteIds.length > 0) {
runAudit.mutate(siteIds);
}
}}
disabled={!projectItems.length || runAudit.isPending}
size="sm"
>
<Play size={14} />
{runAudit.isPending ? 'Lanzando...' : 'Nueva auditoría'}
{runAudit.isPending
? 'Lanzando...'
: projectItems.length > 1
? `Auditar ${projectItems.length} dominios`
: 'Nueva auditoría'}
</Button>
</div>
</div>
Expand Down Expand Up @@ -407,7 +416,7 @@ function ProjectProjectsPage() {
</div>
</div>

{sites.isLoading ? (
{sites.isLoading && !sites.data ? (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{['p1', 'p2', 'p3', 'p4', 'p5', 'p6'].map((slot) => (
<li key={slot} className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
Expand All @@ -428,12 +437,16 @@ function ProjectProjectsPage() {
</p>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<ul
className={`grid gap-3 transition-opacity sm:grid-cols-2 xl:grid-cols-3 ${
sites.isPlaceholderData ? 'opacity-60' : ''
}`}
>
{projectItems.map((site) => (
<li key={site.id}>
<ProjectCard
site={site}
onRun={() => runAudit.mutate(site.id)}
onRun={() => runAudit.mutate([site.id])}
running={runAudit.isPending}
/>
</li>
Expand Down
Loading
Loading