# Story 1.3: Diagram Access Control & Management Status: done ## Story As a diagram owner, I want to rename, delete, and control access to my diagrams, so that I can manage my workspace and protect my work. ## Acceptance Criteria 1. **Given** I am viewing a diagram I own, **When** I click the diagram title in the dashboard card or editor header, **Then** it becomes an inline editable field, **And** changes are saved on blur or Enter keypress via PATCH /diagrams/:id. 2. **Given** I own a diagram, **When** I click the delete action on a diagram card, **Then** a confirmation dialog appears warning that deletion is permanent, **And** on confirmation the diagram is soft-deleted (sets `deletedAt` timestamp) and removed from my dashboard. 3. **Given** I am not the owner of a diagram, **When** I attempt to access the diagram via direct URL, **Then** I receive a 403 Forbidden response, **And** I see a "You don't have access to this diagram" message. 4. **Given** any API request to diagram endpoints (GET /:id, PATCH /:id, DELETE /:id), **When** the request is processed, **Then** server-side middleware verifies the authenticated user is the diagram owner (or has a valid share token — covered in Epic 6), **And** unauthorized requests return 403 before any data mutation. ## Tasks / Subtasks - [x] Task 1: Add PATCH and DELETE endpoints to diagram router (AC: #1, #2, #4) - [x] 1.1: Add `updateDiagramSchema` Zod schema for PATCH (title only for now) - [x] 1.2: Add PATCH /:id endpoint — ownership check → update title → return updated diagram - [x] 1.3: Add DELETE /:id endpoint — ownership check → soft-delete via `deletedAt = new Date()` → return success - [x] 1.4: Refactor GET /:id to distinguish 404 (not found) from 403 (not owner): query without userId filter first, then check ownership - [x] Task 2: Add inline rename to DiagramCard (AC: #1) - [x] 2.1: Add editable title state to DiagramCard — click title → input field, save on blur/Enter, cancel on Escape - [x] 2.2: PATCH mutation via Hono RPC client with React Query invalidation - [x] 2.3: Add kebab menu (DropdownMenu) to DiagramCard with Rename and Delete actions - [x] Task 3: Add delete confirmation dialog (AC: #2) - [x] 3.1: AlertDialog with warning text: "This action cannot be undone. The diagram will be permanently deleted." - [x] 3.2: DELETE mutation via Hono RPC client with React Query invalidation + toast confirmation - [x] Task 4: Add inline rename to diagram editor header (AC: #1) - [x] 4.1: Replace static title in editor page with editable field — same blur/Enter/Escape pattern - [x] 4.2: PATCH mutation with React Query invalidation - [x] Task 5: Handle 403 error state in diagram editor (AC: #3) - [x] 5.1: Detect 403 response from GET /:id and show "You don't have access to this diagram" with appropriate icon - [x] 5.2: Differentiate 403 (access denied) from 404 (not found) in error UI - [x] Task 6: Tests (AC: all) - [x] 6.1: API schema validation tests for updateDiagramSchema - [x] 6.2: Ownership check logic tests (403 vs 404 distinction) ## Dev Notes ### API — PATCH and DELETE Endpoints Modify `packages/api/src/modules/diagram/router.ts` to add two new endpoints: **PATCH /:id — Rename diagram:** ```typescript const updateDiagramBodySchema = z.object({ title: z.string().min(1).max(255).optional(), }).refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided", }); .patch("/:id", enforceAuth, validate("json", updateDiagramBodySchema), async (c) => { const [d] = await db.select().from(diagram) .where(and(eq(diagram.id, c.req.param("id")), isNull(diagram.deletedAt))); if (!d) { throw new HttpException(HttpStatusCode.NOT_FOUND, { code: "error.notFound" }); } if (d.userId !== c.var.user.id) { throw new HttpException(HttpStatusCode.FORBIDDEN, { code: "error.forbidden", message: "You don't have access to this diagram" }); } const [updated] = await db.update(diagram) .set(c.req.valid("json")) .where(eq(diagram.id, c.req.param("id"))) .returning(); return c.json({ data: updated }); }) ``` **DELETE /:id — Soft-delete diagram:** ```typescript .delete("/:id", enforceAuth, async (c) => { const [d] = await db.select().from(diagram) .where(and(eq(diagram.id, c.req.param("id")), isNull(diagram.deletedAt))); if (!d) { throw new HttpException(HttpStatusCode.NOT_FOUND, { code: "error.notFound" }); } if (d.userId !== c.var.user.id) { throw new HttpException(HttpStatusCode.FORBIDDEN, { code: "error.forbidden", message: "You don't have access to this diagram" }); } await db.update(diagram) .set({ deletedAt: new Date() }) .where(eq(diagram.id, c.req.param("id"))); return c.json({ data: { success: true } }); }) ``` **Refactor GET /:id — 403 vs 404 distinction:** Current GET /:id combines ownership + existence in one WHERE clause, returning 404 for both cases. Refactor to: ```typescript .get("/:id", enforceAuth, async (c) => { const [d] = await db.select().from(diagram) .where(and(eq(diagram.id, c.req.param("id")), isNull(diagram.deletedAt))); if (!d) { throw new HttpException(HttpStatusCode.NOT_FOUND, { code: "error.notFound" }); } if (d.userId !== c.var.user.id) { throw new HttpException(HttpStatusCode.FORBIDDEN, { code: "error.forbidden", message: "You don't have access to this diagram" }); } return c.json({ data: d }); }) ``` Note: When share tokens are added (Epic 6), the ownership check will expand to also accept valid share tokens: `if (d.userId !== c.var.user.id && !validShareToken) { 403 }`. ### Frontend — DiagramCard Enhancements Modify `apps/web/src/modules/diagram/components/DiagramCard.tsx`: **Add kebab menu with Rename/Delete actions:** - Use `DropdownMenu` (not ContextMenu) — consistent with Story 1.2's ProjectContextMenu pattern - Menu items: Rename (opens inline edit), Delete (opens AlertDialog) - Import: `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, `DropdownMenuItem` from `@turbostarter/ui-web/dropdown-menu` - Import: `AlertDialog`, `AlertDialogAction`, `AlertDialogCancel`, `AlertDialogContent`, `AlertDialogDescription`, `AlertDialogFooter`, `AlertDialogHeader`, `AlertDialogTitle` from `@turbostarter/ui-web/alert-dialog` **Inline editable title:** - State: `isEditing` boolean, `editValue` string - Click title (or Rename from menu) → show `Input` component - Save on blur/Enter → `api.diagrams[":id"].$patch({ param: { id }, json: { title: editValue } })` - Cancel on Escape → revert to original title - React Query invalidation: `["diagrams"]` and `["diagram", id]` - Use `toast()` from `sonner` for success/error feedback **Delete confirmation:** - AlertDialog matches Story 1.2's project delete pattern - On confirm → `api.diagrams[":id"].$delete({ param: { id } })` - React Query invalidation: `["diagrams"]` + navigate away if in editor - Toast: "Diagram deleted" ### Frontend — Editor Header Rename Modify `apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx`: Replace the static `

{data?.data?.title}

` with an inline editable title using the same pattern as DiagramCard. The editor page already fetches the diagram — just add edit state and PATCH mutation. ### Frontend — 403 Error Handling The editor page currently shows a generic error for any API failure. Enhance to distinguish: ```typescript const { data, isLoading, error } = useQuery({ queryKey: ["diagram", params.id], queryFn: async () => { const res = await api.diagrams[":id"].$get({ param: { id: params.id } }); if (res.status === 403) { throw new Error("forbidden"); } if (!res.ok) { throw new Error("not-found"); } return await res.json(); }, }); ``` Render different error UIs: - 403: `Icons.ShieldAlert` + "You don't have access to this diagram" - 404/other: `Icons.AlertTriangle` + "Failed to load diagram. It may have been deleted." ### Project Structure Notes - `packages/api/src/modules/diagram/router.ts` — MODIFIED: add PATCH /:id, DELETE /:id, refactor GET /:id - `apps/web/src/modules/diagram/components/DiagramCard.tsx` — MODIFIED: add kebab menu, inline rename, delete dialog - `apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx` — MODIFIED: add editable title, 403 handling - `packages/api/tests/diagram/diagram-access-control.test.ts` — NEW: ownership and access control schema tests - Alignment: All paths match architecture doc patterns. No new modules needed. ### Anti-Patterns to Avoid - **NEVER hard-delete diagrams** — always soft-delete via `deletedAt = new Date()`. The `deletedAt` column already exists. - **NEVER return 404 for ownership failures** — return 403 to distinguish "doesn't exist" from "not authorized". This is a security-aware choice for this story (the AC explicitly requires 403). - **NEVER use raw fetch** — always use Hono RPC client (`api.diagrams[":id"].$patch(...)`) - **NEVER inline `.parse()` in Hono handlers** — use `validate("json", schema)` middleware - **NEVER put business logic in API routers** — ownership check is a simple comparison, acceptable inline. If it grows, extract to a helper. - **DO NOT** change the diagram DB schema — `deletedAt` column already exists from Story 1.1 - **DO NOT** implement share token access control — that's Epic 6. Leave a comment placeholder in the ownership check. - **DO NOT** add drag-and-drop — that's Story 1.4 - **DO NOT** add `Icons.ShieldAlert` to icons.tsx without checking if it already exists — check first ### Previous Story Intelligence (Story 1.2) **Key learnings to carry forward:** - `DropdownMenu` preferred over `ContextMenu` for action menus (more discoverable, accessible) - `db.transaction()` for multi-step operations — NOT needed here since each endpoint does a single mutation - `timeAgo()` exported from DiagramCard for reuse - Hono RPC client pattern: `api.diagrams[":id"].$get({ param: { id } })` — NOT raw fetch - `toast()` from `sonner` for user feedback - React Query invalidation: `queryClient.invalidateQueries({ queryKey: ["diagrams"] })` - PATCH validation: use `.refine()` to require at least one field (lesson from code review issue #4) - 107 existing tests pass — don't break them ### Git Intelligence Recent commits: - `85e06c2 feat: implement Story 1.2 — organize diagrams into projects` — 12 files, project CRUD + sidebar - `392da38 feat: implement Story 1.1 — create and view diagrams` — 20 files, diagram schema + CRUD + dashboard Established patterns: - Commit message: `feat: implement Story X.Y — description` - Co-located tests: `packages/api/tests/diagram/` - Zod schemas exported from router files for type-safe client usage ### References - [Source: _bmad-output/planning-artifacts/epics.md#Story 1.3] — Full AC and technical notes - [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns] — Naming, structure, enforcement rules - [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules for AI agents - [Source: _bmad-output/implementation-artifacts/1-2-organize-diagrams-into-projects.md] — Previous story learnings - [Source: _bmad-output/project-context.md] — 62 critical implementation rules - [Source: packages/api/src/modules/diagram/router.ts] — Current diagram router (GET /, GET /:id, POST /) - [Source: packages/db/src/schema/diagram.ts] — Diagram schema with deletedAt column - [Source: apps/web/src/modules/diagram/components/DiagramCard.tsx] — Current card component (no menu/edit) - [Source: apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx] — Current editor page (static title) ## Dev Agent Record ### Agent Model Used Claude Opus 4.6 ### Debug Log References - TypeScript: 0 errors (both `packages/api` and `apps/web`) - Tests: 120 passed (107 existing + 13 new) — 0 failures ### Completion Notes List - Task 1: Added `updateDiagramBodySchema` with `.refine()` for at least one field. PATCH /:id and DELETE /:id endpoints with ownership check (403 vs 404 distinction). Refactored GET /:id to separate existence from ownership. - Task 2: DiagramCard converted to client component with inline editable title, PATCH mutation via Hono RPC, DropdownMenu (kebab) with Rename/Delete actions. - Task 3: AlertDialog for delete confirmation with destructive styling. DELETE mutation via Hono RPC with React Query invalidation + toast. - Task 4: Editor page title replaced with inline editable field using same blur/Enter/Escape pattern as DiagramCard. - Task 5: 403 detection via `res.status === 403` in useQuery. Differentiated error UIs: `Icons.Lock` for 403, `Icons.AlertTriangle` for 404. Note: Used `Icons.Lock` instead of `Icons.ShieldAlert` (not available in icons.tsx). - Task 6: 13 tests total — 7 schema validation tests for `updateDiagramBodySchema` + 6 ownership check logic tests (403 vs 404 contract, shared helper, Epic 6 placeholder). - **Code Review Fixes (Claude Opus 4.6):** - H1: Added onClick to DiagramCard title for inline edit (AC #1 compliance) - H2: Added 6 ownership check logic tests (Task 6.2) - M1+L2: Replaced useState error anti-pattern with typed DiagramError class + useQuery error property - M2: Added custom retry function to skip retries for 403/404 DiagramErrors - M3: Extracted `getOwnedDiagram()` helper to DRY ownership check across GET/PATCH/DELETE - L1: Added useEffect to sync editValue when diagram.title changes externally ### File List - `packages/api/src/modules/diagram/router.ts` — MODIFIED: Added `updateDiagramBodySchema`, PATCH /:id, DELETE /:id, refactored GET /:id for 403 vs 404 - `apps/web/src/modules/diagram/components/DiagramCard.tsx` — MODIFIED: Added inline rename, kebab menu (DropdownMenu), delete confirmation (AlertDialog), PATCH/DELETE mutations - `apps/web/src/app/[locale]/dashboard/(user)/diagram/[id]/page.tsx` — MODIFIED: Added inline editable title, 403/404 error differentiation - `packages/api/tests/diagram/diagram-access-control.test.ts` — NEW: 7 schema validation tests for updateDiagramBodySchema