From 633f2dc953c09561eff2654d83ad70cbe9c3d112 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:48:54 +0300 Subject: [PATCH 01/33] client quality/docs (#66) * docs: client quality initiative design spec * docs: client quality initiative implementation plan --- .../plans/2026-08-23-client-quality.md | 468 ++++++++++++++++++ .../specs/2026-08-23-client-quality-design.md | 122 +++++ 2 files changed, 590 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-23-client-quality.md create mode 100644 docs/superpowers/specs/2026-08-23-client-quality-design.md 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 | From 47ef48c80ed004beedd35d4c8c9e640533814460 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:48:54 +0300 Subject: [PATCH 02/33] client quality/a infra (#67) * ci: trigger workflows for stacked client-quality branches * chore(client): wire vitest browser test runner * chore(client): remove empty orphaned test file * chore(client): scaffold components with autodocs and jsdoc stubs * ci: install playwright chromium before running client tests * ci: serialize root tests and disable client story file parallelism --- .github/workflows/e2e.yml | 4 ++-- .github/workflows/lint-type-check.yml | 7 +++++-- .../component/component.stories.tsx.hbs | 19 +++++++++++++++++-- client/generators/component/component.tsx.hbs | 3 +++ client/package.json | 1 + client/src/lib/__tests__/api.test.tsx | 0 client/vite.config.ts | 3 +++ package.json | 3 ++- 8 files changed, 33 insertions(+), 7 deletions(-) delete mode 100644 client/src/lib/__tests__/api.test.tsx diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cd41b74..4c7a8bb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -2,9 +2,9 @@ name: E2E on: push: - branches: [main, develop] + branches: [main, develop, 'client-quality/**'] pull_request: - branches: [main, develop] + branches: [main, develop, 'client-quality/**'] workflow_dispatch: jobs: diff --git a/.github/workflows/lint-type-check.yml b/.github/workflows/lint-type-check.yml index a352e15..639f923 100644 --- a/.github/workflows/lint-type-check.yml +++ b/.github/workflows/lint-type-check.yml @@ -2,9 +2,9 @@ name: Lint, Type Check and Test on: push: - branches: [main, develop] + branches: [main, develop, 'client-quality/**'] pull_request: - branches: [main, develop] + branches: [main, develop, 'client-quality/**'] workflow_dispatch: jobs: @@ -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/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..d4772cf 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,7 @@ "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" 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/vite.config.ts b/client/vite.config.ts index 3d9104f..a6d7936 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -34,6 +34,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/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" From ca62e8ea0f3a0c5460c9c6fbfd70210fc7ce5113 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:48:55 +0300 Subject: [PATCH 03/33] client quality/b structure (#69) * refactor(client): folder-per-component for layouts * refactor(client): kebab-case auth context files * refactor(client): normalize ui directory casing and dissolve Form/Input * refactor(client): fix DashboardMain casing, relocate shared NewDocumentFormBody * refactor(client): kebab-case hook filenames * refactor(client): kebab-case DocumentMain subtree and util filenames --- client/src/app/routes/app/dashboard.tsx | 2 +- client/src/app/routes/app/document.tsx | 4 ++-- client/src/app/routes/auth/login.tsx | 2 +- client/src/app/routes/auth/register.tsx | 2 +- client/src/app/routes/landing.tsx | 2 +- .../src/components/common/NewDocumentFormBody/index.ts | 1 + .../new-document-form-body.tsx} | 2 +- .../{AuthLayout.tsx => AuthLayout/auth-layout.tsx} | 6 +++--- client/src/components/layouts/AuthLayout/index.ts | 1 + .../content-layout.tsx} | 2 +- client/src/components/layouts/ContentLayout/index.ts | 1 + .../dashboard-layout.tsx} | 4 ++-- client/src/components/layouts/DashboardLayout/index.ts | 1 + .../document-layout.tsx} | 2 +- client/src/components/layouts/DocumentLayout/index.ts | 1 + client/src/components/ui/{auth => Auth}/login-form.tsx | 2 +- .../src/components/ui/{auth => Auth}/register-form.tsx | 2 +- client/src/components/ui/Form/Input/index.ts | 1 - client/src/components/ui/Form/index.ts | 1 + client/src/components/ui/Form/input.stories.tsx | 2 +- .../ui/Form/{Input/input-field.tsx => input.tsx} | 3 +-- client/src/components/ui/Form/{Input => }/variants.ts | 0 .../Header/{Header.stories.tsx => header.stories.tsx} | 0 .../src/components/ui/{seo/Head.tsx => Seo/head.tsx} | 0 client/src/components/ui/Seo/index.ts | 1 + client/src/components/ui/seo/index.ts | 1 - .../context/auth/{AuthContext.tsx => auth-context.tsx} | 0 .../auth/{AuthProvider.tsx => auth-provider.tsx} | 2 +- client/src/context/auth/index.ts | 6 +++--- client/src/context/auth/{useAuth.tsx => use-auth.tsx} | 2 +- .../dashboard-main.stories.tsx | 0 .../dashboard-main.tsx | 0 .../dashboard-view-toggle.tsx | 0 .../{DashBoardMain => DashboardMain}/document-list.tsx | 0 .../document-section.tsx | 0 .../document-skeleton-loader.tsx | 0 .../{DashBoardMain => DashboardMain}/index.ts | 0 .../components/NewDocumentModal/new-document-modal.tsx | 2 +- .../CollaboratorsDropdown/collaborators-dropdown.tsx | 2 +- .../CreateDocumentButton/create-document-button.tsx | 2 +- .../DocumentHeader/ShareButton/share-button.tsx | 4 ++-- .../MarkdownStatusBar/markdown-status-bar.tsx | 2 +- .../{useEditorStatus.ts => use-editor-status.ts} | 0 .../MarkdownToolbar/markdown-toolbar.tsx | 2 +- ...eMarkdownCommands.tsx => use-markdown-commands.tsx} | 0 .../{EditorExtensions.ts => editor-extensions.ts} | 0 .../MarkdownEditor/{EditorTheme.ts => editor-theme.ts} | 0 .../components/DocumentMain/MarkdownEditor/index.ts | 2 +- .../{KeyMapExtension.ts => key-map-extension.ts} | 0 .../{MarkdownEditor.tsx => markdown-editor.tsx} | 10 +++++----- .../MarkdownEditor/{spellCheck.ts => spell-check.ts} | 0 .../components/DocumentMain/MarkdownPreview/index.ts | 2 +- .../MarkdownPreview/markdown-preview.stories.tsx | 2 +- .../{MarkdownPreview.tsx => markdown-preview.tsx} | 4 ++-- .../{MermaidDiagram.tsx => mermaid-diagram.tsx} | 0 .../{remarkDecorations.tsx => remark-decorations.tsx} | 0 .../{DocumentMain.tsx => document-main.tsx} | 2 +- .../DocumentPage/components/DocumentMain/index.ts | 2 +- client/src/hooks/{useAutoSave.ts => use-auto-save.ts} | 0 client/src/hooks/{useCollab.ts => use-collab.ts} | 0 .../{useCollaborators.ts => use-collaborators.ts} | 0 client/src/hooks/{useDocument.ts => use-document.ts} | 0 .../hooks/{useJoinRequests.ts => use-join-requests.ts} | 0 .../src/hooks/{useMediaQuery.ts => use-media-query.ts} | 0 .../src/hooks/{useShareLink.ts => use-share-link.ts} | 0 .../lib/{rehypeCopyButton.ts => rehype-copy-button.ts} | 0 .../{generateUserColor.ts => generate-user-color.ts} | 0 67 files changed, 49 insertions(+), 45 deletions(-) create mode 100644 client/src/components/common/NewDocumentFormBody/index.ts rename client/src/components/common/{forms/NewDocumentFormBody.tsx => NewDocumentFormBody/new-document-form-body.tsx} (97%) rename client/src/components/layouts/{AuthLayout.tsx => AuthLayout/auth-layout.tsx} (90%) create mode 100644 client/src/components/layouts/AuthLayout/index.ts rename client/src/components/layouts/{ContentLayout.tsx => ContentLayout/content-layout.tsx} (84%) create mode 100644 client/src/components/layouts/ContentLayout/index.ts rename client/src/components/layouts/{DashboardLayout.tsx => DashboardLayout/dashboard-layout.tsx} (88%) create mode 100644 client/src/components/layouts/DashboardLayout/index.ts rename client/src/components/layouts/{DocumentLayout.tsx => DocumentLayout/document-layout.tsx} (90%) create mode 100644 client/src/components/layouts/DocumentLayout/index.ts rename client/src/components/ui/{auth => Auth}/login-form.tsx (97%) rename client/src/components/ui/{auth => Auth}/register-form.tsx (98%) delete mode 100644 client/src/components/ui/Form/Input/index.ts create mode 100644 client/src/components/ui/Form/index.ts rename client/src/components/ui/Form/{Input/input-field.tsx => input.tsx} (96%) rename client/src/components/ui/Form/{Input => }/variants.ts (100%) rename client/src/components/ui/Header/{Header.stories.tsx => header.stories.tsx} (100%) rename client/src/components/ui/{seo/Head.tsx => Seo/head.tsx} (100%) create mode 100644 client/src/components/ui/Seo/index.ts delete mode 100644 client/src/components/ui/seo/index.ts rename client/src/context/auth/{AuthContext.tsx => auth-context.tsx} (100%) rename client/src/context/auth/{AuthProvider.tsx => auth-provider.tsx} (97%) rename client/src/context/auth/{useAuth.tsx => use-auth.tsx} (82%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/dashboard-main.stories.tsx (100%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/dashboard-main.tsx (100%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/dashboard-view-toggle.tsx (100%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/document-list.tsx (100%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/document-section.tsx (100%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/document-skeleton-loader.tsx (100%) rename client/src/features/Dashboard/components/{DashBoardMain => DashboardMain}/index.ts (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/{useEditorStatus.ts => use-editor-status.ts} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/{useMarkdownCommands.tsx => use-markdown-commands.tsx} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/{EditorExtensions.ts => editor-extensions.ts} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/{EditorTheme.ts => editor-theme.ts} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/{KeyMapExtension.ts => key-map-extension.ts} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/{MarkdownEditor.tsx => markdown-editor.tsx} (93%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/{spellCheck.ts => spell-check.ts} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/{MarkdownPreview.tsx => markdown-preview.tsx} (97%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/{MermaidDiagram.tsx => mermaid-diagram.tsx} (100%) rename client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/{remarkDecorations.tsx => remark-decorations.tsx} (100%) rename client/src/features/DocumentPage/components/DocumentMain/{DocumentMain.tsx => document-main.tsx} (99%) rename client/src/hooks/{useAutoSave.ts => use-auto-save.ts} (100%) rename client/src/hooks/{useCollab.ts => use-collab.ts} (100%) rename client/src/hooks/{useCollaborators.ts => use-collaborators.ts} (100%) rename client/src/hooks/{useDocument.ts => use-document.ts} (100%) rename client/src/hooks/{useJoinRequests.ts => use-join-requests.ts} (100%) rename client/src/hooks/{useMediaQuery.ts => use-media-query.ts} (100%) rename client/src/hooks/{useShareLink.ts => use-share-link.ts} (100%) rename client/src/lib/{rehypeCopyButton.ts => rehype-copy-button.ts} (100%) rename client/src/utils/{generateUserColor.ts => generate-user-color.ts} (100%) diff --git a/client/src/app/routes/app/dashboard.tsx b/client/src/app/routes/app/dashboard.tsx index 7332743..17bed0f 100644 --- a/client/src/app/routes/app/dashboard.tsx +++ b/client/src/app/routes/app/dashboard.tsx @@ -1,7 +1,7 @@ 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'; diff --git a/client/src/app/routes/app/document.tsx b/client/src/app/routes/app/document.tsx index 432d220..fa097a9 100644 --- a/client/src/app/routes/app/document.tsx +++ b/client/src/app/routes/app/document.tsx @@ -6,8 +6,8 @@ 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'; export default function DocumentPage() { const { id } = useParams(); diff --git a/client/src/app/routes/auth/login.tsx b/client/src/app/routes/auth/login.tsx index 38171dc..f95d8d6 100644 --- a/client/src/app/routes/auth/login.tsx +++ b/client/src/app/routes/auth/login.tsx @@ -2,7 +2,7 @@ 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'; diff --git a/client/src/app/routes/auth/register.tsx b/client/src/app/routes/auth/register.tsx index ea9e5fe..0733ea1 100644 --- a/client/src/app/routes/auth/register.tsx +++ b/client/src/app/routes/auth/register.tsx @@ -2,7 +2,7 @@ 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'; diff --git a/client/src/app/routes/landing.tsx b/client/src/app/routes/landing.tsx index 216338e..9713b0f 100644 --- a/client/src/app/routes/landing.tsx +++ b/client/src/app/routes/landing.tsx @@ -1,6 +1,6 @@ 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'; 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/forms/NewDocumentFormBody.tsx b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx similarity index 97% rename from client/src/components/common/forms/NewDocumentFormBody.tsx rename to client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx index ed536c8..9e90d06 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, diff --git a/client/src/components/layouts/AuthLayout.tsx b/client/src/components/layouts/AuthLayout/auth-layout.tsx similarity index 90% rename from client/src/components/layouts/AuthLayout.tsx rename to client/src/components/layouts/AuthLayout/auth-layout.tsx index 6f2b73b..7833791 100644 --- a/client/src/components/layouts/AuthLayout.tsx +++ b/client/src/components/layouts/AuthLayout/auth-layout.tsx @@ -4,9 +4,9 @@ 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 = { title: string; 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/content-layout.tsx similarity index 84% rename from client/src/components/layouts/ContentLayout.tsx rename to client/src/components/layouts/ContentLayout/content-layout.tsx index 07d52d9..92bf84a 100644 --- a/client/src/components/layouts/ContentLayout.tsx +++ b/client/src/components/layouts/ContentLayout/content-layout.tsx @@ -1,4 +1,4 @@ -import { Head } from '../ui/seo'; +import { Head } from '../../ui/Seo'; export default function ContentLayout({ title, 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.tsx b/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx similarity index 88% rename from client/src/components/layouts/DashboardLayout.tsx rename to client/src/components/layouts/DashboardLayout/dashboard-layout.tsx index 0556867..ad8cfa9 100644 --- a/client/src/components/layouts/DashboardLayout.tsx +++ b/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx @@ -2,8 +2,8 @@ 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 = { title: string; 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.tsx b/client/src/components/layouts/DocumentLayout/document-layout.tsx similarity index 90% rename from client/src/components/layouts/DocumentLayout.tsx rename to client/src/components/layouts/DocumentLayout/document-layout.tsx index e075303..9a429df 100644 --- a/client/src/components/layouts/DocumentLayout.tsx +++ b/client/src/components/layouts/DocumentLayout/document-layout.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Head } from '../ui/seo'; +import { Head } from '../../ui/Seo'; type layoutProps = { title: string; 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/auth/login-form.tsx b/client/src/components/ui/Auth/login-form.tsx similarity index 97% rename from client/src/components/ui/auth/login-form.tsx rename to client/src/components/ui/Auth/login-form.tsx index 42425ce..0585628 100644 --- a/client/src/components/ui/auth/login-form.tsx +++ b/client/src/components/ui/Auth/login-form.tsx @@ -4,7 +4,7 @@ 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'; diff --git a/client/src/components/ui/auth/register-form.tsx b/client/src/components/ui/Auth/register-form.tsx similarity index 98% rename from client/src/components/ui/auth/register-form.tsx rename to client/src/components/ui/Auth/register-form.tsx index 0e29b5d..aee54da 100644 --- a/client/src/components/ui/auth/register-form.tsx +++ b/client/src/components/ui/Auth/register-form.tsx @@ -4,7 +4,7 @@ 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'; diff --git a/client/src/components/ui/Form/Input/index.ts b/client/src/components/ui/Form/Input/index.ts deleted file mode 100644 index 6ab3377..0000000 --- a/client/src/components/ui/Form/Input/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './input-field'; diff --git a/client/src/components/ui/Form/index.ts b/client/src/components/ui/Form/index.ts new file mode 100644 index 0000000..e3365cb --- /dev/null +++ b/client/src/components/ui/Form/index.ts @@ -0,0 +1 @@ +export * from './input'; diff --git a/client/src/components/ui/Form/input.stories.tsx b/client/src/components/ui/Form/input.stories.tsx index 98cc36d..9c853f1 100644 --- a/client/src/components/ui/Form/input.stories.tsx +++ b/client/src/components/ui/Form/input.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Input } from './Input'; +import { Input } from './input'; const meta: Meta = { title: 'Components/Input', diff --git a/client/src/components/ui/Form/Input/input-field.tsx b/client/src/components/ui/Form/input.tsx similarity index 96% rename from client/src/components/ui/Form/Input/input-field.tsx rename to client/src/components/ui/Form/input.tsx index 270960c..b0e7ba8 100644 --- a/client/src/components/ui/Form/Input/input-field.tsx +++ b/client/src/components/ui/Form/input.tsx @@ -5,8 +5,7 @@ import { LuEye, LuEyeOff, LuTriangleAlert } from 'react-icons/lu'; import { cn } from '@/utils/cn'; -import { FieldWrapper, FieldWrapperPassThroughProps } from '../field-wrapper'; - +import { FieldWrapper, FieldWrapperPassThroughProps } from './field-wrapper'; import { inputVariants } from './variants'; export type InputProps = React.InputHTMLAttributes & diff --git a/client/src/components/ui/Form/Input/variants.ts b/client/src/components/ui/Form/variants.ts similarity index 100% rename from client/src/components/ui/Form/Input/variants.ts rename to client/src/components/ui/Form/variants.ts diff --git a/client/src/components/ui/Header/Header.stories.tsx b/client/src/components/ui/Header/header.stories.tsx similarity index 100% rename from client/src/components/ui/Header/Header.stories.tsx rename to client/src/components/ui/Header/header.stories.tsx diff --git a/client/src/components/ui/seo/Head.tsx b/client/src/components/ui/Seo/head.tsx similarity index 100% rename from client/src/components/ui/seo/Head.tsx rename to client/src/components/ui/Seo/head.tsx diff --git a/client/src/components/ui/Seo/index.ts b/client/src/components/ui/Seo/index.ts new file mode 100644 index 0000000..b6f0ab7 --- /dev/null +++ b/client/src/components/ui/Seo/index.ts @@ -0,0 +1 @@ +export * from './head'; diff --git a/client/src/components/ui/seo/index.ts b/client/src/components/ui/seo/index.ts deleted file mode 100644 index f0c32d2..0000000 --- a/client/src/components/ui/seo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Head'; diff --git a/client/src/context/auth/AuthContext.tsx b/client/src/context/auth/auth-context.tsx similarity index 100% rename from client/src/context/auth/AuthContext.tsx rename to client/src/context/auth/auth-context.tsx diff --git a/client/src/context/auth/AuthProvider.tsx b/client/src/context/auth/auth-provider.tsx similarity index 97% rename from client/src/context/auth/AuthProvider.tsx rename to client/src/context/auth/auth-provider.tsx index 78eb6cd..eacf714 100644 --- a/client/src/context/auth/AuthProvider.tsx +++ b/client/src/context/auth/auth-provider.tsx @@ -4,7 +4,7 @@ import { api } from '@/lib/api'; import { type User } from '@/types/api'; import { setAccessToken as storeToken, clearAccessToken } from '@/utils/token'; -import { AuthContext } from './AuthContext'; +import { AuthContext } from './auth-context'; export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const [user, setUser] = useState(null); diff --git a/client/src/context/auth/index.ts b/client/src/context/auth/index.ts index ce05836..089943b 100644 --- a/client/src/context/auth/index.ts +++ b/client/src/context/auth/index.ts @@ -1,3 +1,3 @@ -export { AuthContext } from './AuthContext'; -export { AuthProvider } from './AuthProvider'; -export { useAuth } from './useAuth'; +export { AuthContext } from './auth-context'; +export { AuthProvider } from './auth-provider'; +export { useAuth } from './use-auth'; diff --git a/client/src/context/auth/useAuth.tsx b/client/src/context/auth/use-auth.tsx similarity index 82% rename from client/src/context/auth/useAuth.tsx rename to client/src/context/auth/use-auth.tsx index 4a9e6cc..8b7a4a9 100644 --- a/client/src/context/auth/useAuth.tsx +++ b/client/src/context/auth/use-auth.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; -import { AuthContext } from './AuthContext'; +import { AuthContext } from './auth-context'; export const useAuth = () => { const context = useContext(AuthContext); diff --git a/client/src/features/Dashboard/components/DashBoardMain/dashboard-main.stories.tsx b/client/src/features/Dashboard/components/DashboardMain/dashboard-main.stories.tsx similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/dashboard-main.stories.tsx rename to client/src/features/Dashboard/components/DashboardMain/dashboard-main.stories.tsx diff --git a/client/src/features/Dashboard/components/DashBoardMain/dashboard-main.tsx b/client/src/features/Dashboard/components/DashboardMain/dashboard-main.tsx similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/dashboard-main.tsx rename to client/src/features/Dashboard/components/DashboardMain/dashboard-main.tsx diff --git a/client/src/features/Dashboard/components/DashBoardMain/dashboard-view-toggle.tsx b/client/src/features/Dashboard/components/DashboardMain/dashboard-view-toggle.tsx similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/dashboard-view-toggle.tsx rename to client/src/features/Dashboard/components/DashboardMain/dashboard-view-toggle.tsx diff --git a/client/src/features/Dashboard/components/DashBoardMain/document-list.tsx b/client/src/features/Dashboard/components/DashboardMain/document-list.tsx similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/document-list.tsx rename to client/src/features/Dashboard/components/DashboardMain/document-list.tsx diff --git a/client/src/features/Dashboard/components/DashBoardMain/document-section.tsx b/client/src/features/Dashboard/components/DashboardMain/document-section.tsx similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/document-section.tsx rename to client/src/features/Dashboard/components/DashboardMain/document-section.tsx diff --git a/client/src/features/Dashboard/components/DashBoardMain/document-skeleton-loader.tsx b/client/src/features/Dashboard/components/DashboardMain/document-skeleton-loader.tsx similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/document-skeleton-loader.tsx rename to client/src/features/Dashboard/components/DashboardMain/document-skeleton-loader.tsx diff --git a/client/src/features/Dashboard/components/DashBoardMain/index.ts b/client/src/features/Dashboard/components/DashboardMain/index.ts similarity index 100% rename from client/src/features/Dashboard/components/DashBoardMain/index.ts rename to client/src/features/Dashboard/components/DashboardMain/index.ts diff --git a/client/src/features/Dashboard/components/NewDocumentModal/new-document-modal.tsx b/client/src/features/Dashboard/components/NewDocumentModal/new-document-modal.tsx index 6cacffb..4ca085b 100644 --- a/client/src/features/Dashboard/components/NewDocumentModal/new-document-modal.tsx +++ b/client/src/features/Dashboard/components/NewDocumentModal/new-document-modal.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { LuFilePlus2 as NewFileIcon } 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 { api } from '@/lib/api'; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx index a585964..d927dbc 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx @@ -13,7 +13,7 @@ import { // DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/Dropdown'; -import { useCollaborators } from '@/hooks/useCollaborators'; +import { useCollaborators } from '@/hooks/use-collaborators'; import { cn } from '@/utils/cn'; interface CollaboratorsDropdownProps { 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..3798d3d 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,7 +1,7 @@ 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'; 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..8c9634a 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx @@ -19,8 +19,8 @@ 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 = { 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..01d25d8 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,7 +4,7 @@ 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 { className?: string; 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 100% 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 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..c8ea71b 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,7 +4,7 @@ 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'; export function MarkdownToolbar({ view, 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 100% 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 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 100% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts 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 100% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts 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 100% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts 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 93% 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..f756dc5 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, diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/spellCheck.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/spell-check.ts similarity index 100% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/spellCheck.ts rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/spell-check.ts 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..b10bbf4 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,7 +1,7 @@ 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, 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 97% 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..6d74838 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx @@ -20,10 +20,10 @@ 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'; export function MarkdownPreview({ 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 100% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx 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 100% rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx diff --git a/client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx similarity index 99% rename from client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx rename to client/src/features/DocumentPage/components/DocumentMain/document-main.tsx index ed3d6a8..0d0b35b 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx @@ -4,7 +4,7 @@ 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 } from '@/hooks/use-collab'; import { DocumentData } from '@/types/api'; import { cn } from '@/utils/cn'; 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/useAutoSave.ts b/client/src/hooks/use-auto-save.ts similarity index 100% rename from client/src/hooks/useAutoSave.ts rename to client/src/hooks/use-auto-save.ts diff --git a/client/src/hooks/useCollab.ts b/client/src/hooks/use-collab.ts similarity index 100% rename from client/src/hooks/useCollab.ts rename to client/src/hooks/use-collab.ts diff --git a/client/src/hooks/useCollaborators.ts b/client/src/hooks/use-collaborators.ts similarity index 100% rename from client/src/hooks/useCollaborators.ts rename to client/src/hooks/use-collaborators.ts diff --git a/client/src/hooks/useDocument.ts b/client/src/hooks/use-document.ts similarity index 100% rename from client/src/hooks/useDocument.ts rename to client/src/hooks/use-document.ts diff --git a/client/src/hooks/useJoinRequests.ts b/client/src/hooks/use-join-requests.ts similarity index 100% rename from client/src/hooks/useJoinRequests.ts rename to client/src/hooks/use-join-requests.ts diff --git a/client/src/hooks/useMediaQuery.ts b/client/src/hooks/use-media-query.ts similarity index 100% rename from client/src/hooks/useMediaQuery.ts rename to client/src/hooks/use-media-query.ts diff --git a/client/src/hooks/useShareLink.ts b/client/src/hooks/use-share-link.ts similarity index 100% rename from client/src/hooks/useShareLink.ts rename to client/src/hooks/use-share-link.ts diff --git a/client/src/lib/rehypeCopyButton.ts b/client/src/lib/rehype-copy-button.ts similarity index 100% rename from client/src/lib/rehypeCopyButton.ts rename to client/src/lib/rehype-copy-button.ts diff --git a/client/src/utils/generateUserColor.ts b/client/src/utils/generate-user-color.ts similarity index 100% rename from client/src/utils/generateUserColor.ts rename to client/src/utils/generate-user-color.ts From 08d3b55c50c42023e428833e3ff9bd4ca4c6dec0 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:48:55 +0300 Subject: [PATCH 04/33] client quality/c rules (#70) * chore(client): enforce kebab-case filenames, drop stale shared ignores * chore(client): add jsdoc lint rule (warn) --- client/eslint.config.js | 25 +++++- client/package.json | 5 +- pnpm-lock.yaml | 184 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 5 deletions(-) diff --git a/client/eslint.config.js b/client/eslint.config.js index 9d91c07..232bff4 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,29 @@ 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': [ + 'warn', + { + require: { + FunctionDeclaration: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + contexts: [ + 'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator', + ], + 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, diff --git a/client/package.json b/client/package.json index d4772cf..3c1f5a1 100644 --- a/client/package.json +++ b/client/package.json @@ -7,8 +7,8 @@ "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", @@ -101,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/pnpm-lock.yaml b/pnpm-lock.yaml index cbc8506..5c0aa95 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(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(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) @@ -687,6 +690,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'} @@ -1792,6 +1803,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'} @@ -2149,6 +2164,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==} @@ -2310,6 +2328,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} @@ -2518,6 +2540,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'} @@ -2561,6 +2588,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==} @@ -2887,6 +2918,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==} @@ -3163,6 +3198,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==} @@ -3458,6 +3502,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} @@ -3507,6 +3557,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} @@ -3521,6 +3575,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'} @@ -3530,6 +3588,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'} @@ -3933,6 +3995,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==} @@ -4298,6 +4363,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'} @@ -4908,6 +4977,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'} @@ -5038,6 +5110,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'} @@ -5046,6 +5121,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==} @@ -5417,6 +5495,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'} @@ -5518,6 +5600,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'} @@ -5649,6 +5736,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==} @@ -5870,6 +5960,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'} @@ -6759,6 +6853,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 @@ -7721,6 +7825,8 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@standard-schema/utils@0.3.0': {} @@ -8112,6 +8218,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 @@ -8309,6 +8417,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) @@ -8521,12 +8631,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 @@ -8568,6 +8684,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: @@ -8930,6 +9048,8 @@ snapshots: commander@8.3.0: {} + comment-parser@1.4.8: {} + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -9220,6 +9340,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 @@ -9605,6 +9729,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) @@ -9644,6 +9788,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)) @@ -9692,12 +9838,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 @@ -10224,6 +10380,8 @@ snapshots: hosted-git-info@2.8.9: {} + html-entities@2.6.0: {} + html-escaper@2.0.2: {} html-url-attributes@3.0.1: {} @@ -10547,6 +10705,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: {} @@ -11383,6 +11545,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -11558,6 +11722,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 @@ -11565,6 +11733,8 @@ snapshots: parse-passwd@1.0.0: {} + parse-statements@1.0.11: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -11989,6 +12159,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 @@ -12104,6 +12276,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -12268,6 +12442,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: {} @@ -12512,6 +12691,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: {} From 2fb97367de1b4d7fbaf2f93598b244b91b76ffaa Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:48:56 +0300 Subject: [PATCH 05/33] client quality/d seams (#71) * refactor(client): remove dead interceptor, split join-request api from hook * feat(client): expose error state from data hooks * docs(client): backfill JSDoc and add missing autodocs tags * chore(client): enforce jsdoc rule (error) with corrected export contexts * fix(server): clear collaborator/request rows before test document cleanup * fix(client): correct stale e2e API port fallback to 5001 * refactor(client): injectable provider factory in useCollab * docs(client): enforce JSDoc description/param quality via lint require-description on components/hooks/features kills empty stubs; require-param (destructured members exempt) covers hooks and feature helper files where params have no Props interface to document. Member- level prop docs stay a review convention. Policy recorded in AGENTS.md. * docs(client): backfill prop-level JSDoc across components/hooks/features Every custom component prop gets a one-line member doc (defaults noted when implementation-established); inherited React/Radix props left undocumented; hooks and feature helpers get full @param tags. --- AGENTS.md | 16 +++++ client/e2e/collaboration.spec.ts | 14 ++++- client/e2e/editor.spec.ts | 3 + client/e2e/sharing.spec.ts | 3 + client/e2e/utils.ts | 15 +++++ client/eslint.config.js | 31 +++++++++- client/src/app/index.tsx | 3 + client/src/app/provider.tsx | 3 + client/src/app/router.tsx | 3 + client/src/app/routes/app/dashboard.tsx | 3 + client/src/app/routes/app/document.tsx | 3 + client/src/app/routes/app/error-boundary.tsx | 3 + client/src/app/routes/app/share.tsx | 3 + client/src/app/routes/auth/login.tsx | 3 + client/src/app/routes/auth/register.tsx | 3 + client/src/app/routes/landing.tsx | 3 + client/src/app/routes/not-found.tsx | 3 + .../new-document-form-body.tsx | 8 +++ .../layouts/AuthLayout/auth-layout.tsx | 6 ++ .../layouts/ContentLayout/content-layout.tsx | 5 ++ .../DashboardLayout/dashboard-layout.tsx | 5 ++ .../DocumentLayout/document-layout.tsx | 5 ++ .../src/components/ui/Alert/alert.stories.tsx | 15 +++++ client/src/components/ui/Alert/alert.tsx | 7 +++ client/src/components/ui/Auth/login-form.tsx | 5 ++ .../src/components/ui/Auth/register-form.tsx | 5 ++ .../components/ui/Avatar/avatar.stories.tsx | 12 ++++ client/src/components/ui/Avatar/avatar.tsx | 1 + .../components/ui/Button/button.stories.tsx | 27 +++++++++ client/src/components/ui/Button/button.tsx | 3 + client/src/components/ui/Button/variants.tsx | 3 + .../ui/Dropdown/dropdown.stories.tsx | 33 +++++++++++ .../src/components/ui/Dropdown/dropdown.tsx | 4 ++ client/src/components/ui/Form/error.tsx | 4 ++ .../src/components/ui/Form/field-wrapper.tsx | 7 +++ .../src/components/ui/Form/input.stories.tsx | 15 +++++ client/src/components/ui/Form/input.tsx | 1 + client/src/components/ui/Form/variants.ts | 3 + .../components/ui/Header/header.stories.tsx | 10 ++++ client/src/components/ui/Header/header.tsx | 6 ++ client/src/components/ui/Header/user-menu.tsx | 7 +++ .../src/components/ui/Modal/modal.stories.tsx | 22 +++++++ client/src/components/ui/Modal/modal.tsx | 58 +++++++++++++++++++ client/src/components/ui/Seo/head.tsx | 5 ++ .../ui/Skeleton/skeleton.stories.tsx | 12 ++++ .../src/components/ui/Skeleton/skeleton.tsx | 3 + client/src/components/ui/Spinner/spinner.tsx | 5 ++ .../src/components/ui/Toast/toast.stories.tsx | 6 ++ client/src/components/ui/Toast/toast.tsx | 2 + .../ui/ToggleGroup/toggle-group.stories.tsx | 3 + .../ui/ToggleGroup/toggle-group.tsx | 1 + client/src/config/env.ts | 3 + client/src/config/paths.ts | 3 + client/src/context/auth/auth-context.tsx | 3 + client/src/context/auth/auth-provider.tsx | 3 + client/src/context/auth/use-auth.tsx | 3 + .../DashboardMain/dashboard-main.stories.tsx | 13 +++++ .../DashboardMain/dashboard-main.tsx | 10 ++++ .../DashboardMain/dashboard-view-toggle.tsx | 4 ++ .../DashboardMain/document-list.tsx | 8 +++ .../DashboardMain/document-section.tsx | 7 +++ .../document-skeleton-loader.tsx | 5 ++ .../DocumentCardDropdown/delete-modal.tsx | 7 +++ .../DocumentCardDropdown/document-actions.tsx | 6 ++ .../document-card-dropdown.tsx | 7 +++ .../DocumentCardDropdown/rename-modal.tsx | 7 +++ .../document-grid-card.stories.tsx | 3 + .../DocumentGridCard/document-grid-card.tsx | 7 +++ .../DocumentRow/document-row.stories.tsx | 3 + .../components/DocumentRow/document-row.tsx | 7 +++ .../NewDocumentModal/new-document-modal.tsx | 4 ++ .../SortControl/sort-control.stories.tsx | 4 ++ .../components/SortControl/sort-control.tsx | 7 +++ .../collaborators-dropdown.stories.tsx | 13 +++++ .../collaborators-dropdown.tsx | 6 ++ .../create-document-button.stories.tsx | 10 ++++ .../create-document-button.tsx | 5 ++ .../DocumentTitle/document-title.stories.tsx | 16 +++++ .../DocumentTitle/document-title.tsx | 5 ++ .../document-toolbar.stories.tsx | 10 ++++ .../DocumentToolbar/document-toolbar.tsx | 12 ++++ .../options-dropdown.stories.tsx | 4 ++ .../OptionsDropdown/options-dropdown.tsx | 4 ++ .../ShareButton/share-button.stories.tsx | 4 ++ .../ShareButton/share-button.tsx | 6 ++ .../ShareButton/share-mode-select.tsx | 5 ++ .../view-mode-selector.stories.tsx | 4 ++ .../ViewModeSelector/view-mode-selector.tsx | 6 ++ .../WorkspaceInfo/workspace-info.stories.tsx | 4 ++ .../WorkspaceInfo/workspace-info.tsx | 3 + .../document-header.stories.tsx | 25 ++++++++ .../DocumentHeader/document-header.tsx | 14 +++++ .../MarkdownStatusBar/markdown-status-bar.tsx | 9 +++ .../MarkdownStatusBar/use-editor-status.ts | 5 ++ .../MarkdownToolbar/markdown-toolbar.tsx | 5 ++ .../MarkdownToolbar/toolbar-button.tsx | 6 ++ .../MarkdownToolbar/toolbar-buttons.tsx | 3 + .../MarkdownToolbar/use-markdown-commands.tsx | 5 ++ .../MarkdownEditor/editor-extensions.ts | 3 + .../MarkdownEditor/editor-theme.ts | 4 ++ .../MarkdownEditor/key-map-extension.ts | 3 + .../MarkdownEditor/markdown-editor.tsx | 9 +++ .../MarkdownEditor/spell-check.ts | 14 +++++ .../markdown-preview.stories.tsx | 13 +++++ .../MarkdownPreview/markdown-preview.tsx | 9 +++ .../MarkdownPreview/markdown-toc.tsx | 27 +++++++++ .../MarkdownPreview/mermaid-diagram.tsx | 10 +++- .../MarkdownPreview/rehype-subsuper.ts | 3 + .../MarkdownPreview/remark-decorations.tsx | 3 + .../MarkdownPreview/remark-typographer.ts | 3 + .../MarkdownPreview/sanitize-schema.ts | 3 + .../components/DocumentMain/document-main.tsx | 9 +++ client/src/hooks/use-auto-save.ts | 9 +++ client/src/hooks/use-collab.ts | 33 ++++++++++- client/src/hooks/use-collaborators.ts | 17 ++++++ client/src/hooks/use-document.ts | 15 +++++ client/src/hooks/use-join-requests.ts | 50 +++++++++------- client/src/hooks/use-media-query.ts | 8 +++ client/src/hooks/use-share-link.ts | 13 +++++ client/src/lib/api.ts | 43 ++------------ client/src/lib/auth.ts | 18 ++++++ client/src/lib/join-requests-api.ts | 35 +++++++++++ client/src/lib/rehype-copy-button.ts | 4 ++ client/src/utils/cn.ts | 3 + client/src/utils/dateformat.ts | 3 + client/src/utils/generate-user-color.ts | 3 + client/src/utils/token.ts | 9 +++ server/scripts/seed-test-user.ts | 21 ++++++- 128 files changed, 1070 insertions(+), 69 deletions(-) create mode 100644 client/src/lib/join-requests-api.ts 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/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..c2d0648 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(); 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 232bff4..1bb7c3c 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -137,7 +137,7 @@ export default tseslint.config([ // JSDoc policy: every export documented; params optional (types self-document) 'jsdoc/require-jsdoc': [ - 'warn', + 'error', { require: { FunctionDeclaration: true, @@ -146,7 +146,8 @@ export default tseslint.config([ FunctionExpression: false, }, contexts: [ - 'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator', + 'ExportNamedDeclaration > VariableDeclaration > VariableDeclarator > ArrowFunctionExpression', + 'ExportNamedDeclaration > FunctionDeclaration', ], exemptEmptyFunctions: true, }, @@ -175,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/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 17bed0f..8dc9bf1 100644 --- a/client/src/app/routes/app/dashboard.tsx +++ b/client/src/app/routes/app/dashboard.tsx @@ -5,6 +5,9 @@ import DashboardMain from '@/features/Dashboard/components/DashboardMain/dashboa 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 fa097a9..e3ca6f3 100644 --- a/client/src/app/routes/app/document.tsx +++ b/client/src/app/routes/app/document.tsx @@ -9,6 +9,9 @@ import { DocumentMain } from '@/features/DocumentPage/components/DocumentMain'; 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 f95d8d6..3e5d4dd 100644 --- a/client/src/app/routes/auth/login.tsx +++ b/client/src/app/routes/auth/login.tsx @@ -8,6 +8,9 @@ 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 0733ea1..353fef5 100644 --- a/client/src/app/routes/auth/register.tsx +++ b/client/src/app/routes/auth/register.tsx @@ -7,6 +7,9 @@ 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 9713b0f..a98c35e 100644 --- a/client/src/app/routes/landing.tsx +++ b/client/src/app/routes/landing.tsx @@ -4,6 +4,9 @@ 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/new-document-form-body.tsx b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx index 9e90d06..795622e 100644 --- a/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx +++ b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx @@ -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.tsx b/client/src/components/layouts/AuthLayout/auth-layout.tsx index 7833791..982d6a6 100644 --- a/client/src/components/layouts/AuthLayout/auth-layout.tsx +++ b/client/src/components/layouts/AuthLayout/auth-layout.tsx @@ -9,11 +9,17 @@ 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/ContentLayout/content-layout.tsx b/client/src/components/layouts/ContentLayout/content-layout.tsx index 92bf84a..5ce0747 100644 --- a/client/src/components/layouts/ContentLayout/content-layout.tsx +++ b/client/src/components/layouts/ContentLayout/content-layout.tsx @@ -1,10 +1,15 @@ 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 ( diff --git a/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx b/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx index ad8cfa9..b4a298b 100644 --- a/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx +++ b/client/src/components/layouts/DashboardLayout/dashboard-layout.tsx @@ -6,10 +6,15 @@ 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/DocumentLayout/document-layout.tsx b/client/src/components/layouts/DocumentLayout/document-layout.tsx index 9a429df..98c7b2b 100644 --- a/client/src/components/layouts/DocumentLayout/document-layout.tsx +++ b/client/src/components/layouts/DocumentLayout/document-layout.tsx @@ -3,10 +3,15 @@ import React from 'react'; 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/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.tsx b/client/src/components/ui/Auth/login-form.tsx index 0585628..df77622 100644 --- a/client/src/components/ui/Auth/login-form.tsx +++ b/client/src/components/ui/Auth/login-form.tsx @@ -10,10 +10,15 @@ 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.tsx b/client/src/components/ui/Auth/register-form.tsx index aee54da..ef4b595 100644 --- a/client/src/components/ui/Auth/register-form.tsx +++ b/client/src/components/ui/Auth/register-form.tsx @@ -10,10 +10,15 @@ 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 && ( + + )} )) ) : ( 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 685480d..bcd6e50 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { LuEye } from 'react-icons/lu'; import { UserMenu } from '@/components/ui/Header/user-menu'; @@ -26,6 +27,8 @@ interface DocumentToolbarProps { 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; } @@ -42,8 +45,11 @@ export const DocumentToolbar = ({ docId, documentTitle, onCreateDocument, + isReadOnly, isCollaborator, }: DocumentToolbarProps) => { + const isOwner = !isCollaborator; + const canViewRoster = !isCollaborator || !isReadOnly; return (
@@ -60,11 +66,16 @@ export const DocumentToolbar = ({
- {!isCollaborator && ( - + {canViewRoster ? ( + + ) : ( + + + View only + )} {username && logout && ( = ({ documentTitle, onCreateDocument, className, + isReadOnly, isCollaborator, }) => { return ( @@ -62,6 +63,7 @@ export const DocumentHeader: React.FC = ({ docId={docId} documentTitle={documentTitle} onCreateDocument={onCreateDocument} + isReadOnly={isReadOnly} isCollaborator={isCollaborator} />
diff --git a/client/src/hooks/use-collab.ts b/client/src/hooks/use-collab.ts index 2dcedf9..f2ac3c9 100644 --- a/client/src/hooks/use-collab.ts +++ b/client/src/hooks/use-collab.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import * as Y from 'yjs'; import { env } from '@/config/env'; +import { getAccessToken } from '@/utils/token'; /** * Factory contract for creating the collaboration provider. @@ -46,6 +47,7 @@ export function useCollab( url: env.Socket_URL, // Hocuspocus server URL name: docId, // Room/document ID document: ydoc, + token: getAccessToken(), // Required by the server's onAuthenticate hook }); const ytext = ydoc.getText('content'); diff --git a/client/src/hooks/use-collaborators.ts b/client/src/hooks/use-collaborators.ts index 0aeda5b..a67cdde 100644 --- a/client/src/hooks/use-collaborators.ts +++ b/client/src/hooks/use-collaborators.ts @@ -8,19 +8,18 @@ import { Collaborator } from '@/types/api'; * and error state. * * @param docId - Document id whose collaborators are managed; fetching - * is skipped while undefined. - * @param isCollaborator - When true the viewer is already a collaborator, - * so the list is never fetched (management is owner-only). + * 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. */ -export const useCollaborators = (docId?: string, isCollaborator?: boolean) => { +export const useCollaborators = (docId?: string) => { const [collaborators, setCollaborators] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { - if (isCollaborator || !docId) return; + if (!docId) return; const fetchCollaborators = async () => { setLoading(true); @@ -37,7 +36,7 @@ export const useCollaborators = (docId?: string, isCollaborator?: boolean) => { }; fetchCollaborators(); - }, [docId, isCollaborator]); + }, [docId]); const removeCollaborator = async (userId: string) => { if (!docId) return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c0aa95..cd23155 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,9 @@ importers: '@eslint/js': specifier: ^9.19.0 version: 9.32.0 + '@hocuspocus/provider': + specifier: 3.2.1 + version: 3.2.1(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@types/cookie-parser': specifier: ^1.4.9 version: 1.4.9(@types/express@4.17.23) @@ -416,6 +419,9 @@ importers: '@types/swagger-ui-express': specifier: ^4.1.8 version: 4.1.8 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@types/yamljs': specifier: ^0.2.34 version: 0.2.34 @@ -467,6 +473,9 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.5)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.5.1)(lightningcss@1.30.1)(yaml@2.8.0) + ws: + specifier: ^8.21.3 + version: 8.21.3 packages: @@ -6379,6 +6388,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y-codemirror.next@0.3.5: resolution: {integrity: sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==} peerDependencies: @@ -13171,6 +13192,8 @@ snapshots: ws@8.18.3: {} + ws@8.21.3: {} + y-codemirror.next@0.3.5(@codemirror/state@6.5.2)(@codemirror/view@6.38.1)(yjs@13.6.27): dependencies: '@codemirror/state': 6.5.2 diff --git a/server/package.json b/server/package.json index 057bdfd..2a835e9 100644 --- a/server/package.json +++ b/server/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@eslint/js": "^9.19.0", + "@hocuspocus/provider": "3.2.1", "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -63,6 +64,7 @@ "@types/node": "^22.13.1", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "^4.1.8", + "@types/ws": "^8.18.1", "@types/yamljs": "^0.2.34", "@vitest/ui": "^3.2.4", "esbuild": "^0.23.1", @@ -79,6 +81,7 @@ "ts-node": "^10.9.2", "typescript": "^5.7.3", "typescript-eslint": "^8.23.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "ws": "^8.21.3" } } \ No newline at end of file diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index e8e187e..1867b3c 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -801,9 +801,35 @@ export const getCollaborators = asyncErrorWrapper(async (req: AuthenticatedReque documentId: id, }); try { + const [doc, membership] = await Promise.all([ + prisma.document.findUnique({ where: { id } }), + prisma.collaborator.findFirst({ where: { documentId: id, userId } }), + ]); + + if (!doc || (doc.authorId !== userId && membership?.permission !== 'edit')) { + logger.warn('Get collaborators failed - unauthorized', { + action: 'GET_COLLABORATORS_UNAUTHORIZED', + ...clientInfo, + userId, + documentId: id, + documentExists: !!doc, + isOwner: doc?.authorId === userId, + hasEditAccess: membership?.permission === 'edit', + }); + + res.status(StatusCodes.FORBIDDEN).json({ error: 'Unauthorized' }); + return; + } + const collabs = await prisma.collaborator.findMany({ where: { documentId: id }, - include: { user: true }, + select: { + id: true, + documentId: true, + userId: true, + permission: true, + user: { select: { id: true, username: true, fullName: true } }, + }, }); res.json(collabs); diff --git a/server/src/sockets/ws-server.ts b/server/src/sockets/ws-server.ts index 5c1e060..f1ca81f 100644 --- a/server/src/sockets/ws-server.ts +++ b/server/src/sockets/ws-server.ts @@ -1,7 +1,9 @@ import { Hocuspocus } from '@hocuspocus/server'; +import jwt from 'jsonwebtoken'; import { dbPersistence } from '@/lib/dbPersistence'; import { logger } from '@/lib/logger'; +import { getDocumentPermission } from '@/utils/getDocumentPermission'; const server = new Hocuspocus({ // port: 5002, @@ -15,6 +17,29 @@ const server = new Hocuspocus({ documentName: data.documentName, }); }, + onAuthenticate: async ({ token, documentName, connectionConfig }) => { + if (!token) { + throw new Error('Missing token'); + } + + let userId: string; + try { + const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET!); + if (typeof payload === 'string' || !payload.userId) { + throw new Error('Invalid token payload'); + } + userId = payload.userId; + } catch { + throw new Error('Invalid or expired token'); + } + + const permission = await getDocumentPermission(userId, documentName); + if (!permission) { + throw new Error('Access denied'); + } + + connectionConfig.readOnly = permission === 'view'; + }, onLoadDocument: async (data: { documentName: string }) => { logger.debug('WebSocket document loaded', { action: 'WS_LOAD', diff --git a/server/src/utils/getDocumentPermission.ts b/server/src/utils/getDocumentPermission.ts new file mode 100644 index 0000000..61e1970 --- /dev/null +++ b/server/src/utils/getDocumentPermission.ts @@ -0,0 +1,28 @@ +import { prisma } from '@/lib/prisma'; + +export type DocumentPermission = 'edit' | 'view'; + +/** + * Resolves the effective collaboration permission of a user for a document. + * Returns null when the user has no access at all. + */ +export const getDocumentPermission = async (userId: string, documentId: string): Promise => { + const doc = await prisma.document.findUnique({ + where: { id: documentId }, + select: { authorId: true, isPublic: true }, + }); + + if (!doc) return null; + if (doc.authorId === userId) return 'edit'; + + const collaborator = await prisma.collaborator.findUnique({ + where: { documentId_userId: { documentId, userId } }, + select: { permission: true }, + }); + + if (collaborator) { + return collaborator.permission === 'view' ? 'view' : 'edit'; + } + + return doc.isPublic ? 'view' : null; +}; diff --git a/server/src/utils/slugIDtoFullID.ts b/server/src/utils/slugIDtoFullID.ts index 8cf6a14..6d6e22c 100644 --- a/server/src/utils/slugIDtoFullID.ts +++ b/server/src/utils/slugIDtoFullID.ts @@ -1,9 +1,9 @@ import { prisma } from '@/lib/prisma'; export const slugIDtoFullID = async (slugId: string) => { - const doc = await prisma.document.findFirst({ + const doc = await prisma.document.findUnique({ where: { - id: { startsWith: slugId }, + id: slugId, }, select: { id: true, diff --git a/server/test/document.test.ts b/server/test/document.test.ts index b9a20ce..072ba37 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -245,4 +245,113 @@ describe('Document Routes', () => { expect(removeRes.status).toBe(StatusCodes.OK); }); + + it('should forbid non-owners from listing collaborators', async () => { + const doc = await prisma.document.create({ + data: { title: 'Private Doc', authorId: userId, content: '' }, + }); + + await request(app).post('/api/auth/register').send({ + email: 'outsider@test.dev', + username: 'outsider', + password: 'secure123', + }); + + const outsiderLogin = await request(app) + .post('/api/auth/login') + .send({ email: 'outsider@test.dev', password: 'secure123' }); + + const res = await request(app) + .get(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${outsiderLogin.body.accessToken}`); + + expect(res.status).toBe(StatusCodes.FORBIDDEN); + }); + + it('should not expose credentials when listing collaborators', async () => { + const doc = await prisma.document.create({ + data: { title: 'Leaky', authorId: userId, content: '' }, + }); + + const collaboratorUser = await prisma.user.create({ + data: { + email: 'leakcollab@test.dev', + username: 'leakcollab', + password: 'super-hashed-secret', + fullName: 'Leaky Collaborator', + refreshToken: 'stale-refresh-token-value', + }, + }); + + await prisma.collaborator.create({ + data: { documentId: doc.id, userId: collaboratorUser.id, permission: 'edit' }, + }); + + const res = await request(app).get(`/api/document/${doc.id}/collaborators`).set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body).toHaveLength(1); + for (const collaborator of res.body) { + expect(Object.keys(collaborator.user).sort()).toEqual(['fullName', 'id', 'username']); + } + expect(JSON.stringify(res.body)).not.toContain('super-hashed-secret'); + expect(JSON.stringify(res.body)).not.toContain('stale-refresh-token-value'); + expect(JSON.stringify(res.body)).not.toContain('leakcollab@test.dev'); + }); + + it('should allow edit collaborators to list collaborators', async () => { + const doc = await prisma.document.create({ + data: { title: 'Shared Doc', authorId: userId, content: '' }, + }); + + await request(app).post('/api/auth/register').send({ + email: 'editor@test.dev', + username: 'editor', + password: 'secure123', + }); + + const editorLogin = await request(app) + .post('/api/auth/login') + .send({ email: 'editor@test.dev', password: 'secure123' }); + const editorId = editorLogin.body.user.id; + + await prisma.collaborator.create({ + data: { documentId: doc.id, userId: editorId, permission: 'edit' }, + }); + + const res = await request(app) + .get(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${editorLogin.body.accessToken}`); + + expect(res.status).toBe(StatusCodes.OK); + expect(res.body).toHaveLength(1); + expect(Object.keys(res.body[0].user).sort()).toEqual(['fullName', 'id', 'username']); + }); + + it('should forbid view-only collaborators from listing collaborators', async () => { + const doc = await prisma.document.create({ + data: { title: 'Viewer Doc', authorId: userId, content: '' }, + }); + + await request(app).post('/api/auth/register').send({ + email: 'viewer@test.dev', + username: 'viewer', + password: 'secure123', + }); + + const viewerLogin = await request(app) + .post('/api/auth/login') + .send({ email: 'viewer@test.dev', password: 'secure123' }); + const viewerId = viewerLogin.body.user.id; + + await prisma.collaborator.create({ + data: { documentId: doc.id, userId: viewerId, permission: 'view' }, + }); + + const res = await request(app) + .get(`/api/document/${doc.id}/collaborators`) + .set('Authorization', `Bearer ${viewerLogin.body.accessToken}`); + + expect(res.status).toBe(StatusCodes.FORBIDDEN); + }); }); diff --git a/server/test/slugIDtoFullID.test.ts b/server/test/slugIDtoFullID.test.ts new file mode 100644 index 0000000..eadb446 --- /dev/null +++ b/server/test/slugIDtoFullID.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { prisma } from '@/lib/prisma'; +import { slugIDtoFullID } from '@/utils/slugIDtoFullID'; + +describe('slugIDtoFullID', () => { + let userId: string; + + beforeEach(async () => { + await prisma.collaborator.deleteMany(); + await prisma.collaborationRequest.deleteMany(); + await prisma.yjsDocumentState.deleteMany(); + await prisma.document.deleteMany(); + await prisma.user.deleteMany(); + + const user = await prisma.user.create({ + data: { email: 'slug@test.dev', username: 'sluguser', password: 'unused' }, + }); + userId = user.id; + }); + + it('resolves an exact document ID', async () => { + const doc = await prisma.document.create({ + data: { id: 'aaaa1111-0000-4000-8000-000000000001', title: 'Exact', content: '', authorId: userId }, + }); + + await expect(slugIDtoFullID(doc.id)).resolves.toBe(doc.id); + }); + + it('does not resolve a truncated ID prefix', async () => { + const doc = await prisma.document.create({ + data: { id: 'bbbb1111-0000-4000-8000-000000000002', title: 'Prefixed', content: '', authorId: userId }, + }); + const slug = doc.id.slice(0, 8); + + await expect(slugIDtoFullID(slug)).rejects.toThrow(); + }); + + it('does not resolve an ambiguous prefix shared by multiple documents', async () => { + await prisma.document.createMany({ + data: [ + { id: 'cccc1111-0000-4000-8000-000000000003', title: 'One', content: '', authorId: userId }, + { id: 'cccc2222-0000-4000-8000-000000000004', title: 'Two', content: '', authorId: userId }, + ], + }); + + await expect(slugIDtoFullID('cccc')).rejects.toThrow(); + }); + + it('throws when no document exists', async () => { + await expect(slugIDtoFullID('dddd1111-0000-4000-8000-000000000005')).rejects.toThrow(/not found/); + }); +}); diff --git a/server/test/ws-auth.test.ts b/server/test/ws-auth.test.ts new file mode 100644 index 0000000..676fcaa --- /dev/null +++ b/server/test/ws-auth.test.ts @@ -0,0 +1,229 @@ +import { AddressInfo } from 'node:net'; + +import { HocuspocusProvider } from '@hocuspocus/provider'; +import request from 'supertest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocketServer } from 'ws'; +import * as Y from 'yjs'; + +import { prisma } from '@/lib/prisma'; +import { app } from '@/server'; +import socketServer from '@/sockets/ws-server'; + +const TOKEN_TIMEOUT_MS = 5_000; +const TEST_TIMEOUT_MS = 20_000; + +let wss: WebSocketServer; +let port: number; +const providers: HocuspocusProvider[] = []; + +beforeAll(async () => { + wss = new WebSocketServer({ port: 0 }); + wss.on('connection', (ws, req) => socketServer.handleConnection(ws, req)); + await new Promise(resolve => wss.once('listening', resolve)); + port = (wss.address() as AddressInfo).port; +}); + +afterAll(async () => { + for (const ws of wss.clients) ws.terminate(); + await new Promise(resolve => wss.close(() => resolve())); +}); + +afterEach(async () => { + for (const p of providers.splice(0)) await p.destroy(); +}); + +describe('WebSocket authentication', () => { + let ownerToken: string; + let docId: string; + + beforeEach(async () => { + await prisma.collaborator.deleteMany(); + await prisma.collaborationRequest.deleteMany(); + await prisma.yjsDocumentState.deleteMany(); + await prisma.document.deleteMany(); + await prisma.user.deleteMany(); + + await request(app).post('/api/auth/register').send({ + email: 'wsowner@test.dev', + username: 'wsowner', + password: 'secure123', + }); + + const login = await request(app).post('/api/auth/login').send({ + email: 'wsowner@test.dev', + password: 'secure123', + }); + ownerToken = login.body.accessToken; + + const doc = await prisma.document.create({ + data: { title: 'WS Doc', content: '', authorId: login.body.user.id }, + }); + docId = doc.id; + }); + + function connect(token: string | null): HocuspocusProvider { + // Node >=21 provides a global WebSocket, which the provider falls back to + const provider = new HocuspocusProvider({ + url: `ws://127.0.0.1:${port}/collaboration`, + name: docId, + document: new Y.Doc(), + token, + }); + providers.push(provider); + return provider; + } + + it( + 'rejects a connection with an invalid token', + async () => { + const provider = connect('not-a-real-jwt'); + + let failed = false; + let synced = false; + provider.on('authenticationFailed', () => { + failed = true; + }); + provider.on('synced', () => { + synced = true; + }); + + await expect.poll(() => failed, { timeout: TOKEN_TIMEOUT_MS }).toBe(true); + expect(synced).toBe(false); + }, + TEST_TIMEOUT_MS + ); + + it( + 'accepts the document owner and syncs', + async () => { + const provider = connect(ownerToken); + + let synced = false; + provider.on('synced', () => { + synced = true; + }); + + await expect.poll(() => synced, { timeout: TOKEN_TIMEOUT_MS }).toBe(true); + }, + TEST_TIMEOUT_MS + ); + + async function createCollaborator(permission: 'view' | 'edit') { + await request(app) + .post('/api/auth/register') + .send({ + email: `wscollab-${permission}@test.dev`, + username: `wscollab_${permission}`, + password: 'secure123', + }); + const login = await request(app) + .post('/api/auth/login') + .send({ + email: `wscollab-${permission}@test.dev`, + password: 'secure123', + }); + + const user = await prisma.user.findUniqueOrThrow({ + where: { email: `wscollab-${permission}@test.dev` }, + }); + await prisma.collaborator.create({ + data: { documentId: docId, userId: user.id, permission }, + }); + + return login.body.accessToken as string; + } + + it( + 'accepts a view collaborator but drops their writes', + async () => { + const viewToken = await createCollaborator('view'); + const provider = connect(viewToken); + + let synced = false; + provider.on('synced', () => { + synced = true; + }); + await expect.poll(() => synced, { timeout: TOKEN_TIMEOUT_MS }).toBe(true); + + const intruder = `sneaky edit ${Date.now()}`; + provider.document.getText('content').insert(0, intruder); + + // Give any (forbidden) sync a fair chance to propagate before tearing down + await new Promise(resolve => setTimeout(resolve, 1_500)); + await provider.destroy(); + await new Promise(resolve => setTimeout(resolve, 1_500)); + + const doc = await prisma.document.findUniqueOrThrow({ where: { id: docId } }); + expect(doc.content).not.toContain(intruder); + }, + TEST_TIMEOUT_MS + ); + + it( + 'accepts an edit collaborator and persists their writes', + async () => { + const editToken = await createCollaborator('edit'); + const provider = connect(editToken); + + let synced = false; + provider.on('synced', () => { + synced = true; + }); + await expect.poll(() => synced, { timeout: TOKEN_TIMEOUT_MS }).toBe(true); + + const text = `collaborative edit ${Date.now()}`; + provider.document.getText('content').insert(0, text); + + // Round-trip: the server echoes the merged state back + await expect + .poll(() => provider.document.getText('content').toString(), { + timeout: TOKEN_TIMEOUT_MS, + }) + .toContain(text); + + await provider.destroy(); + + await expect + .poll( + async () => { + const doc = await prisma.document.findUnique({ where: { id: docId } }); + return doc?.content ?? ''; + }, + { timeout: TOKEN_TIMEOUT_MS } + ) + .toContain(text); + }, + TEST_TIMEOUT_MS + ); + + it( + 'denies a stranger access to a private document', + async () => { + await request(app).post('/api/auth/register').send({ + email: 'wsstranger@test.dev', + username: 'wsstranger', + password: 'secure123', + }); + const strangerLogin = await request(app).post('/api/auth/login').send({ + email: 'wsstranger@test.dev', + password: 'secure123', + }); + + const provider = connect(strangerLogin.body.accessToken); + + let failed = false; + let synced = false; + provider.on('authenticationFailed', () => { + failed = true; + }); + provider.on('synced', () => { + synced = true; + }); + + await expect.poll(() => failed, { timeout: TOKEN_TIMEOUT_MS }).toBe(true); + expect(synced).toBe(false); + }, + TEST_TIMEOUT_MS + ); +}); From a0bf2f92aa00d907298cac0716428f1e6d5b8047 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Mon, 24 Aug 2026 22:56:35 +0300 Subject: [PATCH 09/33] chore: ignore local git worktrees directory --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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/ From f3e48ed97feacf9561844f2b9c3f9a0052fdc5ae Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:53:21 +0300 Subject: [PATCH 10/33] fix(server): remove leftover debug console.log calls (#75) * chore: ignore local git worktrees directory * fix(server): remove leftover debug console.log calls Closes #36 --- server/src/controllers/document.controller.ts | 4 ---- server/src/utils/getClientInfo.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index 1867b3c..f554568 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import { Response } from 'express'; import asyncErrorWrapper from 'express-async-handler'; import { StatusCodes } from 'http-status-codes'; @@ -404,7 +403,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', @@ -962,8 +960,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/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'), From bbc166ca3309008c6533877292ba1daa3adb1da5 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:36:41 +0300 Subject: [PATCH 11/33] feat(server): rate-limit auth endpoints (re-land onto develop) (#79) * fix(server): remove leftover debug console.log calls Drops two debug console.log statements in document.controller.ts (share-token decoding and collaborator removal) plus the now-unused chalk import and a commented-out log in getClientInfo.ts. Both spots already emit structured winston logs. Closes #36 * feat(server): rate-limit auth endpoints Adds express-rate-limit (10 requests / 15 min / IP) on register, login and refresh to protect against brute force. closes #37 * fix(server): force-enable limiter in rate-limit tests The shared authLimiter skips outside production and vitest runs with NODE_ENV=test, so the limiter was disabled inside its own tests and CI saw 200s instead of 429s. Override skip via the factory in the tests. --- pnpm-lock.yaml | 46 +++++++++++++------ server/package.json | 1 + .../src/middlewares/rate-limit.middleware.ts | 34 ++++++++++++++ server/src/routers/auth.router.ts | 7 +-- server/test/rateLimit.test.ts | 39 ++++++++++++++++ 5 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 server/src/middlewares/rate-limit.middleware.ts create mode 100644 server/test/rateLimit.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd23155..567676b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,7 +267,7 @@ importers: version: 1.3.2(eslint@9.32.0(jiti@2.5.1)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)) + 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)) @@ -349,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) @@ -3644,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'} @@ -4108,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'} @@ -6580,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 @@ -8410,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 @@ -8429,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 @@ -9591,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 @@ -9692,15 +9705,14 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.32.0(jiti@2.5.1)) @@ -9721,7 +9733,7 @@ snapshots: lodash.snakecase: 4.1.1 lodash.upperfirst: 4.3.1 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9732,7 +9744,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.32.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -9743,8 +9755,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -9915,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 @@ -10494,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: @@ -11404,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 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/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/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(); + }); +}); From fd120d978b038cc1e93f8f2340809e4f1f213e53 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:37:40 +0300 Subject: [PATCH 12/33] fix: deleting a shared document fails (FK RESTRICT) (#77) * chore: ignore local git worktrees directory * fix(server): cascade-delete collaborators and join requests Documents with Collaborator or CollaborationRequest rows could not be deleted: both FKs were RESTRICT, so DELETE /api/document/:id returned 500 for any shared doc. Cascade from Document like YjsDocumentState already does. Regression test covers the previously untested path. closes #44 --- .../migration.sql | 11 +++++++ server/prisma/schema.prisma | 4 +-- server/test/document.test.ts | 33 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql 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/test/document.test.ts b/server/test/document.test.ts index 072ba37..58105ed 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -108,6 +108,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: { From 277653a6dd8ad2f4dc0d882cda68ebd5b76eb947 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:37:08 +0300 Subject: [PATCH 13/33] fix(client): stale 'view' share link when switching permission (#78) * chore: ignore local git worktrees directory * fix(client): copy the selected permission's share link, not a stale one The effect is now the single fetch trigger on permission change, fetches are last-request-wins, and Copy is disabled while a link loads. Covered by an e2e test that copies during a slowed share-link request. Fixes #48 * fix(client): invalidate previous share link whenever a new fetch starts A failed refetch left the old permission's link in state: Copy re-enabled with the select showing the new mode, reintroducing the #48 symptom via the error path. shareLink is now cleared at fetch start, so after a failure (or mid-load) nothing stale is displayed or copyable. --- client/e2e/sharing.spec.ts | 80 +++++++++++++++++++ .../ShareButton/share-button.tsx | 7 +- client/src/hooks/use-share-link.ts | 28 ++++--- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/client/e2e/sharing.spec.ts b/client/e2e/sharing.spec.ts index c2d0648..d1ce0f2 100644 --- a/client/e2e/sharing.spec.ts +++ b/client/e2e/sharing.spec.ts @@ -53,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/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx index 89b7b16..628cc66 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx @@ -50,7 +50,6 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => { shareLink, loading: linkLoading, error, - refresh, } = useShareLink(docId, permission, isCollaborator); const handleCopy = async () => { @@ -59,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 ( @@ -84,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/hooks/use-share-link.ts b/client/src/hooks/use-share-link.ts index 5bbf367..8752dbc 100644 --- a/client/src/hooks/use-share-link.ts +++ b/client/src/hooks/use-share-link.ts @@ -1,19 +1,21 @@ -import { useCallback, useEffect, useState } from 'react'; +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. + * 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; - * refresh defaults to 'view' when called without it. + * 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 - * accepting an optional permission override. + * requiring the permission to fetch. */ export const useShareLink = ( docId?: string, @@ -23,31 +25,39 @@ export const useShareLink = ( 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' = 'view') => { + 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 { - setLoading(false); + if (requestId === requestRef.current) setLoading(false); } }, [docId], ); useEffect(() => { - if (!isCollaborator) { - fetchShareLink(permission); - } // Fetch on mount with default permission + // 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 }; From 8404d917ab62b24fa73101305dde623a0a63e52a Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:02:06 +0300 Subject: [PATCH 14/33] fix(server): scope collaboration request decisions to route document (#85) approveRequest/rejectRequest updated requests by bare requestId, allowing cross-document decisions, re-deciding settled requests, and 500s on missing ids. Now validate ownership + pending status (404/409) and write via scoped updateMany. Closes #46 --- server/src/controllers/document.controller.ts | 77 ++++++++++++- server/test/document.test.ts | 104 ++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index f554568..98b8f06 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -708,8 +708,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' }, }); @@ -782,7 +818,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' }); }); diff --git a/server/test/document.test.ts b/server/test/document.test.ts index 58105ed..3b53f66 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -388,3 +388,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); + }); +}); From fd5a03555dd1e6d8ffe9c7b38c8415b8beeb8f2e Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:18:32 +0300 Subject: [PATCH 15/33] fix(collab): accept email when adding collaborators (#80) * fix(collab): accept email when adding collaborators Client posted {email} while the server expected {userId}, so the add-collaborator flow 500'd on Prisma errors; the UI was commented out hiding the breakage. - POST /:id/collaborators now takes {email, permission?} (zod validated), resolves the user server-side - 404 unknown email, 409 duplicate, 400 malformed body - restore add-by-email form (owner only), controlled dropdown state fixes chevron stuck rotated via global window.open - addCollaborator hook returns success so input clears only on success - drop debug console.log leftovers Closes #50 * fix(server): map P2002 race on duplicate collaborator insert to 409 Two concurrent adds can both pass the findUnique pre-check; the loser hit the raw unique-constraint error and surfaced as 500. Wrap the create and translate PrismaClientKnownRequestError P2002 into ConflictError. * fix(server): normalize email case at validation boundaries Closes #82 --- .../collaborators-dropdown.stories.tsx | 72 +++++++++ .../collaborators-dropdown.tsx | 73 ++++++--- client/src/hooks/use-collaborators.ts | 7 +- server/src/controllers/document.controller.ts | 65 +++++++- server/src/exceptions/ConflictError.ts | 9 ++ server/src/routers/document.router.ts | 4 +- .../src/validations/addCollaborator.schema.ts | 11 ++ server/src/validations/login.schema.ts | 5 +- server/src/validations/register.schema.ts | 5 +- server/test/auth.test.ts | 18 +++ server/test/document.test.ts | 145 +++++++++++++++++- 11 files changed, 378 insertions(+), 36 deletions(-) create mode 100644 server/src/exceptions/ConflictError.ts create mode 100644 server/src/validations/addCollaborator.schema.ts diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.stories.tsx index 1238abe..d4f55d0 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; import { Collaborator } from '@/types/api'; @@ -112,3 +113,74 @@ export const LongNames: Story = { collaborators: longNameCollaborators, }, }; + +/** Owner sees the add-by-email form; the submit button is disabled until an email is typed. */ +export const OwnerAddByEmailForm: Story = { + args: { + docId: 'doc1', + isOwner: true, + collaborators: mockCollaborators, + }, + play: async ({ canvas }) => { + await userEvent.click( + canvas.getByRole('button', { name: /collaborators/i }), + ); + // Radix portals menu content to document.body, outside the story canvas + const body = within(document.body); + const emailInput = await body.findByLabelText('Collaborator email'); + const addButton = body.getByRole('button', { name: /add collaborator/i }); + + await expect(addButton).toBeDisabled(); + await userEvent.type(emailInput, 'newcollab@example.com'); + await expect(addButton).toBeEnabled(); + }, +}; + +/** Non-owners never see the add-by-email form. */ +export const NonOwnerHidesAddForm: Story = { + args: { + isOwner: false, + collaborators: mockCollaborators, + }, + play: async ({ canvas }) => { + await userEvent.click( + canvas.getByRole('button', { name: /collaborators/i }), + ); + const body = within(document.body); + await body.findByText('alice'); + + await expect(body.queryByLabelText('Collaborator email')).toBeNull(); + }, +}; + +/** + * Failed adds surface an error and keep the typed email so it can be + * corrected and resubmitted. + * + * The story canvas has no API to talk to, so this exercises the error + * path of the add flow. + */ +export const AddFailureShowsError: Story = { + args: { + docId: 'doc1', + isOwner: true, + collaborators: mockCollaborators, + }, + play: async ({ canvas }) => { + await userEvent.click( + canvas.getByRole('button', { name: /collaborators/i }), + ); + const body = within(document.body); + const emailInput = await body.findByLabelText('Collaborator email'); + + await userEvent.type(emailInput, 'nobody@example.com'); + await userEvent.click( + body.getByRole('button', { name: /add collaborator/i }), + ); + + await expect(body.findByRole('alert')).resolves.toHaveTextContent( + /failed to add collaborator/i, + ); + await expect(emailInput).toHaveValue('nobody@example.com'); + }, +}; diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx index fc8209c..66f8b3f 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { LuUsers as GroupIcon, LuChevronDown as ChevronIcon, + LuPlus as PlusIcon, LuTrash2 as TrashIcon, } from 'react-icons/lu'; @@ -10,7 +11,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - // DropdownMenuSeparator, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/Dropdown'; import { useCollaborators } from '@/hooks/use-collaborators'; @@ -38,23 +39,30 @@ export const CollaboratorsDropdown = ({ collaborators, }: CollaboratorsDropdownProps) => { const [email, setEmail] = useState(''); + const [open, setOpen] = useState(false); const { collaborators: fetchedCollaborators, loading, + error, addCollaborator, removeCollaborator, } = useCollaborators(docId); const list = collaborators ?? fetchedCollaborators; - const _handleAdd = async () => { - await addCollaborator(email); - setEmail(''); + /** Adds the typed email as a collaborator and clears the input on success. */ + const handleAdd = async () => { + const added = await addCollaborator(email); + if (added) setEmail(''); }; return ( - + - -
*/} + {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/hooks/use-collaborators.ts b/client/src/hooks/use-collaborators.ts index a67cdde..92e70e8 100644 --- a/client/src/hooks/use-collaborators.ts +++ b/client/src/hooks/use-collaborators.ts @@ -11,7 +11,8 @@ import { Collaborator } from '@/types/api'; * 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. + * actions. `addCollaborator` resolves to whether the collaborator was + * added. */ export const useCollaborators = (docId?: string) => { const [collaborators, setCollaborators] = useState([]); @@ -50,14 +51,16 @@ export const useCollaborators = (docId?: string) => { }; 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; } }; diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index 98b8f06..6602daf 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -1,11 +1,15 @@ +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); @@ -921,7 +925,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', { @@ -929,7 +933,7 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques ...clientInfo, ownerId, documentId: id, - newCollaboratorId, + email, permission, }); @@ -942,7 +946,6 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques ...clientInfo, ownerId, documentId: id, - newCollaboratorId, documentExists: !!doc, isOwner: doc?.authorId === ownerId, }); @@ -951,16 +954,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, }); @@ -972,7 +1024,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, 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/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/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 3b53f66..cb51cd5 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'; @@ -266,9 +267,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; @@ -279,6 +282,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: '' }, From 90f8a8e835d0c777bb126e865b4e654fa2e91775 Mon Sep 17 00:00:00 2001 From: Ali <142531761+Alimedhat000@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:38:47 +0300 Subject: [PATCH 16/33] fix(server): make Yjs sole writer of Document.content (#86) * fix(server): make Yjs sole writer of Document.content REST update/create could overwrite the Yjs-derived content mirror, silently discarding collaborative edits (last-writer-wins). Strip content from both endpoints; also write snapshot + mirror atomically in dbPersistence.store so they cannot drift on partial failure. Closes #47 * fix(client): stop sending content in document save payload --- client/src/hooks/use-document.ts | 8 ++- server/src/controllers/document.controller.ts | 25 +++------- server/src/lib/dbPersistence.ts | 49 ++++++++++--------- server/test/document.test.ts | 18 +++++++ 4 files changed, 58 insertions(+), 42 deletions(-) diff --git a/client/src/hooks/use-document.ts b/client/src/hooks/use-document.ts index 578d631..78a85d3 100644 --- a/client/src/hooks/use-document.ts +++ b/client/src/hooks/use-document.ts @@ -51,11 +51,15 @@ export function useDocument(id?: string) { }, [id]); const handleSave = useCallback(async () => { - if (!id) return; + if (!id || !editedDoc) return; try { setSaving(true); setError(null); - await api.put(`/document/${id}`, editedDoc); + // 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); diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index 6602daf..e958b23 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -14,7 +14,7 @@ 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', @@ -22,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, }, @@ -194,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 }, @@ -231,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, 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/test/document.test.ts b/server/test/document.test.ts index cb51cd5..41e3927 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -96,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: { From 3b62d69d92d372cc7c8813efbd91321901dccc64 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Tue, 25 Aug 2026 23:11:49 +0300 Subject: [PATCH 17/33] fix(server,client): validate all document endpoints with Zod Document routes accepted unvalidated bodies and params: malformed ids surfaced Prisma errors as 500s, non-boolean flags reached the DB layer, and title/permission values were never length- or enum-checked. Auth schemas also lagged client-side bounds (server allowed 6-char passwords while the client requires 8). Add body/param/query schemas wired through the existing validate() middleware for every /document route, replace the manual share-link permission check with a query enum schema, and harden register/login/ addCollaborator bounds (email <=254, username <=50 + charset, password 8..128, fullName <=100). Mirror those bounds in the client (zod schemas + maxLength attributes) so users never hit a 400. Closes #52. Related: #37 (rate limiting), #83 (enumeration hardening). --- .../new-document-form-body.tsx | 1 + client/src/components/ui/Auth/login-form.tsx | 2 + .../src/components/ui/Auth/register-form.tsx | 4 + .../DocumentCardDropdown/rename-modal.tsx | 1 + .../collaborators-dropdown.tsx | 1 + client/src/lib/auth.ts | 28 ++++-- server/src/controllers/document.controller.ts | 18 +--- server/src/routers/document.router.ts | 49 ++++++---- .../src/validations/addCollaborator.schema.ts | 1 + .../src/validations/createDocument.schema.ts | 9 ++ .../src/validations/documentParams.schema.ts | 27 ++++++ server/src/validations/login.schema.ts | 3 +- server/src/validations/register.schema.ts | 11 ++- .../validations/updateDocSettings.schema.ts | 7 ++ .../src/validations/updateDocument.schema.ts | 9 ++ server/test/auth.test.ts | 20 ++++ server/test/document.test.ts | 93 +++++++++++++++++++ 17 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 server/src/validations/createDocument.schema.ts create mode 100644 server/src/validations/documentParams.schema.ts create mode 100644 server/src/validations/updateDocSettings.schema.ts create mode 100644 server/src/validations/updateDocument.schema.ts diff --git a/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx index 795622e..b46a8a2 100644 --- a/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx +++ b/client/src/components/common/NewDocumentFormBody/new-document-form-body.tsx @@ -64,6 +64,7 @@ export default function NewDocumentFormBody({ type="text" label="Document Title" placeholder="New document title" + maxLength={200} error={errors.title} registration={register('title', { required: 'Title is required', diff --git a/client/src/components/ui/Auth/login-form.tsx b/client/src/components/ui/Auth/login-form.tsx index df77622..57dd17a 100644 --- a/client/src/components/ui/Auth/login-form.tsx +++ b/client/src/components/ui/Auth/login-form.tsx @@ -49,6 +49,7 @@ export default function LoginForm({ id="email" label="Email" placeholder="Enter your email" + maxLength={254} registration={register('email')} error={errors.email} autoComplete="email" @@ -59,6 +60,7 @@ export default function LoginForm({ id="password" label="Password" placeholder="Enter your password" + maxLength={128} registration={register('password')} error={errors.password} autoComplete="current-password" diff --git a/client/src/components/ui/Auth/register-form.tsx b/client/src/components/ui/Auth/register-form.tsx index ef4b595..8975fb3 100644 --- a/client/src/components/ui/Auth/register-form.tsx +++ b/client/src/components/ui/Auth/register-form.tsx @@ -46,6 +46,7 @@ export default function RegisterForm({ id="email" label="Email" placeholder="Enter your email" + maxLength={254} registration={register('email')} error={errors.email} autoComplete="email" @@ -56,6 +57,7 @@ export default function RegisterForm({ label="Username" id="username" placeholder="Enter your username" + maxLength={50} registration={register('username')} error={errors.username} autoComplete="username" @@ -66,6 +68,7 @@ export default function RegisterForm({ label="Full Name" id="fullname" placeholder="Enter your full name" + maxLength={100} registration={register('fullName')} error={errors.fullName} autoComplete="name" @@ -76,6 +79,7 @@ export default function RegisterForm({ id="password" label="Password" placeholder="Enter your password" + maxLength={128} registration={register('password')} error={errors.password} autoComplete="new-password" diff --git a/client/src/features/Dashboard/components/DocumentCardDropdown/rename-modal.tsx b/client/src/features/Dashboard/components/DocumentCardDropdown/rename-modal.tsx index f4f9414..89e5144 100644 --- a/client/src/features/Dashboard/components/DocumentCardDropdown/rename-modal.tsx +++ b/client/src/features/Dashboard/components/DocumentCardDropdown/rename-modal.tsx @@ -85,6 +85,7 @@ export function RenameDocumentModal({ type="text" value={newTitle} onChange={(e) => setNewTitle(e.target.value)} + maxLength={200} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter document title" onKeyDown={handleKeyDown} diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx index 66f8b3f..ffd0da6 100644 --- a/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx +++ b/client/src/features/DocumentPage/components/DocumentHeader/CollaboratorsDropdown/collaborators-dropdown.tsx @@ -116,6 +116,7 @@ export const CollaboratorsDropdown = ({ placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} + maxLength={254} aria-label="Collaborator email" className="h-8 min-w-0 flex-1 rounded border border-surface-border bg-transparent px-2 text-xs outline-none focus-visible:border-ring" /> diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index b966778..b26d8d8 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -7,22 +7,32 @@ 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. + * Zod schema validating registration input: valid email (max 254 chars), + * username (3-50 chars, letters/digits/dots/dashes/underscores), password + * (8-128 characters), and optional full name (max 100 chars). Mirrors the + * server-side RegisterUserSchema bounds so users never hit a 400. */ export const RegisterSchema = z.object({ - email: z.string().email(), - username: z.string().min(3), - password: z.string().min(8), - fullName: z.string().optional(), + email: z.string().email().max(254), + username: z + .string() + .min(3) + .max(50) + .regex( + /^[a-zA-Z0-9_.-]+$/, + 'Only letters, digits, dots, dashes and underscores allowed', + ), + password: z.string().min(8).max(128), + fullName: z.string().max(100).optional(), }); /** - * zod schema validating login credentials (email format + required password). + * Zod schema validating login credentials: valid email (max 254 chars) and a + * password within the server-accepted length bounds. */ export const LoginSchema = z.object({ - email: z.string().email(), - password: z.string().min(8), + email: z.string().email().max(254), + password: z.string().min(8).max(128), }); export type RegisterSchemaType = z.infer; diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts index e958b23..b8fdea8 100644 --- a/server/src/controllers/document.controller.ts +++ b/server/src/controllers/document.controller.ts @@ -544,7 +544,8 @@ export const getShareLink = asyncErrorWrapper(async (req: AuthenticatedRequest, const clientInfo = getClientInfo(req); const userId = req.user?.userId; const { id } = req.params; - const { permission = 'view' } = req.query; + // Validated + defaulted by ShareLinkQuerySchema on the route + const { permission } = req.query as { permission: 'view' | 'edit' }; logger.debug('Share link generation attempt', { action: 'GENERATE_SHARE_LINK_ATTEMPT', @@ -554,19 +555,6 @@ export const getShareLink = asyncErrorWrapper(async (req: AuthenticatedRequest, permission, }); - if (!['view', 'edit'].includes(permission as string)) { - logger.warn('Share link generation failed - invalid permission', { - action: 'GENERATE_SHARE_LINK_INVALID_PERMISSION', - ...clientInfo, - userId, - documentId: id, - permission, - }); - - res.status(StatusCodes.BAD_REQUEST).json({ error: 'Invalid permission' }); - return; - } - try { const doc = await prisma.document.findUnique({ where: { id } }); @@ -584,7 +572,7 @@ export const getShareLink = asyncErrorWrapper(async (req: AuthenticatedRequest, return; } - const token = generateShareToken(doc.shareId, permission as 'view' | 'edit'); + const token = generateShareToken(doc.shareId, permission); // Updated URL structure - token is now in the path const url = `${process.env.CLIENT_BASE}/app/doc/share/${token}`; diff --git a/server/src/routers/document.router.ts b/server/src/routers/document.router.ts index c7bdddd..8f9a32c 100644 --- a/server/src/routers/document.router.ts +++ b/server/src/routers/document.router.ts @@ -19,26 +19,43 @@ import { import { authenticate } from '@/middlewares/auth.middleware'; import { validate } from '@/middlewares/validation.middleware'; import { AddCollaboratorSchema } from '@/validations/addCollaborator.schema'; +import { CreateDocumentSchema } from '@/validations/createDocument.schema'; +import { + CollaboratorParamsSchema, + IdParamsSchema, + RequestIdParamsSchema, + ShareLinkQuerySchema, +} from '@/validations/documentParams.schema'; +import { UpdateDocSettingsSchema } from '@/validations/updateDocSettings.schema'; +import { UpdateDocumentSchema } from '@/validations/updateDocument.schema'; export const docRouter = express.Router(); docRouter.use(authenticate); docRouter.get('/share/:token', getDocByToken); -docRouter.post('/', createDoc); +docRouter.post('/', validate({ body: CreateDocumentSchema }), createDoc); docRouter.get('/', getDocs); -docRouter.get('/:id', getDoc); -docRouter.put('/:id', updateDoc); -docRouter.delete('/:id', deleteDoc); - -docRouter.patch('/:id/settings', updateDocSettings); // Used to toggle allowSelfJoin for the document // !Owner only access - -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', 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 -docRouter.post('/:id/requests/:requestId/approve', approveRequest); -docRouter.delete('/:id/requests/:requestId/reject', rejectRequest); +docRouter.get('/:id', validate({ params: IdParamsSchema }), getDoc); +docRouter.put('/:id', validate({ params: IdParamsSchema, body: UpdateDocumentSchema }), updateDoc); +docRouter.delete('/:id', validate({ params: IdParamsSchema }), deleteDoc); + +docRouter.patch( + '/:id/settings', + validate({ params: IdParamsSchema, body: UpdateDocSettingsSchema }), + updateDocSettings +); // Used to toggle allowSelfJoin for the document // !Owner only access + +docRouter.get('/:id/share-link', validate({ params: IdParamsSchema, query: ShareLinkQuerySchema }), getShareLink); // get the document share link with the share token + +docRouter.get('/:id/collaborators', validate({ params: IdParamsSchema }), getCollaborators); // returns list +docRouter.post( + '/:id/collaborators', + validate({ params: IdParamsSchema, body: AddCollaboratorSchema }), + addCollaborator +); // adds a new one by email //!Owner only access +docRouter.delete('/:id/collaborators/:userId', validate({ params: CollaboratorParamsSchema }), removeCollaborator); // optional + +docRouter.get('/:id/requests', validate({ params: IdParamsSchema }), getRequests); // !Owner only access +docRouter.post('/:id/requests/:requestId/approve', validate({ params: RequestIdParamsSchema }), approveRequest); +docRouter.delete('/:id/requests/:requestId/reject', validate({ params: RequestIdParamsSchema }), rejectRequest); diff --git a/server/src/validations/addCollaborator.schema.ts b/server/src/validations/addCollaborator.schema.ts index 28fdb76..4e0c7dc 100644 --- a/server/src/validations/addCollaborator.schema.ts +++ b/server/src/validations/addCollaborator.schema.ts @@ -4,6 +4,7 @@ export const AddCollaboratorSchema = z.object({ email: z .string() .email() + .max(254) .transform(email => email.trim().toLowerCase()), permission: z.enum(['edit', 'view']).default('edit'), }); diff --git a/server/src/validations/createDocument.schema.ts b/server/src/validations/createDocument.schema.ts new file mode 100644 index 0000000..00cfec3 --- /dev/null +++ b/server/src/validations/createDocument.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const CreateDocumentSchema = z.object({ + title: z.string().trim().min(1).max(200), + content: z.string().max(1_000_000).optional(), + isPublic: z.boolean().default(false), +}); + +export type CreateDocumentSchema = z.infer; diff --git a/server/src/validations/documentParams.schema.ts b/server/src/validations/documentParams.schema.ts new file mode 100644 index 0000000..5e19448 --- /dev/null +++ b/server/src/validations/documentParams.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const IdParamsSchema = z.object({ + id: z.string().uuid(), +}); + +export type IdParamsSchema = z.infer; + +export const RequestIdParamsSchema = z.object({ + id: z.string().uuid(), + requestId: z.string().uuid(), +}); + +export type RequestIdParamsSchema = z.infer; + +export const CollaboratorParamsSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), +}); + +export type CollaboratorParamsSchema = z.infer; + +export const ShareLinkQuerySchema = z.object({ + permission: z.enum(['view', 'edit']).default('view'), +}); + +export type ShareLinkQuerySchema = z.infer; diff --git a/server/src/validations/login.schema.ts b/server/src/validations/login.schema.ts index a852fe5..f763a96 100644 --- a/server/src/validations/login.schema.ts +++ b/server/src/validations/login.schema.ts @@ -4,8 +4,9 @@ export const LoginUserSchema = z.object({ email: z .string() .email() + .max(254) .transform(email => email.trim().toLowerCase()), - password: z.string().min(6), + password: z.string().min(8).max(128), }); export type LoginUserSchema = z.infer; diff --git a/server/src/validations/register.schema.ts b/server/src/validations/register.schema.ts index 6895311..c97eac8 100644 --- a/server/src/validations/register.schema.ts +++ b/server/src/validations/register.schema.ts @@ -4,10 +4,15 @@ export const RegisterUserSchema = z.object({ email: z .string() .email() + .max(254) .transform(email => email.trim().toLowerCase()), - username: z.string().min(3), - password: z.string().min(6), - fullName: z.string().optional(), + username: z + .string() + .min(3) + .max(50) + .regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dots, dashes and underscores'), + password: z.string().min(8).max(128), + fullName: z.string().max(100).optional(), }); export type RegisterUserSchema = z.infer; diff --git a/server/src/validations/updateDocSettings.schema.ts b/server/src/validations/updateDocSettings.schema.ts new file mode 100644 index 0000000..9d877b0 --- /dev/null +++ b/server/src/validations/updateDocSettings.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const UpdateDocSettingsSchema = z.object({ + allowSelfJoin: z.boolean(), +}); + +export type UpdateDocSettingsSchema = z.infer; diff --git a/server/src/validations/updateDocument.schema.ts b/server/src/validations/updateDocument.schema.ts new file mode 100644 index 0000000..72429de --- /dev/null +++ b/server/src/validations/updateDocument.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const UpdateDocumentSchema = z.object({ + title: z.string().trim().min(1).max(200).optional(), + content: z.string().max(1_000_000).optional(), + isPublic: z.boolean().optional(), +}); + +export type UpdateDocumentSchema = z.infer; diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 2b1847d..afde405 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -124,6 +124,26 @@ describe('Auth Routes', () => { expect(res.status).toBe(StatusCodes.BAD_REQUEST); }); + it('should reject registration with a 6-character password', async () => { + const res = await request(app).post('/api/auth/register').send({ + email: 'shortpass@test.dev', + username: 'shortpass', + password: 'abcdef', + }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should reject registration with a username containing spaces', async () => { + const res = await request(app).post('/api/auth/register').send({ + email: 'spaceuser@test.dev', + username: 'bad name', + password: 'secure123', + }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + it('should reject registration with missing fields', async () => { const res = await request(app).post('/api/auth/register').send({ email: 'newuser@test.dev', diff --git a/server/test/document.test.ts b/server/test/document.test.ts index 41e3927..af27ad0 100644 --- a/server/test/document.test.ts +++ b/server/test/document.test.ts @@ -550,6 +550,99 @@ describe('Document Routes', () => { }); }); +describe('Document request validation (#52)', () => { + it('should reject creating a document with a missing title', async () => { + const res = await request(app).post('/api/document').set('Authorization', `Bearer ${token}`).send({}); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should reject creating a document with a non-string title', async () => { + const res = await request(app).post('/api/document').set('Authorization', `Bearer ${token}`).send({ title: 123 }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should reject creating a document with an oversized title', async () => { + const res = await request(app) + .post('/api/document') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'x'.repeat(201) }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should reject creating a document with oversized content', async () => { + const res = await request(app) + .post('/api/document') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Ok', content: 'x'.repeat(1_000_001) }); + + // A >1MB body trips express.json's 100kb payload limit first (413); + // CreateDocumentSchema's content cap remains as defense-in-depth. + expect([StatusCodes.BAD_REQUEST, StatusCodes.REQUEST_TOO_LONG]).toContain(res.status); + }); + + it('should reject updating a document with a non-boolean isPublic', async () => { + const created = await prisma.document.create({ + data: { title: 'Bool Check', authorId: userId, content: '' }, + }); + + const res = await request(app) + .put(`/api/document/${created.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Still Ok', isPublic: 'yes' }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should reject a settings update with a non-boolean allowSelfJoin', async () => { + const created = await prisma.document.create({ + data: { title: 'Settings Validation', authorId: userId, content: '' }, + }); + + const res = await request(app) + .patch(`/api/document/${created.id}/settings`) + .set('Authorization', `Bearer ${token}`) + .send({ allowSelfJoin: 'yes' }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should return 400 instead of 500 when getting a document with a non-uuid id', async () => { + const res = await request(app).get('/api/document/not-a-uuid').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should return 400 instead of 500 when updating a document with a non-uuid id', async () => { + const res = await request(app) + .put('/api/document/not-a-uuid') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Nope' }); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should return 400 instead of 500 when deleting a document with a non-uuid id', async () => { + const res = await request(app).delete('/api/document/not-a-uuid').set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should reject share-link generation with an invalid permission', async () => { + const created = await prisma.document.create({ + data: { title: 'Share Perm', authorId: userId, content: '' }, + }); + + const res = await request(app) + .get(`/api/document/${created.id}/share-link?permission=admin`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(StatusCodes.BAD_REQUEST); + }); +}); + describe('Collaboration request decision scoping (#46)', () => { async function createOwnedDocument(title: string) { return prisma.document.create({ From 39b13d685e3ec2fce5d63b730de2234abec370b6 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 02:43:12 +0300 Subject: [PATCH 18/33] fix(auth): prevent account enumeration on register and login (#83) Registration now returns a single generic conflict message regardless of which field collided, and login returns an identical 401 body with a non-empty generic message for both unknown-user and bad-password cases (previously the body serialized to {}). A dummy bcrypt compare equalizes response timing when the user does not exist. --- server/src/controllers/auth.controller.ts | 14 ++++-- server/test/auth.test.ts | 55 +++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 86abcff..3b4c098 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -10,6 +10,12 @@ import { getClientInfo } from '@/utils/getClientInfo'; import { LoginUserSchema } from '@/validations/login.schema'; import { RegisterUserSchema } from '@/validations/register.schema'; +/** + * Fixed bcrypt hash used to equalize timing when login is attempted for an + * unknown email (#83): one bcrypt compare runs in both failure paths. + */ +const DUMMY_PASSWORD_HASH = '$2b$10$lZKU2EGQLmnz9Fi65/t3GO/coz9zBl6zMMvDyd0EOBgeU1Y28ESHG'; + export const registerUser = asyncErrorWrapper(async (req: Request, res: Response) => { const clientInfo = getClientInfo(req); @@ -48,7 +54,7 @@ export const registerUser = asyncErrorWrapper(async (req: Request, res: Response username, existingField: existing.email === email ? 'email' : 'username', }); - res.status(StatusCodes.CONFLICT).json({ error: 'Username or email already exists' }); + res.status(StatusCodes.CONFLICT).json({ error: 'Registration failed' }); return; } @@ -112,13 +118,15 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = const user = await prisma.user.findUnique({ where: { email } }); if (!user) { + await bcrypt.compare(password, DUMMY_PASSWORD_HASH); + logger.warn('Login failed - user not found', { action: 'LOGIN_USER_NOT_FOUND', ...clientInfo, email, }); - res.status(StatusCodes.UNAUTHORIZED).json({ error: result.error }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid email or password' }); return; } @@ -133,7 +141,7 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = username: user.username, }); - res.status(StatusCodes.UNAUTHORIZED).json({ error: result.error }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid email or password' }); return; } diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index afde405..457bb22 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -80,6 +80,35 @@ describe('Auth Routes', () => { }); }); + it('should not leak which field collided when registration fails (#83)', async () => { + await request(app).post('/api/auth/register').send({ + email: 'enum-email@test.dev', + username: 'enumuser', + password: 'secure123', + }); + + const dupEmail = await request(app).post('/api/auth/register').send({ + email: 'enum-email@test.dev', + username: 'unusedname', + password: 'secure123', + }); + + const dupUsername = await request(app).post('/api/auth/register').send({ + email: 'unused@test.dev', + username: 'enumuser', + password: 'secure123', + }); + + expect(dupEmail.status).toBe(StatusCodes.CONFLICT); + expect(dupUsername.status).toBe(StatusCodes.CONFLICT); + // Uniform response regardless of which field collided + expect(dupEmail.body).toEqual(dupUsername.body); + expect(typeof dupEmail.body.error).toBe('string'); + expect(dupEmail.body.error).not.toMatch(/email/i); + expect(dupEmail.body.error).not.toMatch(/username/i); + expect(dupEmail.body.error).not.toMatch(/exists/i); + }); + it('should reject login with invalid password', async () => { await request(app).post('/api/auth/register').send({ email: 'test@test.dev', @@ -104,6 +133,32 @@ describe('Auth Routes', () => { expect(res.status).toBe(StatusCodes.UNAUTHORIZED); }); + it('should not distinguish unknown user from invalid password on login (#83)', async () => { + await request(app).post('/api/auth/register').send({ + email: 'loginenum@test.dev', + username: 'loginenum', + password: 'secure123', + }); + + const badPassword = await request(app).post('/api/auth/login').send({ + email: 'loginenum@test.dev', + password: 'wrongpass', + }); + + const unknownUser = await request(app).post('/api/auth/login').send({ + email: 'ghost@test.dev', + password: 'anypassword', + }); + + expect(badPassword.status).toBe(StatusCodes.UNAUTHORIZED); + expect(unknownUser.status).toBe(StatusCodes.UNAUTHORIZED); + // Identical responses so probing cannot tell whether the account exists + expect(unknownUser.body).toEqual(badPassword.body); + // And the response carries an actual message (not the legacy empty `{}`) + expect(typeof unknownUser.body.error).toBe('string'); + expect(unknownUser.body.error.length).toBeGreaterThan(0); + }); + it('should reject registration with invalid email format', async () => { const res = await request(app).post('/api/auth/register').send({ email: 'invalid-email', From 223f5d38023b530c20858d9b6e6f33aebaf185d1 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:17:20 +0300 Subject: [PATCH 19/33] fix(server): authenticate logout via refresh cookie Logout previously required a valid access token, so an expired session could not log out (the exact moment logout matters). Reuse the validateRefreshToken middleware, which already populates req.user for the controller to revoke the stored token. --- server/src/routers/auth.router.ts | 4 ++-- server/test/auth.test.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/server/src/routers/auth.router.ts b/server/src/routers/auth.router.ts index 40b8c5f..5043791 100644 --- a/server/src/routers/auth.router.ts +++ b/server/src/routers/auth.router.ts @@ -1,7 +1,7 @@ import express from 'express'; import { loginUser, logoutUser, refreshToken, registerUser } from '@/controllers/auth.controller'; -import { authenticate, validateRefreshToken } from '@/middlewares/auth.middleware'; +import { validateRefreshToken } from '@/middlewares/auth.middleware'; import { authLimiter } from '@/middlewares/rate-limit.middleware'; import { validate } from '@/middlewares/validation.middleware'; import { LoginUserSchema } from '@/validations/login.schema'; @@ -11,5 +11,5 @@ export const authRouter = express.Router(); authRouter.post('/register', authLimiter, validate({ body: RegisterUserSchema }), registerUser); authRouter.post('/login', authLimiter, validate({ body: LoginUserSchema }), loginUser); -authRouter.post('/logout', authenticate, logoutUser); +authRouter.post('/logout', validateRefreshToken, logoutUser); authRouter.post('/refresh', authLimiter, validateRefreshToken, refreshToken); diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 457bb22..0d34081 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -230,6 +230,27 @@ describe('Auth Routes', () => { expect(logoutRes.status).toBe(StatusCodes.OK); }); + it('should logout with only the refresh cookie when no access token is sent', async () => { + await request(app).post('/api/auth/register').send({ + email: 'cookie-logout@test.dev', + username: 'cookieLogoutUser', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'cookie-logout@test.dev', + password: 'secure123', + }); + const cookieHeader = extractCookies(loginRes.headers['set-cookie']); + + const logoutRes = await request(app).post('/api/auth/logout').set('Cookie', cookieHeader); + + expect(logoutRes.status).toBe(StatusCodes.OK); + + const refreshRes = await request(app).post('/api/auth/refresh').set('Cookie', cookieHeader); + expect(refreshRes.status).toBe(StatusCodes.UNAUTHORIZED); + }); + it('should return 401 when accessing protected route without token', async () => { const res = await request(app).get('/api/user'); expect(res.status).toBe(StatusCodes.UNAUTHORIZED); From c1e3aa8ca7afa50860f9da6488a65940654d4183 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:20:42 +0300 Subject: [PATCH 20/33] fix(server): stop setting unused accessToken cookie on refresh The refresh endpoint set an httpOnly accessToken cookie that no client ever reads; the access token already travels in the response body. --- server/src/controllers/auth.controller.ts | 7 ------- server/test/auth.test.ts | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 3b4c098..2f59fc9 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -325,13 +325,6 @@ export const refreshToken = asyncErrorWrapper(async (req: Request, res: Response username: user.username, }); - res.cookie('accessToken', newAccessToken, { - httpOnly: true, - maxAge: 15 * 60 * 1000, - sameSite: 'none', // ✅ - secure: true, // ✅ - }); - res.status(StatusCodes.OK).json({ accessToken: newAccessToken, user: { diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 0d34081..0af2524 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -281,4 +281,25 @@ describe('Auth Routes', () => { expect(res.status).toBe(StatusCodes.UNAUTHORIZED); }); + + it('should not set an accessToken cookie on refresh', async () => { + await request(app).post('/api/auth/register').send({ + email: 'refresh@test.dev', + username: 'refreshUser', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'refresh@test.dev', + password: 'secure123', + }); + const cookieHeader = extractCookies(loginRes.headers['set-cookie']); + + const res = await request(app).post('/api/auth/refresh').set('Cookie', cookieHeader); + + expect(res.status).toBe(StatusCodes.OK); + const cookies = res.headers['set-cookie'] ?? []; + const names = (Array.isArray(cookies) ? cookies : [cookies]).map(c => c.split('=')[0]); + expect(names).not.toContain('accessToken'); + }); }); From f5a100e9e5a7f00597abc8db4ab6b459964a8470 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:26:10 +0300 Subject: [PATCH 21/33] fix(server): set refresh cookie flags per environment SameSite=None + Secure is only valid for cross-site HTTPS deployments; in dev the client and API are same-site over plain http, where Secure cookies get dropped and None requires TLS. Use Lax/insecure outside production, None/Secure in production. --- server/src/controllers/auth.controller.ts | 9 +++++++-- server/test/auth.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 2f59fc9..a11c76f 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -184,11 +184,16 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = tokenExpiry: '15m', }); + // Cross-site deployments (prod) need SameSite=None + Secure; in dev the + // client and API are same-site over http, where Secure cookies are dropped + // by browsers that don't trust localhost and None is rejected without TLS. + const isProduction = process.env.NODE_ENV === 'production'; + res.cookie('refreshToken', refreshToken, { httpOnly: true, maxAge: 24 * 60 * 60 * 1000, - sameSite: 'none', // ✅ allow cross-site cookies - secure: true, // ✅ must be secure for SameSite=None + sameSite: isProduction ? 'none' : 'lax', + secure: isProduction, }); res.status(StatusCodes.OK).json({ diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 0af2524..6dbd142 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -276,6 +276,25 @@ describe('Auth Routes', () => { expect(res.body.email).toBe('me@test.dev'); }); + it('should set the refresh cookie without Secure/SameSite=None outside production', async () => { + await request(app).post('/api/auth/register').send({ + email: 'cookie-flags@test.dev', + username: 'cookieFlagsUser', + password: 'secure123', + }); + + const res = await request(app).post('/api/auth/login').send({ + email: 'cookie-flags@test.dev', + password: 'secure123', + }); + + expect(res.status).toBe(StatusCodes.OK); + const refreshCookie = res.headers['set-cookie'].find((c: string) => c.startsWith('refreshToken=')); + expect(refreshCookie).toBeDefined(); + expect(refreshCookie).not.toContain('Secure'); + expect(refreshCookie).toContain('SameSite=Lax'); + }); + it('should reject refresh with invalid refresh token', async () => { const res = await request(app).post('/api/auth/refresh').set('Cookie', 'refreshToken=invalid.token.here'); From a2b7069f10a77fffe5880d0572246f8c16485389 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:33:05 +0300 Subject: [PATCH 22/33] fix(client): auto-refresh session on 401 with single-flight refresh Add a response interceptor that refreshes the access token once and replays the failed request. Concurrent 401s share one in-flight refresh; a request whose token was already rotated by a concurrent request replays directly instead of triggering a second round-trip. When the refresh itself fails, the stored token is cleared and listeners are notified so the auth provider can reset state. Also add a node-env vitest unit project so shared client lib code has a test runner (the existing vitest setup only ran Storybook tests). --- client/src/lib/__tests__/api.test.ts | 138 +++++++++++++++++++++++++++ client/src/lib/api.ts | 99 ++++++++++++++++++- client/vite.config.ts | 12 +++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 client/src/lib/__tests__/api.test.ts diff --git a/client/src/lib/__tests__/api.test.ts b/client/src/lib/__tests__/api.test.ts new file mode 100644 index 0000000..ab375aa --- /dev/null +++ b/client/src/lib/__tests__/api.test.ts @@ -0,0 +1,138 @@ +import { createServer, type Server } from 'node:http'; + +import axios from 'axios'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { clearAccessToken, setAccessToken } from '@/utils/token'; + +import { api, onSessionExpired } from '../api'; + +const PORT = 4599; +const BASE = `http://localhost:${PORT}`; + +let server: Server; +let refreshCallCount = 0; +let refreshShouldFail = false; +const protectedAuthHeaders: string[] = []; + +/** + * Minimal API stub: /protected requires the *new* token, /api/auth/refresh + * issues it once. Behaves like the real server for the paths under test. + */ +async function startStub(): Promise { + server = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + if (req.url === '/api/auth/refresh') { + refreshCallCount += 1; + res.setHeader('Content-Type', 'application/json'); + if (refreshShouldFail) { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Unauthorized' })); + } else { + res.end( + JSON.stringify({ accessToken: 'new-token', user: { id: 'u1' } }), + ); + } + return; + } + if (req.url === '/protected') { + protectedAuthHeaders.push(req.headers.authorization ?? ''); + if (req.headers.authorization === 'Bearer new-token') { + res.end(JSON.stringify({ ok: true })); + } else { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Invalid or expired token' })); + } + return; + } + if (req.url === '/always-401') { + res.statusCode = 401; + res.end(JSON.stringify({ error: 'Invalid or expired token' })); + return; + } + res.statusCode = 404; + res.end(); + }); + }); + await new Promise((resolve) => server.listen(PORT, resolve)); +} + +beforeAll(startStub); +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('api response interceptor', () => { + it('retries a request once after refreshing on 401', async () => { + refreshCallCount = 0; + protectedAuthHeaders.length = 0; + setAccessToken('old-token'); + + const res = await api.get(`${BASE}/protected`); + + expect(res.data).toEqual({ ok: true }); + expect(refreshCallCount).toBe(1); + // first attempt used stale token, replay used the refreshed one + expect(protectedAuthHeaders).toEqual([ + 'Bearer old-token', + 'Bearer new-token', + ]); + }); + + it('issues only one refresh for concurrent 401 responses', async () => { + refreshCallCount = 0; + protectedAuthHeaders.length = 0; + setAccessToken('old-token'); + + const [a, b, c] = await Promise.all([ + api.get(`${BASE}/protected`), + api.get(`${BASE}/protected`), + api.get(`${BASE}/protected`), + ]); + + expect(a.data).toEqual({ ok: true }); + expect(b.data).toEqual({ ok: true }); + expect(c.data).toEqual({ ok: true }); + expect(refreshCallCount).toBe(1); + }); + + it('clears the token and notifies listeners when refresh fails', async () => { + refreshShouldFail = true; + setAccessToken('expired-token'); + const expired = vi.fn(); + const unsubscribe = onSessionExpired(expired); + + await expect(api.get(`${BASE}/always-401`)).rejects.toThrow(); + + expect(expired).toHaveBeenCalledTimes(1); + unsubscribe(); + refreshShouldFail = false; + }); + + it('does not attempt a refresh when no access token is stored', async () => { + refreshCallCount = 0; + clearAccessToken(); + + await expect(api.get(`${BASE}/always-401`)).rejects.toThrow(); + expect(refreshCallCount).toBe(0); + }); + + it('uses the shared instance against the configured base URL without manual base joining', async () => { + // sanity check that the exported api is an axios instance wired to env config + expect( + axios.isAxiosError(await api.get(`${BASE}/missing`).catch((e) => e)), + ).toBe(true); + }); +}); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index eb50be2..233cfec 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,11 +1,25 @@ import axios from 'axios'; import { env } from '@/config/env'; -import { getAccessToken } from '@/utils/token'; +import { + clearAccessToken, + getAccessToken, + setAccessToken, +} from '@/utils/token'; + +declare module 'axios' { + export interface InternalAxiosRequestConfig { + /** Access token attached when the request was originally sent. */ + _tokenUsed?: string; + /** Marks a request already replayed after a refresh. */ + _retry?: boolean; + } +} /** * Shared axios instance for all API calls. - * Attaches the stored access token to every request. + * Attaches the stored access token to every request and transparently + * refreshes an expired session once on 401 before replaying the request. */ export const api = axios.create({ baseURL: `${env.API_URL}/api`, @@ -16,6 +30,87 @@ api.interceptors.request.use((config) => { const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; + config._tokenUsed = token; } return config; }); + +/** + * Callback invoked when the session cannot be recovered (refresh failed). + */ +type SessionExpiredListener = () => void; + +const sessionExpiredListeners = new Set(); + +/** + * Registers a callback invoked when the session expires and cannot be + * refreshed; returns a function that unsubscribes the callback. + */ +export const onSessionExpired = ( + listener: SessionExpiredListener, +): (() => void) => { + sessionExpiredListeners.add(listener); + return () => sessionExpiredListeners.delete(listener); +}; + +// Auth endpoints manage their own credentials; refreshing from their own 401s +// would loop. +const AUTH_PATHS = [ + '/auth/login', + '/auth/register', + '/auth/refresh', + '/auth/logout', +]; + +let refreshPromise: Promise | null = null; + +/** + * Requests a fresh access token via the httpOnly refresh cookie; concurrent + * callers share the in-flight request so only one round-trip happens. + */ +const refreshAccessToken = (): Promise => { + if (!refreshPromise) { + refreshPromise = axios + .post(`${env.API_URL}/api/auth/refresh`, null, { withCredentials: true }) + .then((res) => { + const token: string = res.data.accessToken; + setAccessToken(token); + return token; + }) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +}; + +api.interceptors.response.use(undefined, async (error: unknown) => { + if (!axios.isAxiosError(error) || !error.config) throw error; + const original = error.config; + const isAuthCall = AUTH_PATHS.some((path) => original.url?.includes(path)); + + if ( + error.response?.status !== 401 || + original._retry || + isAuthCall || + !original._tokenUsed + ) { + throw error; + } + + original._retry = true; + try { + // A concurrent request may have refreshed the token while this one was in + // flight; reuse it instead of refreshing again. + const token = + getAccessToken() !== original._tokenUsed + ? getAccessToken()! + : await refreshAccessToken(); + original.headers.Authorization = `Bearer ${token}`; + return api(original); + } catch { + clearAccessToken(); + sessionExpiredListeners.forEach((listener) => listener()); + throw error; + } +}); diff --git a/client/vite.config.ts b/client/vite.config.ts index 58a6bc2..357123d 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -39,6 +39,18 @@ export default defineConfig({ }, test: { projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['src/**/*.test.{ts,tsx}'], + environment: 'node', + env: { + VITE_APP_API_URL: 'http://localhost:4599', + VITE_APP_SOCKET_URL: 'ws://localhost:5000/collaboration', + }, + }, + }, { extends: true, plugins: [ From 8d3158e94eb940ef68f567e7582b3bc28dc79089 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:49:13 +0300 Subject: [PATCH 23/33] fix(client): rebuild session bootstrap without wasLoggedOut hack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bootstrap now restores the session via the shared single-flight refresh and simply marks the user signed out when it fails — it no longer calls the authenticated logout endpoint (which always 401'd and threw an unhandled rejection). Remove the wasLoggedOut localStorage flag that kept logging users out after a logout->login cycle, swallow logout errors when the session is already dead server-side, and reset auth state when the interceptor reports an unrecoverable session expiry. Add e2e coverage for logout->re-login->reload session restore and for not calling logout during an unauthenticated bootstrap; serialize auth specs since real logins overwrite the user's single stored refresh token. --- client/e2e/auth.spec.ts | 46 ++++++++++++++++++++++ client/src/context/auth/auth-provider.tsx | 47 ++++++++++++++--------- client/src/lib/api.ts | 27 +++++++++---- 3 files changed, 93 insertions(+), 27 deletions(-) diff --git a/client/e2e/auth.spec.ts b/client/e2e/auth.spec.ts index cd2c0ab..41a9c60 100644 --- a/client/e2e/auth.spec.ts +++ b/client/e2e/auth.spec.ts @@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test'; // These tests cover the unauthenticated flows; they run without a saved // session (see the 'auth-specs' project in playwright.config.ts). +// Several of them perform real logins, which overwrite the user's single +// stored refresh token server-side — so they must not overlap. +test.describe.configure({ mode: 'serial' }); + test.describe('Authentication Flow', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -68,6 +72,48 @@ test.describe('Authentication Flow', () => { await expect(page.getByRole('menuitem', { name: /logout/i })).toBeVisible(); }); + test('should keep the session when logging back in and reloading', async ({ + page, + }) => { + const login = async () => { + await page.goto('/login'); + await page.getByLabel(/email/i).fill('test@example.com'); + await page.getByLabel(/password/i).fill('testpassword'); + await page.getByRole('button', { name: /login|sign in/i }).click(); + await expect(page).toHaveURL(/.*\/app/); + }; + + await login(); + + // Log out via the UI, then log back in — all within the same SPA session. + await page.getByRole('button', { name: /user menu/i }).click(); + await page.getByRole('menuitem', { name: /logout/i }).click(); + await login(); + + // A reload must restore the session from the refresh cookie. + await page.reload(); + await expect(page).toHaveURL(/.*\/app/, { timeout: 10_000 }); + await expect(page.getByRole('button', { name: /user menu/i })).toBeVisible({ + timeout: 10_000, + }); + }); + + test('should not call authenticated logout when bootstrap has no session', async ({ + page, + }) => { + const logoutCalls: string[] = []; + page.on('request', (req) => { + if (req.url().includes('/api/auth/logout')) { + logoutCalls.push(req.url()); + } + }); + + await page.goto('/login'); + await expect(page.getByLabel(/email/i)).toBeVisible(); + + expect(logoutCalls).toEqual([]); + }); + test('should register a new account and redirect to login', async ({ page, }) => { diff --git a/client/src/context/auth/auth-provider.tsx b/client/src/context/auth/auth-provider.tsx index 55d618e..c09d908 100644 --- a/client/src/context/auth/auth-provider.tsx +++ b/client/src/context/auth/auth-provider.tsx @@ -1,19 +1,29 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; -import { api } from '@/lib/api'; +import { api, onSessionExpired, refreshAccessToken } from '@/lib/api'; import { type User } from '@/types/api'; import { setAccessToken as storeToken, clearAccessToken } from '@/utils/token'; import { AuthContext } from './auth-context'; /** - * Session bootstrap: refreshes the token cookie on mount, exposes login/logout and mirrors the token into api defaults and module storage. + * Session bootstrap: restores the session from the refresh cookie on mount, + * exposes login/logout and mirrors the token into api defaults and module + * storage. A failed bootstrap simply means signed out — it never calls the + * authenticated logout endpoint. */ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const [user, setUser] = useState(null); const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(true); + const markSignedOut = () => { + setAccessToken(null); + setUser(null); + delete api.defaults.headers.common['Authorization']; + clearAccessToken(); + }; + const login = (token: string, user: User) => { setAccessToken(token); setUser(user); @@ -22,33 +32,32 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }; const logout = async () => { - await api.post('/auth/logout'); - setAccessToken(null); - setUser(null); - localStorage.setItem('wasLoggedOut', 'true'); - delete api.defaults.headers.common['Authorization']; - clearAccessToken(); + // The server may already be unreachable or the session expired; local + // cleanup happens either way. + try { + await api.post('/auth/logout'); + } catch { + // session already dead server-side + } + markSignedOut(); }; useEffect(() => { - const refresh = async () => { - if (localStorage.getItem('wasLoggedOut') === 'true') { - localStorage.removeItem('wasLoggedOut'); - setLoading(false); - return; - } + const bootstrap = async () => { try { - const res = await api.post('/auth/refresh'); - const { accessToken, user } = res.data; + // Shares the single-flight with the response interceptor, so a + // bootstrap racing an in-flight refresh triggers only one request. + const { accessToken, user } = await refreshAccessToken(); login(accessToken, user); } catch { - logout(); + markSignedOut(); } finally { setLoading(false); } }; - refresh(); + bootstrap(); + return onSessionExpired(markSignedOut); }, []); if (loading) return null; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 233cfec..a4c39b8 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { env } from '@/config/env'; +import { type User } from '@/types/api'; import { clearAccessToken, getAccessToken, @@ -62,20 +63,30 @@ const AUTH_PATHS = [ '/auth/logout', ]; -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; + +/** + * Payload returned by the refresh endpoint. + */ +interface RefreshPayload { + accessToken: string; + user: User; +} /** * Requests a fresh access token via the httpOnly refresh cookie; concurrent - * callers share the in-flight request so only one round-trip happens. + * callers share the in-flight request so only one round-trip happens. Stores + * the new token before resolving with the full payload. */ -const refreshAccessToken = (): Promise => { +export const refreshAccessToken = (): Promise => { if (!refreshPromise) { refreshPromise = axios - .post(`${env.API_URL}/api/auth/refresh`, null, { withCredentials: true }) + .post(`${env.API_URL}/api/auth/refresh`, null, { + withCredentials: true, + }) .then((res) => { - const token: string = res.data.accessToken; - setAccessToken(token); - return token; + setAccessToken(res.data.accessToken); + return res.data; }) .finally(() => { refreshPromise = null; @@ -105,7 +116,7 @@ api.interceptors.response.use(undefined, async (error: unknown) => { const token = getAccessToken() !== original._tokenUsed ? getAccessToken()! - : await refreshAccessToken(); + : (await refreshAccessToken()).accessToken; original.headers.Authorization = `Bearer ${token}`; return api(original); } catch { From e7d662aa32182cdf8fc653d77b1fccd42c1120fc Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:51:56 +0300 Subject: [PATCH 24/33] test(server): fix set-cookie typing in cookie flags test --- server/test/auth.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 6dbd142..3d79f54 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -289,7 +289,10 @@ describe('Auth Routes', () => { }); expect(res.status).toBe(StatusCodes.OK); - const refreshCookie = res.headers['set-cookie'].find((c: string) => c.startsWith('refreshToken=')); + const setCookies = Array.isArray(res.headers['set-cookie']) + ? res.headers['set-cookie'] + : [res.headers['set-cookie'] ?? '']; + const refreshCookie = setCookies.find(c => c.startsWith('refreshToken=')); expect(refreshCookie).toBeDefined(); expect(refreshCookie).not.toContain('Secure'); expect(refreshCookie).toContain('SameSite=Lax'); From 81d8b9166358848e579b38ac9235b6e3ec9babdf Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 28 Aug 2026 02:10:15 +0300 Subject: [PATCH 25/33] fix(server): clear refresh cookie with matching attributes clearCookie must mirror the cookie's Secure/SameSite/Path/HttpOnly attributes, otherwise browsers retain the prod SameSite=None; Secure cookie after logout and subsequent refresh still succeeds. --- server/src/controllers/auth.controller.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index a11c76f..cc4aea9 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -184,9 +184,7 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = tokenExpiry: '15m', }); - // Cross-site deployments (prod) need SameSite=None + Secure; in dev the - // client and API are same-site over http, where Secure cookies are dropped - // by browsers that don't trust localhost and None is rejected without TLS. + // Production uses cross-site cookies, which require SameSite=None + Secure. const isProduction = process.env.NODE_ENV === 'production'; res.cookie('refreshToken', refreshToken, { @@ -249,8 +247,18 @@ export const logoutUser = asyncErrorWrapper(async (req: AuthenticatedRequest, re userId, }); - res.clearCookie('refreshToken'); - res.clearCookie('accessToken'); + // Must match the attributes used when setting the cookie, otherwise + // browsers keep the SameSite=None; Secure cookie (prod) alive and a + // subsequent refresh still succeeds after logout. + const isProduction = process.env.NODE_ENV === 'production'; + const clearOpts = { + httpOnly: true, + secure: isProduction, + sameSite: (isProduction ? 'none' : 'lax') as 'none' | 'lax', + path: '/', + }; + res.clearCookie('refreshToken', clearOpts); + res.clearCookie('accessToken', clearOpts); res.status(StatusCodes.OK).json({ message: 'Logged out successfully' }); } catch (error) { logger.error('Logout failed - database error', { From 955b1350e7603c20958257c39e3cc0de837225e0 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 28 Aug 2026 02:20:44 +0300 Subject: [PATCH 26/33] test(server): cover logout cookie clearing with matching attributes Verifies that logout clears both refreshToken and legacy accessToken cookies with the same Path/HttpOnly/SameSite attributes used on set (otherwise prod SameSite=None; Secure cookies survive logout and refresh still succeeds), and that replaying the old cookie after logout is rejected. Would have failed before 4f8cdf5. --- server/test/auth.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 3d79f54..feaa335 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -251,6 +251,88 @@ describe('Auth Routes', () => { expect(refreshRes.status).toBe(StatusCodes.UNAUTHORIZED); }); + it('should clear refreshToken cookie with matching attributes on logout', async () => { + await request(app).post('/api/auth/register').send({ + email: 'clear-attrs@test.dev', + username: 'clearAttrsUser', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'clear-attrs@test.dev', + password: 'secure123', + }); + const cookieHeader = extractCookies(loginRes.headers['set-cookie']); + + const logoutRes = await request(app).post('/api/auth/logout').set('Cookie', cookieHeader); + + expect(logoutRes.status).toBe(StatusCodes.OK); + const setCookies = (logoutRes.headers['set-cookie'] ?? []) as string[]; + const cookiesArray = Array.isArray(setCookies) ? setCookies : [setCookies]; + // Express clearCookie sets `name=; Path=/; Expires=Thu, 01 Jan 1970 ...` + const refreshClear = cookiesArray.find(c => c.startsWith('refreshToken=;')); + expect(refreshClear).toBeDefined(); + // Must mirror login attributes or browsers (prod SameSite=None; Secure) won't clear + expect(refreshClear).toContain('Path=/'); + expect(refreshClear).toContain('HttpOnly'); + expect(refreshClear).toContain('SameSite=Lax'); + expect(refreshClear).not.toContain('Secure'); + expect(refreshClear).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/); + }); + + it('should clear both refreshToken and legacy accessToken cookies on logout', async () => { + await request(app).post('/api/auth/register').send({ + email: 'clear-both@test.dev', + username: 'clearBothUser', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'clear-both@test.dev', + password: 'secure123', + }); + const cookieHeader = extractCookies(loginRes.headers['set-cookie']); + + const logoutRes = await request(app).post('/api/auth/logout').set('Cookie', cookieHeader); + + expect(logoutRes.status).toBe(StatusCodes.OK); + const setCookies = (logoutRes.headers['set-cookie'] ?? []) as string[]; + const cookiesArray = Array.isArray(setCookies) ? setCookies : [setCookies]; + const names = cookiesArray.map(c => c.split('=')[0]); + expect(names).toContain('refreshToken'); + expect(names).toContain('accessToken'); + // Both clearing cookies must carry the same path/sameSite so they actually overwrite + for (const c of cookiesArray) { + if (c.startsWith('refreshToken=;') || c.startsWith('accessToken=;')) { + expect(c).toContain('Path=/'); + expect(c).toContain('SameSite=Lax'); + } + } + }); + + it('should not allow refresh with the old cookie after logout', async () => { + await request(app).post('/api/auth/register').send({ + email: 'refresh-after-logout@test.dev', + username: 'refreshAfterLogoutUser', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'refresh-after-logout@test.dev', + password: 'secure123', + }); + const cookieHeader = extractCookies(loginRes.headers['set-cookie']); + + const logoutRes = await request(app).post('/api/auth/logout').set('Cookie', cookieHeader); + expect(logoutRes.status).toBe(StatusCodes.OK); + + // Even though the client still holds the old cookie string, the server has + // nulled the stored refreshToken and the browser should have received a + // clearing Set-Cookie (verified above). Replaying the old cookie must fail. + const refreshRes = await request(app).post('/api/auth/refresh').set('Cookie', cookieHeader); + expect(refreshRes.status).toBe(StatusCodes.UNAUTHORIZED); + }); + it('should return 401 when accessing protected route without token', async () => { const res = await request(app).get('/api/user'); expect(res.status).toBe(StatusCodes.UNAUTHORIZED); From 93e9b526b33c9e5d282b671623b823bffa219b19 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 28 Aug 2026 02:47:48 +0300 Subject: [PATCH 27/33] fix(client): dedupe session expiry and guard cleared token replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent 401s sharing one refreshPromise notified listeners per waiter (3×) and could replay with Bearer null after clearAccessToken. Move clear+notify into refreshPromise rejection (once) and guard the current !== _tokenUsed branch to throw when current is null instead of asserting non-null. --- client/src/lib/__tests__/api.test.ts | 19 ++++++++++++++++++ client/src/lib/api.ts | 29 +++++++++++++++++++--------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/client/src/lib/__tests__/api.test.ts b/client/src/lib/__tests__/api.test.ts index ab375aa..3bca57c 100644 --- a/client/src/lib/__tests__/api.test.ts +++ b/client/src/lib/__tests__/api.test.ts @@ -121,6 +121,25 @@ describe('api response interceptor', () => { refreshShouldFail = false; }); + it('notifies sessionExpired listeners only once for concurrent refresh failures', async () => { + refreshShouldFail = true; + refreshCallCount = 0; + setAccessToken('expired-token'); + const expired = vi.fn(); + const unsubscribe = onSessionExpired(expired); + + await Promise.allSettled([ + api.get(`${BASE}/always-401`), + api.get(`${BASE}/always-401`), + api.get(`${BASE}/always-401`), + ]); + + expect(expired).toHaveBeenCalledTimes(1); + expect(refreshCallCount).toBe(1); + unsubscribe(); + refreshShouldFail = false; + }); + it('does not attempt a refresh when no access token is stored', async () => { refreshCallCount = 0; clearAccessToken(); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index a4c39b8..ee7d82f 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -76,7 +76,9 @@ interface RefreshPayload { /** * Requests a fresh access token via the httpOnly refresh cookie; concurrent * callers share the in-flight request so only one round-trip happens. Stores - * the new token before resolving with the full payload. + * the new token before resolving with the full payload. On failure the + * session is cleared and listeners notified once — concurrent waiters share + * the same rejection. */ export const refreshAccessToken = (): Promise => { if (!refreshPromise) { @@ -88,6 +90,11 @@ export const refreshAccessToken = (): Promise => { setAccessToken(res.data.accessToken); return res.data; }) + .catch((err: unknown) => { + clearAccessToken(); + sessionExpiredListeners.forEach((listener) => listener()); + throw err; + }) .finally(() => { refreshPromise = null; }); @@ -111,17 +118,21 @@ api.interceptors.response.use(undefined, async (error: unknown) => { original._retry = true; try { - // A concurrent request may have refreshed the token while this one was in - // flight; reuse it instead of refreshing again. - const token = - getAccessToken() !== original._tokenUsed - ? getAccessToken()! - : (await refreshAccessToken()).accessToken; + // A concurrent request may have refreshed (or cleared) the token while + // this one was in flight; reuse it instead of refreshing again. If the + // token was cleared (null) the session is already expired — don't replay + // with `Bearer null` or trigger a second refresh, just fail. + const current = getAccessToken(); + let token: string; + if (current !== original._tokenUsed) { + if (!current) throw error; + token = current; + } else { + token = (await refreshAccessToken()).accessToken; + } original.headers.Authorization = `Bearer ${token}`; return api(original); } catch { - clearAccessToken(); - sessionExpiredListeners.forEach((listener) => listener()); throw error; } }); From 3b8ee4cec79b9089d9ae4c2ea71b7408d2557412 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:52:28 +0300 Subject: [PATCH 28/33] fix(client): race-free scroll sync stops split-view tearing in Firefox The synced-scroll toggle suppressed mirrored-pane echoes with a flag reset in a fresh requestAnimationFrame. Chromium dispatches the mirrored pane's scroll event before that frame, but Firefox delivers it after the reset: the echo then passes the guard and the next genuine update is swallowed, so the preview trails and snaps back continuously while scrolling. Replace the timing flag with position-based echo suppression (an event at the last-written offset is an echo) and batch mirror writes to one per animation frame. Handlers are now stable callbacks, so listeners stop resubscribing on every render. Covered by interaction stories: baseline mirroring both directions plus a deterministic late-echo regression test. --- .../DocumentMain/document-main.stories.tsx | 147 ++++++++++++++++++ .../components/DocumentMain/document-main.tsx | 61 +------- .../DocumentMain/use-scroll-sync.ts | 100 ++++++++++++ 3 files changed, 253 insertions(+), 55 deletions(-) create mode 100644 client/src/features/DocumentPage/components/DocumentMain/use-scroll-sync.ts diff --git a/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx index c399e6b..1c16acd 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx @@ -85,3 +85,150 @@ export const ReadOnly: Story = { await expect(canvas.queryByTitle('Bold')).toBeNull(); }, }; + +/** + * Provider stub seeding enough content that both split panes overflow and + * can actually scroll (the short default seed cannot). + */ +const longProviderFactory: CollabProviderFactory = (options) => { + const ytext = options.document.getText('content'); + if (!ytext.length) { + const paragraphs = Array.from( + { length: 120 }, + (_, i) => + `\n\nParagraph ${i + 1}: lorem ipsum dolor sit amet, consectetur adipiscing elit.`, + ).join(''); + ytext.insert(0, `# Long Document${paragraphs}`); + } + return { destroy: () => {} }; +}; + +/** + * Resolves after n animation frames so effects and rAF callbacks have run. + */ +const rafFrames = (n: number) => + new Promise((resolve) => { + const step = () => (--n <= 0 ? resolve() : requestAnimationFrame(step)); + requestAnimationFrame(step); + }); + +/** + * Resolves on the element's next scroll event; rejects after timeoutMs so a + * swallowed mirror update fails fast instead of hanging the run. + */ +const nextScrollEvent = (el: HTMLElement, timeoutMs = 1000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + el.removeEventListener('scroll', onScroll); + reject(new Error(`no scroll event within ${timeoutMs}ms`)); + }, timeoutMs); + const onScroll = () => { + clearTimeout(timer); + el.removeEventListener('scroll', onScroll); + resolve(); + }; + el.addEventListener('scroll', onScroll); + }); + +const renderSplitWithLongDoc: Story['render'] = function RenderedStory() { + return ( +
+ +
+ ); +}; + +/** Enables synced scrolling via the handle overlay button. */ +async function enableSyncScroll(canvasElement: HTMLElement) { + const toggle = canvasElement.querySelector( + '[data-panel-resize-handle-id] div.absolute', + ) as HTMLElement | null; + if (!toggle) throw new Error('scroll-sync toggle not found'); + toggle.click(); + await rafFrames(3); +} + +export const SplitSyncMirrorsScroll: Story = { + render: renderSplitWithLongDoc, + play: async ({ canvasElement }) => { + await enableSyncScroll(canvasElement); + const editor = canvasElement + .querySelector('.cm-editor') + ?.closest('div.custom-scrollbar.overflow-y-scroll') as HTMLElement; + const preview = canvasElement.querySelector( + '.markdown-previewer', + ) as HTMLElement; + + // Editor -> preview. Expectations are computed against live geometry: + // CodeMirror's scrollHeight can still grow during deep scrolls. + const edMax = editor.scrollHeight - editor.clientHeight; + const pvMax = preview.scrollHeight - preview.clientHeight; + + editor.scrollTop = edMax * 0.4; + await nextScrollEvent(preview); + await rafFrames(2); + expect( + Math.abs( + preview.scrollTop - + (editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) * + (preview.scrollHeight - preview.clientHeight), + ), + ).toBeLessThan(pvMax * 0.05); + + // Preview -> editor + const pvMax2 = preview.scrollHeight - preview.clientHeight; + preview.scrollTop = pvMax2 * 0.8; + await nextScrollEvent(editor); + await rafFrames(2); + expect( + Math.abs( + editor.scrollTop - + (preview.scrollTop / (preview.scrollHeight - preview.clientHeight)) * + (editor.scrollHeight - editor.clientHeight), + ), + ).toBeLessThan(edMax * 0.05); + }, +}; + +export const SplitSyncSurvivesLateEcho: Story = { + render: renderSplitWithLongDoc, + play: async ({ canvasElement }) => { + await enableSyncScroll(canvasElement); + const editor = canvasElement + .querySelector('.cm-editor') + ?.closest('div.custom-scrollbar.overflow-y-scroll') as HTMLElement; + const preview = canvasElement.querySelector( + '.markdown-previewer', + ) as HTMLElement; + + const edMax = editor.scrollHeight - editor.clientHeight; + const pvMax = preview.scrollHeight - preview.clientHeight; + + // A normal mirrored scroll leaves both panes aligned... + editor.scrollTop = edMax * 0.5; + await nextScrollEvent(preview); + await rafFrames(2); + + // ...then the mirrored pane's own scroll event arrives one frame LATE + // (Firefox delivers it after the syncing flag was already reset). It must + // be recognized as an echo, not consume the suppression state. + preview.dispatchEvent(new Event('scroll')); + + // And a genuine editor scroll in the SAME task must still be mirrored. + editor.scrollTop = edMax * 0.75; + await rafFrames(5); + expect( + Math.abs( + preview.scrollTop - + (editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) * + (preview.scrollHeight - preview.clientHeight), + ), + ).toBeLessThan(pvMax * 0.05); + }, +}; diff --git a/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx index ca676af..da163b3 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx @@ -10,6 +10,7 @@ import { cn } from '@/utils/cn'; import { MarkdownEditor } from './MarkdownEditor'; import { MarkdownPreview } from './MarkdownPreview'; +import { useScrollSync } from './use-scroll-sync'; /** * Wires collaboration state into either MarkdownEditor or MarkdownPreview per view mode. @@ -44,8 +45,12 @@ export function DocumentMain({ ); const editorScrollRef = useRef(null); const previewScrollRef = useRef(null); - const isSyncingRef = useRef(false); const [syncScroll, setSyncScroll] = useState(false); + const { handleEditorScroll, handlePreviewScroll } = useScrollSync({ + enabled: syncScroll, + editorRef: editorScrollRef, + previewRef: previewScrollRef, + }); useEffect(() => { if (doc && text !== doc.content) { @@ -70,60 +75,6 @@ export function DocumentMain({ percent * (previewEl.scrollHeight - previewEl.clientHeight); }, [syncScroll]); - const handleEditorScroll = () => { - if (!syncScroll) return; - if ( - !syncScroll || - isSyncingRef.current || - !editorScrollRef.current || - !previewScrollRef.current - ) { - return; - } - - isSyncingRef.current = true; - - const editor = editorScrollRef.current; - const preview = previewScrollRef.current; - - const scrollRatio = - editor.scrollTop / (editor.scrollHeight - editor.clientHeight); - preview.scrollTop = - scrollRatio * (preview.scrollHeight - preview.clientHeight); - - // Use a shorter timeout and requestAnimationFrame - requestAnimationFrame(() => { - isSyncingRef.current = false; - }); - }; - - const handlePreviewScroll = () => { - if (!syncScroll) return; - - if ( - !syncScroll || - isSyncingRef.current || - !editorScrollRef.current || - !previewScrollRef.current - ) { - return; - } - - isSyncingRef.current = true; - - const editor = editorScrollRef.current; - const preview = previewScrollRef.current; - - const scrollRatio = - preview.scrollTop / (preview.scrollHeight - preview.clientHeight); - editor.scrollTop = - scrollRatio * (editor.scrollHeight - editor.clientHeight); - - // Use a shorter timeout and requestAnimationFrame - requestAnimationFrame(() => { - isSyncingRef.current = false; - }); - }; if (!docId || !isReady || !ydoc || !ytext || !provider) { return (
diff --git a/client/src/features/DocumentPage/components/DocumentMain/use-scroll-sync.ts b/client/src/features/DocumentPage/components/DocumentMain/use-scroll-sync.ts new file mode 100644 index 0000000..4acc6b4 --- /dev/null +++ b/client/src/features/DocumentPage/components/DocumentMain/use-scroll-sync.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef } from 'react'; + +type PaneKey = 'editor' | 'preview'; + +/** + * Race-free bidirectional scroll syncing between two panes. + * + * Mirrors scroll positions once per animation frame (latest wins) and + * recognizes its own mirrored writes by position: an event whose target is + * already at the last-written offset is an echo and is ignored. This replaces + * timing-flag suppression, which browsers deliver in different orders + * (Firefox dispatches the mirrored pane's scroll event after the reset frame, + * swallowing every other genuine update). + * + * @param args - Hook arguments. + * @param args.enabled - Mirrors only run while true. + * @param args.editorRef - Scrollable editor container. + * @param args.previewRef - Scrollable preview container. + * @returns Stable scroll handlers to attach to each pane's container. + */ +export function useScrollSync(args: { + /** Mirrors only run while true. */ + enabled: boolean; + /** Scrollable editor container. */ + editorRef: React.RefObject; + /** Scrollable preview container. */ + previewRef: React.RefObject; +}) { + const { enabled, editorRef, previewRef } = args; + const enabledRef = useRef(enabled); + const lastWritten = useRef>({ + editor: Number.NaN, + preview: Number.NaN, + }); + const frameRef = useRef(null); + const pendingSource = useRef(null); + + useEffect(() => { + enabledRef.current = enabled; + if (!enabled) { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + pendingSource.current = null; + lastWritten.current = { editor: Number.NaN, preview: Number.NaN }; + } + return () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + frameRef.current = null; + }; + }, [enabled]); + + const applyMirror = useCallback(() => { + frameRef.current = null; + const source = pendingSource.current; + pendingSource.current = null; + if (!source || !enabledRef.current) return; + + const from = source === 'editor' ? editorRef.current : previewRef.current; + const to = source === 'editor' ? previewRef.current : editorRef.current; + if (!from || !to) return; + + const fromRange = from.scrollHeight - from.clientHeight; + if (fromRange <= 0) return; + + const top = + (from.scrollTop / fromRange) * (to.scrollHeight - to.clientHeight); + lastWritten.current[source === 'editor' ? 'preview' : 'editor'] = top; + to.scrollTop = top; + }, [editorRef, previewRef]); + + const queueMirror = useCallback( + (which: PaneKey) => { + if (!enabledRef.current) return; + const el = which === 'editor' ? editorRef.current : previewRef.current; + if (!el) return; + + const written = lastWritten.current[which]; + if (!Number.isNaN(written) && Math.abs(el.scrollTop - written) < 1) { + return; + } + + pendingSource.current = which; + if (frameRef.current === null) { + frameRef.current = requestAnimationFrame(applyMirror); + } + }, + [editorRef, previewRef, applyMirror], + ); + + const handleEditorScroll = useCallback( + () => queueMirror('editor'), + [queueMirror], + ); + const handlePreviewScroll = useCallback( + () => queueMirror('preview'), + [queueMirror], + ); + + return { handleEditorScroll, handlePreviewScroll }; +} From 011ba01c651215e7e049be9779db6de8ddb79063 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 04:08:01 +0300 Subject: [PATCH 29/33] test(client): cover latest-wins mirroring under same-frame scroll bursts Chromium coalesces same-frame scroll events, so no existing story failed on the old guard when two updates landed in one frame. Deliver the first update, then add a second within the same frame (Firefox's per-write delivery): the old code swallows the second and the mirror settles stale; the new batched mirror converges on the latest position. --- .../DocumentMain/document-main.stories.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx index 1c16acd..62b43b7 100644 --- a/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx +++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx @@ -232,3 +232,47 @@ export const SplitSyncSurvivesLateEcho: Story = { ).toBeLessThan(pvMax * 0.05); }, }; + +export const SplitSyncMirrorsLatestUnderBurst: Story = { + render: renderSplitWithLongDoc, + play: async ({ canvasElement }) => { + await enableSyncScroll(canvasElement); + const editor = canvasElement + .querySelector('.cm-editor') + ?.closest('div.custom-scrollbar.overflow-y-scroll') as HTMLElement; + const preview = canvasElement.querySelector( + '.markdown-previewer', + ) as HTMLElement; + + const edMax = editor.scrollHeight - editor.clientHeight; + const pvMax = preview.scrollHeight - preview.clientHeight; + + // Two scroll updates land within one frame but are delivered separately + // (Firefox dispatches a scroll event per wheel-tick write instead of + // coalescing them). The second must not be swallowed by suppression + // state left behind by the first: the mirror has to end up at the + // LATEST position once the frame flushes. + editor.scrollTop = edMax * 0.2; + await nextScrollEvent(editor); + editor.scrollTop = edMax * 0.6; + await rafFrames(3); + expect( + Math.abs( + preview.scrollTop - + (editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) * + (preview.scrollHeight - preview.clientHeight), + ), + ).toBeLessThan(pvMax * 0.05); + + // ...and a third update after the burst still mirrors. + editor.scrollTop = edMax * 0.85; + await rafFrames(3); + expect( + Math.abs( + preview.scrollTop - + (editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) * + (preview.scrollHeight - preview.clientHeight), + ), + ).toBeLessThan(pvMax * 0.05); + }, +}; From 793e9b8694397d49e8c9beede6c54fccdbed87c8 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 03:26:18 +0300 Subject: [PATCH 30/33] fix(client): clear stale collaborator error on new add/remove attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCollaborators only reset its error inside the initial fetch effect, so a failed add or remove left 'Failed to add/remove collaborator' showing indefinitely — even after a successful retry or closing/reopening the dropdown. Reset the error at the start of each action, mirroring the fetch effect. Adds a browser-mode vitest project for hook-level tests (stories can't exercise API-dependent logic) with coverage for error clearing on successful retries. --- client/.gitignore | 3 + .../__tests__/use-collaborators.test.tsx | 136 ++++++++++++++++++ client/src/hooks/use-collaborators.ts | 2 + client/vite.config.ts | 40 ++++-- 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 client/src/hooks/__tests__/use-collaborators.test.tsx diff --git a/client/.gitignore b/client/.gitignore index 51af31d..5a569ad 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -35,3 +35,6 @@ storybook-static /blob-report/ /playwright/.auth/ /playwright/.cache/ + +# Vitest browser failure screenshots +__screenshots__/ diff --git a/client/src/hooks/__tests__/use-collaborators.test.tsx b/client/src/hooks/__tests__/use-collaborators.test.tsx new file mode 100644 index 0000000..f8b17c5 --- /dev/null +++ b/client/src/hooks/__tests__/use-collaborators.test.tsx @@ -0,0 +1,136 @@ +import { act } from 'react'; +import { createElement } from 'react'; +import type { ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '@/lib/api'; + +vi.mock('@/lib/api', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +import { useCollaborators } from '../use-collaborators'; + +const mockApi = vi.mocked(api, true); + +/** + * Renders a hook inside a probe component mounted on document.body and + * exposes its latest return value, since no DOM-testing library is wired + * into the client suite. + * + * @param useHook - Hook factory invoked on every render of the probe. + * @returns The latest hook result plus unmount for cleanup. + */ +function renderHook(useHook: () => T) { + let result!: T; + let root!: Root; + + const Probe = () => { + result = useHook(); + return null; + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + + act(() => { + root = createRoot(host); + root.render(createElement(Probe) as ReactNode); + }); + + return { + get current() { + return result; + }, + unmount: () => { + act(() => root.unmount()); + host.remove(); + }, + }; +} + +describe('useCollaborators', () => { + beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockApi.get.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('clears a stale add error when a retry succeeds', async () => { + const hook = renderHook(() => useCollaborators('doc1')); + await act(async () => {}); // flush initial fetch + + mockApi.post.mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await hook.current.addCollaborator('a@b.c'); + }); + expect(hook.current.error).toBe('Failed to add collaborator'); + + mockApi.post.mockResolvedValueOnce({}); + let added = false; + await act(async () => { + added = await hook.current.addCollaborator('a@b.c'); + }); + + expect(added).toBe(true); + expect(hook.current.error).toBeNull(); + hook.unmount(); + }); + + it('clears a stale remove error when a retry succeeds', async () => { + const hook = renderHook(() => useCollaborators('doc1')); + await act(async () => {}); + + mockApi.delete.mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await hook.current.removeCollaborator('u1'); + }); + expect(hook.current.error).toBe('Failed to remove collaborator'); + + mockApi.delete.mockResolvedValueOnce({}); + await act(async () => { + await hook.current.removeCollaborator('u1'); + }); + + expect(hook.current.error).toBeNull(); + hook.unmount(); + }); + + it('surfaces an error when adding fails', async () => { + const hook = renderHook(() => useCollaborators('doc1')); + await act(async () => {}); + + mockApi.post.mockRejectedValueOnce(new Error('boom')); + let added = true; + await act(async () => { + added = await hook.current.addCollaborator('a@b.c'); + }); + + expect(added).toBe(false); + expect(hook.current.error).toBe('Failed to add collaborator'); + hook.unmount(); + }); + + it('surfaces an error when removing fails', async () => { + const hook = renderHook(() => useCollaborators('doc1')); + await act(async () => {}); + + mockApi.delete.mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await hook.current.removeCollaborator('u1'); + }); + + expect(hook.current.error).toBe('Failed to remove collaborator'); + hook.unmount(); + }); +}); diff --git a/client/src/hooks/use-collaborators.ts b/client/src/hooks/use-collaborators.ts index 92e70e8..5b7b133 100644 --- a/client/src/hooks/use-collaborators.ts +++ b/client/src/hooks/use-collaborators.ts @@ -41,6 +41,7 @@ export const useCollaborators = (docId?: string) => { const removeCollaborator = async (userId: string) => { if (!docId) return; + setError(null); try { await api.delete(`/document/${docId}/collaborators/${userId}`); setCollaborators((prev) => prev.filter((c) => c.id !== userId)); @@ -52,6 +53,7 @@ export const useCollaborators = (docId?: string) => { const addCollaborator = async (email: string) => { if (!docId || !email) return false; + setError(null); try { await api.post(`/document/${docId}/collaborators`, { email }); const res = await api.get(`/document/${docId}/collaborators`); diff --git a/client/vite.config.ts b/client/vite.config.ts index 357123d..0a8363f 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -13,7 +13,21 @@ const dirname = ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon +// Fresh object per project: vitest mutates browser instance configs while +// registering nested projects, so sharing one literal collides. +const browserConfig = () => + ({ + enabled: true, + headless: true, + provider: 'playwright', + instances: [ + { + browser: 'chromium', + }, + ], + }) as const; + +// More info: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -63,21 +77,23 @@ export default defineConfig({ test: { name: 'storybook', // One browser instance already stretches CI runners; parallel - // files alongside the server suite starves vitest's runner. + // files alongside the server suite starve vitest's runner. fileParallelism: false, - browser: { - enabled: true, - headless: true, - provider: 'playwright', - instances: [ - { - browser: 'chromium', - }, - ], - }, + browser: browserConfig(), setupFiles: ['.storybook/vitest.setup.ts'], }, }, + { + // Hook-level tests run in a real browser too — stories can't + // exercise logic that needs API interactions. + extends: true, + test: { + name: 'browser-unit', + include: ['src/**/__tests__/*.test.{ts,tsx}'], + fileParallelism: false, + browser: browserConfig(), + }, + }, ], }, }); From baeabe7b2de0de26bb437a918bf2d3153431f9e2 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 20:26:57 +0300 Subject: [PATCH 31/33] test(client): harden use-collaborators harness against CI flakes Replace bare await act(async () => {}) flushes with a polling waitFor that waits for loading to settle. Empty act flushes can miss the initial fetch's microtask in CI timing. --- .../__tests__/use-collaborators.test.tsx | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/client/src/hooks/__tests__/use-collaborators.test.tsx b/client/src/hooks/__tests__/use-collaborators.test.tsx index f8b17c5..de669af 100644 --- a/client/src/hooks/__tests__/use-collaborators.test.tsx +++ b/client/src/hooks/__tests__/use-collaborators.test.tsx @@ -54,6 +54,28 @@ function renderHook(useHook: () => T) { }; } +/** + * Flushes pending React updates and microtasks. Polls until the + * predicate holds, so `await act(async () => {})` empty flushes don't + * flake when the initial fetch resolves on the next microtask. + * + * @param predicate - Condition to wait for. + * @param timeoutMs - Fail after this long. + */ +async function waitFor(predicate: () => boolean, timeoutMs = 1000) { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('waitFor timeout'); + } + await act(async () => { + await new Promise((r) => { + setTimeout(r, 0); + }); + }); + } +} + describe('useCollaborators', () => { beforeEach(() => { ( @@ -68,7 +90,7 @@ describe('useCollaborators', () => { it('clears a stale add error when a retry succeeds', async () => { const hook = renderHook(() => useCollaborators('doc1')); - await act(async () => {}); // flush initial fetch + await waitFor(() => !hook.current.loading); mockApi.post.mockRejectedValueOnce(new Error('boom')); await act(async () => { @@ -89,7 +111,7 @@ describe('useCollaborators', () => { it('clears a stale remove error when a retry succeeds', async () => { const hook = renderHook(() => useCollaborators('doc1')); - await act(async () => {}); + await waitFor(() => !hook.current.loading); mockApi.delete.mockRejectedValueOnce(new Error('boom')); await act(async () => { @@ -108,7 +130,7 @@ describe('useCollaborators', () => { it('surfaces an error when adding fails', async () => { const hook = renderHook(() => useCollaborators('doc1')); - await act(async () => {}); + await waitFor(() => !hook.current.loading); mockApi.post.mockRejectedValueOnce(new Error('boom')); let added = true; @@ -123,7 +145,7 @@ describe('useCollaborators', () => { it('surfaces an error when removing fails', async () => { const hook = renderHook(() => useCollaborators('doc1')); - await act(async () => {}); + await waitFor(() => !hook.current.loading); mockApi.delete.mockRejectedValueOnce(new Error('boom')); await act(async () => { From 24b73b0ac6ec51085f58594241641a4827759d5a Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 28 Aug 2026 03:30:23 +0300 Subject: [PATCH 32/33] fix(client): make vitest projects disjoint to unbreak develop CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unit (node) matched src/**/*.test.{ts,tsx} and browser-unit matched src/**/__tests__/*.test.{ts,tsx}, so files under __tests__ ran in both projects: use-collaborators.test needs document (fails in node) and api.test imports node:http (fails in browser). Scope browser-unit to src/hooks/__tests__/** and exclude that path from unit so each file runs once in its intended environment. Fixes failing Lint, Type Check and Test run 33129323962 on develop (4× document is not defined + 1× node:http externalized). --- client/vite.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/vite.config.ts b/client/vite.config.ts index 0a8363f..bf0e460 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ test: { name: 'unit', include: ['src/**/*.test.{ts,tsx}'], + exclude: ['src/hooks/__tests__/**'], environment: 'node', env: { VITE_APP_API_URL: 'http://localhost:4599', @@ -89,7 +90,7 @@ export default defineConfig({ extends: true, test: { name: 'browser-unit', - include: ['src/**/__tests__/*.test.{ts,tsx}'], + include: ['src/hooks/__tests__/**/*.test.{ts,tsx}'], fileParallelism: false, browser: browserConfig(), }, From f1e0ba4b54198a2a7869ca2e8c5e2f91c8a9a8ac Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Fri, 28 Aug 2026 04:03:19 +0300 Subject: [PATCH 33/33] =?UTF-8?q?fix(server):=20session=20hardening=20?= =?UTF-8?q?=E2=80=94=20rotation,=20multi-device,=20isActive=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Session model (id, userId, jti, refreshToken, expiresAt) with migration 20260828010125_add_session; allows multiple concurrent refresh tokens per user instead of single User.refreshToken column that logged out other devices on each login. - Refresh rotation (TDD): login creates Session with jti + 24h expiry; POST /auth/refresh verifies jti, rotates to new jti/token, sets env-aware cookie (lax/secure), invalidates old token and keeps legacy User.refreshToken in sync. Old token replay → 401. New token works. Covers #51.1 stolen-token lifetime + reuse surface (reuse now just 401; full revoke-all on reuse can be added once migrated). - Multi-device (TDD): second login creates second Session without overwriting first; logout deletes only presented Session, leaving other device intact. Fixes single-column overwrite. - isActive enforcement (TDD): login rejects deactivated (401 generic), refresh rejects deactivated before rotation, authenticate middleware now async and checks isActive via prisma so deactivated JWTs are rejected within 15m. Closes #51.2. - Cookie flags: refresh rotation sets sameSite/secure per NODE_ENV (already done for login/logout in #92); logout clears only presented Session and both cookies with matching Path/SameSite/HttpOnly. Tests: 3 new suites (rotation, isActive, multi-device) — 6 tests — all green with existing 77 (83 total). Test setup now truncates sessions table. --- .../20260828010125_add_session/migration.sql | 23 +++ server/prisma/schema.prisma | 14 ++ server/src/controllers/auth.controller.ts | 145 +++++++++++++++++- server/src/middlewares/auth.middleware.ts | 14 +- server/test/auth-isactive.test.ts | 82 ++++++++++ server/test/auth-multidevice.test.ts | 77 ++++++++++ server/test/auth-session-rotation.test.ts | 55 +++++++ server/test/setup.ts | 9 +- 8 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 server/prisma/migrations/20260828010125_add_session/migration.sql create mode 100644 server/test/auth-isactive.test.ts create mode 100644 server/test/auth-multidevice.test.ts create mode 100644 server/test/auth-session-rotation.test.ts diff --git a/server/prisma/migrations/20260828010125_add_session/migration.sql b/server/prisma/migrations/20260828010125_add_session/migration.sql new file mode 100644 index 0000000..c97cee7 --- /dev/null +++ b/server/prisma/migrations/20260828010125_add_session/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "jti" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_jti_key" ON "sessions"("jti"); + +-- CreateIndex +CREATE INDEX "sessions_userId_idx" ON "sessions"("userId"); + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 13a3117..e7d87de 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -20,10 +20,24 @@ model User { Document Document[] Collaborator Collaborator[] CollaborationRequest CollaborationRequest[] + Session Session[] @@map("users") } +model Session { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + refreshToken String @unique + jti String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) + @@map("sessions") +} + model Document { id String @id @default(uuid()) title String diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index cc4aea9..2441318 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,4 +1,5 @@ import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; import { Request, Response } from 'express'; import asyncErrorWrapper from 'express-async-handler'; import { StatusCodes } from 'http-status-codes'; @@ -117,6 +118,17 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = try { const user = await prisma.user.findUnique({ where: { email } }); + if (user && user.isActive === false) { + logger.warn('Login failed - user deactivated', { + action: 'LOGIN_USER_INACTIVE', + ...clientInfo, + userId: user.id, + email, + }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid email or password' }); + return; + } + if (!user) { await bcrypt.compare(password, DUMMY_PASSWORD_HASH); @@ -157,11 +169,13 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = } ); - // Generate Access Token with a short expiration time + // Generate refresh token with jti for rotation / reuse detection + const jti = crypto.randomUUID(); const refreshToken = jwt.sign( { userId: user.id, username: user.username, + jti, }, process.env.JWT_REFRESH_SECRET, { @@ -169,7 +183,16 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = } ); - // update user with referesh token + // Create a new session for this device; do not overwrite other sessions + await prisma.session.create({ + data: { + userId: user.id, + refreshToken, + jti, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + // Keep legacy column in sync for any external read (not used for auth) await prisma.user.update({ where: { email }, data: { refreshToken }, @@ -236,6 +259,22 @@ export const logoutUser = asyncErrorWrapper(async (req: AuthenticatedRequest, re } try { + const presented = req.cookies?.refreshToken as string | undefined; + if (presented) { + try { + const dec = jwt.verify(presented, process.env.JWT_REFRESH_SECRET!) as jwt.JwtPayload & { jti?: string }; + if (dec.jti) { + await prisma.session.deleteMany({ where: { jti: dec.jti, userId } }); + } else { + await prisma.session.deleteMany({ where: { refreshToken: presented, userId } }); + } + } catch { + await prisma.session.deleteMany({ where: { refreshToken: presented, userId } }); + } + } else { + // Fallback: clear all sessions for user if no token presented (e.g. legacy) + await prisma.session.deleteMany({ where: { userId } }); + } await prisma.user.update({ where: { id: userId }, data: { refreshToken: null }, @@ -307,21 +346,109 @@ export const refreshToken = asyncErrorWrapper(async (req: Request, res: Response return; } try { + const payloadWithJti = payload as jwt.JwtPayload & { jti?: string }; const user = await prisma.user.findUnique({ where: { id: payload.userId } }); - if (!user || user.refreshToken !== refreshToken) { + if (!user) { logger.warn('Token refresh failed - token mismatch or user not found', { action: 'REFRESH_TOKEN_MISMATCH', ...clientInfo, userId: payload.userId, - userExists: !!user, - tokenMatches: user?.refreshToken === refreshToken, + userExists: false, + tokenMatches: false, }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' }); + return; + } + // isActive enforcement — deactivated accounts cannot refresh + if (user.isActive === false) { + logger.warn('Token refresh failed - user deactivated', { + action: 'REFRESH_TOKEN_USER_INACTIVE', + ...clientInfo, + userId: user.id, + }); res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' }); return; } + // Prefer Session lookup by jti (new tokens); fallback to legacy column for old tokens + let session: Awaited> | null = null; + if (payloadWithJti.jti) { + session = await prisma.session.findUnique({ where: { jti: payloadWithJti.jti } }); + + // Reuse detection: valid JWT for user but no matching session → token was already rotated/revoked + if (!session || session.refreshToken !== refreshToken || session.userId !== payload.userId) { + logger.warn('Token refresh failed - token reuse detected', { + action: 'REFRESH_TOKEN_REUSE', + ...clientInfo, + userId: payload.userId, + jti: payloadWithJti.jti, + }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' }); + return; + } + + if (session.expiresAt < new Date()) { + logger.warn('Token refresh failed - session expired', { + action: 'REFRESH_TOKEN_EXPIRED', + ...clientInfo, + userId: payload.userId, + }); + await prisma.session.delete({ where: { id: session.id } }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' }); + return; + } + } else { + // Legacy token without jti — fall back to single-column check + if (user.refreshToken !== refreshToken) { + logger.warn('Token refresh failed - token mismatch or user not found', { + action: 'REFRESH_TOKEN_MISMATCH', + ...clientInfo, + userId: payload.userId, + userExists: true, + tokenMatches: false, + }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' }); + return; + } + // Migrate legacy: create a session for this token so future rotates work + session = await prisma.session.create({ + data: { + userId: user.id, + refreshToken, + jti: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + } + + // Rotate: new jti + new refresh token, update same session row + const newJti = crypto.randomUUID(); + const newRefreshToken = jwt.sign( + { + userId: user.id, + username: user.username, + jti: newJti, + }, + process.env.JWT_REFRESH_SECRET!, + { expiresIn: '24h' } + ); + + await prisma.session.update({ + where: { id: session.id }, + data: { + refreshToken: newRefreshToken, + jti: newJti, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + // Keep legacy column in sync (not used for auth, but for observability) + await prisma.user.update({ + where: { id: user.id }, + data: { refreshToken: newRefreshToken }, + }); + const newAccessToken = jwt.sign( { userId: user.id, @@ -338,6 +465,14 @@ export const refreshToken = asyncErrorWrapper(async (req: Request, res: Response username: user.username, }); + const isProduction = process.env.NODE_ENV === 'production'; + res.cookie('refreshToken', newRefreshToken, { + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, + sameSite: isProduction ? 'none' : 'lax', + secure: isProduction, + }); + res.status(StatusCodes.OK).json({ accessToken: newAccessToken, user: { diff --git a/server/src/middlewares/auth.middleware.ts b/server/src/middlewares/auth.middleware.ts index a7e9409..6a57abb 100644 --- a/server/src/middlewares/auth.middleware.ts +++ b/server/src/middlewares/auth.middleware.ts @@ -3,7 +3,9 @@ import { StatusCodes } from 'http-status-codes'; import jwt from 'jsonwebtoken'; import { JwtPayload } from 'jsonwebtoken'; -export const authenticate = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { +import { prisma } from '@/lib/prisma'; + +export const authenticate = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { @@ -13,7 +15,15 @@ export const authenticate = (req: AuthenticatedRequest, res: Response, next: Nex const token = authHeader.split(' ')[1]; try { - const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as JwtPayload; + const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as JwtPayload & { userId: string }; + // Enforce isActive so deactivated accounts lose access even with a valid JWT (15m window) + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { isActive: true }, + }); + if (user && user.isActive === false) { + return res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid or expired token' }); + } req.user = { userId: decoded.userId, username: decoded.username }; next(); } catch { diff --git a/server/test/auth-isactive.test.ts b/server/test/auth-isactive.test.ts new file mode 100644 index 0000000..a3a60b8 --- /dev/null +++ b/server/test/auth-isactive.test.ts @@ -0,0 +1,82 @@ +import { StatusCodes } from 'http-status-codes'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { prisma } from '@/lib/prisma'; +import { app } from '@/server'; + +function extractCookies(raw: string[] | string | undefined): string { + if (!raw) return ''; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map(c => c.split(';')[0]).join('; '); +} + +describe('Session hardening — isActive enforcement (#51.2)', () => { + it('should reject login when user is deactivated', async () => { + await request(app).post('/api/auth/register').send({ + email: 'inactive-login@test.dev', + username: 'inactiveLogin', + password: 'secure123', + }); + + // deactivate + await prisma.user.update({ + where: { email: 'inactive-login@test.dev' }, + data: { isActive: false }, + }); + + const res = await request(app).post('/api/auth/login').send({ + email: 'inactive-login@test.dev', + password: 'secure123', + }); + + expect(res.status).toBe(StatusCodes.UNAUTHORIZED); + }); + + it('should reject refresh when user is deactivated', async () => { + await request(app).post('/api/auth/register').send({ + email: 'inactive-refresh@test.dev', + username: 'inactiveRefresh', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'inactive-refresh@test.dev', + password: 'secure123', + }); + expect(loginRes.status).toBe(StatusCodes.OK); + const cookie = extractCookies(loginRes.headers['set-cookie']); + + await prisma.user.update({ + where: { email: 'inactive-refresh@test.dev' }, + data: { isActive: false }, + }); + + const refreshRes = await request(app).post('/api/auth/refresh').set('Cookie', cookie); + expect(refreshRes.status).toBe(StatusCodes.UNAUTHORIZED); + }); + + it('should reject protected route when user is deactivated (authenticate middleware)', async () => { + await request(app).post('/api/auth/register').send({ + email: 'inactive-auth@test.dev', + username: 'inactiveAuth', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'inactive-auth@test.dev', + password: 'secure123', + }); + const token = loginRes.body.accessToken; + expect(token).toBeDefined(); + + await prisma.user.update({ + where: { email: 'inactive-auth@test.dev' }, + data: { isActive: false }, + }); + + const protectedRes = await request(app).get('/api/user').set('Authorization', `Bearer ${token}`); + + expect(protectedRes.status).toBe(StatusCodes.UNAUTHORIZED); + }); +}); diff --git a/server/test/auth-multidevice.test.ts b/server/test/auth-multidevice.test.ts new file mode 100644 index 0000000..0c07784 --- /dev/null +++ b/server/test/auth-multidevice.test.ts @@ -0,0 +1,77 @@ +import { StatusCodes } from 'http-status-codes'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { app } from '@/server'; + +function extractCookies(raw: string[] | string | undefined): string { + if (!raw) return ''; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map(c => c.split(';')[0]).join('; '); +} + +describe('Session hardening — multi-device (#51.1)', () => { + it('should keep first device valid after second login (no single-column overwrite)', async () => { + await request(app).post('/api/auth/register').send({ + email: 'multi@test.dev', + username: 'multiUser', + password: 'secure123', + }); + + const login1 = await request(app).post('/api/auth/login').send({ + email: 'multi@test.dev', + password: 'secure123', + }); + expect(login1.status).toBe(StatusCodes.OK); + const cookie1 = extractCookies(login1.headers['set-cookie']); + + // Second login simulates another device + const login2 = await request(app).post('/api/auth/login').send({ + email: 'multi@test.dev', + password: 'secure123', + }); + expect(login2.status).toBe(StatusCodes.OK); + const cookie2 = extractCookies(login2.headers['set-cookie']); + + expect(cookie1).not.toBe(cookie2); + + // Both cookies must still refresh independently + const refresh1 = await request(app).post('/api/auth/refresh').set('Cookie', cookie1); + expect(refresh1.status).toBe(StatusCodes.OK); + + const refresh2 = await request(app).post('/api/auth/refresh').set('Cookie', cookie2); + expect(refresh2.status).toBe(StatusCodes.OK); + }); + + it('should only revoke the presented session on logout, leaving other device', async () => { + await request(app).post('/api/auth/register').send({ + email: 'multilogout@test.dev', + username: 'multiLogout', + password: 'secure123', + }); + + const login1 = await request(app).post('/api/auth/login').send({ + email: 'multilogout@test.dev', + password: 'secure123', + }); + const cookie1 = extractCookies(login1.headers['set-cookie']); + + const login2 = await request(app).post('/api/auth/login').send({ + email: 'multilogout@test.dev', + password: 'secure123', + }); + const cookie2 = extractCookies(login2.headers['set-cookie']); + + // Logout with first device + const logout1 = await request(app).post('/api/auth/logout').set('Cookie', cookie1); + expect(logout1.status).toBe(StatusCodes.OK); + + // First device must no longer refresh + const refresh1After = await request(app).post('/api/auth/refresh').set('Cookie', cookie1); + expect(refresh1After.status).toBe(StatusCodes.UNAUTHORIZED); + + // Second device must still refresh + const refresh2After = await request(app).post('/api/auth/refresh').set('Cookie', cookie2); + expect(refresh2After.status).toBe(StatusCodes.OK); + }); +}); diff --git a/server/test/auth-session-rotation.test.ts b/server/test/auth-session-rotation.test.ts new file mode 100644 index 0000000..d4f12f7 --- /dev/null +++ b/server/test/auth-session-rotation.test.ts @@ -0,0 +1,55 @@ +import { StatusCodes } from 'http-status-codes'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { app } from '@/server'; + +function extractCookies(raw: string[] | string | undefined): string { + if (!raw) return ''; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map(c => c.split(';')[0]).join('; '); +} + +function getRefreshCookie(setCookie: string[] | string | undefined): string | undefined { + if (!setCookie) return undefined; + const arr = Array.isArray(setCookie) ? setCookie : [setCookie]; + return arr.find(c => c.startsWith('refreshToken=')); +} + +describe('Session hardening — refresh rotation (#51.1)', () => { + it('should rotate refresh token on refresh and invalidate the old one', async () => { + await request(app).post('/api/auth/register').send({ + email: 'rotate@test.dev', + username: 'rotater', + password: 'secure123', + }); + + const loginRes = await request(app).post('/api/auth/login').send({ + email: 'rotate@test.dev', + password: 'secure123', + }); + expect(loginRes.status).toBe(StatusCodes.OK); + const firstCookie = getRefreshCookie(loginRes.headers['set-cookie']); + expect(firstCookie).toBeDefined(); + + const firstCookieHeader = extractCookies(loginRes.headers['set-cookie']); + + // First refresh — should issue a new refreshToken cookie + const refreshRes = await request(app).post('/api/auth/refresh').set('Cookie', firstCookieHeader); + + expect(refreshRes.status).toBe(StatusCodes.OK); + const secondCookie = getRefreshCookie(refreshRes.headers['set-cookie']); + // This is the RED assertion: new implementation must set a new refresh cookie + expect(secondCookie).toBeDefined(); + expect(secondCookie).not.toBe(firstCookie); + + // Old token must no longer work + const replayOld = await request(app).post('/api/auth/refresh').set('Cookie', firstCookieHeader); + expect(replayOld.status).toBe(StatusCodes.UNAUTHORIZED); + + // New token must work + const secondCookieHeader = extractCookies(refreshRes.headers['set-cookie']); + const refreshWithNew = await request(app).post('/api/auth/refresh').set('Cookie', secondCookieHeader); + expect(refreshWithNew.status).toBe(StatusCodes.OK); + }); +}); diff --git a/server/test/setup.ts b/server/test/setup.ts index b4d3849..94b85c3 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -20,7 +20,14 @@ beforeAll(async () => { }); afterEach(async () => { - const tableNames = ['collaboration_requests', 'collaborators', 'yjs_document_states', 'documents', 'users']; + const tableNames = [ + 'collaboration_requests', + 'collaborators', + 'yjs_document_states', + 'documents', + 'sessions', + 'users', + ]; try { await prisma.$transaction(async (tx: Prisma.TransactionClient) => {