diff --git a/.github/workflows/lint-type-check.yml b/.github/workflows/lint-type-check.yml index a352e15..2e87596 100644 --- a/.github/workflows/lint-type-check.yml +++ b/.github/workflows/lint-type-check.yml @@ -83,5 +83,8 @@ jobs: - name: Run build run: pnpm build + - name: Install Playwright browsers + run: pnpm --filter client exec playwright install --with-deps chromium + - name: Run tests run: pnpm test diff --git a/.gitignore b/.gitignore index 1476f13..584184d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules .env -.env.test \ No newline at end of file +.env.test +# Git worktrees +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 8a2be18..338cbc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,22 @@ Real-time collaborative Markdown editor. pnpm workspace monorepo: `client/` (Rea - Hocuspocus WS is mounted at `/collaboration` on the same Express app and port (`server.ts` calls `app.ws('/collaboration', ...)`) — no separate WS port. Yjs state persistence is `server/src/lib/dbPersistence.ts` (`@hocuspocus/extension-database`). - Schema lives in `server/prisma/schema.prisma` (Prisma 6). Use `db:migrate` (dev, creates migrations) / `db:migrate:prod` (deploy); CI runs `db:migrate:prod`. +## Client conventions (enforced by lint — see `client/eslint.config.js`) + +- Folder-per-component: `ComponentName/{component-name.tsx, index.ts, component-name.stories.tsx}`. **Directories PascalCase, all `.ts`/`.tsx` files kebab-case** (`check-file/filename-naming-convention`, `ignoreMiddleExtensions: true`). +- Composite "kit" folders (e.g. `ui/Form`) may hold small internal parts flat when they have no external consumers; the external API goes through the folder barrel. +- Every export carries JSDoc (`jsdoc/require-jsdoc` at `error`). One concise block per component/hook/util. +- Component props: document each **custom** prop with a one-line JSDoc on its member in the Props interface/type (e.g. `/** Visual style preset. */ variant?: 'default' | 'ghost';`). Note defaults established in implementation when not obvious from the type (`Defaults to 'default'.`); never re-document inherited React/Radix props, and don't restate what the type already says. Lint enforces non-empty descriptions (`jsdoc/require-description` in components/hooks/features); member-level completeness is a review responsibility. +- Hook/helper params: `@param` for every param wherever a JSDoc block exists in `src/hooks/**` / `src/features/**/*.ts` (`jsdoc/require-param`, destructured-object members exempt); add `@returns` when the return value is non-obvious. Component functions stay exempt — their props live on the Props interface. +- Every story meta has `tags: ['autodocs']`; plop (`pnpm --filter client generate`) scaffolds this automatically. +- Component tests = Storybook interaction tests: state stories for every variant/boolean prop + `play()` assertions using `{ expect, fn, userEvent, within } from 'storybook/test'`. Run via `pnpm --filter client test` (browser mode, needs `playwright install chromium` once). +- Story gotchas: + - Never pass complex objects (e.g. CodeMirror views) through story `args` — Storybook JSON-serializes args and circular refs hang the run forever. Build them inside `render()` instead. + - Radix portals render outside the story canvas — query `within(document.body)` for dropdown/modal content. + - Animated Radix open/close: prefer existence-based assertions (`findByText`) over `toBeVisible()`, and `waitFor(..., { timeout })` before asserting unmount. + - First run after adding a heavy dep to stories can fail imports mid-run while Vite optimizes deps — add it to `optimizeDeps.include` in `client/vite.config.ts`. +- Global decorators live in `client/.storybook/preview.tsx`: every story is wrapped in `MemoryRouter` + an authenticated mock auth context. Override with `parameters.auth` (partial context) or `parameters.auth: null` for signed-out; `parameters.modal: true` adds a Modal ancestor for components emitting ModalContent parts. + ## Docker - `docker-compose.dev.yml`: full dev stack driven by **compose watch** (`develop.watch`). Images use dedicated `dev` stages with source baked in and nothing compiled at build time. Run `docker compose -f docker-compose.dev.yml up --build --watch` (or `pnpm docker:dev`); later runs can skip `--build`. Source edits sync into containers live (server: esbuild rebuild + nodemon restart; client: Vite HMR). Changes to package.json / lockfile / vite.config.ts trigger automatic rebuild+restart of that service. Schema changes: edit `schema.prisma`, then `docker compose -f docker-compose.dev.yml exec server pnpm exec prisma migrate dev`. Prisma Studio is opt-in via `docker compose -f docker-compose.dev.yml --profile tools up studio` (port 5555, runs from the `generated` stage). diff --git a/client/.storybook/preview.ts b/client/.storybook/preview.ts deleted file mode 100644 index 64b2d15..0000000 --- a/client/.storybook/preview.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Preview } from '@storybook/react-vite'; -import '../src/index.css'; - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - backgrounds: { - options: { - // 👇 Default options - dark: { name: 'Dark', value: '#18181b' }, - light: { name: 'Light', value: '#F7F9F2' }, - }, - }, - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail C I on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, - }, - initialGlobals: { - // 👇 Set the initial background color - backgrounds: { value: 'dark' }, - }, -}; - -export default preview; diff --git a/client/.storybook/preview.tsx b/client/.storybook/preview.tsx new file mode 100644 index 0000000..6ba648d --- /dev/null +++ b/client/.storybook/preview.tsx @@ -0,0 +1,89 @@ +import type { Decorator, Preview } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router'; + +import { Modal } from '@/components/ui/Modal'; +import { AuthContext, type AuthContextType } from '@/context/auth/auth-context'; +import type { User } from '@/types/api'; +import '../src/index.css'; + +const mockUser: User = { + id: 'story-user-1', + email: 'story@example.com', + username: 'storyuser', + fullName: 'Story User', +}; + +const defaultAuth: AuthContextType = { + user: mockUser, + accessToken: 'storybook-access-token', + isAuthenticated: true, + loading: false, + login: () => {}, + logout: async () => {}, +}; + +/** + * Wraps every story in a router and an authenticated auth context. + * Override per story via parameters.auth (partial AuthContextType, or + * `null` to render the signed-out state). + */ +const withRouterAndAuth: Decorator = (Story, context) => { + const override = context.parameters.auth; + const value = + override === null + ? { + ...defaultAuth, + user: null, + accessToken: null, + isAuthenticated: false, + } + : override + ? { ...defaultAuth, ...override } + : defaultAuth; + return ( + + + + + + ); +}; + +/** + * Opt-in modal ancestor for components that emit ModalContent parts + * directly (e.g. NewDocumentFormBody). Enable via parameters.modal = true. + */ +const withOptionalModal: Decorator = (Story, context) => { + if (!context.parameters.modal) return ; + return ( + {}}> + + + ); +}; + +const preview: Preview = { + decorators: [withRouterAndAuth, withOptionalModal], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + options: { + dark: { name: 'Dark', value: '#18181b' }, + light: { name: 'Light', value: '#F7F9F2' }, + }, + }, + a11y: { + test: 'todo', + }, + }, + initialGlobals: { + backgrounds: { value: 'dark' }, + }, +}; + +export default preview; diff --git a/client/e2e/collaboration.spec.ts b/client/e2e/collaboration.spec.ts index e5f768e..40262fc 100644 --- a/client/e2e/collaboration.spec.ts +++ b/client/e2e/collaboration.spec.ts @@ -7,10 +7,13 @@ import { import { getSharePath, openDocumentEditor } from './utils'; -const API_URL = process.env.E2E_API_URL ?? 'http://localhost:5000'; +const API_URL = process.env.E2E_API_URL ?? 'http://localhost:5001'; // The second user is created via the API: the registration form is already // covered by auth.spec.ts and proved flaky to drive from a second context. +/** + * Register a throwaway second user via the API for collaboration specs. + */ async function createUserViaApi(requestCtx: APIRequestContext, suffix: number) { const res = await requestCtx.post(`${API_URL}/api/auth/register`, { data: { @@ -24,6 +27,9 @@ async function createUserViaApi(requestCtx: APIRequestContext, suffix: number) { expect(res.status()).toBe(201); } +/** + * Log the second user in through the login UI. + */ async function loginUser2(page: Page, suffix: number) { await page.goto('/login'); await page.getByLabel(/email/i).fill(`e2e-${suffix}@test.local`); @@ -34,6 +40,9 @@ async function loginUser2(page: Page, suffix: number) { // Owner resolves the pending request from the Share menu. The hook fetching // join requests runs once on mount (no polling), so the page is reloaded first. +/** + * Owner approves or rejects a pending collaboration request via the UI. + */ async function resolvePendingRequest( page: Page, username: string, @@ -61,6 +70,9 @@ async function resolvePendingRequest( // other client's editor. Retried because a CodeMirror remount (awareness // updates, provider reconnect) can swallow a click's focus, dropping the // whole keystroke burst. +/** + * Type into one client and wait for the text to appear in the other. + */ async function typeAndSync(from: Page, to: Page, text: string) { const content = from.locator('.cm-content').first(); for (let attempt = 0; attempt < 3; attempt++) { diff --git a/client/e2e/editor.spec.ts b/client/e2e/editor.spec.ts index 082c8a8..7bb9e44 100644 --- a/client/e2e/editor.spec.ts +++ b/client/e2e/editor.spec.ts @@ -4,6 +4,9 @@ import { createTestDocument, getDocumentCard } from './utils'; const MARKDOWN = '# Hello E2E\n\nSome *emphasis* text.'; +/** + * Open an existing dashboard document in the editor. + */ async function openInEditor(page: Page, title: string) { await getDocumentCard(page, title).first().click(); await expect(page).toHaveURL(/.*\/app\/doc\/.+/); diff --git a/client/e2e/sharing.spec.ts b/client/e2e/sharing.spec.ts index 21b71a3..d1ce0f2 100644 --- a/client/e2e/sharing.spec.ts +++ b/client/e2e/sharing.spec.ts @@ -13,6 +13,9 @@ test.describe('Document Sharing', () => { await expect(page).toHaveURL(/.*\/app\/doc\/.+/); }); + /** + * Open the share dialog for the current document. + */ async function openShareMenu(page: Page) { await page.getByRole('button', { name: 'Share' }).click(); @@ -50,6 +53,86 @@ test.describe('Document Sharing', () => { expect(new URL(clipboard).pathname).toBe(sharePath); }); + /** + * Decode the permission claim from a share-link JWT. + */ + function tokenPermission(shareUrl: string): string { + const token = new URL(shareUrl).pathname.split('/').pop() ?? ''; + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64url').toString(), + ); + return payload.permission; + } + + test('should copy an edit-mode link right after switching permission', async ({ + page, + }) => { + // Slow down share-link responses so the copy lands while the + // post-switch refetch is still in flight (exposes stale-link races) + await page.route('**/share-link*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + await route.continue(); + }); + + await openShareMenu(page); // initial view-mode link loaded + + const menu = page.getByRole('menu'); + await menu.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Edit mode' }).click(); + + const copyButton = menu.getByRole('button').first(); + await expect(copyButton).toBeEnabled(); + await copyButton.click({ force: true }); // skip radix open-animation checks + + await expect(page.getByRole('status')).toContainText(/cop{2}ied|copied/i); + + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(new URL(clipboard).pathname).toContain('/app/doc/share/'); + // The copied token must match the newly selected mode, not the previous one + expect(tokenPermission(clipboard)).toBe('edit'); + }); + + test('should not keep a copyable link after a failed refetch', async ({ + page, + }) => { + // Only the post-switch edit fetch fails; earlier view fetches + // (including StrictMode's dev double-invoke) succeed + await page.route('**/share-link*', async (route) => { + if (route.request().url().includes('permission=edit')) { + return route.abort('connectionrefused'); + } + return route.continue(); + }); + + await openShareMenu(page); // view link loaded, copy enabled + + const menu = page.getByRole('menu'); + await menu.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Edit mode' }).click(); + + // The failed edit fetch must invalidate the old view link entirely: + // nothing copyable, and no view URL displayed under an "Edit mode" select + await expect(menu.getByText('Failed to fetch share link')).toBeVisible(); + await expect(menu.locator('p').first()).toHaveText(/No link available/); + await expect(menu.getByRole('button').first()).toBeDisabled(); + + // Recovery: switching back to View (not intercepted) must clear the + // error and restore a working, copyable link without a page reload + await menu.getByRole('combobox').click(); + await page.getByRole('option', { name: 'View mode' }).click(); + + const recoveredLink = menu.locator('p').first(); + await expect(recoveredLink).toHaveText(/\/app\/doc\/share\/\S+/); + await expect(menu.getByText('Failed to fetch share link')).toBeHidden(); + const recoveredCopy = menu.getByRole('button').first(); + await expect(recoveredCopy).toBeEnabled(); + await recoveredCopy.click({ force: true }); // skip radix open-animation checks + const clipboardAfterRecovery = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(tokenPermission(clipboardAfterRecovery)).toBe('view'); + }); + test('should grant access through the share link', async ({ page }) => { const sharePath = await openShareMenu(page); diff --git a/client/e2e/utils.ts b/client/e2e/utils.ts index 68da62e..ad660da 100644 --- a/client/e2e/utils.ts +++ b/client/e2e/utils.ts @@ -1,9 +1,15 @@ import { expect, type Page } from '@playwright/test'; +/** + * Locate a document card link by its title. + */ export function getDocumentCard(page: Page, title: string) { return page.getByRole('link').filter({ hasText: title }); } +/** + * Open a document card's action dropdown. + */ export async function openDocumentMenu(page: Page, title: string) { const card = getDocumentCard(page, title); await expect(card).toBeVisible(); @@ -11,6 +17,9 @@ export async function openDocumentMenu(page: Page, title: string) { await expect(page.getByRole('menu')).toBeVisible(); } +/** + * Create a document end-to-end from the dashboard. + */ export async function createTestDocument(page: Page, title: string) { await page.goto('/app'); await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); @@ -27,6 +36,9 @@ export async function createTestDocument(page: Page, title: string) { await expect(getDocumentCard(page, title)).toBeVisible(); } +/** + * Create a document and open it in the editor. + */ export async function openDocumentEditor(page: Page, title: string) { await createTestDocument(page, title); await getDocumentCard(page, title).first().click(); @@ -35,6 +47,9 @@ export async function openDocumentEditor(page: Page, title: string) { // Server-generated links currently omit the port (e.g. http://localhost/...), // so navigate by pathname against the test baseURL instead of the raw URL. +/** + * Extract the share-link path from the share dialog, rebased onto the test baseURL. + */ export async function getSharePath( page: Page, permission?: 'view' | 'edit', diff --git a/client/eslint.config.js b/client/eslint.config.js index 9d91c07..1bb7c3c 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -10,11 +10,12 @@ import prettier from 'eslint-config-prettier'; import eslintPluginPrettier from 'eslint-plugin-prettier'; import checkFile from 'eslint-plugin-check-file'; import importPlugin from 'eslint-plugin-import'; +import jsdoc from 'eslint-plugin-jsdoc'; export default tseslint.config([ // Global ignores { - ignores: ['dist/**', 'build/**', 'node_modules/**', 'src/shared/**'], + ignores: ['dist/**', 'build/**', 'node_modules/**'], }, // Base config for all files @@ -51,6 +52,7 @@ export default tseslint.config([ 'react-refresh': reactRefresh, 'check-file': checkFile, import: importPlugin, + jsdoc: jsdoc, prettier: eslintPluginPrettier, }, rules: { @@ -133,12 +135,30 @@ export default tseslint.config([ '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', + // JSDoc policy: every export documented; params optional (types self-document) + 'jsdoc/require-jsdoc': [ + 'error', + { + require: { + FunctionDeclaration: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + contexts: [ + 'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression', + 'ExportNamedDeclaration > FunctionDeclaration', + ], + exemptEmptyFunctions: true, + }, + ], + 'jsdoc/require-param': 'off', + // File naming conventions 'check-file/filename-naming-convention': [ 'error', { - '**/*.{tsx}': 'PASCAL_CASE', - '**/*.{ts}': 'KEBAB_CASE', + '**/*.{ts,tsx}': 'KEBAB_CASE', }, { ignoreMiddleExtensions: true, @@ -156,4 +176,30 @@ export default tseslint.config([ }, }, }, + + // Doc quality (components/hooks/features only): JSDoc blocks must carry a + // real description — no empty stubs. Member-level prop docs and param docs + // are conventions (see AGENTS.md), not mechanically enforceable. + { + files: [ + 'src/components/**/*.{ts,tsx}', + 'src/features/**/*.{ts,tsx}', + 'src/hooks/**/*.{ts,tsx}', + ], + ignores: ['**/*.stories.*'], + settings: { jsdoc: { mode: 'typescript' } }, + rules: { + 'jsdoc/require-description': 'error', + }, + }, + + // Hook/helper params are documented via @param wherever a JSDoc block + // exists (component files stay exempt — props live on the Props interface). + { + files: ['src/features/**/*.ts', 'src/hooks/**/*.ts'], + settings: { jsdoc: { mode: 'typescript' } }, + rules: { + 'jsdoc/require-param': ['error', { checkDestructured: false }], + }, + }, ], storybook.configs["flat/recommended"]); diff --git a/client/generators/component/component.stories.tsx.hbs b/client/generators/component/component.stories.tsx.hbs index 44cc1f0..a3d870b 100644 --- a/client/generators/component/component.stories.tsx.hbs +++ b/client/generators/component/component.stories.tsx.hbs @@ -2,14 +2,29 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { {{pascalCase name}} } from './{{kebabCase name}}'; -const meta: Meta = { +const meta: Meta = { title: '{{titlePath}}/{{pascalCase name}}', component: {{pascalCase name}}, + tags: ['autodocs'], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: {}, }; + +// Interaction-test example — uncomment and adapt: +// import { expect, fn, userEvent, within } from 'storybook/test'; +// +// const onClickFn = fn(); +// +// export const Clicked: Story = { +// args: {}, +// play: async ({ canvasElement }) => { +// const canvas = within(canvasElement); +// await userEvent.click(canvas.getByText('{{pascalCase name}} works!')); +// await expect(onClickFn).toHaveBeenCalledTimes(1); +// }, +// }; diff --git a/client/generators/component/component.tsx.hbs b/client/generators/component/component.tsx.hbs index cea9b17..4287387 100644 --- a/client/generators/component/component.tsx.hbs +++ b/client/generators/component/component.tsx.hbs @@ -1,3 +1,6 @@ +/** + * {{pascalCase name}} — TODO: one-line description of purpose. + */ import React from 'react'; export interface {{pascalCase name}}Props { diff --git a/client/package.json b/client/package.json index 4ae2e55..3c1f5a1 100644 --- a/client/package.json +++ b/client/package.json @@ -7,11 +7,12 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "lint:fix": "eslint . --fix --ignore-pattern src/shared", - "lint:ci": "eslint --ignore-pattern src/shared --max-warnings 0 . ", + "lint:fix": "eslint . --fix", + "lint:ci": "eslint --max-warnings 0 . ", "typecheck": "tsc --noEmit", "preview": "vite preview", "generate": "plop", + "test": "vitest run", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test:e2e": "pnpm --filter server run seed:test && playwright test" @@ -100,6 +101,7 @@ "eslint-plugin-check-file": "^3.3.0", "eslint-plugin-filenames": "^1.3.2", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsdoc": "^64.2.0", "eslint-plugin-prettier": "^5.5.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", diff --git a/client/src/app/index.tsx b/client/src/app/index.tsx index ffa0471..c313320 100644 --- a/client/src/app/index.tsx +++ b/client/src/app/index.tsx @@ -1,6 +1,9 @@ import { AppProvider } from './provider'; import { AppRouter } from './router'; +/** + * Application root: providers wrapped around the router. + */ function App() { return ( diff --git a/client/src/app/provider.tsx b/client/src/app/provider.tsx index 3387431..975a180 100644 --- a/client/src/app/provider.tsx +++ b/client/src/app/provider.tsx @@ -4,6 +4,9 @@ import { HelmetProvider } from 'react-helmet-async'; import { Spinner } from '@/components/ui/Spinner'; import { AuthProvider } from '@/context/auth'; +/** + * Composes global providers (Helmet, Auth) for the whole app. + */ export function AppProvider({ children }: { children: React.ReactNode }) { return ( }> diff --git a/client/src/app/router.tsx b/client/src/app/router.tsx index 1372beb..bbc0097 100644 --- a/client/src/app/router.tsx +++ b/client/src/app/router.tsx @@ -68,6 +68,9 @@ const createAppRouter = () => }, ]); +/** + * Creates and renders the route tree, separating protected and public routes. + */ export function AppRouter() { const router = createAppRouter(); return ; diff --git a/client/src/app/routes/app/dashboard.tsx b/client/src/app/routes/app/dashboard.tsx index 7332743..8dc9bf1 100644 --- a/client/src/app/routes/app/dashboard.tsx +++ b/client/src/app/routes/app/dashboard.tsx @@ -1,10 +1,13 @@ import { useEffect, useState } from 'react'; import { DashboardLayout } from '@/components/layouts/DashboardLayout'; -import DashboardMain from '@/features/Dashboard/components/DashBoardMain/dashboard-main'; +import DashboardMain from '@/features/Dashboard/components/DashboardMain/dashboard-main'; import { api } from '@/lib/api'; import { Document } from '@/types/api'; +/** + * Dashboard page listing owned documents alongside shared ones with view-mode controls. + */ export default function Dashboard() { const [ownedDocs, setOwnedDocs] = useState([]); const [collaboratedDocs, setCollaboratedDocs] = useState([]); diff --git a/client/src/app/routes/app/document.tsx b/client/src/app/routes/app/document.tsx index 432d220..e3ca6f3 100644 --- a/client/src/app/routes/app/document.tsx +++ b/client/src/app/routes/app/document.tsx @@ -6,9 +6,12 @@ import { Spinner } from '@/components/ui/Spinner'; import { paths } from '@/config/paths'; import { DocumentHeader } from '@/features/DocumentPage/components/DocumentHeader'; import { DocumentMain } from '@/features/DocumentPage/components/DocumentMain'; -import { useDocument } from '@/hooks/useDocument'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useDocument } from '@/hooks/use-document'; +import { useMediaQuery } from '@/hooks/use-media-query'; +/** + * Editor page: resolves the :id param and wires the document header, collaboration and editor/preview panes. + */ export default function DocumentPage() { const { id } = useParams(); const { doc, editedDoc, setEditedDoc, loading, /*handleSave,*/ access } = diff --git a/client/src/app/routes/app/error-boundary.tsx b/client/src/app/routes/app/error-boundary.tsx index 543ea30..5287b0a 100644 --- a/client/src/app/routes/app/error-boundary.tsx +++ b/client/src/app/routes/app/error-boundary.tsx @@ -1,3 +1,6 @@ +/** + * Simple fallback UI shown when routing fails. + */ export const ErrorBoundary = () => { return
Something went wrong!
; }; diff --git a/client/src/app/routes/app/share.tsx b/client/src/app/routes/app/share.tsx index 9f5a4cd..f947dd4 100644 --- a/client/src/app/routes/app/share.tsx +++ b/client/src/app/routes/app/share.tsx @@ -13,6 +13,9 @@ import { DashboardLayout } from '@/components/layouts/DashboardLayout'; import { paths } from '@/config/paths'; import { api } from '@/lib/api'; +/** + * Public share entry point: consumes the URL token to grant access, then routes into the document. + */ export default function Share() { const { token } = useParams(); // Now getting token from URL params directly const navigate = useNavigate(); diff --git a/client/src/app/routes/auth/login.tsx b/client/src/app/routes/auth/login.tsx index 38171dc..3e5d4dd 100644 --- a/client/src/app/routes/auth/login.tsx +++ b/client/src/app/routes/auth/login.tsx @@ -2,12 +2,15 @@ import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { AuthLayout } from '@/components/layouts/AuthLayout'; -import LoginForm from '@/components/ui/auth/login-form'; +import LoginForm from '@/components/ui/Auth/login-form'; import { paths } from '@/config/paths'; import { useAuth } from '@/context/auth'; import { api } from '@/lib/api'; import { type LoginSchemaType } from '@/lib/auth'; +/** + * Login page wiring LoginForm to the auth context and post-login navigation. + */ export default function Login() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); diff --git a/client/src/app/routes/auth/register.tsx b/client/src/app/routes/auth/register.tsx index ea9e5fe..353fef5 100644 --- a/client/src/app/routes/auth/register.tsx +++ b/client/src/app/routes/auth/register.tsx @@ -2,11 +2,14 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; import { AuthLayout } from '@/components/layouts/AuthLayout'; -import RegisterForm from '@/components/ui/auth/register-form'; +import RegisterForm from '@/components/ui/Auth/register-form'; import { paths } from '@/config/paths'; import { RegisterUser } from '@/lib/auth'; import { type RegisterSchemaType } from '@/lib/auth'; +/** + * Registration page wiring RegisterForm to the auth flow and redirect after signup. + */ export default function Register() { const navigate = useNavigate(); const [error, setError] = useState(null); diff --git a/client/src/app/routes/landing.tsx b/client/src/app/routes/landing.tsx index 216338e..a98c35e 100644 --- a/client/src/app/routes/landing.tsx +++ b/client/src/app/routes/landing.tsx @@ -1,9 +1,12 @@ import { useNavigate } from 'react-router'; -import { Head } from '@/components/ui/seo'; +import { Head } from '@/components/ui/Seo'; import { paths } from '@/config/paths'; import { useAuth } from '@/context/auth'; +/** + * Marketing landing page; call-to-action adapts to authentication state. + */ export default function Landing() { const { isAuthenticated } = useAuth(); const navigate = useNavigate(); diff --git a/client/src/app/routes/not-found.tsx b/client/src/app/routes/not-found.tsx index 91c56f5..1a1d91b 100644 --- a/client/src/app/routes/not-found.tsx +++ b/client/src/app/routes/not-found.tsx @@ -2,6 +2,9 @@ import React from 'react'; +/** + * 404 fallback page. + */ export default function NotFound() { return
404 not-found
; } diff --git a/client/src/components/common/NewDocumentFormBody/index.ts b/client/src/components/common/NewDocumentFormBody/index.ts new file mode 100644 index 0000000..d47e8dc --- /dev/null +++ b/client/src/components/common/NewDocumentFormBody/index.ts @@ -0,0 +1 @@ +export { default } from './new-document-form-body'; diff --git a/client/src/components/common/NewDocumentFormBody/new-document-form-body.stories.tsx b/client/src/components/common/NewDocumentFormBody/new-document-form-body.stories.tsx new file mode 100644 index 0000000..d252bdc --- /dev/null +++ b/client/src/components/common/NewDocumentFormBody/new-document-form-body.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import NewDocumentFormBody from './new-document-form-body'; + +const meta: Meta = { + title: 'Common/NewDocumentFormBody', + component: NewDocumentFormBody, + tags: ['autodocs'], + parameters: { + modal: true, + }, +}; +export default meta; + +type Story = StoryObj; + +// Per-story fn() — a shared module-scope mock makes .not.toHaveBeenCalled() +// order-dependent (breaks under filtered reruns/retries). + +export const Default: Story = { + args: { onSubmit: fn() }, +}; + +export const ValidationBlocksEmptyTitle: Story = { + args: { onSubmit: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /create/i })); + await expect(args.onSubmit).not.toHaveBeenCalled(); + // existence-based: robust against Radix open/close animation timing + await canvas.findByText(/title is required/i); + }, +}; + +export const SuccessfulSubmission: Story = { + args: { onSubmit: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type( + canvas.getByLabelText('Document Title'), + 'Meeting notes', + ); + await userEvent.click(canvas.getByRole('button', { name: /^create$/i })); + await expect(args.onSubmit).toHaveBeenCalledWith({ + title: 'Meeting notes', + }); + }, +}; + +export const SubmittingState: Story = { + args: { + onSubmit: () => new Promise(() => {}), // never resolves — shows pending UI + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText('Document Title'), 'Slow doc'); + const createButton = canvas.getByRole('button', { name: /^create$/i }); + await userEvent.click(createButton); + await expect(canvas.getByText(/creating\.\.\./i)).toBeVisible(); + await expect(createButton).toBeDisabled(); + }, +}; diff --git a/client/src/components/common/forms/NewDocumentFormBody.tsx b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx similarity index 79% rename from client/src/components/common/forms/NewDocumentFormBody.tsx rename to client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx index ed536c8..795622e 100644 --- a/client/src/components/common/forms/NewDocumentFormBody.tsx +++ b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useForm } from 'react-hook-form'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Form/Input'; +import { Input } from '@/components/ui/Form'; import { ModalBody, ModalFooter, @@ -15,12 +15,20 @@ import { import { CreateDocumentForm } from '@/types/api'; type Props = { + /** Heading text for the modal title. Defaults to 'Create New Item'. */ title?: string; + /** Copy under the title; defaults to 'Provide a title to get started.' */ description?: string; + /** Submit button label while idle. Defaults to 'Create'. */ submittingLabel?: string; + /** Handler receiving the entered title; form resets after it resolves. */ onSubmit: (data: { title: string }) => Promise; }; +/** + * Shared create-document form body rendered inside modals by both the Dashboard NewDocumentModal and DocumentPage CreateDocumentButton. + * Must be mounted inside a ancestor: it emits ModalContent/Header/Footer parts directly. + */ export default function NewDocumentFormBody({ title = 'Create New Item', description = 'Provide a title to get started.', diff --git a/client/src/components/layouts/AuthLayout/auth-layout.stories.tsx b/client/src/components/layouts/AuthLayout/auth-layout.stories.tsx new file mode 100644 index 0000000..73217ea --- /dev/null +++ b/client/src/components/layouts/AuthLayout/auth-layout.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { AuthLayout } from './auth-layout'; + +const meta: Meta = { + title: 'Layouts/AuthLayout', + component: AuthLayout, + tags: ['autodocs'], + parameters: { + auth: null, + }, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Sign in to Codown', + error: null, + children:
Auth form goes here.
, + }, +}; + +export const WithError: Story = { + args: { + title: 'Sign in to Codown', + error: 'Invalid email or password.', + children:
Auth form goes here.
, + }, +}; diff --git a/client/src/components/layouts/AuthLayout.tsx b/client/src/components/layouts/AuthLayout/auth-layout.tsx similarity index 74% rename from client/src/components/layouts/AuthLayout.tsx rename to client/src/components/layouts/AuthLayout/auth-layout.tsx index 6f2b73b..982d6a6 100644 --- a/client/src/components/layouts/AuthLayout.tsx +++ b/client/src/components/layouts/AuthLayout/auth-layout.tsx @@ -4,16 +4,22 @@ import { useNavigate, useSearchParams } from 'react-router'; import { paths } from '@/config/paths'; import { useAuth } from '@/context/auth'; -import { Alert } from '../ui/Alert'; -import Header from '../ui/Header/header'; -import { Head } from '../ui/seo'; +import { Alert } from '../../ui/Alert'; +import Header from '../../ui/Header/header'; +import { Head } from '../../ui/Seo'; type layoutProps = { + /** Large page heading, reused as the document title. */ title: string; + /** Centered page content rendered beneath the heading. */ children: React.ReactNode; + /** Warning banner shown above the heading; null hides it. */ error: string | null; }; +/** + * Card layout for unauthenticated pages: centered column with optional title and error alert. + */ export const AuthLayout = ({ children, title, error }: layoutProps) => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); diff --git a/client/src/components/layouts/AuthLayout/index.ts b/client/src/components/layouts/AuthLayout/index.ts new file mode 100644 index 0000000..1fca9eb --- /dev/null +++ b/client/src/components/layouts/AuthLayout/index.ts @@ -0,0 +1 @@ +export { AuthLayout } from './auth-layout'; diff --git a/client/src/components/layouts/ContentLayout.tsx b/client/src/components/layouts/ContentLayout.tsx deleted file mode 100644 index 07d52d9..0000000 --- a/client/src/components/layouts/ContentLayout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Head } from '../ui/seo'; - -export default function ContentLayout({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( - <> - - {children} - - ); -} diff --git a/client/src/components/layouts/ContentLayout/content-layout.stories.tsx b/client/src/components/layouts/ContentLayout/content-layout.stories.tsx new file mode 100644 index 0000000..85d3b52 --- /dev/null +++ b/client/src/components/layouts/ContentLayout/content-layout.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import ContentLayout from './content-layout'; + +const meta: Meta = { + title: 'Layouts/ContentLayout', + component: ContentLayout, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Content Page', + children:
Page content.
, + }, +}; diff --git a/client/src/components/layouts/ContentLayout/content-layout.tsx b/client/src/components/layouts/ContentLayout/content-layout.tsx new file mode 100644 index 0000000..5ce0747 --- /dev/null +++ b/client/src/components/layouts/ContentLayout/content-layout.tsx @@ -0,0 +1,21 @@ +import { Head } from '../../ui/Seo'; + +/** + * Generic page shell: document head metadata, optional title heading and a padded content region. + */ +export default function ContentLayout({ + title, + children, +}: { + /** Document title set via Head; no visible heading rendered. */ + title: string; + /** Page content rendered after the head metadata. */ + children: React.ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/client/src/components/layouts/ContentLayout/index.ts b/client/src/components/layouts/ContentLayout/index.ts new file mode 100644 index 0000000..e46338c --- /dev/null +++ b/client/src/components/layouts/ContentLayout/index.ts @@ -0,0 +1 @@ +export { default } from './content-layout'; diff --git a/client/src/components/layouts/DashboardLayout/dashboard-layout.stories.tsx b/client/src/components/layouts/DashboardLayout/dashboard-layout.stories.tsx new file mode 100644 index 0000000..4e24ba4 --- /dev/null +++ b/client/src/components/layouts/DashboardLayout/dashboard-layout.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { DashboardLayout } from './dashboard-layout'; + +const meta: Meta = { + title: 'Layouts/DashboardLayout', + component: DashboardLayout, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Authenticated: Story = { + args: { + title: 'Dashboard', + children:
Dashboard content.
, + }, +}; + +export const SignedOut: Story = { + args: { + title: 'Dashboard', + children:
Dashboard content.
, + }, + parameters: { + auth: null, + }, +}; + +export const SessionLoading: Story = { + args: { + title: 'Dashboard', + children:
Dashboard content.
, + }, + parameters: { + auth: { loading: true }, + }, +}; diff --git a/client/src/components/layouts/DashboardLayout.tsx b/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx similarity index 67% rename from client/src/components/layouts/DashboardLayout.tsx rename to client/src/components/layouts/DashboardLayout/dashboard-layout.tsx index 0556867..b4a298b 100644 --- a/client/src/components/layouts/DashboardLayout.tsx +++ b/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx @@ -2,14 +2,19 @@ import React from 'react'; import { useAuth } from '@/context/auth'; -import Header from '../ui/Header/header'; -import { Head } from '../ui/seo'; +import Header from '../../ui/Header/header'; +import { Head } from '../../ui/Seo'; type layoutProps = { + /** Document title set via Head. */ title: string; + /** Routed page content rendered under the header. */ children: React.ReactNode; }; +/** + * Authenticated app shell: top Header with user menu plus routed outlet; sends still-loading or signed-out users to login. + */ export const DashboardLayout = ({ children, title }: layoutProps) => { const { user, loading, logout } = useAuth(); diff --git a/client/src/components/layouts/DashboardLayout/index.ts b/client/src/components/layouts/DashboardLayout/index.ts new file mode 100644 index 0000000..65aa610 --- /dev/null +++ b/client/src/components/layouts/DashboardLayout/index.ts @@ -0,0 +1 @@ +export { DashboardLayout } from './dashboard-layout'; diff --git a/client/src/components/layouts/DocumentLayout/document-layout.stories.tsx b/client/src/components/layouts/DocumentLayout/document-layout.stories.tsx new file mode 100644 index 0000000..73eb2c0 --- /dev/null +++ b/client/src/components/layouts/DocumentLayout/document-layout.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { DocumentLayout } from './document-layout'; + +const meta: Meta = { + title: 'Layouts/DocumentLayout', + component: DocumentLayout, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'My Document', + children: ( +
+ Full-height document surface. +
+ ), + }, +}; diff --git a/client/src/components/layouts/DocumentLayout.tsx b/client/src/components/layouts/DocumentLayout/document-layout.tsx similarity index 61% rename from client/src/components/layouts/DocumentLayout.tsx rename to client/src/components/layouts/DocumentLayout/document-layout.tsx index e075303..98c7b2b 100644 --- a/client/src/components/layouts/DocumentLayout.tsx +++ b/client/src/components/layouts/DocumentLayout/document-layout.tsx @@ -1,12 +1,17 @@ import React from 'react'; -import { Head } from '../ui/seo'; +import { Head } from '../../ui/Seo'; type layoutProps = { + /** Document title set via Head. */ title: string; + /** Full-height document workspace filling the shell. */ children: React.ReactNode; }; +/** + * Minimal document page shell: head metadata plus a titled main region wrapping children. + */ export const DocumentLayout = ({ title, children }: layoutProps) => { return ( <> diff --git a/client/src/components/layouts/DocumentLayout/index.ts b/client/src/components/layouts/DocumentLayout/index.ts new file mode 100644 index 0000000..a2774f6 --- /dev/null +++ b/client/src/components/layouts/DocumentLayout/index.ts @@ -0,0 +1 @@ +export { DocumentLayout } from './document-layout'; diff --git a/client/src/components/ui/Alert/alert.stories.tsx b/client/src/components/ui/Alert/alert.stories.tsx index c2d4ed2..a6aeeaf 100644 --- a/client/src/components/ui/Alert/alert.stories.tsx +++ b/client/src/components/ui/Alert/alert.stories.tsx @@ -19,6 +19,9 @@ export default meta; type Story = StoryObj; +/** + * Info variant. + */ export const Info: Story = { args: { variant: 'info', @@ -27,6 +30,9 @@ export const Info: Story = { }, }; +/** + * Success variant. + */ export const Success: Story = { args: { variant: 'success', @@ -35,6 +41,9 @@ export const Success: Story = { }, }; +/** + * Warning variant. + */ export const Warning: Story = { args: { variant: 'warning', @@ -43,6 +52,9 @@ export const Warning: Story = { }, }; +/** + * Error variant. + */ export const Error: Story = { args: { variant: 'error', @@ -51,6 +63,9 @@ export const Error: Story = { }, }; +/** + * Dismissable via the onDismiss close button. + */ export const Dismissible: Story = { args: { variant: 'info', diff --git a/client/src/components/ui/Alert/alert.tsx b/client/src/components/ui/Alert/alert.tsx index 9b3f04d..05cacb0 100644 --- a/client/src/components/ui/Alert/alert.tsx +++ b/client/src/components/ui/Alert/alert.tsx @@ -9,9 +9,13 @@ import { import { cn } from '@/utils/cn'; interface AlertProps { + /** Severity preset driving colors and icon. Defaults to 'info'. */ variant?: 'error' | 'success' | 'warning' | 'info'; + /** Optional bold heading above the message. */ title?: string; + /** Message content beside the severity icon. */ children: React.ReactNode; + /** Shows an X button that calls this on click. */ onDismiss?: () => void; className?: string; } @@ -37,6 +41,9 @@ const iconColors = { info: 'text-blue-500', }; +/** + * Themed notification banner with severity icon, optional title, message content and optional dismiss button. + */ export function Alert({ variant = 'info', title, diff --git a/client/src/components/ui/Auth/login-form.stories.tsx b/client/src/components/ui/Auth/login-form.stories.tsx new file mode 100644 index 0000000..eb63001 --- /dev/null +++ b/client/src/components/ui/Auth/login-form.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import LoginForm from './login-form'; + +const meta: Meta = { + title: 'Components/Auth/LoginForm', + component: LoginForm, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +// Per-story fn() — a shared module-scope mock makes .not.toHaveBeenCalled() +// order-dependent (breaks under filtered reruns/retries). + +export const Default: Story = { + args: { onSubmit: fn() }, +}; + +export const ValidationBlocksEmptySubmit: Story = { + args: { onSubmit: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /sign in/i })); + // DOM proof, not just callback absence: + await canvas.findByText(/invalid email/i); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const SuccessfulSubmission: Story = { + args: { onSubmit: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText('Email'), 'user@test.com'); + await userEvent.type(canvas.getByLabelText('Password'), 'secret123'); + await userEvent.click(canvas.getByRole('button', { name: /sign in/i })); + await expect(args.onSubmit).toHaveBeenCalledWith({ + email: 'user@test.com', + password: 'secret123', + }); + }, +}; + +export const Loading: Story = { + args: { onSubmit: fn(), isLoading: true }, +}; diff --git a/client/src/components/ui/auth/login-form.tsx b/client/src/components/ui/Auth/login-form.tsx similarity index 88% rename from client/src/components/ui/auth/login-form.tsx rename to client/src/components/ui/Auth/login-form.tsx index 42425ce..df77622 100644 --- a/client/src/components/ui/auth/login-form.tsx +++ b/client/src/components/ui/Auth/login-form.tsx @@ -4,16 +4,21 @@ import { useForm } from 'react-hook-form'; import { Link, useSearchParams } from 'react-router'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Form/Input'; +import { Input } from '@/components/ui/Form'; import { paths } from '@/config/paths'; import type { LoginSchemaType } from '@/lib/auth'; import { LoginSchema } from '@/lib/auth'; interface LoginFormProps { + /** Called with validated credentials on successful submit. */ onSubmit: (data: LoginSchemaType) => void | Promise; + /** Overrides pending UI while an outer operation runs. Defaults to false. */ isLoading?: boolean; } +/** + * Login form built on react-hook-form; validates credentials and delegates submission to onSubmit. + */ export default function LoginForm({ onSubmit, isLoading = false, diff --git a/client/src/components/ui/Auth/register-form.stories.tsx b/client/src/components/ui/Auth/register-form.stories.tsx new file mode 100644 index 0000000..645c42b --- /dev/null +++ b/client/src/components/ui/Auth/register-form.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import RegisterForm from './register-form'; + +const meta: Meta = { + title: 'Components/Auth/RegisterForm', + component: RegisterForm, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +// Per-story fn() — a shared module-scope mock makes .not.toHaveBeenCalled() +// order-dependent (breaks under filtered reruns/retries). + +export const Default: Story = { + args: { onSubmit: fn() }, +}; + +export const ValidationBlocksEmptySubmit: Story = { + args: { onSubmit: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole('button', { name: /create account/i }), + ); + // DOM proof, not just callback absence: + await canvas.findByText(/invalid email/i); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const SuccessfulSubmission: Story = { + args: { onSubmit: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByLabelText('Email'), 'new@test.com'); + await userEvent.type(canvas.getByLabelText('Username'), 'newuser'); + await userEvent.type(canvas.getByLabelText('Full Name'), 'New User'); + await userEvent.type(canvas.getByLabelText('Password'), 'longsecret1'); + await userEvent.click( + canvas.getByRole('button', { name: /create account/i }), + ); + await expect(args.onSubmit).toHaveBeenCalledWith({ + email: 'new@test.com', + username: 'newuser', + fullName: 'New User', + password: 'longsecret1', + }); + }, +}; + +export const Loading: Story = { + args: { onSubmit: fn(), isLoading: true }, +}; diff --git a/client/src/components/ui/auth/register-form.tsx b/client/src/components/ui/Auth/register-form.tsx similarity index 90% rename from client/src/components/ui/auth/register-form.tsx rename to client/src/components/ui/Auth/register-form.tsx index 0e29b5d..ef4b595 100644 --- a/client/src/components/ui/auth/register-form.tsx +++ b/client/src/components/ui/Auth/register-form.tsx @@ -4,16 +4,21 @@ import { useForm } from 'react-hook-form'; import { Link, useSearchParams } from 'react-router'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Form/Input'; +import { Input } from '@/components/ui/Form'; import { paths } from '@/config/paths'; import type { RegisterSchemaType } from '@/lib/auth'; import { RegisterSchema } from '@/lib/auth'; interface RegisterFormProps { + /** Called with validated form data on successful submit. */ onSubmit: (data: RegisterSchemaType) => void | Promise; + /** Overrides pending UI while an outer operation runs. Defaults to false. */ isLoading?: boolean; } +/** + * Registration form built on react-hook-form; validates input and delegates account creation to onSubmit. + */ export default function RegisterForm({ onSubmit, isLoading = false, diff --git a/client/src/components/ui/Avatar/avatar.stories.tsx b/client/src/components/ui/Avatar/avatar.stories.tsx index 624e1ec..ef2b736 100644 --- a/client/src/components/ui/Avatar/avatar.stories.tsx +++ b/client/src/components/ui/Avatar/avatar.stories.tsx @@ -12,6 +12,9 @@ export default meta; type Story = StoryObj; +/** + * Standard avatar with image source. + */ export const WithImage: Story = { render: () => ( @@ -21,6 +24,9 @@ export const WithImage: Story = { ), }; +/** + * Fallback rendering when no image is provided. + */ export const WithFallbackOnly: Story = { render: () => ( @@ -29,6 +35,9 @@ export const WithFallbackOnly: Story = { ), }; +/** + * Fallback shown when the image fails to load. + */ export const BrokenImage: Story = { render: () => ( @@ -38,6 +47,9 @@ export const BrokenImage: Story = { ), }; +/** + * Non-default size. + */ export const CustomSize: Story = { render: () => ( diff --git a/client/src/components/ui/Avatar/avatar.tsx b/client/src/components/ui/Avatar/avatar.tsx index 90a53c3..72a06b2 100644 --- a/client/src/components/ui/Avatar/avatar.tsx +++ b/client/src/components/ui/Avatar/avatar.tsx @@ -21,6 +21,7 @@ Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { + /** Delay before the image fades in; masks slow loads. Defaults to 1500. */ delayMS?: number; } >(({ className, delayMS = 1500, ...props }, ref) => { diff --git a/client/src/components/ui/Button/button.stories.tsx b/client/src/components/ui/Button/button.stories.tsx index f4bc340..67450c7 100644 --- a/client/src/components/ui/Button/button.stories.tsx +++ b/client/src/components/ui/Button/button.stories.tsx @@ -31,6 +31,9 @@ const meta: Meta = { export default meta; type Story = StoryObj; +/** + * Default variant and size. + */ export const Default: Story = { args: { children: 'Default Button', @@ -39,6 +42,9 @@ export const Default: Story = { }, }; +/** + * Destructive variant for irreversible actions. + */ export const Destructive: Story = { args: { children: 'Delete', @@ -46,6 +52,9 @@ export const Destructive: Story = { }, }; +/** + * Outline variant. + */ export const Outline: Story = { args: { children: 'Outline', @@ -53,6 +62,9 @@ export const Outline: Story = { }, }; +/** + * Secondary variant. + */ export const Secondary: Story = { args: { children: 'Secondary', @@ -60,6 +72,9 @@ export const Secondary: Story = { }, }; +/** + * Ghost variant. + */ export const Ghost: Story = { args: { children: 'Ghost', @@ -67,6 +82,9 @@ export const Ghost: Story = { }, }; +/** + * Link-styled variant. + */ export const Link: Story = { args: { children: 'Link Button', @@ -74,6 +92,9 @@ export const Link: Story = { }, }; +/** + * Small size preset. + */ export const Small: Story = { args: { children: 'Small', @@ -81,6 +102,9 @@ export const Small: Story = { }, }; +/** + * Large size preset. + */ export const Large: Story = { args: { children: 'Large', @@ -88,6 +112,9 @@ export const Large: Story = { }, }; +/** + * Icon-only ghost button using size='icon'. + */ export const IconButton: Story = { args: { children: '🔔', diff --git a/client/src/components/ui/Button/button.tsx b/client/src/components/ui/Button/button.tsx index 0ca7d45..d0e59b5 100644 --- a/client/src/components/ui/Button/button.tsx +++ b/client/src/components/ui/Button/button.tsx @@ -10,8 +10,11 @@ import { buttonVariants } from './variants'; export type ButtonProps = React.ButtonHTMLAttributes & VariantProps & { + /** Render the child via Slot instead of a - */} + {isOwner && ( + <> + +
{ + e.preventDefault(); + handleAdd(); + }} + > + setEmail(e.target.value)} + 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" + /> + +
+ {error && ( +

+ {error} +

+ )} + + )} ); diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx index f97e470..626fde0 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx @@ -6,6 +6,7 @@ import { CreateDocumentButton } from './create-document-button'; const meta: Meta = { title: 'DocumentPage/CreateDocumentButton', component: CreateDocumentButton, + tags: ['autodocs'], parameters: { layout: 'centered', }, @@ -17,10 +18,16 @@ export default meta; type Story = StoryObj; +/** + * Button opening the create-document modal. + */ export const Default: Story = { args: {}, }; +/** + * Pending state while creation resolves. + */ export const WithSlowCreation: Story = { args: { onCreateDocument: fn().mockImplementation( @@ -30,6 +37,9 @@ export const WithSlowCreation: Story = { }, }; +/** + * Error surfaced when creation fails. + */ export const WithError: Story = { args: { onCreateDocument: fn().mockRejectedValue( diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx index cfac41d..24050c3 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx @@ -1,16 +1,21 @@ import React, { useState } from 'react'; import { LuPlus as AddIcon } from 'react-icons/lu'; -import NewDocumentFormBody from '@/components/common/forms/NewDocumentFormBody'; +import NewDocumentFormBody from '@/components/common/NewDocumentFormBody'; import { Button } from '@/components/ui/Button'; import { Modal, ModalOverlay, ModalTrigger } from '@/components/ui/Modal'; import type { CreateDocumentForm } from '@/types/api'; interface CreateDocumentButtonProps { + /** Creates a document with the submitted title; closes the modal on success. */ onCreateDocument?: (title: string) => Promise; + /** Extra classes merged onto the trigger button. */ className?: string; } +/** + * Header button driving the shared create-document flow with pending/error handling. + */ export const CreateDocumentButton = ({ onCreateDocument, className, diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx index 9aedff0..32d79c5 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx @@ -5,6 +5,7 @@ import { DocumentTitle } from './document-title'; const meta: Meta = { title: 'DocumentPage/DocumentTitle', component: DocumentTitle, + tags: ['autodocs'], parameters: { layout: 'centered', }, @@ -13,16 +14,25 @@ export default meta; type Story = StoryObj; +/** + * Default rendering. + */ export const Default: Story = { args: {}, }; +/** + * Standard inline title. + */ export const DocumentTitleDefault: Story = { args: { title: 'My Document', }, }; +/** + * Long title truncation. + */ export const LongTitle: Story = { args: { title: @@ -30,12 +40,18 @@ export const LongTitle: Story = { }, }; +/** + * Missing title renders nothing. + */ export const NoTitle: Story = { args: { title: undefined, }, }; +/** + * Empty-string title renders nothing. + */ export const EmptyTitle: Story = { args: { title: '', diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx index c5c5a46..c224f89 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx @@ -3,10 +3,15 @@ import React from 'react'; import { cn } from '@/utils/cn'; interface DocumentTitleProps { + /** Title text; when absent the component renders nothing. */ title?: string; + /** Extra classes merged onto the title element. */ className?: string; } +/** + * Inline document title in the header; renders nothing when title is absent. + */ export const DocumentTitle = ({ title, className }: DocumentTitleProps) => { if (!title) return null; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx index 641d0db..5d338fc 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { MemoryRouter } from 'react-router'; import { fn } from 'storybook/test'; import { DocumentToolbar } from './document-toolbar'; @@ -7,16 +6,15 @@ import { DocumentToolbar } from './document-toolbar'; const meta: Meta = { title: 'DocumentPage/DocumentToolbar', component: DocumentToolbar, + tags: ['autodocs'], parameters: { layout: 'centered', }, decorators: [ (Story) => ( - -
- -
-
+
+ +
), ], args: { @@ -30,12 +28,18 @@ export default meta; type Story = StoryObj; +/** + * Toolbar with minimal props. + */ export const ToolbarMinimal: Story = { args: { mode: 'both', }, }; +/** + * Toolbar including user-specific controls. + */ export const ToolbarWithUser: Story = { args: { mode: 'edit', @@ -45,6 +49,9 @@ export const ToolbarWithUser: Story = { }, }; +/** + * Fully populated toolbar. + */ export const ToolbarComplete: Story = { args: { mode: 'both', diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx index 2f41013..bcd6e50 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx @@ -11,18 +11,31 @@ import { ShareButton } from '../ShareButton/share-button'; import { ViewModeSelector } from '../ViewModeSelector'; interface DocumentToolbarProps { + /** Active editor layout: edit-only, split, or view-only. */ mode: 'edit' | 'both' | 'view'; + /** Called with the selected layout when it changes. */ setMode: (mode: 'edit' | 'both' | 'view') => void; + /** Signed-in username shown in the user menu. */ username?: string; + /** Signed-in user's avatar image URL. */ avatarUrl?: string; + /** Signs the user out from the account menu. */ logout?: () => void; + /** Title of the open document. */ documentTitle?: string; + /** Creates a document with the given title from the header actions. */ onCreateDocument?: (title: string) => Promise; + /** ID of the open document; enables document-scoped controls. */ docId?: string; + /** Renders read-only affordances (View-only badge instead of roster controls). */ isReadOnly?: boolean; + /** Hides owner-only controls (share/options) for collaborators. */ isCollaborator?: boolean; } +/** + * Header toolbar switching contents between edit and view modes. + */ export const DocumentToolbar = ({ mode, setMode, diff --git a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx index c9f4553..35a0b22 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx @@ -5,11 +5,15 @@ import { MoreOptionsDropdown } from './options-dropdown'; const meta: Meta = { title: 'DocumentPage/OptionsDropdown', component: MoreOptionsDropdown, + tags: ['autodocs'], }; export default meta; type Story = StoryObj; +/** + * More-options menu. + */ export const Default: Story = { args: {}, }; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx index 71a67fe..c89960e 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx @@ -14,9 +14,13 @@ import { } from '@/components/ui/Dropdown'; type Props = { + /** Extra classes merged onto the kebab-menu trigger button. */ className?: string; }; +/** + * Kebab menu exposing additional document actions. + */ export const MoreOptionsDropdown = ({ className }: Props) => { return ( diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx index 89d69a4..af7dddd 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx @@ -5,6 +5,7 @@ import { ShareButton } from './share-button'; const meta: Meta = { title: 'DocumentPage/ShareButton', component: ShareButton, + tags: ['autodocs'], parameters: { layout: 'centered', }, @@ -13,6 +14,9 @@ export default meta; type Story = StoryObj; +/** + * Button opening the share dialog. + */ export const Default: Story = { args: {}, }; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx index 4f406ab..628cc66 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx @@ -19,16 +19,22 @@ import { ToastClose, ToastProvider, } from '@/components/ui/Toast'; -import { useJoinRequests } from '@/hooks/useJoinRequests'; -import { useShareLink } from '@/hooks/useShareLink'; +import { useJoinRequests } from '@/hooks/use-join-requests'; +import { useShareLink } from '@/hooks/use-share-link'; import { ShareModeSelect } from './share-mode-select'; type Props = { + /** Extra classes merged onto the share trigger button. */ className?: string; + /** ID of the document being shared. */ docId?: string; + /** Switches hooks to read-only collaborator access. */ isCollaborator?: boolean; }; +/** + * Share flow trigger managing permission selection and the share dialog state. + */ export const ShareButton = ({ className, docId, isCollaborator }: Props) => { const [permission, setPermission] = useState<'view' | 'edit'>('view'); const [toast, setToast] = useState(false); @@ -44,7 +50,6 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => { shareLink, loading: linkLoading, error, - refresh, } = useShareLink(docId, permission, isCollaborator); const handleCopy = async () => { @@ -53,9 +58,7 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => { }; const handlePermissionChange = (value: 'view' | 'edit') => { - console.log('changed permission to', value); - setPermission(value); - refresh(); // This will use the new `permission` from state + setPermission(value); // the hook refetches on permission change }; return ( @@ -78,7 +81,7 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => { size="icon" variant="outline" onClick={handleCopy} - disabled={!shareLink} + disabled={!shareLink || linkLoading} > diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx index 6b19bea..98c71ad 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx @@ -11,11 +11,16 @@ const options = [ { label: 'Edit mode', value: 'edit' }, ]; +/** + * Select between view/edit permission for a share link. + */ export function ShareModeSelect({ onChange, value, }: { + /** Called with the chosen permission level. */ onChange: (permission: 'view' | 'edit') => void; + /** Currently selected permission level. */ value: 'view' | 'edit'; }) { return ( diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx index 9d445df..42dda6f 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx @@ -6,6 +6,7 @@ import { ViewModeSelector } from './view-mode-selector'; const meta: Meta = { title: 'DocumentPage/ViewModeSelector', component: ViewModeSelector, + tags: ['autodocs'], parameters: { layout: 'centered', }, @@ -17,6 +18,9 @@ export default meta; type Story = StoryObj; +/** + * Edit/preview mode toggle group. + */ export const Default: Story = { args: {}, }; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx index 75cf63c..841605e 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx @@ -9,11 +9,17 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'; import { cn } from '@/utils/cn'; interface ViewModeSelectorProps { + /** Active editor layout shown as the selected toggle. */ mode: 'edit' | 'both' | 'view'; + /** Called with the selected layout when the toggle changes. */ setMode: (mode: 'edit' | 'both' | 'view') => void; + /** Extra classes merged onto the toggle group. */ className?: string; } +/** + * Toggle between editing and preview modes. + */ export const ViewModeSelector: React.FC = ({ mode, setMode, diff --git a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx index 82519b6..39a390f 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx @@ -1,24 +1,22 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { MemoryRouter } from 'react-router'; import { WorkspaceInfo } from './workspace-info'; const meta: Meta = { title: 'DocumentPage/WorkspaceInfo', component: WorkspaceInfo, + tags: ['autodocs'], parameters: { layout: 'centered', }, - decorators: (Story) => ( - - - - ), }; export default meta; type Story = StoryObj; +/** + * Workspace badge display. + */ export const Default: Story = { args: {}, }; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx index 6bfafea..4a5bd5a 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx @@ -4,6 +4,9 @@ import { Link } from 'react-router'; import { paths } from '@/config/paths'; +/** + * Static workspace label rendered in the header. + */ export const WorkspaceInfo: React.FC = () => { return ( = { title: 'DocumentPage/DocumentHeader', component: DocumentHeader, + tags: ['autodocs'], parameters: { layout: 'fullscreen', }, decorators: [ (Story) => ( - -
- -
-
+
+ +
), ], args: { @@ -29,12 +27,18 @@ const meta: Meta = { export default meta; type Story = StoryObj; +/** + * Header with minimal props. + */ export const Default: Story = { args: { mode: 'both', }, }; +/** + * Header with authenticated user present. + */ export const WithUser: Story = { args: { mode: 'edit', @@ -44,6 +48,9 @@ export const WithUser: Story = { }, }; +/** + * Header bound to a document. + */ export const WithDocument: Story = { args: { mode: 'view', @@ -61,6 +68,9 @@ export const WithDocument: Story = { }, }; +/** + * Dense collaborator avatar stack. + */ export const WithManyCollaborators: Story = { args: { mode: 'both', @@ -91,6 +101,9 @@ export const WithManyCollaborators: Story = { }, }; +/** + * Long document title truncation. + */ export const LongDocumentTitle: Story = { args: { mode: 'edit', @@ -101,6 +114,9 @@ export const LongDocumentTitle: Story = { }, }; +/** + * Collaborator UI hidden when the list is empty. + */ export const NoCollaborators: Story = { args: { mode: 'both', @@ -110,6 +126,9 @@ export const NoCollaborators: Story = { }, }; +/** + * Toolbar in edit mode. + */ export const EditMode: Story = { args: { mode: 'edit', @@ -118,6 +137,9 @@ export const EditMode: Story = { }, }; +/** + * Toolbar in view mode. + */ export const ViewMode: Story = { args: { mode: 'view', diff --git a/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx b/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx index 2ea6968..9fd180d 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx @@ -6,19 +6,33 @@ import { DocumentToolbar } from './DocumentToolbar/document-toolbar'; import { WorkspaceInfo } from './WorkspaceInfo'; export interface DocumentHeaderProps { + /** Active editor layout: edit-only, split, or view-only. */ mode: 'edit' | 'both' | 'view'; + /** Called with the selected layout when it changes. */ setMode: (mode: 'edit' | 'both' | 'view') => void; + /** Signed-in username shown in the toolbar. */ username?: string; + /** Signed-in user's avatar image URL. */ avatarUrl?: string; + /** Signs the user out from the account menu. */ logout?: () => void; + /** Title of the open document. */ documentTitle?: string; + /** Creates a document with the given title from the header actions. */ onCreateDocument?: (title: string) => Promise; + /** Extra classes merged onto the header container. */ className?: string; + /** ID of the open document; enables document-scoped controls. */ docId?: string; + /** Disables editing controls for read-only viewers. */ isReadOnly?: boolean; + /** Unlocks collaborator-level controls on shared documents. */ isCollaborator?: boolean; } +/** + * Composes the document top bar: title, collaborators, share, view-mode and options controls. + */ export const DocumentHeader: React.FC = ({ mode, setMode, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.stories.tsx new file mode 100644 index 0000000..d14c600 --- /dev/null +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.stories.tsx @@ -0,0 +1,80 @@ +import { EditorState } from '@codemirror/state'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { EditorView } from 'codemirror'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import { MarkdownStatusBar } from './markdown-status-bar'; + +const meta: Meta = { + title: 'Features/DocumentPage/MarkdownStatusBar', + component: MarkdownStatusBar, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +const setUseTabsFn = fn(); +const setSpellcheckFn = fn(); + +// NOTE: never pass an EditorView through `args` — its circular internals +// send Storybook's JSON arg serialization into an infinite loop. + +export const WithoutView: Story = { + args: { + view: null, + useTabs: true, + setUseTabs: setUseTabsFn, + spellcheck: false, + setSpellcheck: setSpellcheckFn, + }, +}; + +/** Clicking "Tab: 4" flips indentation mode even without a live view. */ +export const TogglesIndentationMode: Story = { + args: { + view: null, + useTabs: true, + setUseTabs: setUseTabsFn, + spellcheck: false, + setSpellcheck: setSpellcheckFn, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByText(/^tab: 4$/i)); + await expect(setUseTabsFn).toHaveBeenCalledWith(false); + }, +}; + +/** The first toggle control flips spellchecking. */ +export const TogglesSpellcheck: Story = { + args: { + view: null, + useTabs: true, + setUseTabs: setUseTabsFn, + spellcheck: false, + setSpellcheck: setSpellcheckFn, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getAllByRole('button')[0]); + await expect(setSpellcheckFn).toHaveBeenCalledWith(true); + }, +}; + +/** Smoke: renders live status against a real (unattached) editor view. */ +export const WithLiveView: Story = { + render: () => ( + {}} + spellcheck={false} + setSpellcheck={() => {}} + /> + ), +}; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx index 29f0e31..d7557da 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx @@ -4,17 +4,26 @@ import { LuCheck, LuX } from 'react-icons/lu'; import { cn } from '@/utils/cn'; -import { useEditorStatus } from './useEditorStatus'; +import { useEditorStatus } from './use-editor-status'; export interface MarkdownStatusBarProps { + /** Extra classes merged onto the status bar container. */ className?: string; + /** Editor view whose cursor/document stats are displayed. */ view: EditorView | null; + /** Whether tab indentation is enabled (vs 4 spaces). */ useTabs: boolean; + /** Toggles tab indentation from the bar. */ setUseTabs: React.Dispatch>; + /** Whether spellcheck highlighting is enabled. */ spellcheck: boolean; + /** Toggles spellcheck from the bar. */ setSpellcheck: React.Dispatch>; } +/** + * Word/character/status readout derived from the editor view. + */ export const MarkdownStatusBar = ({ className, view, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/use-editor-status.ts similarity index 80% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/use-editor-status.ts index 9eccc1d..d90d2d0 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/use-editor-status.ts @@ -2,6 +2,11 @@ import { StateEffect } from '@codemirror/state'; import { EditorView } from 'codemirror'; import { useEffect, useState } from 'react'; +/** + * Derives live editor status (counts, cursor info) from a CodeMirror view. + * @param view - Editor view to observe; status stays static while null. + * @returns Current line/column position, line total and document length. + */ export function useEditorStatus(view: EditorView | null) { const [status, setStatus] = useState({ line: 1, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.stories.tsx new file mode 100644 index 0000000..f0048e5 --- /dev/null +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.stories.tsx @@ -0,0 +1,73 @@ +import { history } from '@codemirror/commands'; +import { EditorState } from '@codemirror/state'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { EditorView } from 'codemirror'; +import { expect, userEvent, within } from 'storybook/test'; + +import { MarkdownToolbar } from './markdown-toolbar'; + +const meta: Meta = { + title: 'Features/DocumentPage/MarkdownToolbar', + component: MarkdownToolbar, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +let currentView: EditorView | null = null; +const makeView = (doc: string) => { + // Stories remount without unmounting the previous one — destroy the old + // unattached view or it leaks for the rest of the browser session. + currentView?.destroy(); + currentView = new EditorView({ + // history() is part of basicSetup in the real editor; required for undo + state: EditorState.create({ doc, extensions: [history()] }), + }); + return currentView; +}; + +export const Default: Story = { + render: () => , +}; + +/** Clicking Bold wraps the selection with ** markers. */ +export const AppliesBold: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // select all text so the command has a target + const view = currentView!; + view.dispatch({ + selection: { anchor: 0, head: view.state.doc.length }, + }); + await userEvent.click(await canvas.findByTitle('Bold')); + await expect(view.state.doc.toString()).toBe('**word**'); + }, +}; + +export const UndoRestores: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const view = currentView!; + view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); + await userEvent.click(await canvas.findByTitle('Bold')); + await expect(view.state.doc.toString()).toBe('**plain**'); + await userEvent.click(await canvas.findByTitle('Undo')); + await expect(view.state.doc.toString()).toBe('plain'); + }, +}; + +export const NullViewRendersInert: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const bold = canvas.getByTitle('Bold'); + // Buttons render enabled but clicking must be a no-op without a view — + // no throw, and the toolbar stays mounted and interactive. + await userEvent.click(bold); + await expect(bold).toBeEnabled(); + await expect(canvas.getByTitle('Italic')).toBeEnabled(); + }, +}; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx index 6c72d26..e134fbb 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx @@ -4,13 +4,18 @@ import { cn } from '@/utils/cn'; import { ToolbarButton } from './toolbar-button'; import { toolbarButtons } from './toolbar-buttons'; -import { useMarkdownCommands } from './useMarkdownCommands'; +import { useMarkdownCommands } from './use-markdown-commands'; +/** + * Formatting button row dispatching commands into the CodeMirror view. + */ export function MarkdownToolbar({ view, className, }: { + /** Editor view the formatting commands dispatch into. */ view: EditorView | null; + /** Extra classes merged onto the toolbar container. */ className?: string; }) { const { handleUndo, handleRedo, runCommand, runOrderedListCommand } = diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx index 5a60461..39cf0d8 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx @@ -1,12 +1,18 @@ import React from 'react'; +/** + * Icon button wrapper for a single toolbar command. + */ export function ToolbarButton({ icon: Icon, title, onClick, }: { + /** Icon component rendered inside the button. */ icon: React.ElementType; + /** Native tooltip and accessible name of the button. */ title: string; + /** Invoked when the button is clicked. */ onClick: () => void; }) { return ( diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx index 4680e8e..26e8a5f 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx @@ -37,6 +37,9 @@ type ToolbarCommand = } | { type: 'divider' }; +/** + * Ordered registry of toolbar commands (undo/redo, inline formatting, headings). + */ export const toolbarButtons: ToolbarCommand[] = [ { type: 'button', icon: LuUndo, title: 'Undo', action: 'undo' }, { type: 'button', icon: LuRedo, title: 'Redo', action: 'redo' }, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/use-markdown-commands.tsx similarity index 93% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/use-markdown-commands.tsx index 31ff23e..3df760d 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/use-markdown-commands.tsx @@ -3,6 +3,11 @@ import { undo, redo } from '@codemirror/commands'; import { EditorSelection } from '@codemirror/state'; import { EditorView } from 'codemirror'; +/** + * Runs named markdown formatting commands against the editor view. + * @param view - Editor view to dispatch commands into; no-ops while null. + * @returns Command handlers wired to the toolbar buttons. + */ export function useMarkdownCommands(view: EditorView | null) { const runCommand = ( before: string, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts similarity index 91% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts index a71eed5..55168b8 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts @@ -6,6 +6,9 @@ import { lineNumbers, gutter } from '@codemirror/view'; import { EditorView, keymap, highlightActiveLine } from '@codemirror/view'; // import { githubMarkdown } from "@codemirror/lang-markdown"; // optional preset +/** + * CodeMirror extension list composing the markdown language, keymap and editor behavior plugins. + */ export const editorExtensions = [ markdown({ base: markdownLanguage, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts similarity index 93% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts index e479191..389f12d 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts @@ -2,6 +2,7 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { EditorView } from '@codemirror/view'; import { tags } from '@lezer/highlight'; +/** Dracula palette and metric values driving the theme and highlight style. */ const config = { name: 'dracula', dark: true, @@ -30,6 +31,7 @@ const config = { invalid: '#FF5555', regexp: '#F1FA8C', }; +/** Editor-chrome theme (gutters, tooltips, selections) in the Dracula palette. */ const draculaTheme = EditorView.theme( { '&': { @@ -95,6 +97,7 @@ const draculaTheme = EditorView.theme( }, { dark: config.dark }, ); +/** Syntax highlight rules mapping code/markdown tags to palette colors. */ const draculaHighlightStyle = HighlightStyle.define([ { tag: tags.keyword, color: config.keyword }, { @@ -150,6 +153,7 @@ const draculaHighlightStyle = HighlightStyle.define([ { tag: tags.invalid, color: config.invalid }, { tag: tags.strikethrough, textDecoration: 'line-through' }, ]); +/** Combined theme + highlighting extension list consumed by MarkdownEditor. */ const MyTheme = [draculaTheme, syntaxHighlighting(draculaHighlightStyle)]; export { config, MyTheme, draculaHighlightStyle, draculaTheme }; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts index 1808a93..3599b1b 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts @@ -1 +1 @@ -export * from './MarkdownEditor'; +export * from './markdown-editor'; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts similarity index 97% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts index c756e49..38d5b60 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts @@ -1,5 +1,8 @@ import { EditorView } from 'codemirror'; +/** + * Named markdown formatting commands (toggle bold, italic, etc.) used by toolbar and shortcuts. + */ export const markdownCommands = { toggleBold: (view: EditorView) => { const { state } = view; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.stories.tsx new file mode 100644 index 0000000..f4ce2e9 --- /dev/null +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import * as Y from 'yjs'; + +import { MarkdownEditor } from './markdown-editor'; + +const meta: Meta = { + title: 'Features/DocumentPage/MarkdownEditor', + component: MarkdownEditor, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +/** + * The provider stub replaces the live websocket (D6 seam); awareness is + * left undefined, which the editor tolerates by skipping presence features. + */ +const stubProvider = { + destroy: () => {}, +}; + +const makeYText = (initial = '') => { + const ydoc = new Y.Doc(); + const ytext = ydoc.getText('content'); + if (initial) ytext.insert(0, initial); + return ytext; +}; + +export const Editable: Story = { + render: () => ( +
+ {}} + /> +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Status bar must reflect the live view (non-zero length) — guards + // against the toolbar/status receiving a stale null view on mount. + await canvas.findByText(/3 lines/i); + await expect(canvas.getByText(/length:/i).textContent).toMatch( + /length: (?!0\b)\d+/i, + ); + }, +}; + +export const ReadOnly: Story = { + render: () => ( +
+ {}} + isReadOnly + /> +
+ ), +}; + +export const EmptyDocument: Story = { + render: () => ( +
+ {}} + /> +
+ ), +}; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownEditor.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.tsx similarity index 76% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownEditor.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.tsx index 3f66d74..1ef8021 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownEditor.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.tsx @@ -9,14 +9,14 @@ import * as Y from 'yjs'; import { useAuth } from '@/context/auth'; import { cn } from '@/utils/cn'; -import { generateUserColor } from '@/utils/generateUserColor'; +import { generateUserColor } from '@/utils/generate-user-color'; -import { editorExtensions } from './EditorExtensions'; -import { MyTheme } from './EditorTheme'; -import { markdownCommands } from './KeyMapExtension'; +import { editorExtensions } from './editor-extensions'; +import { MyTheme } from './editor-theme'; +import { markdownCommands } from './key-map-extension'; import { MarkdownStatusBar } from './MarkdownStatusBar'; import { MarkdownToolbar } from './MarkdownToolbar'; -import { createAdvancedSpellcheckExtension } from './spellCheck'; +import { createAdvancedSpellcheckExtension } from './spell-check'; const markdownKeymap = keymap.of([ indentWithTab, @@ -27,6 +27,9 @@ const markdownKeymap = keymap.of([ { key: 'Mod-e', run: markdownCommands.insertCode }, ]); +/** + * CodeMirror 6 editor bound to Yjs collaborative types; routes local edits into ytext and applies remote updates. + */ export function MarkdownEditor({ ytext, provider, @@ -35,28 +38,32 @@ export function MarkdownEditor({ syncScroll, isReadOnly, }: { + /** Shared Yjs text bound to the CodeMirror view. */ ytext: Y.Text | null; + /** Collaboration provider supplying awareness state. */ provider: any; + /** Ref receiving the scrollable editor element for scroll syncing. */ editorScrollRef?: React.RefObject; + /** Scroll handler attached while synced scrolling is enabled. */ onScroll: () => void; + /** Attaches the parent's scroll handler when true. */ syncScroll?: boolean; + /** Disables editing and hides toolbar/status controls when true. */ isReadOnly?: boolean; }) { const { user } = useAuth(); // Get current user const [spellcheck, setSpellcheck] = useState(false); const [useTabs, setUseTabs] = useState(true); + // Held in state (not a ref): toolbar/status bar receive the view as a prop, + // and ref writes don't re-render — a ref here would hand them null until an + // unrelated re-render happened. + const [view, setView] = useState(null); const editorRef = useRef(null); - const viewRef = useRef(null); useEffect(() => { if (!editorRef.current || !ytext || !provider) return; - // Clean up previous editor if remounting - if (viewRef.current) { - viewRef.current.destroy(); - } - if (user && provider.awareness) { provider.awareness.setLocalStateField('user', { name: user.username, @@ -97,7 +104,7 @@ export function MarkdownEditor({ parent: editorRef.current, }); - viewRef.current = view; + setView(view); if (editorScrollRef) { editorScrollRef.current = editorRef.current; @@ -105,6 +112,7 @@ export function MarkdownEditor({ return () => { view.destroy(); + setView(null); }; }, [ytext, provider, useTabs, spellcheck, editorScrollRef, isReadOnly, user]); // Add spellcheck to dependencies @@ -126,7 +134,7 @@ export function MarkdownEditor({
{!isReadOnly && ( )} @@ -136,7 +144,7 @@ export function MarkdownEditor({ ref={editorRef} /> = new Map(); @@ -223,6 +226,9 @@ class DictionaryManager { } // Suggestion widget for displaying spelling corrections +/** + * CodeMirror widget rendering inline correction suggestions. + */ class SuggestionWidget extends WidgetType { constructor( private suggestions: string[], @@ -469,6 +475,11 @@ const spellcheckTheme = EditorView.theme({ }); // Main spellcheck extension factory +/** + * Assembles the spellcheck extension: misspelling decorations plus suggestion widgets. + * @param language - Hunspell dictionary locale to load. Defaults to 'en_US'. + * @returns CodeMirror extensions applying decorations and styling. + */ export function createAdvancedSpellcheckExtension( language: string = 'en_US', ): Extension { @@ -476,6 +487,9 @@ export function createAdvancedSpellcheckExtension( } // Custom word management +/** + * Global custom-word store for words the user added to the dictionary. + */ export class SpellcheckManager { private static customWords: Set = new Set(); diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts index 6c1863d..8c83320 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts @@ -1 +1 @@ -export * from './MarkdownPreview'; +export * from './markdown-preview'; diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx index 323090a..cd9757f 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, waitFor } from 'storybook/test'; -import { MarkdownPreview } from './MarkdownPreview'; +import { MarkdownPreview } from './markdown-preview'; const meta: Meta = { component: MarkdownPreview, + tags: ['autodocs'], title: 'Features/DocumentPage/MarkdownPreview', }; @@ -25,6 +26,9 @@ const XSS_PAYLOAD = [ '', ].join('\n'); +/** + * Raw HTML attacks are stripped by sanitization. + */ export const SanitizesMaliciousHtml: StoryObj = { args: { content: XSS_PAYLOAD, @@ -70,6 +74,9 @@ const MERMAID_DOC = [ '```', ].join('\n'); +/** + * Fenced mermaid blocks render as diagrams. + */ export const RendersMermaidDiagrams: StoryObj = { args: { content: MERMAID_DOC, @@ -116,6 +123,9 @@ const RICH_DOC = [ 'Math: $E = mc^2$', ].join('\n'); +/** + * Safe markdown features survive sanitization intact. + */ export const PreservesSafeMarkdownFeatures: StoryObj = { args: { content: RICH_DOC, @@ -154,6 +164,9 @@ export const PreservesSafeMarkdownFeatures: StoryObj = { }, }; +/** + * Grab the rendered prose container from the story canvas. + */ function getProse(canvasElement: HTMLElement): HTMLElement { const preview = canvasElement.querySelector( '.markdown-previewer', diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx similarity index 86% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx index a3f1a3b..89ffa25 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx @@ -20,12 +20,16 @@ import { import { dateFormat } from '@/utils/dateformat'; import { MarkdownToc } from './markdown-toc'; -import { MermaidDiagram } from './MermaidDiagram'; +import { MermaidDiagram } from './mermaid-diagram'; import { rehypeSupSub } from './rehype-subsuper'; +import { rehypeTextDecorations } from './remark-decorations'; import { remarkTypographer } from './remark-typographer'; -import { rehypeTextDecorations } from './remarkDecorations'; import { markdownSanitizeSchema } from './sanitize-schema'; +/** + * Sanitized markdown renderer: unified pipeline with GFM, math, mermaid, + * syntax highlighting and copy-code buttons over a hardened allow-list. + */ export function MarkdownPreview({ content, lastUpdated, @@ -33,10 +37,15 @@ export function MarkdownPreview({ onScroll, syncScroll, }: { + /** Raw markdown source to render. */ content: string; + /** Ref receiving the scrollable container for scroll syncing. */ previewScrollRef?: React.RefObject; + /** Scroll handler attached while synced scrolling is enabled. */ onScroll?: () => void; + /** Attaches the parent's scroll handler when true. */ syncScroll?: boolean; + /** ISO timestamp shown as "Last Edited"; falls back to "Unknown". */ lastUpdated?: string; }) { useEffect(() => { diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx index ab5a6f8..b7e6549 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx @@ -34,6 +34,9 @@ interface CollapsibleProps { children: React.ReactNode; } +/** + * Local collapsible trigger primitive. + */ function CollapsibleTrigger({ children, onClick, @@ -57,6 +60,9 @@ function CollapsibleTrigger({ ); } +/** + * Local collapsible content primitive. + */ function CollapsibleContent({ children, className = '', @@ -69,6 +75,9 @@ function CollapsibleContent({ ); } +/** + * Local open-state container for the TOC groups. + */ function Collapsible({ open = false, onOpenChange, @@ -106,6 +115,11 @@ function Collapsible({ } // Extract headings from markdown content +/** + * Parse ATX markdown headings into flat TOC entries. + * @param markdown - Raw markdown source to scan. + * @returns Headings nested into a level-based tree. + */ function extractHeadings(markdown: string): TocHeading[] { const headingRegex = /^(#{1,6})\s+(.+)$/gm; const headings: TocHeading[] = []; @@ -132,6 +146,9 @@ function extractHeadings(markdown: string): TocHeading[] { } // Build a nested tree structure from flat headings array +/** + * Nest flat heading entries into a level-based tree. + */ function buildHeadingTree(headings: TocHeading[]): TocHeading[] { const root: TocHeading[] = []; const stack: TocHeading[] = []; @@ -155,6 +172,9 @@ function buildHeadingTree(headings: TocHeading[]): TocHeading[] { } // Individual TOC item component +/** + * Recursive TOC node renderer with anchor links. + */ function TocItem({ heading, onHeadingClick, @@ -221,15 +241,22 @@ function TocItem({ } // Main TOC component +/** + * Table-of-contents panel extracted from the document headings. + */ export function MarkdownToc({ content, className = '', onHeadingClick, collapsible = true, }: { + /** Raw markdown source headings are extracted from. */ content: string; + /** Extra classes merged onto the TOC panel. */ className?: string; + /** Notifies parents when a TOC entry is selected. */ onHeadingClick?: (id: string) => void; + /** Wraps entries in an expandable group. Defaults to true. */ collapsible?: boolean; }) { const [isOpen, setIsOpen] = useState(true); diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx similarity index 90% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx index 146f2c9..8dff07c 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx @@ -45,7 +45,15 @@ const SVG_SANITIZE_CONFIG: Config = { ], }; -export function MermaidDiagram({ code }: { code: string }) { +/** + * Renders fenced mermaid code to SVG asynchronously. + */ +export function MermaidDiagram({ + code, +}: { + /** Mermaid diagram source to render. */ + code: string; +}) { const [svg, setSvg] = useState(''); const [failed, setFailed] = useState(false); const renderId = useMemo( diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts index 6934631..daa291f 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts @@ -3,6 +3,9 @@ import { visit } from 'unist-util-visit'; const supersubRegex = /(\^([^\s^]+)\^)|(_([^\s_]+)_)/g; +/** + * Rehype plugin adding superscript/subscript handling to the preview pipeline. + */ export const rehypeSupSub: Plugin = () => { return (tree: any) => { visit(tree, 'text', (node: any, index, parent) => { diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx similarity index 95% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx index 61e5f6b..3eac5bb 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx @@ -4,6 +4,9 @@ import { visit } from 'unist-util-visit'; // Regex patterns for text decorations const textDecorationRegex = /(\+\+([^+]+)\+\+)|(==([^=]+)==)/g; +/** + * Rehype plugin applying extra inline text decorations during preview rendering. + */ export const rehypeTextDecorations: Plugin = () => { return (tree: any) => { visit(tree, 'text', (node: any, index, parent) => { diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts index a0a71bd..e53186c 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts @@ -43,6 +43,9 @@ const typographerPatterns = [ }, ]; +/** + * Remark plugin performing typographic adjustments on the markdown AST. + */ export const remarkTypographer: Plugin = () => { return (tree: Node) => { visit(tree, 'text', (node: any) => { diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts index 8969a6a..22305b8 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts @@ -3,6 +3,9 @@ import type { Options } from 'rehype-sanitize'; const HIGHLIGHT_CLASS_PATTERN = /^(hljs(-[\w-]+)?|language-[\w-]+)$/; +/** + * Allow-list schema extending hast-util-sanitize defaults for the preview pipeline. + */ export const markdownSanitizeSchema: Options = { ...defaultSchema, attributes: { diff --git a/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx new file mode 100644 index 0000000..c399e6b --- /dev/null +++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, within } from 'storybook/test'; + +import type { CollabProviderFactory } from '@/hooks/use-collab'; +import type { DocumentData } from '@/types/api'; + +import { DocumentMain } from './document-main'; + +const meta: Meta = { + title: 'Features/DocumentPage/DocumentMain', + component: DocumentMain, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +const mockDoc: DocumentData = { + id: 'story-doc', + title: 'Storybook Document', + content: '', + authorId: 'story-author', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +/** + * Offline provider stub: seeds the shared Y.Doc so the editor/preview render + * real content without a websocket (D6 seam via the createProvider prop). + * Module-level so the factory identity stays stable across renders. + */ +const seededProviderFactory: CollabProviderFactory = (options) => { + const ytext = options.document.getText('content'); + if (!ytext.length) { + ytext.insert(0, '# Storybook Document\n\nSynced without a websocket.'); + } + return { destroy: () => {} }; +}; + +const renderWith = ( + mode: 'edit' | 'view', + isReadOnly?: boolean, +): Story['render'] => + function RenderedStory() { + return ( +
+ +
+ ); + }; + +export const EditMode: Story = { + render: renderWith('edit'), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The seeded ytext must reach the editor — proves the provider stub + // actually feeds collaboration state instead of an empty doc. + await canvas.findByText(/Synced without a websocket/i); + }, +}; + +export const ViewMode: Story = { + render: renderWith('view'), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Preview renders the seeded markdown as a heading + paragraph. + await canvas.findByRole('heading', { name: /storybook document/i }); + await canvas.findByText(/synced without a websocket/i); + }, +}; + +export const ReadOnly: Story = { + render: renderWith('edit', true), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(/synced without a websocket/i); + // Read-only: toolbar must not mount. + await expect(canvas.queryByTitle('Bold')).toBeNull(); + }, +}; diff --git a/client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx similarity index 87% rename from client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx rename to client/src/features/DocumentPage/components/DocumentMain/document-main.tsx index ed3d6a8..ca676af 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx @@ -4,13 +4,16 @@ import { LuLink, LuUnlink } from 'react-icons/lu'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Spinner } from '@/components/ui/Spinner'; -import { useCollab } from '@/hooks/useCollab'; +import { useCollab, type CollabProviderFactory } from '@/hooks/use-collab'; import { DocumentData } from '@/types/api'; import { cn } from '@/utils/cn'; import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownPreview } from './MarkdownPreview'; +/** + * Wires collaboration state into either MarkdownEditor or MarkdownPreview per view mode. + */ export function DocumentMain({ docId, mode, @@ -18,15 +21,27 @@ export function DocumentMain({ setDoc, className, isReadOnly, + createProvider, }: { + /** ID of the document to collaborate on; renders a spinner while absent. */ docId: string | undefined; + /** Active editor layout: edit-only, split, or view-only. */ mode: EditorMode; + /** Loaded document metadata; content is kept in sync with the editor. */ doc: DocumentData | null; + /** Persists editor text back into the parent's document state. */ setDoc: (doc: DocumentData) => void; + /** Extra classes merged onto the layout container. */ className?: string; + /** Renders the editor as read-only when true. */ isReadOnly?: boolean; + /** Provider factory override for tests/stories; live websocket by default. */ + createProvider?: CollabProviderFactory; }) { - const { text, isReady, ydoc, ytext, provider } = useCollab(docId); + const { text, isReady, ydoc, ytext, provider } = useCollab( + docId, + createProvider, + ); const editorScrollRef = useRef(null); const previewScrollRef = useRef(null); const isSyncingRef = useRef(false); diff --git a/client/src/features/DocumentPage/components/DocumentMain/index.ts b/client/src/features/DocumentPage/components/DocumentMain/index.ts index 5f5041a..e7e7ab5 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/index.ts +++ b/client/src/features/DocumentPage/components/DocumentMain/index.ts @@ -1 +1 @@ -export * from './DocumentMain'; +export * from './document-main'; diff --git a/client/src/hooks/use-auto-save.ts b/client/src/hooks/use-auto-save.ts new file mode 100644 index 0000000..5d566da --- /dev/null +++ b/client/src/hooks/use-auto-save.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; + +import { DocumentData } from '@/types/api'; + +/** + * Interval autosave hook: calls saveFn on a fixed interval. + * + * @param saveFn - Persist callback invoked on every tick. + * @param doc - Document draft watched by the effect; any identity change + * resets the timer, so a save fires intervalMs after the last change + * rather than on a fixed global schedule. + * @param intervalMs - Delay between ticks in ms. Defaults to 5000. + */ +export function useAutoSave( + saveFn: () => void, + doc: DocumentData, + intervalMs = 5000, +) { + useEffect(() => { + const interval = setInterval(() => { + saveFn(); + }, intervalMs); + return () => clearInterval(interval); + }, [saveFn, doc, intervalMs]); +} diff --git a/client/src/hooks/useCollab.ts b/client/src/hooks/use-collab.ts similarity index 59% rename from client/src/hooks/useCollab.ts rename to client/src/hooks/use-collab.ts index 41b0240..f2ac3c9 100644 --- a/client/src/hooks/useCollab.ts +++ b/client/src/hooks/use-collab.ts @@ -5,18 +5,45 @@ import * as Y from 'yjs'; import { env } from '@/config/env'; import { getAccessToken } from '@/utils/token'; -export function useCollab(docId: string | undefined) { +/** + * Factory contract for creating the collaboration provider. + * The default builds a live HocuspocusProvider; tests/stories may supply + * an offline or stubbed adapter instead. + */ +export type CollabProviderFactory = ( + options: ConstructorParameters[0] & { + url: string; + name: string; + document: Y.Doc; + }, +) => { destroy: () => void }; + +const defaultProviderFactory: CollabProviderFactory = (options) => + new HocuspocusProvider(options); + +/** + * Yjs collaboration session for a document id: creates a Y.Doc plus a provider + * (live websocket by default), mirrors ytext into React text state, and + * destroys everything on docId change/unmount. + * + * @param docId — room/document id to join. + * @param createProvider — optional provider factory override for tests/stories. + */ +export function useCollab( + docId: string | undefined, + createProvider: CollabProviderFactory = defaultProviderFactory, +) { const [text, setText] = useState(''); const [isReady, setIsReady] = useState(false); const ydocRef = useRef(null); const ytextRef = useRef(null); - const providerRef = useRef(null); + const providerRef = useRef | null>(null); useEffect(() => { if (!docId) return; const ydoc = new Y.Doc(); - const provider = new HocuspocusProvider({ + const provider = createProvider({ url: env.Socket_URL, // Hocuspocus server URL name: docId, // Room/document ID document: ydoc, @@ -46,7 +73,7 @@ export function useCollab(docId: string | undefined) { providerRef.current = null; setIsReady(false); }; - }, [docId]); + }, [docId, createProvider]); return { text, diff --git a/client/src/hooks/useCollaborators.ts b/client/src/hooks/use-collaborators.ts similarity index 65% rename from client/src/hooks/useCollaborators.ts rename to client/src/hooks/use-collaborators.ts index 3d4585a..92e70e8 100644 --- a/client/src/hooks/useCollaborators.ts +++ b/client/src/hooks/use-collaborators.ts @@ -3,20 +3,34 @@ import { useEffect, useState } from 'react'; import { api } from '@/lib/api'; import { Collaborator } from '@/types/api'; +/** + * Collaborator management: fetch, add by email and remove, with loading + * and error state. + * + * @param docId - Document id whose collaborators are managed; fetching + * is skipped while undefined. Mount gating (owner/editor-only UI) lives + * in the caller; the fetch itself is allowed for any mounted docId. + * @returns Collaborator list, loading/error flags and the add/remove + * actions. `addCollaborator` resolves to whether the collaborator was + * added. + */ export const useCollaborators = (docId?: string) => { const [collaborators, setCollaborators] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); useEffect(() => { if (!docId) return; const fetchCollaborators = async () => { setLoading(true); + setError(null); try { const res = await api.get(`/document/${docId}/collaborators`); setCollaborators(res.data || []); } catch (err) { console.error('Failed to fetch collaborators:', err); + setError('Failed to load collaborators'); } finally { setLoading(false); } @@ -32,23 +46,28 @@ export const useCollaborators = (docId?: string) => { setCollaborators((prev) => prev.filter((c) => c.id !== userId)); } catch (err) { console.error('Failed to remove collaborator:', err); + setError('Failed to remove collaborator'); } }; const addCollaborator = async (email: string) => { - if (!docId || !email) return; + if (!docId || !email) return false; try { await api.post(`/document/${docId}/collaborators`, { email }); const res = await api.get(`/document/${docId}/collaborators`); setCollaborators(res.data || []); + return true; } catch (err) { console.error('Failed to add collaborator:', err); + setError('Failed to add collaborator'); + return false; } }; return { collaborators, loading, + error, removeCollaborator, addCollaborator, }; diff --git a/client/src/hooks/useDocument.ts b/client/src/hooks/use-document.ts similarity index 63% rename from client/src/hooks/useDocument.ts rename to client/src/hooks/use-document.ts index dafe225..78a85d3 100644 --- a/client/src/hooks/useDocument.ts +++ b/client/src/hooks/use-document.ts @@ -3,6 +3,15 @@ import { useEffect, useState, useCallback } from 'react'; import { api } from '@/lib/api'; import { DocumentData } from '@/types/api'; +/** + * Fetches a document into doc plus an editedDoc draft, exposes save + * (handleSave), permission flags (access) and loading/saving/error state. + * + * @param id - Document id to load; refetches whenever it changes and + * skips loading entirely when undefined. + * @returns Server doc, editable draft with setter, save action, access + * flags and loading/saving/error state. + */ export function useDocument(id?: string) { const [doc, setDoc] = useState(null); const [editedDoc, setEditedDoc] = useState(null); @@ -13,6 +22,7 @@ export function useDocument(id?: string) { isCollaborator: boolean; permission: 'view' | 'edit'; } | null>(null); + const [error, setError] = useState(null); useEffect(() => { if (!id) { @@ -21,6 +31,7 @@ export function useDocument(id?: string) { } setLoading(true); + setError(null); api .get(`/document/${id}`) @@ -31,6 +42,7 @@ export function useDocument(id?: string) { }) .catch((err) => { console.error('Failed to fetch document:', err); + setError('Failed to load document'); setAccess(null); // ensure we don’t reuse old access }) .finally(() => { @@ -39,13 +51,19 @@ export function useDocument(id?: string) { }, [id]); const handleSave = useCallback(async () => { - if (!id) return; + if (!id || !editedDoc) return; try { setSaving(true); - await api.put(`/document/${id}`, editedDoc); + setError(null); + // Content is owned by Yjs sync; REST persists metadata only (issue #47). + await api.put(`/document/${id}`, { + title: editedDoc.title, + isPublic: editedDoc.isPublic, + }); setDoc(editedDoc); } catch (err) { console.error('Failed to save document:', err); + setError('Failed to save document'); } finally { setSaving(false); } @@ -59,5 +77,6 @@ export function useDocument(id?: string) { saving, handleSave, access, + error, }; } diff --git a/client/src/hooks/useJoinRequests.ts b/client/src/hooks/use-join-requests.ts similarity index 53% rename from client/src/hooks/useJoinRequests.ts rename to client/src/hooks/use-join-requests.ts index 32daed2..603a42e 100644 --- a/client/src/hooks/useJoinRequests.ts +++ b/client/src/hooks/use-join-requests.ts @@ -1,51 +1,60 @@ import { useCallback, useEffect, useState } from 'react'; -import { api } from '@/lib/api'; +import { + approveJoinRequest, + getJoinRequests, + rejectJoinRequest, +} from '@/lib/join-requests-api'; import { CollaborationRequest } from '@/types/api'; -export const getJoinRequests = async (docId: string) => { - const res = await api.get(`/document/${docId}/requests`); - return res.data; -}; - -export const approveJoinRequest = async (docId: string, requestId: string) => { - const res = await api.post( - `/document/${docId}/requests/${requestId}/approve`, - ); - return res.data; -}; - -export const rejectJoinRequest = async (docId: string, requestId: string) => { - const res = await api.delete( - `/document/${docId}/requests/${requestId}/reject`, - ); - return res.data; -}; - +/** + * Manages collaboration requests for a document. + * Fetches pending requests when the user is not yet a collaborator and + * exposes approve/reject actions that update the local list optimistically. + * + * @param docId — id of the document whose requests to manage. + * @param isCollaborator — when true, request polling is skipped. + * @returns requests list, loading flag, approve/reject actions and a refetch. + */ export function useJoinRequests( docId?: string | null, isCollaborator?: boolean, ) { const [requests, setRequests] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const fetchRequests = useCallback(async () => { try { setLoading(true); + setError(null); const data = await getJoinRequests(docId!); setRequests(data); } catch (err) { console.error('Failed to fetch join requests:', err); + setError('Failed to load join requests'); } finally { setLoading(false); } }, [docId]); + /** + * Approves a request by id and removes it from the local list. + * + * @param requestId - Join request id to approve; removed from the + * local list once the API call resolves. + */ const approve = async (requestId: string) => { await approveJoinRequest(docId!, requestId); setRequests((prev) => prev.filter((r) => r.id !== requestId)); }; + /** + * Rejects a request by id and removes it from the local list. + * + * @param requestId - Join request id to reject; removed from the + * local list once the API call resolves. + */ const reject = async (requestId: string) => { await rejectJoinRequest(docId!, requestId); setRequests((prev) => prev.filter((r) => r.id !== requestId)); @@ -59,6 +68,7 @@ export function useJoinRequests( return { requests, loading, + error, approve, reject, refetch: fetchRequests, diff --git a/client/src/hooks/useMediaQuery.ts b/client/src/hooks/use-media-query.ts similarity index 65% rename from client/src/hooks/useMediaQuery.ts rename to client/src/hooks/use-media-query.ts index 1ae3f40..71dfa79 100644 --- a/client/src/hooks/useMediaQuery.ts +++ b/client/src/hooks/use-media-query.ts @@ -1,5 +1,13 @@ import { useEffect, useState } from 'react'; +/** + * Live boolean match state for a CSS media query string. + * + * @param query - CSS media query to watch, e.g. '(min-width: 768px)'; + * the listener re-subscribes whenever it changes. + * @returns Whether the query currently matches, kept live via the + * matchMedia change event. + */ export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState( () => window.matchMedia(query).matches, diff --git a/client/src/hooks/use-share-link.ts b/client/src/hooks/use-share-link.ts new file mode 100644 index 0000000..8752dbc --- /dev/null +++ b/client/src/hooks/use-share-link.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { api } from '@/lib/api'; + +/** + * Fetches (and can refresh) a share link for a permission level; + * surfaces shareLink, loading and error. Concurrent fetches resolve + * last-request-wins, so a stale response never overwrites a newer one, + * and any previous link is invalidated whenever a new fetch starts. + * + * @param docId - Document id whose share link is fetched; fetching is + * skipped while undefined. + * @param permission - Permission level used for the automatic fetch; + * falls back to 'view' while undefined. + * @param isCollaborator - When true, the automatic fetch effect is + * skipped. + * @returns Share link URL, loading/error flags and a manual refresh + * requiring the permission to fetch. + */ +export const useShareLink = ( + docId?: string, + permission?: 'view' | 'edit', + isCollaborator?: boolean, +) => { + const [shareLink, setShareLink] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestRef = useRef(0); + + const fetchShareLink = useCallback( + async (permission: 'view' | 'edit') => { + if (!docId) return; + const requestId = ++requestRef.current; + // Invalidate any previous link up front so a stale URL can never be + // displayed or copied while loading or after a failure + setShareLink(''); + setLoading(true); + setError(null); + try { + const res = await api.get(`/document/${docId}/share-link`, { + params: { permission }, + }); + if (requestId !== requestRef.current) return; + setShareLink(res.data?.url ?? ''); + } catch (err: any) { + if (requestId !== requestRef.current) return; + setError('Failed to fetch share link'); + console.error(err); + } finally { + if (requestId === requestRef.current) setLoading(false); + } + }, + [docId], + ); + + useEffect(() => { + // Fetch on mount and whenever the selected permission changes + if (!isCollaborator && docId) { + fetchShareLink(permission ?? 'view'); + } + }, [docId, permission, isCollaborator, fetchShareLink]); + + return { shareLink, loading, error, refresh: fetchShareLink }; +}; diff --git a/client/src/hooks/useAutoSave.ts b/client/src/hooks/useAutoSave.ts deleted file mode 100644 index 675950b..0000000 --- a/client/src/hooks/useAutoSave.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react'; - -import { DocumentData } from '@/types/api'; - -export function useAutoSave( - saveFn: () => void, - doc: DocumentData, - intervalMs = 5000, -) { - useEffect(() => { - const interval = setInterval(() => { - saveFn(); - }, intervalMs); - return () => clearInterval(interval); - }, [saveFn, doc, intervalMs]); -} diff --git a/client/src/hooks/useShareLink.ts b/client/src/hooks/useShareLink.ts deleted file mode 100644 index bd0126f..0000000 --- a/client/src/hooks/useShareLink.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { api } from '@/lib/api'; - -export const useShareLink = ( - docId?: string, - permission?: 'view' | 'edit', - isCollaborator?: boolean, -) => { - const [shareLink, setShareLink] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchShareLink = useCallback( - async (permission: 'view' | 'edit' = 'view') => { - if (!docId) return; - setLoading(true); - setError(null); - try { - const res = await api.get(`/document/${docId}/share-link`, { - params: { permission }, - }); - setShareLink(res.data?.url ?? ''); - } catch (err: any) { - setError('Failed to fetch share link'); - console.error(err); - } finally { - setLoading(false); - } - }, - [docId], - ); - - useEffect(() => { - if (!isCollaborator) { - fetchShareLink(permission); - } // Fetch on mount with default permission - }, [docId, permission, isCollaborator, fetchShareLink]); - - return { shareLink, loading, error, refresh: fetchShareLink }; -}; diff --git a/client/src/lib/__tests__/api.test.tsx b/client/src/lib/__tests__/api.test.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 76b6e0c..eb50be2 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3,19 +3,15 @@ import axios from 'axios'; import { env } from '@/config/env'; import { getAccessToken } from '@/utils/token'; +/** + * Shared axios instance for all API calls. + * Attaches the stored access token to every request. + */ export const api = axios.create({ baseURL: `${env.API_URL}/api`, withCredentials: true, }); -// a refresh API instance to handle token refresh -// to avoid circular dependency issues - -// const refreshApi = axios.create({ -// baseURL: env.API_URL, -// withCredentials: true, -// }); - api.interceptors.request.use((config) => { const token = getAccessToken(); if (token) { @@ -23,34 +19,3 @@ api.interceptors.request.use((config) => { } return config; }); - -// api.interceptors.response.use( -// (res) => res, -// async (error) => { -// const originalRequest = error.config; - -// if (error.response?.status === 401 && !originalRequest._retry) { -// originalRequest._retry = true; - -// try { -// const res = await refreshApi.get('/auth/refresh'); -// const newAccessToken = res.data.accessToken; -// setAccessToken(newAccessToken); - -// originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; -// return api(originalRequest); -// } catch (err) { -// // TODO: clear token, redirect to login, etc. -// console.error('Token refresh failed:', err); -// const searchParams = new URLSearchParams(); -// const redirectTo = -// searchParams.get('redirectTo') || window.location.pathname; -// window.location.href = paths.auth.login.getHref(redirectTo); - -// return Promise.reject(err); -// } -// } - -// return Promise.reject(error); -// }, -// ); diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index f70b2d5..b966778 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -6,6 +6,10 @@ import { useAuth } from '@/context/auth'; import { api } from './api'; +/** + * Zod schema validating registration input: valid email, username (min 3 + * characters), password (min 8 characters), and optional full name. + */ export const RegisterSchema = z.object({ email: z.string().email(), username: z.string().min(3), @@ -13,6 +17,9 @@ export const RegisterSchema = z.object({ fullName: z.string().optional(), }); +/** + * zod schema validating login credentials (email format + required password). + */ export const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(8), @@ -21,6 +28,10 @@ export const LoginSchema = z.object({ export type RegisterSchemaType = z.infer; export type LoginSchemaType = z.infer; +/** + * Registers a user by POSTing the data to /auth/register. Resolves true on + * success, false on failure (the error is logged, not thrown). + */ export const RegisterUser = async ( data: RegisterSchemaType, ): Promise => { @@ -33,6 +44,10 @@ export const RegisterUser = async ( } }; +/** + * Logs a user in by POSTing credentials to /auth/login and resolving with the + * response payload (access/refresh tokens and user); rethrows on failure. + */ export const LoginUser = async (data: LoginSchemaType) => { try { const res = await api.post('/auth/login', data); @@ -43,6 +58,9 @@ export const LoginUser = async (data: LoginSchemaType) => { } }; +/** + * Route guard redirecting unauthenticated users away from protected children. + */ export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const auth = useAuth(); const location = useLocation(); diff --git a/client/src/lib/join-requests-api.ts b/client/src/lib/join-requests-api.ts new file mode 100644 index 0000000..372d3b4 --- /dev/null +++ b/client/src/lib/join-requests-api.ts @@ -0,0 +1,35 @@ +import { api } from '@/lib/api'; + +/** + * Fetches all pending collaboration requests for a document. + * @param docId — id of the document whose requests to fetch. + * @returns list of pending collaboration requests. + */ +export const getJoinRequests = async (docId: string) => { + const res = await api.get(`/document/${docId}/requests`); + return res.data; +}; + +/** + * Approves a pending collaboration request, granting the requester access. + * @param docId — id of the document the request targets. + * @param requestId — id of the request to approve. + */ +export const approveJoinRequest = async (docId: string, requestId: string) => { + const res = await api.post( + `/document/${docId}/requests/${requestId}/approve`, + ); + return res.data; +}; + +/** + * Rejects a pending collaboration request. + * @param docId — id of the document the request targets. + * @param requestId — id of the request to reject. + */ +export const rejectJoinRequest = async (docId: string, requestId: string) => { + const res = await api.delete( + `/document/${docId}/requests/${requestId}/reject`, + ); + return res.data; +}; diff --git a/client/src/lib/rehypeCopyButton.ts b/client/src/lib/rehype-copy-button.ts similarity index 84% rename from client/src/lib/rehypeCopyButton.ts rename to client/src/lib/rehype-copy-button.ts index e658c24..8b815a1 100644 --- a/client/src/lib/rehypeCopyButton.ts +++ b/client/src/lib/rehype-copy-button.ts @@ -1,6 +1,10 @@ import type { Root, Element } from 'hast'; import { visit } from 'unist-util-visit'; +/** + * Rehype plugin that appends a 🔗 button to every heading that has an id; + * the button copies `#` to the clipboard on click. + */ export function rehypeCopyHeadingLinks() { return (tree: Root) => { visit(tree, 'element', (node) => { diff --git a/client/src/utils/cn.ts b/client/src/utils/cn.ts index 9ad0df4..29dae18 100644 --- a/client/src/utils/cn.ts +++ b/client/src/utils/cn.ts @@ -1,6 +1,9 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; +/** + * Merge conditional Tailwind class values, resolving conflicts via tailwind-merge. + */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/client/src/utils/dateformat.ts b/client/src/utils/dateformat.ts index fb775ff..954bdc3 100644 --- a/client/src/utils/dateformat.ts +++ b/client/src/utils/dateformat.ts @@ -1,5 +1,8 @@ import { formatDistanceToNow } from 'date-fns'; +/** + * Humanized relative timestamp ("about 5 minutes ago") via date-fns. + */ export function dateFormat(date: Date) { const timeAgo = formatDistanceToNow(date, { addSuffix: true, diff --git a/client/src/utils/generateUserColor.ts b/client/src/utils/generate-user-color.ts similarity index 83% rename from client/src/utils/generateUserColor.ts rename to client/src/utils/generate-user-color.ts index c0d93e5..7d1f238 100644 --- a/client/src/utils/generateUserColor.ts +++ b/client/src/utils/generate-user-color.ts @@ -1,3 +1,6 @@ +/** + * Deterministically pick an avatar color from a fixed palette based on userId. + */ export function generateUserColor(userId: string): string { const colors = [ '#FF6B6B', diff --git a/client/src/utils/token.ts b/client/src/utils/token.ts index c287994..f85d6b5 100644 --- a/client/src/utils/token.ts +++ b/client/src/utils/token.ts @@ -1,11 +1,20 @@ let accessToken: string | null = null; +/** + * Store the access token in module scope for the axios interceptor. + */ export const setAccessToken = (token: string) => { accessToken = token; }; +/** + * Current access token, or null when signed out. + */ export const getAccessToken = () => accessToken; +/** + * Clear the stored access token. + */ export const clearAccessToken = () => { accessToken = null; }; diff --git a/client/vite.config.ts b/client/vite.config.ts index 3d9104f..58a6bc2 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -21,6 +21,22 @@ export default defineConfig({ '@': path.resolve(__dirname, 'src'), // ⬅️ this is required }, }, + optimizeDeps: { + // Pre-bundled on first storybook-test run otherwise, which reloads the + // browser mid-run and fails imports ("Failed to fetch dynamically + // imported module"). Keep in sync with heavy deps used by stories. + include: [ + 'react-dom/client', + '@hocuspocus/provider', + 'yjs', + 'y-codemirror.next', + 'codemirror', + '@codemirror/view', + '@codemirror/state', + '@codemirror/language', + 'react-resizable-panels', + ], + }, test: { projects: [ { @@ -34,6 +50,9 @@ export default defineConfig({ ], test: { name: 'storybook', + // One browser instance already stretches CI runners; parallel + // files alongside the server suite starves vitest's runner. + fileParallelism: false, browser: { enabled: true, headless: true, diff --git a/docs/adr/0001-defer-auth-token-consolidation.md b/docs/adr/0001-defer-auth-token-consolidation.md new file mode 100644 index 0000000..ea8ab7d --- /dev/null +++ b/docs/adr/0001-defer-auth-token-consolidation.md @@ -0,0 +1,37 @@ +# ADR 0001: Defer auth token consolidation + +- **Status:** Accepted (deferred) +- **Date:** 2026-08-23 +- **Context:** Client Quality Initiative (`docs/superpowers/specs/2026-08-23-client-quality-design.md` §6) + +## Context + +During the client quality survey, the access-token lifecycle was found to be +spread across three stores simultaneously: + +1. `AuthContext` React state (`context/auth/auth-provider.tsx`) +2. Module-level storage in `utils/token.ts` (read by the axios request interceptor) +3. Mutated axios defaults (`api.defaults.headers.common.Authorization`) + +Additionally, a hidden `localStorage['wasLoggedOut']` flag drives mount-time +refresh behavior, and `lib/api.ts` contained an entire commented-out +401-refresh interceptor — a competing design for the same concern, left as +archaeology. Understanding "how does auth work" requires bouncing across four +or more files; no single module owns token lifecycle. + +## Decision + +Defer consolidation to a dedicated future initiative. The client-quality work +limited itself to deleting the dead interceptor code and documenting current +behavior honestly. + +## Consequences + +- Auth remains triplicated; changes to token handling must touch all three + stores. Anyone touching auth should read `auth-provider.tsx`, `utils/token.ts`, + and `lib/api.ts` together. +- The consolidation deserves its own spec: it alters login/refresh/logout flows + with server-coupled behavior and cannot ride along a documentation initiative. +- Future architecture reviews should NOT re-flag this as a quick fix — it was + assessed during 2026-08 and deliberately deferred (this record exists so the + analysis isn't repeated). diff --git a/docs/superpowers/plans/2026-08-23-client-quality.md b/docs/superpowers/plans/2026-08-23-client-quality.md new file mode 100644 index 0000000..e00ce44 --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-client-quality.md @@ -0,0 +1,468 @@ +# Client Quality Initiative Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Normalize client structure, enforce JSDoc via lint, achieve full Storybook state coverage with stories-as-tests, wired into CI. + +**Architecture:** Seven-layer stacked PR flow managed by `gh-stack`, rooted on `develop`. Each layer is one PR; each task inside a layer is its own commit. Every layer ends green on local gates before the next begins. + +**Tech Stack:** Storybook 9 + `@storybook/addon-vitest` (browser mode, Playwright chromium), vitest 3, ESLint flat config + `eslint-plugin-check-file` + `eslint-plugin-jsdoc` (only new dep, dev), plop, gh-stack. + +**Spec:** `docs/superpowers/specs/2026-08-23-client-quality-design.md` + +## Stack Layout + +``` +develop (trunk) +└── client-quality/docs ← spec + this plan (PR1) + └── client-quality/a-infra ← CI filters, test wiring, plop (PR2) + └── client-quality/b-structure ← all renames/moves (PR3) + └── client-quality/c-rules ← naming rule flip + jsdoc warn (PR4) + └── client-quality/d-seams ← JSDoc backfill + error fields + D6 (PR5) + └── client-quality/e-stories ← decorators + stories + play tests (PR6) + └── client-quality/f-ci ← CI revert, AGENTS.md, ADR 0001 (PR7) +``` + +Merge strictly bottom-up via `gh stack merge --yes`; never plain `gh pr merge`. + +## Global Constraints + +- Directories PascalCase, files kebab-case (all `.ts`/`.tsx`) +- Feature layering untouched (`import/no-restricted-paths` zones stay as-is) +- No new runtime dependencies; sole new dev dependency: `eslint-plugin-jsdoc` +- No MSW/network mocking — story data via props and decorators +- All file moves via `git mv` +- Existing Playwright e2e must stay green throughout +- One task = one commit; D6 is isolated with its own e2e gate and drop-out policy +- Per-layer verification: `pnpm --filter client typecheck && pnpm --filter client build` minimum; plus `pnpm --filter client test` from Layer 1 onward; plus `pnpm lint` from Layer 3 onward + +--- + +## Layer 0: `client-quality/docs` + +### Task 0.1: Commit design spec ✅ (done — 34f7cd9) + +### Task 0.2: Commit implementation plan + +- [x] Write this document +- [ ] `git add docs/superpowers/plans/2026-08-23-client-quality.md && git commit -m "docs: client quality initiative implementation plan"` +- [ ] `gh stack submit --auto` → creates draft PR1 (docs) + +--- + +## Layer 1: `client-quality/a-infra` + +Create with: `gh stack add client-quality/a-infra` + +### Task 1.1: Extend CI branch filters + +**Files:** Modify `.github/workflows/lint-type-check.yml`, `.github/workflows/e2e.yml` + +In both files, change the `on.pull_request.branches` and `on.push.branches` lists: + +```yaml +branches: [main, develop, 'client-quality/**'] +``` + +(Leave everything else untouched; removal of `'client-quality/**'` happens in Task 6.1.) + +- [ ] Edit both workflows +- [ ] Commit: `ci: trigger workflows for stacked client-quality branches` + +### Task 1.2: Wire the test runner + +**Files:** Modify `client/package.json`, root `package.json` + +- [ ] In `client/package.json` scripts add: `"test": "vitest run",` +- [ ] In root `package.json` scripts add: `"test:client": "pnpm --filter client test",` (joins the existing `run-p test:*` chain automatically) +- [ ] Local prerequisite (not committed): `pnpm exec playwright install chromium` +- [ ] Verify: `pnpm --filter client test` runs the existing 24 story files and passes +- [ ] Commit: `chore(client): wire vitest browser test runner` + +### Task 1.3: Delete empty orphaned test + +**Files:** Delete `client/src/lib/__tests__/api.test.tsx` (verified 0 lines) and the now-empty `__tests__/` dir + +- [ ] Delete via `git rm client/src/lib/__tests__/api.test.tsx` +- [ ] Verify typecheck still green +- [ ] Commit: `chore(client): remove empty orphaned test file` + +### Task 1.4: Plop templates emit compliant scaffolding + +**Files:** Modify `client/generators/component/component.tsx.hbs`, `client/generators/component/component.stories.tsx.hbs` + +New component template: + +```hbs +/** + * {{pascalCase name}} — TODO: one-line description of purpose. + */ +import React from 'react'; + +export interface {{pascalCase name}}Props { + // define props +} + +export const {{pascalCase name}} = ({}: {{pascalCase name}}Props) => { + return
{{pascalCase name}} works!
; +}; +``` + +Note: import order must satisfy the repo's `import/order` rule once real props exist; keep `React` import first-line external group as generated today. + +New stories template: + +```hbs +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { {{pascalCase name}} } from './{{kebabCase name}}'; + +const meta: Meta = { + title: '{{titlePath}}/{{pascalCase name}}', + component: {{pascalCase name}}, + tags: ['autodocs'], +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +/* +Example interaction test: +import { expect } from 'storybook/test'; +export const Clicked: Story = { + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(/* assertion *\/).toHaveBeenCalled(); + }, +}; +*/ +``` + +- [ ] Update both templates (keep `index.cjs` logic unchanged) +- [ ] Verify: `pnpm --filter client generate`, generate a throwaway component into Root, confirm shape, then discard the generated files before committing +- [ ] Commit: `chore(client): scaffold components with autodocs and jsdoc stubs` + +--- + +## Layer 2: `client-quality/b-structure` + +Create with: `gh stack add client-quality/b-structure`. Every task: `git mv` + fix imports + verify (`typecheck && build`). Commit per task. + +### Task 2.1 (B1): Layouts → folder-per-component + +| From | To | +|---|---| +| `src/components/layouts/AuthLayout.tsx` | `src/components/layouts/AuthLayout/auth-layout.tsx` + `AuthLayout/index.ts` | +| `src/components/layouts/ContentLayout.tsx` | `ContentLayout/content-layout.tsx` + index | +| `src/components/layouts/DashboardLayout.tsx` | `DashboardLayout/dashboard-layout.tsx` + index | +| `src/components/layouts/DocumentLayout.tsx` | `DocumentLayout/document-layout.tsx` + index | + +index.ts content: `export { default } from './auth-layout';` (adjust per named/default export found on read). Fix any deep imports discovered via grep. + +Commit: `refactor(client): folder-per-component for layouts` + +### Task 2.2 (B2): context/auth normalization + +| From | To | +|---|---| +| `src/context/auth/AuthContext.tsx` | `auth-context.tsx` | +| `src/context/auth/AuthProvider.tsx` | `auth-provider.tsx` | +| `src/context/auth/useAuth.tsx` | `use-auth.tsx` | + +Update `context/auth/index.ts` re-export paths. + +Commit: `refactor(client): kebab-case auth context files` + +### Task 2.3 (B3): ui oddballs + Form/Input dissolution + +1. `git mv src/components/ui/seo src/components/ui/Seo`; `git mv Seo/Head.tsx Seo/head.tsx`; create `Seo/index.ts` +2. `git mv src/components/ui/auth src/components/ui/Auth` +3. `git mv src/components/ui/Header/Header.stories.tsx …/header.stories.tsx` +4. **Dissolve `Form/Input/`:** + - `git mv src/components/ui/Form/Input/input-field.tsx src/components/ui/Form/input.tsx` + - `git mv src/components/ui/Form/Input/variants.ts src/components/ui/Form/variants.ts` + - Delete `Form/Input/index.ts`; create `Form/index.ts`: `export * from './input';` + - In `input.tsx`: `'../field-wrapper'` → `'./field-wrapper'` + - In `input.stories.tsx`: `'./Input'` → `'./input'` + - Update 3 external imports: `@/components/ui/Form/Input` → `@/components/ui/Form` (`NewDocumentFormBody.tsx`, `login-form.tsx`, `register-form.tsx`) +5. Audit `Seo/Auth/Header` internals for remaining PascalCase files (grep sweep must come back clean except intentionally-PascalCase dirs) + +Verify + Storybook smoke test: `timeout 60 pnpm --filter client exec storybook --ci --smoke-test` (or equivalent headless boot check). + +Commit: `refactor(client): normalize ui directory casing and dissolve Form/Input` + +### Task 2.4 (B4): Feature fixes + +1. `git mv src/features/Dashboard/components/DashBoardMain src/features/Dashboard/components/DashboardMain` +2. `git mv src/components/common/forms/NewDocumentFormBody.tsx src/components/common/NewDocumentFormBody/new-document-form-body.tsx` + `index.ts`; remove now-empty `common/forms/`; update 2 imports (`create-document-button.tsx`, `new-document-modal.tsx`) to barrel form `@/components/common/NewDocumentFormBody` + +Commit: `refactor(client): fix DashboardMain casing, relocate shared NewDocumentFormBody` + +### Task 2.5 (B5): Hooks renames (7 files, 6 verified import sites) + +| From | To | +|---|---| +| `useAutoSave.ts` | `use-auto-save.ts` | +| `useCollab.ts` | `use-collab.ts` | +| `useCollaborators.ts` | `use-collaborators.ts` | +| `useDocument.ts` | `use-document.ts` | +| `useJoinRequests.ts` | `use-join-requests.ts` | +| `useMediaQuery.ts` | `use-media-query.ts` | +| `useShareLink.ts` | `use-share-link.ts` | + +Import sites to update: `app/routes/app/document.tsx` (×2: useDocument, useMediaQuery), `features/…/DocumentMain/DocumentMain.tsx` (useCollab), `features/…/ShareButton/share-button.tsx` (×2: useJoinRequests, useShareLink), `features/…/CollaboratorsDropdown/collaborators-dropdown.tsx` (useCollaborators). + +Commit: `refactor(client): kebab-case hook filenames` + +### Task 2.6 (B6): DocumentMain subtree + leaf renames + +| From (under `features/DocumentPage/components/DocumentMain/`) | To | +|---|---| +| `DocumentMain.tsx` | `document-main.tsx` | +| `MarkdownEditor/MarkdownEditor.tsx` | `markdown-editor.tsx` | +| `MarkdownEditor/EditorExtensions.ts` | `editor-extensions.ts` | +| `MarkdownEditor/EditorTheme.ts` | `editor-theme.ts` | +| `MarkdownEditor/KeyMapExtension.ts` | `key-map-extension.ts` | +| `MarkdownEditor/spellCheck.ts` | `spell-check.ts` | +| `MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts` | `use-editor-status.ts` | +| `MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx` | `use-markdown-commands.tsx` | +| `MarkdownPreview/MarkdownPreview.tsx` | `markdown-preview.tsx` | +| `MarkdownPreview/MermaidDiagram.tsx` | `mermaid-diagram.tsx` | +| `MarkdownPreview/remarkDecorations.tsx` | `remark-decorations.tsx` | +| `src/lib/rehypeCopyButton.ts` | `rehype-copy-button.ts` | +| `src/utils/generateUserColor.ts` | `generate-user-color.ts` | + +Fix barrels (`DocumentMain/index.ts`, `MarkdownEditor/index.ts`, `MarkdownPreview/index.ts`), sibling relative imports (MermaidDiagram in MarkdownPreview.tsx, EditorExtensions/Theme/KeyMap/spellCheck in MarkdownEditor.tsx, FieldWrapper-style relative refs, `markdown-preview.stories.tsx` import), and any `@/lib/rehypeCopyButton` / `@/utils/generateUserColor` call sites (grep first). + +Final sweep gate: `find client/src \( -name '*.ts' -o -name '*.tsx' \) | grep -E '/[A-Za-z]*[A-Z][A-Za-z]*(\.[a-z]+)*\.(ts|tsx)$'` must return nothing (middle-extension aware). + +Commit: `refactor(client): kebab-case DocumentMain subtree and util filenames` + +--- + +## Layer 3: `client-quality/c-rules` + +Create with: `gh stack add client-quality/c-rules` + +### Task 3.1: Naming rule flip + stale-ignore cleanup + +**Modify `client/eslint.config.js`:** + +```js +'check-file/filename-naming-convention': [ + 'error', + { + '**/*.{ts,tsx}': 'KEBAB_CASE', + }, + { + ignoreMiddleExtensions: true, + }, +], +``` + +Remove `src/shared/**` from `ignores` (line ~17) and `--ignore-pattern src/shared` from `lint:fix`/`lint:ci` scripts in `client/package.json` (directory does not exist). + +- [ ] Edits +- [ ] Verify: `pnpm --filter client lint` exits clean (proves Layer 2 completeness) +- [ ] Commit: `chore(client): enforce kebab-case filenames, drop stale shared ignores` + +### Task 3.2: Add eslint-plugin-jsdoc (warn) + +- [ ] `pnpm --filter client add -D eslint-plugin-jsdoc` +- [ ] Config additions: + +```js +import jsdoc from 'eslint-plugin-jsdoc'; +// plugins: { jsdoc } +// extends: jsdoc.configs['flat/recommended-typescript-flavor'] OR manual rules below +'jsdoc/require-jsdoc': ['warn', { + require: { FunctionDeclaration: true, ClassDeclaration: true, ArrowFunctionExpression: false, FunctionExpression: false }, + contexts: ['ExportNamedDeclaration > VariableDeclaration > VariableDeclarator'], + exemptEmptyFunctions: true, +}], +'jsdoc/require-param': 'off', +``` + +Pragmatic target: every exported component/hook/util gets a JSDoc block; params documented only when non-obvious. Interim `warn` flips to `error` in Task 4.5. + +- [ ] Verify: `pnpm lint` runs (warnings expected and acceptable until Layer 4 completes; `lint:ci` unaffected since root `pnpm lint` doesn't pass `--max-warnings 0`) +- [ ] Commit: `chore(client): add jsdoc lint rule (warn)` + +--- + +## Layer 4: `client-quality/d-seams` + +Create with: `gh stack add client-quality/d-seams`. Commits per batch; typecheck+build between each. + +### Task 4.1: Dead code deletion + +Delete commented refresh interceptor block (`client/src/lib/api.ts:11-17,27-55`). +Commit: `chore(client): remove dead token-refresh interceptor code` + +### Task 4.2: Relocate bare API fns out of hooks file + +Move `getJoinRequests` / `approveJoinRequest` / `rejectJoinRequest` from `hooks/use-join-requests.ts` into new `src/lib/join-requests-api.ts` (kebab, JSDoc'd); hook imports them internally. Grep for external callers first; update if any. +Commit: `refactor(client): split join-request api functions from hook` + +### Task 4.3: Additive error fields on data hooks + +For `use-document.ts`, `use-join-requests.ts`, `use-collaborators.ts`, `use-share-link.ts`: add `error: string | null` (set in catch, cleared on success) to state + return object. Purely additive — existing destructuring consumers unaffected. +Commit: `feat(client): expose error state from data hooks` + +### Task 4.4: JSDoc backfill (batches, one commit per batch) + +Order: ui primitives → Auth forms → layouts → context/auth → hooks → lib/utils → Dashboard features → DocumentPage features (~70 exports total). Each export gets a concise JSDoc block; props interface members documented when non-obvious; missing `tags: ['autodocs']` added to story metas on touch. + +Suggested batch commits: `docs(client): jsdoc — `. + +### Task 4.5: Flip jsdoc rule to error + +Change `jsdoc/require-jsdoc` severity `warn` → `error`. +- [ ] `pnpm lint` fully clean +- [ ] Commit: `chore(client): enforce jsdoc rule` + +### Task 4.6 (D6 — ISOLATED behavioral commit): Provider seam in useCollab + +**Files:** Modify `hooks/use-collab.ts` ONLY. + +Add optional provider factory parameter; default constructs `HocuspocusProvider` with `env.Socket_URL` exactly as today. Zero caller changes required. + +```ts +import { HocuspocusProvider } from '@hocuspocus/provider'; +// ... +export interface CollabProviderFactory { + (options: { url: string; name: string; document: Y.Doc }): HocuspocusProvider; +} +const defaultProviderFactory: CollabProviderFactory = (opts) => + new HocuspocusProvider(opts); + +export function useCollab( + docId: string | undefined, + createProvider: CollabProviderFactory = defaultProviderFactory, +) { + // ... unchanged body, but construct via createProvider({ url: env.Socket_URL, name: docId, document: ydoc }) +} +``` + +Verification sequence (all must pass): +- [ ] `pnpm --filter client typecheck && pnpm --filter client build` +- [ ] `pnpm --filter client test` (existing stories green) +- [ ] `pnpm --filter client test:e2e -- --project=collaboration-specs` (auto-runs auth→setup→chromium incl. sharing/editor specs → collaboration specs) +- [ ] Commit alone: `refactor(client): injectable provider factory in useCollab` + +**Failure policy:** one evidence-backed retry max; otherwise drop D6 from scope (revert commit), MarkdownEditor story returns to deferred-residual status, Phase E proceeds without it. + +--- + +## Layer 5: `client-quality/e-stories` + +Create with: `gh stack add client-quality/e-stories`. Verify `pnpm --filter client test` after each batch. + +### Task 5.1: Decorators in `.storybook/preview.ts` + +```tsx +import type { Preview } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router'; +// mock auth context matching AuthContextType shape + +const preview: Preview = { + decorators: [ + (Story) => , + // ModalAncestor decorator: wraps story in root when story sets parameters.modalStory === true + ], +}; +``` + +Plus parameter-driven auth mock: decorator reading `parameters.auth` and supplying a matching context value (user/loading/isAuthenticated variants). Exact implementation follows the real `AuthContextType` interface read at execution time. +Commit: `test(client): storybook router/auth/modal decorators` + +### Task 5.2: Gap-fill stories (12) + +Each: folder-per-component story file, `tags:['autodocs']`, states listed, play() where noted. Data via props. + +| Component dir | States | play() | +|---|---|---| +| `ui/Spinner` | sizes, color inheritance | — | +| `ui/Seo` | render smoke + docs | — | +| `ui/Auth` login-form | default, validation errors | fill+submit asserts validation | +| `ui/Auth` register-form | same | same | +| 4× layouts | render with child outlet content | — | +| `ui/Form` Input | label, error, disabled | typing fires onChange | +| `common/NewDocumentFormBody` | default, validation errors | valid submit fires onSubmit | +| `Dashboard/…/DocumentCardDropdown` | closed/open items | open→select fires handler | +| `Dashboard/…/NewDocumentModal` | open, open-with-form | open→close esc/cancel | +| `DocumentPage/…/MarkdownStatusBar` | ready/saving states | — | +| `DocumentPage/…/MarkdownToolbar` | active/inactive tools | tool click invokes command | +| `DocumentPage/…/MarkdownEditor` | render smoke via offline provider adapter (contingent on D6 success; else deferred) | — | + +Also: add `NewDocumentModal/index.ts` barrel (sibling consistency). +Batch commits: `test(client): stories for ` + +### Task 5.3: Enrichment of existing 24 stories + +Recipe per component (worked exemplar — Button): + +```tsx +import { expect, fn, userEvent, within } from 'storybook/test'; + +const onClickFn = fn(); + +export const Loading: Story = { args: { isLoading: true, children: 'Saving' } }; +export const Disabled: Story = { args: { disabled: true } }; +export const AsChild: Story = { args: { asChild: true }, render: (args) => ( + +) }; +export const ClickBehavior: Story = { + args: { onClick: onClickFn, children: 'Click me' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button')); + await expect(onClickFn).toHaveBeenCalledTimes(1); + }, +}; +``` + +Application order & coverage targets: +1. Primitives: Button (above), Modal (open/close/esc play), Dropdown (open→select play), Toast (show/autohide), Alert (variants), Avatar (image-break fallback), Skeleton, ToggleGroup (select play) +2. Header (auth states via auth decorator) +3. Dashboard: dashboard-main (loading skeleton/populated/empty grid), document-row + document-grid-card (long-title truncation, menu), sort-control (selection play) +4. DocumentHeader cluster: title, toolbar, view-mode (toggle play), share-button (opens modal), options-dropdown, collaborators-dropdown, workspace-info, create-document-button (opens modal) +5. MarkdownPreview: fixture markdown covering code blocks, math, mermaid, sanitization cases + +Commits: `test(client): state coverage for ` + +--- + +## Layer 6: `client-quality/f-ci` + +Create with: `gh stack add client-quality/f-ci` + +### Task 6.1: Remove temporary CI branch filters + +Revert both workflow `branches:` lists to `[main, develop]`. +Commit: `ci: drop temporary client-quality branch triggers` + +### Task 6.2: AGENTS.md conventions section + +Add client section: folder-per-component layout, PascalCase dirs/kebab files, `autodocs` tag requirement, JSDoc-on-export policy (lint-enforced), story-state checklist, `pnpm --filter client test` command. +Commit: `docs: record client conventions in AGENTS.md` + +### Task 6.3: ADR 0001 — defer auth token consolidation + +Write `docs/adr/0001-defer-auth-token-consolidation.md`: context (token triplication across context/utils/api.defaults + wasLoggedOut flag + deleted dead interceptor), decision (deferred to dedicated initiative), consequences. +Commit: `docs: adr 0001 defer auth consolidation` + +### Task 6.4: Final gate + +`pnpm lint && pnpm typecheck && pnpm build && pnpm test` + `pnpm --filter client test:e2e` +Commit (if anything outstanding): `chore: client quality initiative complete` + +--- + +## Execution Handoff + +Run inline in this session (executing-plans style) given heavy local-verification coupling; dispatch subagents only for mechanical batches (JSDoc backfill, story enrichment) where context isolation helps. diff --git a/docs/superpowers/specs/2026-08-23-client-quality-design.md b/docs/superpowers/specs/2026-08-23-client-quality-design.md new file mode 100644 index 0000000..0c050b7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-client-quality-design.md @@ -0,0 +1,122 @@ +# Client Quality Initiative — Design Spec + +- **Date:** 2026-08-23 +- **Status:** Draft — awaiting user review before commit +- **Plan:** `docs/superpowers/plans/2026-08-23-client-quality.md` (written after spec approval) + +## 1. Problem + +The client (`client/`) is hard to work with: + +1. **Confusing file structure** — mixed casing conventions, misplaced shared components, inconsistent folder shapes +2. **Undocumented components** — zero JSDoc anywhere in `client/src` +3. **Insufficient Storybook coverage** — 12 component dirs have no story; existing stories lack state coverage (e.g., `Button` has an `isLoading` prop but no loading story) +4. **No component tests** — the `@storybook/addon-vitest` browser project is configured in `vitest.config.ts` but there is no `test` script; nothing runs it + +## 2. Goals / Non-goals + +**Goals:** one dedicated cleanup push that normalizes structure, enforces documentation via lint, brings every component into Storybook with real state coverage, makes stories executable as tests, and wires all of it into CI so it cannot regress. + +**Non-goals:** server-side changes; new runtime dependencies; MSW or network mocking; auth consolidation (deferred, see §9); hook unit-testing infrastructure beyond what stories provide; flipping a11y from `'todo'` to failing mode. + +## 3. Decisions Locked + +| Decision | Choice | Rationale | +|---|---|---| +| Effort shape | Dedicated cleanup push, phased | Conventions agreed once, mass migration once, lint keeps it honest | +| Test strategy | Storybook interaction tests (`play()` via addon-vitest browser project) | Infra already 90% wired; one artifact = docs + visual states + test; zero new deps | +| Structure scope | Full normalization | Mechanical `git mv`; partial fixes leave drift | +| File naming | kebab-case for all `.ts`/`.tsx` | Matches ~90% majority, shadcn heritage, existing story names; ESLint rule flips to match reality | +| Directory naming | PascalCase (unchanged) | Component identity matches exported symbol; dominant existing pattern | +| JSDoc policy | Lint-enforced (`eslint-plugin-jsdoc`) | Backfill is pointless if new code can skip it | +| Sequencing fix | Renames land **before** the KEBAB_CASE rule flips; JSDoc rule starts as `warn` | Rule-before-rename would go red mid-flight; `warn` interim because backfill hasn't happened | + +## 4. Target Conventions + +- Folder-per-component: `/{.tsx, index.ts, .stories.tsx}` — directories PascalCase, files kebab-case +- Every story meta gets `tags: ['autodocs']` so JSDoc renders as Storybook docs pages automatically +- Every interactive component gets state stories (loading/error/disabled/empty where applicable); interactive behavior gets `play()` assertions — stories are the component tests +- Every export carries JSDoc (components, hooks, lib/utils functions) +- Composite kits (e.g., `ui/Form`) may hold internal parts flat inside their folder when those parts have no external consumers; external API goes through the folder barrel + +## 5. Current-State Evidence + +- ~60 component `.tsx` files; 24 story files; 12 dirs without stories (enumerated in §7 Phase E) +- Full uppercase-basename sweep found **29 violator files** for the kebab-case rule (all addressed in Phase B) +- `lib/__tests__/api.test.tsx` is empty (0 lines) and attached to no runner +- ESLint naming rule exists but is inert today (`button.tsx` passes; probe yields warning-level result only) +- CI (`lint-type-check.yml`) already runs root `pnpm lint` / `pnpm test`, so client rules/tests flow into CI automatically once scripts exist; only Playwright chromium install needs adding +- Playwright e2e project dependency chain: `auth-specs → setup → chromium → collaboration-specs → logout-specs` + +## 6. Architecture Findings Folded In + +Survey vocabulary: *module* = interface + implementation; *seam* = where an interface lives; *locality* = change concentrated in one place. + +| # | Finding | Disposition | +|---|---|---| +| 1 | Auth/token state triplicated: React context + `utils/token` localStorage + mutated `api.defaults.headers`, plus hidden `wasLoggedOut` flag and a fully commented-out 401-refresh interceptor (`api.ts:27-55`) | **Deferred** to its own initiative; ADR stub written in Phase F. Dead interceptor deleted in Phase D. | +| 2 | All data hooks swallow errors (`console.error`, no `error` in return interface) — callers cannot render error states | **Folded in, minimal variant**: additive `error` field on hook returns during Phase D; enables real error-state stories in Phase E | +| 3 | `useCollab.ts:18-22` constructs `HocuspocusProvider` inline — no seam; blocks any editor story without a live websocket | **Folded in as Task D6** (isolated behavioral commit + targeted e2e gate); un-defers the MarkdownEditor story | +| 4 | Misc: bare API fns mixed into `useJoinRequests.ts`; `useAutoSave` interval-resets-on-edit semantics undocumented; `NewDocumentFormBody` renders Radix `ModalContent` needing a `` ancestor in stories | Folded into Phases D/E respectively | + +## 7. Phase Plan + +### Phase A — Non-breaking infra +- **A1:** `"test": "vitest run"` in `client/package.json`; root `test:client`; local `playwright install chromium`; verify 24 existing stories pass +- **A2:** Delete empty `lib/__tests__/api.test.tsx` +- **A3:** Plop templates emit JSDoc stub, `autodocs` tag, typed meta, commented `play()` example + +### Phase B — Structural normalization (all `git mv`) +- **B1:** Layouts → folder-per-component (`AuthLayout/auth-layout.tsx` + index, etc.) +- **B2:** `context/auth`: `auth-context.tsx`, `auth-provider.tsx`, `use-auth.tsx` +- **B3:** ui oddballs: `seo/`→`Seo/` (+`Head.tsx`→`head.tsx`+index), `auth/`→`Auth/`, `Header.stories.tsx`→`header.stories.tsx`; **dissolve `Form/Input/`** — `input-field.tsx`→`Form/input.tsx`, `variants.ts` up, `Form/index.ts` created, 3 external imports updated (evidence: only `Input` crosses the seam externally) +- **B4:** `DashBoardMain`→`DashboardMain`; `common/forms/NewDocumentFormBody.tsx`→`common/NewDocumentFormBody/new-document-form-body.tsx` (**drop single-child `forms/` layer**; stays shared — used by Dashboard modal AND DocumentPage CreateDocumentButton, feature move would violate isolation lint) +- **B5:** Hooks renames (7): `use-auto-save`, `use-collab`, `use-collaborators`, `use-document`, `use-join-requests`, `use-media-query`, `use-share-link`; 6 verified import sites across 4 files. No hook-name exemption: B2 already renames `useAuth.tsx`; function names keep `useX` casing (what react-hooks lint tracks) +- **B6:** DocumentMain subtree (11 files): `document-main.tsx`, `markdown-editor.tsx`, `editor-extensions.ts`, `editor-theme.ts`, `key-map-extension.ts`, `spell-check.ts`, `use-editor-status.ts`, `use-markdown-commands.tsx`, `markdown-preview.tsx`, `mermaid-diagram.tsx`, `remark-decorations.tsx`; leaves: `rehype-copy-button.ts`, `generate-user-color.ts`. External consumers go through barrels — churn confined to barrels/siblings/one story import +- Each task verified: typecheck + build; batch commits + +### Phase C — Enforcement rules +- **C1:** Naming rule → `'**/*.{ts,tsx}': 'KEBAB_CASE'` (now passes post-B); remove stale `src/shared` ignores (dir doesn't exist). Dir convention documented, not linted +- **C2:** Add `eslint-plugin-jsdoc` (sole new dev dep), `require-jsdoc` as **`warn`**, exports-focused, `require-param` off (types self-document) + +### Phase D — Documentation + seam fixes +- JSDoc backfill batches (commit per batch): ui → auth forms → layouts → context/hooks → lib/utils → features (~70 exports); missing `autodocs` tags added on touch +- Dead refresh interceptor deleted (`api.ts`); bare API fns relocated from `useJoinRequests.ts` to `lib/`; `error` fields added to data hooks (additive, non-breaking) +- **D6 (isolated, last):** injectable provider factory param on `useCollab(docId, createProvider?)`, default preserves current behavior exactly. Verify: typecheck + build + `pnpm --filter client test` + `pnpm --filter client test:e2e -- --project=collaboration-specs` (dependency chain auto-covers setup/chromium/sharing/editor/collaboration specs). Own commit; **failure policy:** one evidenced retry max, else D6 drops from scope and the MarkdownEditor story returns to deferred-residual — the initiative never blocks on it +- End of phase: flip `jsdoc/require-jsdoc` to `error` + +### Phase E — Stories & interaction tests +- Groundwork decorators in `preview.ts`: global `MemoryRouter` (harmless to non-router stories), parameter-driven mock auth context, `Modal` ancestor wrapper for form-body stories +- Gap-fill (12): Spinner (sizes), Seo (smoke), login/register forms (validation states + submit play), 4× layouts, Form/Input (label/error/disabled + typing play), NewDocumentFormBody (validation + submit play), DocumentCardDropdown (open/select play), NewDocumentModal (open/close play), NewDocumentModal gains its `index.ts` +- MarkdownEditor story now feasible post-D6 via offline provider adapter; StatusBar/Toolbar get full state coverage regardless +- Enrichment of existing 24: enumerate cva variants → one story each; boolean props → state story each; interactions → play() with `fn()` args + `within(canvasElement)` asserts. Fully worked exemplar: Button (Loading/Disabled/AsChild/ClickBehavior). Priority: primitives → Dashboard cards/menus → DocumentHeader cluster → MarkdownPreview fixtures (code/math/mermaid) +- Verify each batch: `pnpm --filter client test` green + +### Phase F — CI, docs, records +- `lint-type-check.yml`: add Playwright chromium install step before "Run tests" +- AGENTS.md: client conventions section (folder shape, casing, autodocs, JSDoc policy, story checklist, test command) +- Write `docs/adr/0001-defer-auth-token-consolidation.md` recording finding #1 + deferral rationale +- Full gate: `pnpm lint && pnpm typecheck && pnpm build && pnpm test` + `pnpm --filter client test:e2e` + +## 8. Verification Gates & Failure Policies + +- Every phase ends green: lint + typecheck + build (tests from Phase A onward) +- Renames never precede rule flips; rules never exceed current compliance level (naming after B; jsdoc `error` only after backfill) +- D6 is the only runtime-behavioral commit in Phase D and carries its own e2e gate + drop-out policy +- Existing Playwright e2e must stay green throughout; suites select by role/text, not filenames, so renames shouldn't touch them + +## 9. Residuals (explicit) + +- Auth token consolidation — deferred, recorded in ADR 0001 +- Hook internals (autosave timing, collab lifecycle) documented but not logic-tested under the storybook-only strategy; a node vitest project remains a cheap future add +- MarkdownEditor story contingent on D6 success (else deferred again) +- a11y stays `'todo'`; strictness flip is future work after violations triage + +## 10. Risks + +| Risk | Mitigation | +|---|---| +| Rename churn vs open branches | Land early on clean branch; phases ship as separate PR-sized chunks | +| Vitest browser mode needs chromium locally | One-time `playwright install chromium`; documented in AGENTS.md | +| D6 regression in collab path | Isolated commit, targeted e2e project run, explicit drop-out policy | +| Scope creep beyond four complaints | §6 table bounds every architecture fold-in; anything else requires its own spec | diff --git a/package.json b/package.json index 0929a0c..74c5d19 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "typecheck": "run-p typecheck:*", "typecheck:client": "pnpm --filter client typecheck", "typecheck:server": "pnpm --filter server typecheck", - "test": "run-p test:*", + "test": "run-s test:*", + "test:client": "pnpm --filter client test", "test:server": "pnpm --filter server test", "format": "pnpm --filter server lint:fix", "prepare": "husky" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a3a528..567676b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-jsdoc: + specifier: ^64.2.0 + version: 64.2.0(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-prettier: specifier: ^5.5.1 version: 5.5.3(eslint-config-prettier@9.1.2(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1))(prettier@3.6.2) @@ -346,6 +349,9 @@ importers: express-async-handler: specifier: ^1.2.0 version: 1.2.0 + express-rate-limit: + specifier: ^8.6.2 + version: 8.6.2(express@4.21.2) express-ws: specifier: ^5.0.2 version: 5.0.2(express@4.21.2) @@ -696,6 +702,14 @@ packages: '@emnapi/wasi-threads@1.0.4': resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@es-joy/jsdoccomment@0.95.0': + resolution: {integrity: sha512-jbzwtRPuw1Nzld0lacvr1vwzPU/6zEHFGK5YOTstl2MQYMZBuKmSW3HMuEwsq1v4meK5Do4lizxyd/hi25rCZg==} + engines: {node: ^22.22.2 || >=24.15.0} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.23.1': resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} engines: {node: '>=18'} @@ -1801,6 +1815,10 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2158,6 +2176,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} @@ -2319,6 +2340,10 @@ packages: resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.38.0': resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2527,6 +2552,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + aggregate-error@4.0.1: resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==} engines: {node: '>=12'} @@ -2570,6 +2600,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2896,6 +2930,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -3172,6 +3210,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -3467,6 +3514,12 @@ packages: '@typescript-eslint/parser': optional: true + eslint-plugin-jsdoc@64.2.0: + resolution: {integrity: sha512-z3zGmJoPhOdKnxzQ3R+8MZeJjW8vrRW8r7sYp/ErBp8K9+05RIa6vWXtbEGr7D1obBHk64LdKyLHoMLSzHFINA==} + engines: {node: ^22.22.2 || >=24.15.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-prettier@5.5.3: resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3516,6 +3569,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.32.0: resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3530,6 +3587,10 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -3539,6 +3600,10 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -3582,6 +3647,12 @@ packages: express-async-handler@1.2.0: resolution: {integrity: sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==} + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express-ws@5.0.2: resolution: {integrity: sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==} engines: {node: '>=4.5.0'} @@ -3942,6 +4013,9 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -4043,6 +4117,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4307,6 +4385,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdoc-type-pratt-parser@9.1.1: + resolution: {integrity: sha512-kojSbQb9iQM2bk2PmHiKa3reG0U4v2gN9U8D8+eCQq+4KM5ZXBUyBVeF8KmR0RiwqyrW4+uVWYWTOLnpsavnkw==} + engines: {node: ^22.22.2 || >=24.15.0} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4917,6 +4999,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -5047,6 +5132,9 @@ packages: resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==} engines: {node: '>=0.8'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -5055,6 +5143,9 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -5426,6 +5517,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-dir@1.0.1: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} engines: {node: '>=0.10.0'} @@ -5527,6 +5622,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -5658,6 +5758,9 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + spdx-license-ids@3.0.21: resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} @@ -5879,6 +5982,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -6486,7 +6593,7 @@ snapshots: '@babel/parser': 7.28.0 '@babel/template': 7.27.2 '@babel/types': 7.28.2 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6780,6 +6887,16 @@ snapshots: tslib: 2.8.1 optional: true + '@es-joy/jsdoccomment@0.95.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.67.0 + comment-parser: 1.4.8 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 9.1.1 + + '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.23.1': optional: true @@ -7742,6 +7859,8 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@standard-schema/utils@0.3.0': {} @@ -8133,6 +8252,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.6': dependencies: '@types/node': 22.16.5 @@ -8302,7 +8423,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) '@typescript-eslint/types': 8.38.0 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -8321,7 +8442,7 @@ snapshots: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) '@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 eslint: 9.32.0(jiti@2.5.1) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 @@ -8330,6 +8451,8 @@ snapshots: '@typescript-eslint/types@8.38.0': {} + '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': dependencies: '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) @@ -8542,12 +8665,18 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-walk@8.3.4: dependencies: acorn: 8.15.0 acorn@8.15.0: {} + acorn@8.18.0: {} + aggregate-error@4.0.1: dependencies: clean-stack: 4.2.0 @@ -8589,6 +8718,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + are-docs-informative@0.1.1: {} + arg@4.1.3: {} argparse@1.0.10: @@ -8951,6 +9082,8 @@ snapshots: commander@8.3.0: {} + comment-parser@1.4.8: {} + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -9241,6 +9374,10 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -9467,7 +9604,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.23.1): dependencies: - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 esbuild: 0.23.1 transitivePeerDependencies: - supports-color @@ -9623,6 +9760,26 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-jsdoc@64.2.0(eslint@9.32.0(jiti@2.5.1)): + dependencies: + '@es-joy/jsdoccomment': 0.95.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 + debug: 4.4.3 + escape-string-regexp: 5.0.0 + eslint: 9.32.0(jiti@2.5.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-prettier@5.5.3(eslint-config-prettier@9.1.2(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1))(prettier@3.6.2): dependencies: eslint: 9.32.0(jiti@2.5.1) @@ -9662,6 +9819,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.32.0(jiti@2.5.1): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1)) @@ -9710,12 +9869,22 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} esquery@1.6.0: dependencies: estraverse: 5.3.0 + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -9756,6 +9925,14 @@ snapshots: express-async-handler@1.2.0: {} + express-rate-limit@8.6.2(express@4.21.2): + dependencies: + debug: 4.4.3 + express: 4.21.2 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + express-ws@5.0.2(express@4.21.2): dependencies: express: 4.21.2 @@ -10242,6 +10419,8 @@ snapshots: hosted-git-info@2.8.9: {} + html-entities@2.6.0: {} + html-escaper@2.0.2: {} html-url-attributes@3.0.1: {} @@ -10333,6 +10512,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + ip-address@10.5.0: {} + ipaddr.js@1.9.1: {} is-absolute@1.0.0: @@ -10565,6 +10746,10 @@ snapshots: dependencies: argparse: 2.0.1 + jsdoc-type-pratt-parser@9.1.1: + dependencies: + '@types/estree': 1.0.9 + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -11239,7 +11424,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@5.5.0) + debug: 4.4.3 decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -11401,6 +11586,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -11576,6 +11763,10 @@ snapshots: map-cache: 0.2.2 path-root: 0.1.1 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + parse-json@4.0.0: dependencies: error-ex: 1.3.2 @@ -11583,6 +11774,8 @@ snapshots: parse-passwd@1.0.0: {} + parse-statements@1.0.11: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -12007,6 +12200,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + reserved-identifiers@1.2.0: {} + resolve-dir@1.0.1: dependencies: expand-tilde: 2.0.2 @@ -12122,6 +12317,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -12286,6 +12483,11 @@ snapshots: spdx-exceptions: 2.5.0 spdx-license-ids: 3.0.21 + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.21 + spdx-license-ids@3.0.21: {} sprintf-js@1.0.3: {} @@ -12530,6 +12732,11 @@ snapshots: dependencies: is-number: 7.0.0 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + toidentifier@1.0.1: {} totalist@3.0.1: {} diff --git a/server/package.json b/server/package.json index 2a835e9..3ba70b7 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "dotenv": "^16.4.7", "express": "^4.21.2", "express-async-handler": "^1.2.0", + "express-rate-limit": "^8.6.2", "express-ws": "^5.0.2", "helmet": "^7.2.0", "http-status-codes": "^2.3.0", diff --git a/server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql b/server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql new file mode 100644 index 0000000..138db62 --- /dev/null +++ b/server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "collaboration_requests" DROP CONSTRAINT "collaboration_requests_documentId_fkey"; + +-- DropForeignKey +ALTER TABLE "collaborators" DROP CONSTRAINT "collaborators_documentId_fkey"; + +-- AddForeignKey +ALTER TABLE "collaborators" ADD CONSTRAINT "collaborators_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "collaboration_requests" ADD CONSTRAINT "collaboration_requests_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8be423e..13a3117 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -46,7 +46,7 @@ model Document { model Collaborator { id String @id @default(uuid()) - document Document @relation(fields: [documentId], references: [id]) + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) documentId String user User @relation(fields: [userId], references: [id]) userId String @@ -60,7 +60,7 @@ model CollaborationRequest { id String @id @default(uuid()) user User @relation(fields: [userId], references: [id]) userId String - document Document @relation(fields: [documentId], references: [id]) + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) documentId String status String @default("pending") // pending | accepted | rejected permission String @default("edit") // "edit" or "view" diff --git a/server/scripts/seed-test-user.ts b/server/scripts/seed-test-user.ts index 92673b4..862a3da 100644 --- a/server/scripts/seed-test-user.ts +++ b/server/scripts/seed-test-user.ts @@ -18,12 +18,27 @@ async function main() { }, }); - // Start every e2e run with a clean dashboard for the test user + // Start every e2e run with a clean dashboard for the test user. + // Collaborator rows referencing the documents must go first or the + // document delete fails on the collaborators_documentId_fkey constraint. + const testDocs = await prisma.document.findMany({ + where: { authorId: user.id }, + select: { id: true }, + }); + const testDocIds = testDocs.map(d => d.id); + const { count: removedCollaborators } = await prisma.collaborator.deleteMany({ + where: { documentId: { in: testDocIds } }, + }); + const { count: removedRequests } = await prisma.collaborationRequest.deleteMany({ + where: { documentId: { in: testDocIds } }, + }); const { count } = await prisma.document.deleteMany({ where: { authorId: user.id }, }); - if (count > 0) { - console.log(`🧹 Removed ${count} leftover test document(s)`); + if (count > 0 || removedCollaborators > 0 || removedRequests > 0) { + console.log( + `🧹 Removed ${count} leftover test document(s), ${removedCollaborators} collaborator link(s) and ${removedRequests} collaboration request(s)` + ); } // Remove throwaway accounts created by the registration specs in past runs diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index 1867b3c..e958b23 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -1,17 +1,20 @@ -import chalk from 'chalk'; +import { Prisma } from '@prisma/client'; import { Response } from 'express'; import asyncErrorWrapper from 'express-async-handler'; import { StatusCodes } from 'http-status-codes'; +import { ConflictError } from '@/exceptions/ConflictError'; +import { NotFoundError } from '@/exceptions/NotFoundError'; import { logger } from '@/lib/logger'; import { prisma } from '@/lib/prisma'; import { generateShareToken, verifyShareToken } from '@/lib/shareToken'; import { getClientInfo } from '@/utils/getClientInfo'; +import { AddCollaboratorSchema } from '@/validations/addCollaborator.schema'; export const createDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => { const clientInfo = getClientInfo(req); const userId = req.user?.userId; - const { title, content = '', isPublic = false } = req.body; + const { title, isPublic = false } = req.body; logger.debug('Document creation attempt', { action: 'CREATE_DOCUMENT_ATTEMPT', @@ -19,14 +22,15 @@ export const createDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res userId, title, isPublic, - contentLength: content.length, }); try { + // `content` starts empty and is owned by Yjs sync afterwards (issue #47); + // accepting it here would store text no reader ever sees. const newDoc = await prisma.document.create({ data: { title, - content, + content: '', isPublic, authorId: req.user?.userId, }, @@ -191,18 +195,7 @@ export const updateDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res const clientInfo = getClientInfo(req); const userId = req.user?.userId; const documentId = req.params.id; - const { title, content, isPublic } = req.body; - - // logger.info('Document update attempt', { - // action: 'UPDATE_DOCUMENT_ATTEMPT', - // ...clientInfo, - // userId, - // documentId, - // title, - // isPublic, - // contentLength: content?.length, - // }); - + const { title, isPublic } = req.body; try { const doc = await prisma.document.findFirst({ where: { id: documentId }, @@ -228,11 +221,12 @@ export const updateDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res return; } + // `content` is intentionally not writable here: it is a mirror of the + // Yjs document state maintained by dbPersistence.store (issue #47). const updatedDoc = await prisma.document.update({ where: { id: doc.id }, - data: { title, content, isPublic }, + data: { title, isPublic }, }); - // logger.info('Document updated successfully', { // action: 'UPDATE_DOCUMENT_SUCCESS', // ...clientInfo, @@ -404,7 +398,6 @@ export const getDocByToken = asyncErrorWrapper(async (req: AuthenticatedRequest, try { decoded = verifyShareToken(token); - console.log(chalk.bold.red('DECODED'), decoded); } catch (error) { logger.warn('Shared document access failed - token verification failed', { action: 'ACCESS_SHARED_DOCUMENT_TOKEN_VERIFICATION_FAILED', @@ -710,8 +703,44 @@ export const approveRequest = asyncErrorWrapper(async (req: AuthenticatedRequest } // Get the request to extract the requester user ID - const request = await prisma.collaborationRequest.update({ + const request = await prisma.collaborationRequest.findUnique({ where: { id: requestId }, + }); + + if (!request || request.documentId !== documentId) { + logger.warn('Collaboration request approval failed - request not found on document', { + action: 'APPROVE_COLLABORATION_REQUEST_NOT_FOUND', + ...clientInfo, + userId, + documentId, + requestId, + requestExists: !!request, + belongsToDocument: request?.documentId === documentId, + }); + + res.status(StatusCodes.NOT_FOUND).json({ error: 'Request not found' }); + + return; + } + + if (request.status !== 'pending') { + logger.warn('Collaboration request approval failed - request already decided', { + action: 'APPROVE_COLLABORATION_REQUEST_ALREADY_DECIDED', + ...clientInfo, + userId, + documentId, + requestId, + status: request.status, + }); + + res.status(StatusCodes.CONFLICT).json({ error: 'Request already decided' }); + + return; + } + + // Scoped update as defense-in-depth against races between read and write + await prisma.collaborationRequest.updateMany({ + where: { id: requestId, documentId, status: 'pending' }, data: { status: 'approved' }, }); @@ -784,7 +813,44 @@ export const rejectRequest = asyncErrorWrapper(async (req: AuthenticatedRequest, return; } - await prisma.collaborationRequest.update({ where: { id: requestId }, data: { status: 'rejected' } }); + const request = await prisma.collaborationRequest.findUnique({ + where: { id: requestId }, + }); + + if (!request || request.documentId !== id) { + logger.warn('Collaboration request rejection failed - request not found on document', { + action: 'REJECT_COLLABORATION_REQUEST_NOT_FOUND', + ...clientInfo, + userId, + documentId: id, + requestId, + requestExists: !!request, + belongsToDocument: request?.documentId === id, + }); + + res.status(StatusCodes.NOT_FOUND).json({ error: 'Request not found' }); + return; + } + + if (request.status !== 'pending') { + logger.warn('Collaboration request rejection failed - request already decided', { + action: 'REJECT_COLLABORATION_REQUEST_ALREADY_DECIDED', + ...clientInfo, + userId, + documentId: id, + requestId, + status: request.status, + }); + + res.status(StatusCodes.CONFLICT).json({ error: 'Request already decided' }); + return; + } + + // Scoped update as defense-in-depth against races between read and write + await prisma.collaborationRequest.updateMany({ + where: { id: requestId, documentId: id, status: 'pending' }, + data: { status: 'rejected' }, + }); res.json({ message: 'Rejected' }); }); @@ -850,7 +916,7 @@ export const getCollaborators = asyncErrorWrapper(async (req: AuthenticatedReque export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => { const clientInfo = getClientInfo(req); const { id } = req.params; - const { userId: newCollaboratorId, permission } = req.body; + const { email, permission } = req.body as AddCollaboratorSchema; const ownerId = req.user?.userId; logger.debug('Add collaborator attempt', { @@ -858,7 +924,7 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques ...clientInfo, ownerId, documentId: id, - newCollaboratorId, + email, permission, }); @@ -871,7 +937,6 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques ...clientInfo, ownerId, documentId: id, - newCollaboratorId, documentExists: !!doc, isOwner: doc?.authorId === ownerId, }); @@ -880,16 +945,65 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques return; } - const collab = await prisma.collaborator.create({ - data: { documentId: id, userId: newCollaboratorId, permission }, + const user = await prisma.user.findUnique({ where: { email } }); + + if (!user) { + logger.warn('Add collaborator failed - unknown email', { + action: 'ADD_COLLABORATOR_UNKNOWN_EMAIL', + ...clientInfo, + ownerId, + documentId: id, + }); + + throw new NotFoundError('No user found with that email'); + } + + const existing = await prisma.collaborator.findUnique({ + where: { documentId_userId: { documentId: id, userId: user.id } }, }); + if (existing) { + logger.warn('Add collaborator failed - already a collaborator', { + action: 'ADD_COLLABORATOR_DUPLICATE', + ...clientInfo, + ownerId, + documentId: id, + newCollaboratorId: user.id, + }); + + throw new ConflictError('User is already a collaborator on this document'); + } + + let collab; + + try { + collab = await prisma.collaborator.create({ + data: { documentId: id, userId: user.id, permission }, + }); + } catch (error) { + // Safety net for the check-then-create race above: two concurrent adds + // can both pass the pre-check and the loser hits the unique constraint. + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + logger.warn('Add collaborator failed - concurrent duplicate insert', { + action: 'ADD_COLLABORATOR_RACE_DUPLICATE', + ...clientInfo, + ownerId, + documentId: id, + newCollaboratorId: user.id, + }); + + throw new ConflictError('User is already a collaborator on this document'); + } + + throw error; + } + logger.debug('Collaborator added successfully', { action: 'ADD_COLLABORATOR_SUCCESS', ...clientInfo, ownerId, documentId: id, - newCollaboratorId, + newCollaboratorId: user.id, permission, collaboratorId: collab.id, }); @@ -901,7 +1015,6 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques ...clientInfo, ownerId, documentId: id, - newCollaboratorId, permission, error: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined, @@ -962,8 +1075,6 @@ export const removeCollaborator = asyncErrorWrapper(async (req: AuthenticatedReq }, }); - console.log(chalk.red('HERE'), result, collaboratorId, id); - logger.debug('Collaborator removed successfully', { action: 'REMOVE_COLLABORATOR_SUCCESS', ...clientInfo, diff --git a/server/src/exceptions/ConflictError.ts b/server/src/exceptions/ConflictError.ts new file mode 100644 index 0000000..e6f39c7 --- /dev/null +++ b/server/src/exceptions/ConflictError.ts @@ -0,0 +1,9 @@ +import { StatusCodes } from 'http-status-codes'; + +import { AppError } from './AppError'; + +export class ConflictError extends AppError { + constructor(message?: string) { + super(StatusCodes.CONFLICT, message ?? 'Conflict'); + } +} diff --git a/server/src/lib/dbPersistence.ts b/server/src/lib/dbPersistence.ts index 05bc50e..13fabe3 100644 --- a/server/src/lib/dbPersistence.ts +++ b/server/src/lib/dbPersistence.ts @@ -54,35 +54,38 @@ export const dbPersistence = new Database({ }); if (existing) { - await prisma.yjsDocumentState.update({ - where: { documentId: id }, - data: { - state: Buffer.from(state), - version: { increment: 1 }, - }, - }); - - await prisma.document.update({ - where: { id: id }, - data: { content: plainText }, - }); + // Snapshot and its plaintext mirror must stay consistent: write both atomically. + await prisma.$transaction([ + prisma.yjsDocumentState.update({ + where: { documentId: id }, + data: { + state: Buffer.from(state), + version: { increment: 1 }, + }, + }), + prisma.document.update({ + where: { id: id }, + data: { content: plainText }, + }), + ]); } else { const documentExists = await prisma.document.findFirst({ where: { id: id }, }); if (documentExists) { - await prisma.yjsDocumentState.create({ - data: { - documentId: documentExists.id, - state: Buffer.from(state), - }, - }); - - await prisma.document.update({ - where: { id: documentExists.id }, - data: { content: plainText }, - }); + await prisma.$transaction([ + prisma.yjsDocumentState.create({ + data: { + documentId: documentExists.id, + state: Buffer.from(state), + }, + }), + prisma.document.update({ + where: { id: documentExists.id }, + data: { content: plainText }, + }), + ]); } else { logger.warn(`No Document found for ID prefix: ${documentName}`, { action: 'DB_STORE_DOC_NOT_FOUND', diff --git a/server/src/middlewares/rate-limit.middleware.ts b/server/src/middlewares/rate-limit.middleware.ts new file mode 100644 index 0000000..e7de846 --- /dev/null +++ b/server/src/middlewares/rate-limit.middleware.ts @@ -0,0 +1,34 @@ +import { type Options, rateLimit } from 'express-rate-limit'; + +import { logger } from '@/lib/logger'; + +export const AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; +export const AUTH_RATE_LIMIT_MAX = 10; + +/** + * Builds a rate limiter for the auth endpoints (register/login/refresh). + * + * @param overrides Partial express-rate-limit options; used by tests to shrink the window/limit. + */ +export const createAuthRateLimiter = (overrides: Partial = {}) => + rateLimit({ + windowMs: AUTH_RATE_LIMIT_WINDOW_MS, + limit: AUTH_RATE_LIMIT_MAX, + standardHeaders: 'draft-7', + legacyHeaders: false, + // The limiter is a production safeguard only: in development/E2E the + // Playwright suite performs many logins from one IP and would trip it. + skip: () => process.env.NODE_ENV !== 'production', + handler: (req, res, _next, options) => { + logger.warn('Auth rate limit exceeded', { + action: 'AUTH_RATE_LIMIT_EXCEEDED', + ip: req.ip, + path: req.originalUrl, + }); + res.status(options.statusCode).json({ error: 'Too many requests, please try again later.' }); + }, + ...overrides, + }); + +/** Shared limiter instance applied to the unauthenticated auth endpoints. */ +export const authLimiter = createAuthRateLimiter(); diff --git a/server/src/routers/auth.router.ts b/server/src/routers/auth.router.ts index 0eb2ee3..40b8c5f 100644 --- a/server/src/routers/auth.router.ts +++ b/server/src/routers/auth.router.ts @@ -2,13 +2,14 @@ import express from 'express'; import { loginUser, logoutUser, refreshToken, registerUser } from '@/controllers/auth.controller'; import { authenticate, validateRefreshToken } from '@/middlewares/auth.middleware'; +import { authLimiter } from '@/middlewares/rate-limit.middleware'; import { validate } from '@/middlewares/validation.middleware'; import { LoginUserSchema } from '@/validations/login.schema'; import { RegisterUserSchema } from '@/validations/register.schema'; export const authRouter = express.Router(); -authRouter.post('/register', validate({ body: RegisterUserSchema }), registerUser); -authRouter.post('/login', validate({ body: LoginUserSchema }), loginUser); +authRouter.post('/register', authLimiter, validate({ body: RegisterUserSchema }), registerUser); +authRouter.post('/login', authLimiter, validate({ body: LoginUserSchema }), loginUser); authRouter.post('/logout', authenticate, logoutUser); -authRouter.post('/refresh', validateRefreshToken, refreshToken); +authRouter.post('/refresh', authLimiter, validateRefreshToken, refreshToken); diff --git a/server/src/routers/document.router.ts b/server/src/routers/document.router.ts index 4daa8ee..c7bdddd 100644 --- a/server/src/routers/document.router.ts +++ b/server/src/routers/document.router.ts @@ -17,6 +17,8 @@ import { updateDocSettings, } from '@/controllers/document.controller'; import { authenticate } from '@/middlewares/auth.middleware'; +import { validate } from '@/middlewares/validation.middleware'; +import { AddCollaboratorSchema } from '@/validations/addCollaborator.schema'; export const docRouter = express.Router(); @@ -34,7 +36,7 @@ docRouter.patch('/:id/settings', updateDocSettings); // Used to toggle allowSelf docRouter.get('/:id/share-link', getShareLink); // get the document share link with the share token docRouter.get('/:id/collaborators', getCollaborators); // returns list -docRouter.post('/:id/collaborators', addCollaborator); // adds a new one //!Owner only access +docRouter.post('/:id/collaborators', validate({ body: AddCollaboratorSchema }), addCollaborator); // adds a new one by email //!Owner only access docRouter.delete('/:id/collaborators/:userId', removeCollaborator); // optional docRouter.get('/:id/requests', getRequests); // !Owner only access diff --git a/server/src/utils/getClientInfo.ts b/server/src/utils/getClientInfo.ts index 14e7740..ae9746d 100644 --- a/server/src/utils/getClientInfo.ts +++ b/server/src/utils/getClientInfo.ts @@ -7,7 +7,6 @@ export const getClientInfo = (req: Request) => { ip = ip.replace('::ffff:', ''); } - //console.log(ip); return { ip, userAgent: req.get('User-Agent'), diff --git a/server/src/validations/addCollaborator.schema.ts b/server/src/validations/addCollaborator.schema.ts new file mode 100644 index 0000000..28fdb76 --- /dev/null +++ b/server/src/validations/addCollaborator.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const AddCollaboratorSchema = z.object({ + email: z + .string() + .email() + .transform(email => email.trim().toLowerCase()), + permission: z.enum(['edit', 'view']).default('edit'), +}); + +export type AddCollaboratorSchema = z.infer; diff --git a/server/src/validations/login.schema.ts b/server/src/validations/login.schema.ts index 098cd79..a852fe5 100644 --- a/server/src/validations/login.schema.ts +++ b/server/src/validations/login.schema.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; export const LoginUserSchema = z.object({ - email: z.string().email(), + email: z + .string() + .email() + .transform(email => email.trim().toLowerCase()), password: z.string().min(6), }); diff --git a/server/src/validations/register.schema.ts b/server/src/validations/register.schema.ts index dfe08e9..6895311 100644 --- a/server/src/validations/register.schema.ts +++ b/server/src/validations/register.schema.ts @@ -1,7 +1,10 @@ import { z } from 'zod'; export const RegisterUserSchema = z.object({ - email: z.string().email(), + email: z + .string() + .email() + .transform(email => email.trim().toLowerCase()), username: z.string().min(3), password: z.string().min(6), fullName: z.string().optional(), diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 4ff56bc..2b1847d 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -38,6 +38,24 @@ describe('Auth Routes', () => { expect(res.status).toBe(StatusCodes.CONFLICT); }); + it('should normalize email case on register and allow lowercase login', async () => { + const register = await request(app).post('/api/auth/register').send({ + email: 'MixedCase@Test.DEV', + username: 'mixedcase', + password: 'secure123', + }); + + expect(register.status).toBe(StatusCodes.CREATED); + + const res = await request(app).post('/api/auth/login').send({ + email: 'mixedcase@test.dev', + password: 'secure123', + }); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body.user.email).toBe('mixedcase@test.dev'); + }); + it('should login with valid credentials and receive cookies', async () => { await request(app).post('/api/auth/register').send({ email: 'test@test.dev', diff --git a/server/test/document.test.ts b/server/test/document.test.ts index 072ba37..41e3927 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { StatusCodes } from 'http-status-codes'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -95,6 +96,24 @@ describe('Document Routes', () => { expect(res.body.title).toBe('Updated Title'); }); + it('should not overwrite document content via REST update (Yjs is the source of truth)', async () => { + const created = await prisma.document.create({ + data: { title: 'Synced Doc', content: 'yjs-derived-content', authorId: userId }, + }); + + const res = await request(app) + .put(`/api/document/${created.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Renamed', content: 'rest-overwrite-attempt' }); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body.title).toBe('Renamed'); + expect(res.body.content).toBe('yjs-derived-content'); + + const doc = await prisma.document.findUniqueOrThrow({ where: { id: created.id } }); + expect(doc.content).toBe('yjs-derived-content'); + }); + it('should delete a document', async () => { const created = await prisma.document.create({ data: { @@ -108,6 +127,39 @@ describe('Document Routes', () => { expect(res.status).toBe(StatusCodes.NO_CONTENT); }); + it('should delete a document that has collaborators and join requests', async () => { + const created = await prisma.document.create({ + data: { + title: 'Shared ToDelete', + authorId: userId, + content: '', + }, + }); + + const otherUser = await prisma.user.create({ + data: { + email: 'shared-collab@test.dev', + username: 'sharedcollab', + password: 'hashedpass', + }, + }); + + await prisma.collaborator.create({ + data: { documentId: created.id, userId: otherUser.id }, + }); + + await prisma.collaborationRequest.create({ + data: { documentId: created.id, userId: otherUser.id }, + }); + + const res = await request(app).delete(`/api/document/${created.id}`).set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(StatusCodes.NO_CONTENT); + + expect(await prisma.document.findUnique({ where: { id: created.id } })).toBeNull(); + expect(await prisma.collaborator.count({ where: { documentId: created.id } })).toBe(0); + expect(await prisma.collaborationRequest.count({ where: { documentId: created.id } })).toBe(0); + }); + it('should update document settings (allowSelfJoin)', async () => { const created = await prisma.document.create({ data: { @@ -233,9 +285,11 @@ describe('Document Routes', () => { const addRes = await request(app) .post(`/api/document/${doc.id}/collaborators`) .set('Authorization', `Bearer ${token}`) - .send({ userId: newUser.id, permission: 'edit' }); + .send({ email: newUser.email, permission: 'edit' }); expect(addRes.status).toBe(StatusCodes.OK); + expect(addRes.body.userId).toBe(newUser.id); + expect(addRes.body.permission).toBe('edit'); const collaboratorId = addRes.body.id; @@ -246,6 +300,146 @@ describe('Document Routes', () => { expect(removeRes.status).toBe(StatusCodes.OK); }); + it('should add a collaborator by email with default edit permission', async () => { + const doc = await prisma.document.create({ + data: { title: 'Default Permission', authorId: userId, content: '' }, + }); + + const newUser = await prisma.user.create({ + data: { + email: 'defaultperm@test.dev', + username: 'defaultperm', + password: 'hashedpass', + }, + }); + + const res = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: newUser.email }); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body.userId).toBe(newUser.id); + expect(res.body.permission).toBe('edit'); + }); + + it('should add a collaborator by email regardless of case', async () => { + const doc = await prisma.document.create({ + data: { title: 'Case Insensitive', authorId: userId, content: '' }, + }); + + const newUser = await prisma.user.create({ + data: { + email: 'caseinsensitive@test.dev', + username: 'caseinsensitive', + password: 'hashedpass', + }, + }); + + const res = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: 'CaseInsensitive@Test.DEV' }); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body.userId).toBe(newUser.id); + }); + + it('should return 404 when adding a collaborator with an unknown email', async () => { + const doc = await prisma.document.create({ + data: { title: 'Unknown Email', authorId: userId, content: '' }, + }); + + const res = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: 'nobody@test.dev' }); + + expect(res.status).toBe(StatusCodes.NOT_FOUND); + }); + + it('should return 409 when adding an existing collaborator', async () => { + const doc = await prisma.document.create({ + data: { title: 'Duplicate Collab', authorId: userId, content: '' }, + }); + + const newUser = await prisma.user.create({ + data: { + email: 'dupcollab@test.dev', + username: 'dupcollab', + password: 'hashedpass', + }, + }); + + const first = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: newUser.email }); + + expect(first.status).toBe(StatusCodes.OK); + + const second = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: newUser.email }); + + expect(second.status).toBe(StatusCodes.CONFLICT); + }); + + it('should return 400 when adding a collaborator with an invalid body', async () => { + const doc = await prisma.document.create({ + data: { title: 'Invalid Body', authorId: userId, content: '' }, + }); + + const res = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: 'not-an-email' }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should return 409 when a concurrent duplicate insert loses the race (P2002)', async () => { + const doc = await prisma.document.create({ + data: { title: 'Race Collab', authorId: userId, content: '' }, + }); + + const newUser = await prisma.user.create({ + data: { + email: 'racecollab@test.dev', + username: 'racecollab', + password: 'hashedpass', + }, + }); + + // Simulate the losing insert of two concurrent adds that both passed the + // pre-check: the unique constraint on (documentId, userId) rejects it. + const p2002 = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`documentId`,`userId`)', + { code: 'P2002', clientVersion: '6.12.0' } + ); + /* eslint-disable @typescript-eslint/no-explicit-any */ + const originalFindUnique = prisma.collaborator.findUnique; + const originalCreate = prisma.collaborator.create; + (prisma.collaborator as any).findUnique = async () => null; + (prisma.collaborator as any).create = async () => { + throw p2002; + }; + + try { + const res = await request(app) + .post(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${token}`) + .send({ email: newUser.email }); + + expect(res.status).toBe(StatusCodes.CONFLICT); + } finally { + (prisma.collaborator as any).findUnique = originalFindUnique; + (prisma.collaborator as any).create = originalCreate; + /* eslint-enable @typescript-eslint/no-explicit-any */ + } + }); + it('should forbid non-owners from listing collaborators', async () => { const doc = await prisma.document.create({ data: { title: 'Private Doc', authorId: userId, content: '' }, @@ -355,3 +549,107 @@ describe('Document Routes', () => { expect(res.status).toBe(StatusCodes.FORBIDDEN); }); }); + +describe('Collaboration request decision scoping (#46)', () => { + async function createOwnedDocument(title: string) { + return prisma.document.create({ + data: { title, authorId: userId, content: '' }, + }); + } + + async function createPendingRequest(documentId: string, suffix: string) { + const requester = await prisma.user.create({ + data: { + email: `requester-${suffix}@test.dev`, + username: `requester-${suffix}`, + password: 'hashedpw', + }, + }); + + return prisma.collaborationRequest.create({ + data: { userId: requester.id, documentId }, + }); + } + + it('should not approve a request belonging to a different document', async () => { + const otherDoc = await createOwnedDocument('Other Doc'); + const collabRequest = await createPendingRequest(otherDoc.id, 'a'); + const targetDoc = await createOwnedDocument('Target Doc'); + + const res = await request(app) + .post(`/api/document/${targetDoc.id}/requests/${collabRequest.id}/approve`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.NOT_FOUND); + + const untouched = await prisma.collaborationRequest.findUnique({ where: { id: collabRequest.id } }); + expect(untouched?.status).toBe('pending'); + }); + + it('should not reject a request belonging to a different document', async () => { + const otherDoc = await createOwnedDocument('Other Doc'); + const collabRequest = await createPendingRequest(otherDoc.id, 'b'); + const targetDoc = await createOwnedDocument('Target Doc'); + + const res = await request(app) + .delete(`/api/document/${targetDoc.id}/requests/${collabRequest.id}/reject`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.NOT_FOUND); + + const untouched = await prisma.collaborationRequest.findUnique({ where: { id: collabRequest.id } }); + expect(untouched?.status).toBe('pending'); + }); + + it('should return 404 when approving a nonexistent request', async () => { + const doc = await createOwnedDocument('Target Doc'); + + const res = await request(app) + .post(`/api/document/${doc.id}/requests/00000000-0000-4000-8000-000000000000/approve`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.NOT_FOUND); + }); + + it('should return 404 when rejecting a nonexistent request', async () => { + const doc = await createOwnedDocument('Target Doc'); + + const res = await request(app) + .delete(`/api/document/${doc.id}/requests/00000000-0000-4000-8000-000000000000/reject`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.NOT_FOUND); + }); + + it('should return 409 when approving an already-approved request', async () => { + const doc = await createOwnedDocument('Target Doc'); + const collabRequest = await createPendingRequest(doc.id, 'c'); + + const first = await request(app) + .post(`/api/document/${doc.id}/requests/${collabRequest.id}/approve`) + .set('Authorization', `Bearer ${token}`); + expect(first.status).toBe(StatusCodes.OK); + + const second = await request(app) + .post(`/api/document/${doc.id}/requests/${collabRequest.id}/approve`) + .set('Authorization', `Bearer ${token}`); + + expect(second.status).toBe(StatusCodes.CONFLICT); + }); + + it('should return 409 when rejecting an already-rejected request', async () => { + const doc = await createOwnedDocument('Target Doc'); + const collabRequest = await createPendingRequest(doc.id, 'd'); + + const first = await request(app) + .delete(`/api/document/${doc.id}/requests/${collabRequest.id}/reject`) + .set('Authorization', `Bearer ${token}`); + expect(first.status).toBe(StatusCodes.OK); + + const second = await request(app) + .delete(`/api/document/${doc.id}/requests/${collabRequest.id}/reject`) + .set('Authorization', `Bearer ${token}`); + + expect(second.status).toBe(StatusCodes.CONFLICT); + }); +}); diff --git a/server/test/rateLimit.test.ts b/server/test/rateLimit.test.ts new file mode 100644 index 0000000..63de4e8 --- /dev/null +++ b/server/test/rateLimit.test.ts @@ -0,0 +1,39 @@ +import express from 'express'; +import { StatusCodes } from 'http-status-codes'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { createAuthRateLimiter } from '@/middlewares/rate-limit.middleware'; + +const buildApp = () => { + const app = express(); + app.use(express.json()); + // The shared limiter skips outside production (vitest runs with NODE_ENV=test), + // so the tests force it on via the factory's overrides. + app.post('/login', createAuthRateLimiter({ limit: 3, windowMs: 60_000, skip: () => false }), (_req, res) => { + res.status(StatusCodes.OK).json({ ok: true }); + }); + return app; +}; + +describe('auth rate limiter', () => { + it('allows requests up to the limit and returns 429 afterwards', async () => { + const app = buildApp(); + + for (let i = 0; i < 3; i++) { + const res = await request(app).post('/login'); + expect(res.status).toBe(StatusCodes.OK); + } + + const limited = await request(app).post('/login'); + expect(limited.status).toBe(StatusCodes.TOO_MANY_REQUESTS); + expect(limited.body).toEqual({ error: 'Too many requests, please try again later.' }); + }); + + it('sets the standard RateLimit header', async () => { + const res = await request(buildApp()).post('/login'); + + expect(res.headers['ratelimit']).toBeDefined(); + expect(res.headers['ratelimit-policy']).toBeDefined(); + }); +});