Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
633f2dc
client quality/docs (#66)
Alimedhat000 Aug 24, 2026
47ef48c
client quality/a infra (#67)
Alimedhat000 Aug 24, 2026
ca62e8e
client quality/b structure (#69)
Alimedhat000 Aug 24, 2026
08d3b55
client quality/c rules (#70)
Alimedhat000 Aug 24, 2026
2fb9736
client quality/d seams (#71)
Alimedhat000 Aug 24, 2026
d2ba295
client quality/e stories (#72)
Alimedhat000 Aug 24, 2026
c3e040b
client quality/f ci (#73)
Alimedhat000 Aug 24, 2026
95837dd
fix(server): WS authentication + exact-ID document resolution (from m…
Alimedhat000 Aug 24, 2026
a0bf2f9
chore: ignore local git worktrees directory
Alimedhat000 Aug 24, 2026
f3e48ed
fix(server): remove leftover debug console.log calls (#75)
Alimedhat000 Aug 24, 2026
bbc166c
feat(server): rate-limit auth endpoints (re-land onto develop) (#79)
Alimedhat000 Aug 24, 2026
fd120d9
fix: deleting a shared document fails (FK RESTRICT) (#77)
Alimedhat000 Aug 24, 2026
277653a
fix(client): stale 'view' share link when switching permission (#78)
Alimedhat000 Aug 25, 2026
8404d91
fix(server): scope collaboration request decisions to route document …
Alimedhat000 Aug 25, 2026
fd5a035
fix(collab): accept email when adding collaborators (#80)
Alimedhat000 Aug 25, 2026
90f8a8e
fix(server): make Yjs sole writer of Document.content (#86)
Alimedhat000 Aug 25, 2026
526eca8
Merge branch 'main' into develop
Alimedhat000 Aug 25, 2026
3b62d69
fix(server,client): validate all document endpoints with Zod
Alimedhat000 Aug 25, 2026
39b13d6
fix(auth): prevent account enumeration on register and login (#83)
Alimedhat000 Aug 25, 2026
223f5d3
fix(server): authenticate logout via refresh cookie
Alimedhat000 Aug 26, 2026
c1e3aa8
fix(server): stop setting unused accessToken cookie on refresh
Alimedhat000 Aug 26, 2026
f5a100e
fix(server): set refresh cookie flags per environment
Alimedhat000 Aug 26, 2026
a2b7069
fix(client): auto-refresh session on 401 with single-flight refresh
Alimedhat000 Aug 26, 2026
8d3158e
fix(client): rebuild session bootstrap without wasLoggedOut hack
Alimedhat000 Aug 26, 2026
e7d662a
test(server): fix set-cookie typing in cookie flags test
Alimedhat000 Aug 26, 2026
81d8b91
fix(server): clear refresh cookie with matching attributes
Alimedhat000 Aug 27, 2026
955b135
test(server): cover logout cookie clearing with matching attributes
Alimedhat000 Aug 27, 2026
93e9b52
fix(client): dedupe session expiry and guard cleared token replay
Alimedhat000 Aug 27, 2026
3b8ee4c
fix(client): race-free scroll sync stops split-view tearing in Firefox
Alimedhat000 Aug 26, 2026
011ba01
test(client): cover latest-wins mirroring under same-frame scroll bursts
Alimedhat000 Aug 26, 2026
793e9b8
fix(client): clear stale collaborator error on new add/remove attempts
Alimedhat000 Aug 26, 2026
baeabe7
test(client): harden use-collaborators harness against CI flakes
Alimedhat000 Aug 26, 2026
24b73b0
fix(client): make vitest projects disjoint to unbreak develop CI
Alimedhat000 Aug 28, 2026
f1e0ba4
fix(server): session hardening — rotation, multi-device, isActive (#51)
Alimedhat000 Aug 28, 2026
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
3 changes: 3 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ storybook-static
/blob-report/
/playwright/.auth/
/playwright/.cache/

# Vitest browser failure screenshots
__screenshots__/
46 changes: 46 additions & 0 deletions client/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test';

// These tests cover the unauthenticated flows; they run without a saved
// session (see the 'auth-specs' project in playwright.config.ts).
// Several of them perform real logins, which overwrite the user's single
// stored refresh token server-side — so they must not overlap.
test.describe.configure({ mode: 'serial' });

test.describe('Authentication Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
Expand Down Expand Up @@ -68,6 +72,48 @@ test.describe('Authentication Flow', () => {
await expect(page.getByRole('menuitem', { name: /logout/i })).toBeVisible();
});

test('should keep the session when logging back in and reloading', async ({
page,
}) => {
const login = async () => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('test@example.com');
await page.getByLabel(/password/i).fill('testpassword');
await page.getByRole('button', { name: /login|sign in/i }).click();
await expect(page).toHaveURL(/.*\/app/);
};

await login();

// Log out via the UI, then log back in — all within the same SPA session.
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /logout/i }).click();
await login();

// A reload must restore the session from the refresh cookie.
await page.reload();
await expect(page).toHaveURL(/.*\/app/, { timeout: 10_000 });
await expect(page.getByRole('button', { name: /user menu/i })).toBeVisible({
timeout: 10_000,
});
});

test('should not call authenticated logout when bootstrap has no session', async ({
page,
}) => {
const logoutCalls: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/api/auth/logout')) {
logoutCalls.push(req.url());
}
});

await page.goto('/login');
await expect(page.getByLabel(/email/i)).toBeVisible();

expect(logoutCalls).toEqual([]);
});

test('should register a new account and redirect to login', async ({
page,
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default function NewDocumentFormBody({
type="text"
label="Document Title"
placeholder="New document title"
maxLength={200}
error={errors.title}
registration={register('title', {
required: 'Title is required',
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/ui/Auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function LoginForm({
id="email"
label="Email"
placeholder="Enter your email"
maxLength={254}
registration={register('email')}
error={errors.email}
autoComplete="email"
Expand All @@ -59,6 +60,7 @@ export default function LoginForm({
id="password"
label="Password"
placeholder="Enter your password"
maxLength={128}
registration={register('password')}
error={errors.password}
autoComplete="current-password"
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/ui/Auth/register-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default function RegisterForm({
id="email"
label="Email"
placeholder="Enter your email"
maxLength={254}
registration={register('email')}
error={errors.email}
autoComplete="email"
Expand All @@ -56,6 +57,7 @@ export default function RegisterForm({
label="Username"
id="username"
placeholder="Enter your username"
maxLength={50}
registration={register('username')}
error={errors.username}
autoComplete="username"
Expand All @@ -66,6 +68,7 @@ export default function RegisterForm({
label="Full Name"
id="fullname"
placeholder="Enter your full name"
maxLength={100}
registration={register('fullName')}
error={errors.fullName}
autoComplete="name"
Expand All @@ -76,6 +79,7 @@ export default function RegisterForm({
id="password"
label="Password"
placeholder="Enter your password"
maxLength={128}
registration={register('password')}
error={errors.password}
autoComplete="new-password"
Expand Down
47 changes: 28 additions & 19 deletions client/src/context/auth/auth-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';

import { api } from '@/lib/api';
import { api, onSessionExpired, refreshAccessToken } from '@/lib/api';
import { type User } from '@/types/api';
import { setAccessToken as storeToken, clearAccessToken } from '@/utils/token';

import { AuthContext } from './auth-context';

/**
* Session bootstrap: refreshes the token cookie on mount, exposes login/logout and mirrors the token into api defaults and module storage.
* Session bootstrap: restores the session from the refresh cookie on mount,
* exposes login/logout and mirrors the token into api defaults and module
* storage. A failed bootstrap simply means signed out — it never calls the
* authenticated logout endpoint.
*/
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

const markSignedOut = () => {
setAccessToken(null);
setUser(null);
delete api.defaults.headers.common['Authorization'];
clearAccessToken();
};

const login = (token: string, user: User) => {
setAccessToken(token);
setUser(user);
Expand All @@ -22,33 +32,32 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
};

const logout = async () => {
await api.post('/auth/logout');
setAccessToken(null);
setUser(null);
localStorage.setItem('wasLoggedOut', 'true');
delete api.defaults.headers.common['Authorization'];
clearAccessToken();
// The server may already be unreachable or the session expired; local
// cleanup happens either way.
try {
await api.post('/auth/logout');
} catch {
// session already dead server-side
}
markSignedOut();
};

useEffect(() => {
const refresh = async () => {
if (localStorage.getItem('wasLoggedOut') === 'true') {
localStorage.removeItem('wasLoggedOut');
setLoading(false);
return;
}
const bootstrap = async () => {
try {
const res = await api.post('/auth/refresh');
const { accessToken, user } = res.data;
// Shares the single-flight with the response interceptor, so a
// bootstrap racing an in-flight refresh triggers only one request.
const { accessToken, user } = await refreshAccessToken();
login(accessToken, user);
} catch {
logout();
markSignedOut();
} finally {
setLoading(false);
}
};

refresh();
bootstrap();
return onSessionExpired(markSignedOut);
}, []);

if (loading) return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function RenameDocumentModal({
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
maxLength={200}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter document title"
onKeyDown={handleKeyDown}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const CollaboratorsDropdown = ({
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
maxLength={254}
aria-label="Collaborator email"
className="h-8 min-w-0 flex-1 rounded border border-surface-border bg-transparent px-2 text-xs outline-none focus-visible:border-ring"
/>
Expand Down
Loading
Loading