feat: implement Story 1.3 — diagram access control and management

Add PATCH and DELETE endpoints with ownership checks (403 vs 404),
inline rename on DiagramCard and editor header, delete confirmation
dialog, and differentiated error states for forbidden/not-found.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 22:18:28 +00:00
parent 85e06c25c7
commit e9cd685d3d
6 changed files with 790 additions and 53 deletions

View File

@@ -1,21 +1,92 @@
"use client";
import { useRef, useEffect } from "react";
import { useState } from "react";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { api } from "~/lib/api/client";
class DiagramError extends Error {
constructor(
public readonly type: "forbidden" | "not-found",
message: string,
) {
super(message);
this.name = "DiagramError";
}
}
export default function DiagramEditorPage() {
const params = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
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 DiagramError("forbidden", "You don't have access to this diagram");
}
if (!res.ok) {
throw new DiagramError("not-found", "Diagram not found");
}
return await res.json();
},
retry: (failureCount, err) => {
if (err instanceof DiagramError) return false;
return failureCount < 3;
},
});
const errorType = error instanceof DiagramError ? error.type : error ? "not-found" : null;
const title = data?.data?.title ?? "Diagram";
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const renameMutation = useMutation({
mutationFn: async (newTitle: string) => {
const res = await api.diagrams[":id"].$patch({
param: { id: params.id },
json: { title: newTitle },
});
if (!res.ok) throw new Error("Failed to rename diagram");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagram", params.id] });
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
toast.success("Diagram renamed");
},
onError: () => {
toast.error("Failed to rename diagram");
},
});
const handleSaveRename = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== title) {
renameMutation.mutate(trimmed);
}
setIsEditing(false);
};
const handleCancelRename = () => {
setEditValue(title);
setIsEditing(false);
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
@@ -24,12 +95,23 @@ export default function DiagramEditorPage() {
);
}
if (isError) {
if (errorType === "forbidden") {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
<Icons.Lock className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground">
You don&apos;t have access to this diagram.
</p>
</div>
);
}
if (errorType === "not-found" || !data) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
<Icons.AlertTriangle className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground">
Failed to load diagram. It may have been deleted or you don't have access.
Failed to load diagram. It may have been deleted or does not exist.
</p>
</div>
);
@@ -39,7 +121,35 @@ export default function DiagramEditorPage() {
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<Icons.LayoutDashboard className="h-16 w-16 text-muted-foreground/30" />
<div className="text-center">
<h1 className="text-xl font-semibold">{data?.data?.title ?? "Diagram"}</h1>
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSaveRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveRename();
} else if (e.key === "Escape") {
handleCancelRename();
}
}}
className="text-xl font-semibold text-center"
maxLength={255}
/>
) : (
<h1
className="text-xl font-semibold cursor-pointer hover:text-primary/80 transition-colors"
onClick={() => {
setEditValue(title);
setIsEditing(true);
}}
title="Click to rename"
>
{title}
</h1>
)}
<p className="mt-2 text-sm text-muted-foreground">
The diagram editor canvas will be implemented in Epic 2.
</p>

View File

@@ -1,6 +1,31 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
import { Badge } from "@turbostarter/ui-web/badge";
import { Icons } from "@turbostarter/ui-web/icons";
import { Button } from "@turbostarter/ui-web/button";
import { Input } from "@turbostarter/ui-web/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@turbostarter/ui-web/alert-dialog";
import { api } from "~/lib/api/client";
import type { SelectDiagram } from "@turbostarter/db/schema";
@@ -43,37 +68,196 @@ interface DiagramCardProps {
export function DiagramCard({ diagram, onClick }: DiagramCardProps) {
const config = diagramTypeConfig[diagram.type];
const TypeIcon = config.icon;
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(diagram.title);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isEditing) {
setEditValue(diagram.title);
}
}, [diagram.title, isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const res = await api.diagrams[":id"].$patch({
param: { id: diagram.id },
json: { title },
});
if (!res.ok) throw new Error("Failed to rename diagram");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
queryClient.invalidateQueries({ queryKey: ["diagram", diagram.id] });
toast.success("Diagram renamed");
},
onError: () => {
setEditValue(diagram.title);
toast.error("Failed to rename diagram");
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await api.diagrams[":id"].$delete({
param: { id: diagram.id },
});
if (!res.ok) throw new Error("Failed to delete diagram");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
toast.success("Diagram deleted");
},
onError: () => {
toast.error("Failed to delete diagram");
},
});
const handleSaveRename = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== diagram.title) {
renameMutation.mutate(trimmed);
} else {
setEditValue(diagram.title);
}
setIsEditing(false);
};
const handleCancelRename = () => {
setEditValue(diagram.title);
setIsEditing(false);
};
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium truncate">{diagram.title}</CardTitle>
<TypeIcon className={`h-4 w-4 shrink-0 ${config.color}`} />
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">
{diagram.updatedAt
? timeAgo(new Date(diagram.updatedAt))
: "just now"}
</span>
</div>
</CardContent>
</Card>
<>
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (!isEditing) {
e.preventDefault();
onClick?.();
}
}
}}
onClick={() => {
if (!isEditing) onClick?.();
}}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSaveRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveRename();
} else if (e.key === "Escape") {
handleCancelRename();
}
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
className="h-6 text-sm font-medium px-1"
maxLength={255}
/>
) : (
<CardTitle
className="text-sm font-medium truncate flex-1 cursor-pointer hover:text-primary/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
setEditValue(diagram.title);
setIsEditing(true);
}}
title="Click to rename"
>
{diagram.title}
</CardTitle>
)}
<div className="flex items-center gap-1 shrink-0 ml-2">
<TypeIcon className={`h-4 w-4 ${config.color}`} />
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Icons.MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setEditValue(diagram.title);
setIsEditing(true);
}}
>
<Icons.Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setShowDeleteDialog(true);
}}
>
<Icons.Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">
{diagram.updatedAt
? timeAgo(new Date(diagram.updatedAt))
: "just now"}
</span>
</div>
</CardContent>
</Card>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete diagram</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The diagram &quot;{diagram.title}&quot; will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteMutation.mutate()}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}