Fix review issues: authz, race conditions, UX safety, and consolidate migrations

This commit is contained in:
CREDO23 2026-03-31 19:09:05 +02:00
parent 5f4f7780d1
commit aecb58e22b
10 changed files with 227 additions and 217 deletions

View file

@ -1,14 +1,13 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { getBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground";
import { GoogleLoginButton } from "./GoogleLoginButton";
@ -17,19 +16,11 @@ import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() {
const t = useTranslations("auth");
const tCommon = useTranslations("common");
const router = useRouter();
const [authType, setAuthType] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
const searchParams = useSearchParams();
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
return;
}
}, [router]);
useEffect(() => {
// Check for various URL parameters that might indicate success or error states
const registered = searchParams.get("registered");

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { Copy, Globe, Sparkles } from "lucide-react";
import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react";
import { useCallback, useState } from "react";
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
@ -9,20 +9,24 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export function CommunityPromptsContent() {
const { data: prompts, isLoading } = useAtomValue(publicPromptsAtom);
const { mutateAsync: copyPrompt, isPending: isCopying } = useAtomValue(copyPromptMutationAtom);
const [copyingId, setCopyingId] = useState<number | null>(null);
const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom);
const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom);
const [copyingIds, setCopyingIds] = useState<Set<number>>(new Set());
const [expandedId, setExpandedId] = useState<number | null>(null);
const handleCopy = useCallback(
async (id: number) => {
setCopyingId(id);
setCopyingIds((prev) => new Set(prev).add(id));
try {
await copyPrompt(id);
} catch {
// toast handled by mutation atom
} finally {
setCopyingId(null);
setCopyingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
},
[copyPrompt]
@ -38,6 +42,16 @@ export function CommunityPromptsContent() {
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load community prompts</p>
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
</div>
);
}
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<p className="text-sm text-muted-foreground">
@ -91,20 +105,20 @@ export function CommunityPromptsContent() {
</button>
)}
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={copyingId === prompt.id && isCopying}
onClick={() => handleCopy(prompt.id)}
>
{copyingId === prompt.id && isCopying ? (
<Spinner className="size-3" />
) : (
<Copy className="size-3" />
)}
Add to mine
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={copyingIds.has(prompt.id)}
onClick={() => handleCopy(prompt.id)}
>
{copyingIds.has(prompt.id) ? (
<Spinner className="size-3" />
) : (
<Copy className="size-3" />
)}
Add to mine
</Button>
</div>
))}
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
@ -10,6 +10,16 @@ import {
updatePromptMutationAtom,
} from "@/atoms/prompts/prompts-mutation.atoms";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -27,7 +37,7 @@ interface PromptFormData {
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
export function PromptsContent() {
const { data: prompts, isLoading } = useAtomValue(promptsAtom);
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
@ -37,6 +47,7 @@ export function PromptsContent() {
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
const [isSaving, setIsSaving] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const handleSave = useCallback(async () => {
if (!formData.name.trim() || !formData.prompt.trim()) {
@ -46,7 +57,7 @@ export function PromptsContent() {
setIsSaving(true);
try {
if (editingId) {
if (editingId !== null) {
await updatePrompt({ id: editingId, ...formData });
} else {
await createPrompt(formData);
@ -72,16 +83,16 @@ export function PromptsContent() {
setShowForm(true);
}, []);
const handleDelete = useCallback(
async (id: number) => {
try {
await deletePrompt(id);
} catch {
// toast handled by mutation atom
}
},
[deletePrompt]
);
const handleConfirmDelete = useCallback(async () => {
if (deleteTarget === null) return;
try {
await deletePrompt(deleteTarget);
} catch {
// toast handled by mutation atom
} finally {
setDeleteTarget(null);
}
}, [deleteTarget, deletePrompt]);
const handleTogglePublic = useCallback(
async (prompt: PromptRead) => {
@ -110,6 +121,16 @@ export function PromptsContent() {
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load prompts</p>
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
</div>
);
}
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
@ -137,7 +158,7 @@ export function PromptsContent() {
{showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
<h3 className="text-sm font-semibold tracking-tight">
{editingId ? "Edit prompt" : "New prompt"}
{editingId !== null ? "Edit prompt" : "New prompt"}
</h3>
<div className="space-y-2">
@ -200,7 +221,7 @@ export function PromptsContent() {
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Spinner className="size-3.5" /> : editingId ? "Update" : "Create"}
{isSaving ? <Spinner className="size-3.5" /> : editingId !== null ? "Update" : "Create"}
</Button>
</div>
</div>
@ -279,7 +300,7 @@ export function PromptsContent() {
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(prompt.id)}
onClick={() => setDeleteTarget(prompt.id)}
>
<Trash2 className="size-3.5" />
</Button>
@ -288,6 +309,21 @@ export function PromptsContent() {
))}
</div>
)}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete prompt</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The prompt will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}