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:
@@ -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'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>
|
||||
|
||||
@@ -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 "{diagram.title}" 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user