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,24 +0,0 @@
"""add is_public to prompts
Revision ID: 112
Revises: 111
"""
from collections.abc import Sequence
from alembic import op
revision: str = "112"
down_revision: str | None = "111"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false"
)
def downgrade() -> None:
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public")

View file

@ -0,0 +1,54 @@
"""add prompt library schema: is_public, default_prompt_slug, version, drop icon
Revision ID: 112
Revises: 111
"""
from collections.abc import Sequence
from alembic import op
revision: str = "112"
down_revision: str | None = "111"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" is_public BOOLEAN NOT NULL DEFAULT false"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_prompts_is_public"
" ON prompts (is_public) WHERE is_public = true"
)
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" default_prompt_slug VARCHAR(100)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug"
" ON prompts (default_prompt_slug)"
)
op.execute(
"ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug"
" UNIQUE (user_id, default_prompt_slug)"
)
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" version INTEGER NOT NULL DEFAULT 1"
)
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon")
def downgrade() -> None:
op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version")
op.execute(
"ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug"
)
op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug")
op.execute("DROP INDEX IF EXISTS ix_prompts_is_public")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public")

View file

@ -1,120 +0,0 @@
"""add default_prompt_slug, version, drop icon, seed defaults
Revision ID: 113
Revises: 112
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "113"
down_revision: str | None = "112"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
DEFAULTS = [
(
"fix-grammar",
"Fix grammar",
"Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
"transform",
),
(
"make-shorter",
"Make shorter",
"Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
"transform",
),
(
"translate",
"Translate",
"Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
"transform",
),
(
"rewrite",
"Rewrite",
"Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
"transform",
),
(
"summarize",
"Summarize",
"Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
"transform",
),
(
"explain",
"Explain",
"Explain the following text in simple terms:\n\n{selection}",
"explore",
),
(
"ask-knowledge-base",
"Ask my knowledge base",
"Search my knowledge base for information related to:\n\n{selection}",
"explore",
),
(
"look-up-web",
"Look up on the web",
"Search the web for information about:\n\n{selection}",
"explore",
),
]
def upgrade() -> None:
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS default_prompt_slug VARCHAR(100)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug"
" ON prompts (default_prompt_slug)"
)
op.execute(
"ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug"
" UNIQUE (user_id, default_prompt_slug)"
)
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" version INTEGER NOT NULL DEFAULT 1"
)
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon")
conn = op.get_bind()
users = conn.execute(sa.text('SELECT id FROM "user"')).fetchall()
for user_row in users:
user_id = user_row[0]
for slug, name, prompt, mode in DEFAULTS:
conn.execute(
sa.text(
"INSERT INTO prompts"
" (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)"
" VALUES (:user_id, :slug, :name, :prompt, :mode::prompt_mode, :version, false, now())"
" ON CONFLICT (user_id, default_prompt_slug) DO NOTHING"
),
{
"user_id": user_id,
"slug": slug,
"name": name,
"prompt": prompt,
"mode": mode,
"version": 1,
},
)
def downgrade() -> None:
op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL")
op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version")
op.execute(
"ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug"
)
op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug")

View file

@ -0,0 +1,45 @@
"""seed default prompts for all existing users
Revision ID: 113
Revises: 112
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "113"
down_revision: str | None = "112"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
INSERT INTO prompts
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
SELECT u.id, d.slug, d.name, d.prompt, d.mode::prompt_mode, 1, false, now()
FROM "user" u
CROSS JOIN (VALUES
('fix-grammar', 'Fix grammar', 'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}', 'transform'),
('make-shorter', 'Make shorter', 'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}', 'transform'),
('translate', 'Translate', 'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}', 'transform'),
('rewrite', 'Rewrite', 'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}', 'transform'),
('summarize', 'Summarize', 'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}', 'transform'),
('explain', 'Explain', 'Explain the following text in simple terms:\n\n{selection}', 'explore'),
('ask-knowledge-base','Ask my knowledge base', 'Search my knowledge base for information related to:\n\n{selection}', 'explore'),
('look-up-web', 'Look up on the web', 'Search the web for information about:\n\n{selection}', 'explore')
) AS d(slug, name, prompt, mode)
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
"""
)
)
def downgrade() -> None:
op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL")

View file

@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import Prompt, User, get_async_session
from app.db import Prompt, SearchSpaceMembership, User, get_async_session
from app.schemas.prompts import (
PromptCreate,
PromptRead,
@ -35,6 +35,19 @@ async def create_prompt(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
if body.search_space_id is not None:
membership = await session.execute(
select(SearchSpaceMembership).where(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == body.search_space_id,
)
)
if not membership.scalar_one_or_none():
raise HTTPException(
status_code=403,
detail="You are not a member of this search space",
)
prompt = Prompt(
user_id=user.id,
search_space_id=body.search_space_id,
@ -74,7 +87,7 @@ async def update_prompt(
setattr(prompt, field, value)
if has_content_change:
prompt.version = (prompt.version or 0) + 1
prompt.version = Prompt.version + 1
session.add(prompt)
await session.commit()
@ -111,7 +124,7 @@ async def list_public_prompts(
result = await session.execute(
select(Prompt)
.options(selectinload(Prompt.user))
.where(Prompt.is_public.is_(True))
.where(Prompt.is_public.is_(True), Prompt.user_id != user.id)
.order_by(Prompt.created_at.desc())
)
prompts = result.scalars().all()

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>
);
}

View file

@ -31,7 +31,6 @@ export const updatePromptMutationAtom = atomWithMutation(() => ({
onSuccess: () => {
toast.success("Prompt updated");
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update prompt");

View file

@ -98,35 +98,37 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
[filtered.length, highlightedIndex, handleSelect]
);
if (filtered.length === 0) return null;
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{filtered.map((action, index) => (
<button
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">
<Zap className="size-3.5" />
</span>
<span className="truncate">{action.name}</span>
</button>
))}
{filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
) : (
filtered.map((action, index) => (
<button
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">
<Zap className="size-3.5" />
</span>
<span className="truncate">{action.name}</span>
</button>
))
)}
<div className="my-1 h-px bg-border mx-2" />
<button