refactor(api): rename personal access tokens to API keys and update related UI components for consistency

This commit is contained in:
Anish Sarkar 2026-06-25 23:22:11 +05:30
parent 96b64166b1
commit d5e2540e51
9 changed files with 162 additions and 81 deletions

View file

@ -1,10 +1,20 @@
"use client"; "use client";
import { Check, Copy, Info, Plus, Trash2 } from "lucide-react"; import { Check, Copy, Info, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -16,6 +26,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { usePats } from "@/hooks/use-pats"; import { usePats } from "@/hooks/use-pats";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils"; import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
@ -26,6 +37,7 @@ export function ApiKeyContent() {
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [expiresInDays, setExpiresInDays] = useState(""); const [expiresInDays, setExpiresInDays] = useState("");
const [copiedToken, setCopiedToken] = useState(false); const [copiedToken, setCopiedToken] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null);
const sortedTokens = useMemo(() => tokens, [tokens]); const sortedTokens = useMemo(() => tokens, [tokens]);
@ -51,93 +63,112 @@ export function ApiKeyContent() {
} }
}, [createdToken]); }, [createdToken]);
const handleDelete = useCallback( const handleConfirmDelete = useCallback(async () => {
async (id: number, tokenLabel: string) => { if (!deleteTarget) return;
if (!window.confirm(`Delete personal access token "${tokenLabel}"? This cannot be undone.`)) {
return; await deleteToken(deleteTarget.id);
} setDeleteTarget(null);
await deleteToken(id); }, [deleteTarget, deleteToken]);
},
[deleteToken]
);
return ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0">
<Alert> <Alert>
<Info /> <Info />
<AlertDescription> <AlertDescription>
Personal access tokens are long-lived credentials for extensions, Obsidian, and API keys let extensions, Obsidian, and other apps connect to SurfSense.
programmatic API clients. Copy a token when you create it; it is shown only once.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h3 className="text-sm font-semibold tracking-tight">Personal access tokens</h3> <h3 className="text-sm font-semibold tracking-tight">API keys</h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Expired tokens stay listed until you delete them. Expired API keys stay listed until you delete them.
</p> </p>
</div> </div>
<Button size="sm" onClick={() => setCreateOpen(true)}> <Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Create API key
Create token
</Button> </Button>
</div> </div>
<div className="min-w-0 overflow-hidden rounded-lg border border-border/60"> {isLoading ? (
{isLoading ? ( <div className="-m-1 grid grid-cols-1 gap-3 p-1">
<div className="space-y-3 p-4"> {["skeleton-a", "skeleton-b"].map((key) => (
<Skeleton className="h-12 w-full" /> <Card
<Skeleton className="h-12 w-full" /> key={key}
</div> className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
) : sortedTokens.length > 0 ? ( >
<div className="divide-y divide-border/60"> <CardContent className="p-4 flex flex-col gap-3 h-full min-h-24">
{sortedTokens.map((token) => { <Skeleton className="h-4 w-32 md:w-40 bg-accent" />
const expiresAt = token.expires_at ? new Date(token.expires_at) : null; <Skeleton className="h-3 w-full bg-accent" />
const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false; <Skeleton className="h-3 w-24 md:w-28 bg-accent" />
return ( </CardContent>
<div key={token.id} className="flex items-center gap-3 p-4"> </Card>
))}
</div>
) : sortedTokens.length > 0 ? (
<div className="-m-1 grid grid-cols-1 gap-3 p-1">
{sortedTokens.map((token) => {
const expiresAt = token.expires_at ? new Date(token.expires_at) : null;
const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
return (
<Card
key={token.id}
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
>
<CardContent className="flex min-h-24 items-center gap-3 p-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<p className="truncate text-sm font-medium">{token.label}</p> <div className="flex items-center gap-2">
{isExpired ? <Badge variant="secondary">Expired</Badge> : null} <h4 className="truncate text-sm font-semibold tracking-tight">
{token.label}
</h4>
{isExpired ? (
<span className="rounded-md border-0 bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Expired
</span>
) : null}
</div>
<p className="truncate font-mono text-xs text-muted-foreground">
{token.prefix}...
</p>
<p className="text-xs text-muted-foreground">
Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "}
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : "Never"}
</p>
</div> </div>
<p className="font-mono text-xs text-muted-foreground">{token.prefix}...</p>
<p className="text-xs text-muted-foreground">
Expires: {expiresAt ? expiresAt.toLocaleDateString() : "Never"} · Last used:{" "}
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : "Never"}
</p>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled={isMutating} disabled={isMutating}
onClick={() => handleDelete(token.id, token.label)} onClick={() => setDeleteTarget({ id: token.id, label: token.label })}
className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground transition-opacity duration-150 hover:text-accent-foreground sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto"
> >
<Trash2 className="h-4 w-4 text-muted-foreground" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </CardContent>
); </Card>
})} );
</div> })}
) : ( </div>
<p className="p-6 text-center text-sm text-muted-foreground"> ) : (
No personal access tokens yet. <p className="py-6 text-center text-sm text-muted-foreground">
</p> No API keys yet.
)} </p>
</div> )}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create personal access token</DialogTitle> <DialogTitle>Create API key</DialogTitle>
<DialogDescription> <DialogDescription>
Name this token so you can recognize where it is used later. Name this API key so you can recognize where it is used later.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="pat-label">Label</Label> <Label htmlFor="pat-label">Name</Label>
<Input <Input
id="pat-label" id="pat-label"
value={label} value={label}
@ -158,11 +189,24 @@ export function ApiKeyContent() {
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}> <Button
type="button"
variant="secondary"
size="sm"
onClick={() => setCreateOpen(false)}
disabled={isMutating}
className="text-sm h-9"
>
Cancel Cancel
</Button> </Button>
<Button disabled={isMutating || !label.trim()} onClick={handleCreate}> <Button
Create token size="sm"
disabled={isMutating || !label.trim()}
onClick={handleCreate}
className="relative text-sm h-9 min-w-[128px]"
>
<span className={isMutating ? "opacity-0" : ""}>Create API key</span>
{isMutating && <Spinner size="sm" className="absolute" />}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -171,16 +215,21 @@ export function ApiKeyContent() {
<Dialog open={!!createdToken} onOpenChange={(open) => !open && setCreatedToken(null)}> <Dialog open={!!createdToken} onOpenChange={(open) => !open && setCreatedToken(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Copy your token now</DialogTitle> <DialogTitle>Copy your API key now</DialogTitle>
<DialogDescription> <DialogDescription>
This token is shown only once. Store it somewhere secure before closing this dialog. This API key is shown only once. Store it somewhere secure before closing this dialog.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 p-2"> <div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 p-2">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-xs"> <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-xs">
{createdToken?.token} {createdToken?.token}
</code> </code>
<Button variant="outline" size="sm" onClick={copyCreatedToken}> <Button
variant="outline"
size="sm"
onClick={copyCreatedToken}
className="border-0 bg-muted/30 hover:bg-muted/50"
>
{copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} {copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button> </Button>
</div> </div>
@ -189,6 +238,41 @@ export function ApiKeyContent() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
<AlertDialogDescription>
<span className="font-medium text-foreground">{deleteTarget?.label}</span> will be
permanently removed. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isMutating}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isMutating}
className="bg-destructive text-white hover:bg-destructive/90"
onClick={(event) => {
event.preventDefault();
void handleConfirmDelete();
}}
>
{isMutating ? (
<span className="inline-flex items-center gap-2">
<Spinner size="xs" />
Deleting...
</span>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View file

@ -38,13 +38,13 @@ export function CommunityPromptsContent() {
const list = prompts ?? []; const list = prompts ?? [];
return ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Prompts shared by other users. Add any to your collection with one click. Prompts shared by other users. Add any to your collection with one click.
</p> </p>
{isLoading && ( {isLoading && (
<div className="space-y-2"> <div className="-m-1 space-y-2 p-1">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20"> <Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-24"> <CardContent className="p-4 flex flex-col gap-3 min-h-24">
@ -76,7 +76,7 @@ export function CommunityPromptsContent() {
)} )}
{!isLoading && !isError && list.length > 0 && ( {!isLoading && !isError && list.length > 0 && (
<div className="space-y-2"> <div className="-m-1 space-y-2 p-1">
{list.map((prompt) => ( {list.map((prompt) => (
<Card <Card
key={prompt.id} key={prompt.id}

View file

@ -148,7 +148,7 @@ export function PromptsContent() {
const list = prompts ?? []; const list = prompts ?? [];
return ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in Create prompt templates triggered with <ShortcutKbd keys={["/"]} className="ml-0" /> in
@ -276,7 +276,7 @@ export function PromptsContent() {
</Dialog> </Dialog>
{isLoading && ( {isLoading && (
<div className="space-y-2"> <div className="-m-1 space-y-2 p-1">
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => ( {["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-accent bg-accent/20"> <Card key={key} className="border-accent bg-accent/20">
<CardContent className="p-4 flex flex-col gap-3 min-h-24"> <CardContent className="p-4 flex flex-col gap-3 min-h-24">
@ -308,7 +308,7 @@ export function PromptsContent() {
)} )}
{!isLoading && !isError && list.length > 0 && ( {!isLoading && !isError && list.length > 0 && (
<div className="space-y-2"> <div className="-m-1 space-y-2 p-1">
{list.map((prompt) => ( {list.map((prompt) => (
<div <div
key={prompt.id} key={prompt.id}

View file

@ -49,7 +49,7 @@ export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
toast.success("Public link deleted"); toast.success("Public link deleted");
}, },
onError: (error: Error) => { onError: (error: Error) => {
console.error("Failed to delete public chat link:", error); console.error("Failed to delete public chat:", error);
toast.error("Failed to delete public link"); toast.error("Failed to delete public link");
}, },
})); }));

View file

@ -15,9 +15,9 @@ This update brings **public sharing, image generation**, a redesigned Documents
#### Public Sharing #### Public Sharing
- **Public Chat Links**: Share snapshots of chats via public links. - **Public Chats**: Share snapshots of chats via public links.
- **Sharing Permissions**: Search Space owners control who can create and manage public links. - **Sharing Permissions**: Search Space owners control who can create and manage public links.
- **Link Management Page**: View and revoke all public chat links from Search Space Settings. - **Link Management Page**: View and revoke all public chats from Search Space Settings.
#### Auto (Load Balanced) Mode #### Auto (Load Balanced) Mode

View file

@ -1,12 +1,10 @@
import { Link2Off } from "lucide-react";
interface PublicChatSnapshotsEmptyStateProps { interface PublicChatSnapshotsEmptyStateProps {
title?: string; title?: string;
description?: string; description?: string;
} }
export function PublicChatSnapshotsEmptyState({ export function PublicChatSnapshotsEmptyState({
title = "No public chat links", title = "No public chats",
description = "When you create public links to share chats, they will appear here.", description = "When you create public links to share chats, they will appear here.",
}: PublicChatSnapshotsEmptyStateProps) { }: PublicChatSnapshotsEmptyStateProps) {
return ( return (

View file

@ -115,7 +115,7 @@ export function PublicChatSnapshotsManager({
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Failed to load public chat links. Please try again later. Failed to load public chats. Please try again later.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
@ -127,7 +127,7 @@ export function PublicChatSnapshotsManager({
<Alert> <Alert>
<Info /> <Info />
<AlertDescription> <AlertDescription>
You don't have permission to view public chat links in this search space. You don't have permission to view public chats in this search space.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
@ -140,8 +140,8 @@ export function PublicChatSnapshotsManager({
<Alert> <Alert>
<Info /> <Info />
<AlertDescription> <AlertDescription>
Public chat links allow anyone with the URL to view a snapshot of a chat. These links do Public chats allow anyone with the URL to view a snapshot of a chat. They do not update
not update when the original chat changes. when the original chat changes.
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View file

@ -208,10 +208,9 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="api-access-enabled">Programmatic API access</Label> <Label htmlFor="api-access-enabled">API key access</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow personal access tokens to use this search space. Web and desktop sessions are not Allow API keys to access this search space.
affected.
</p> </p>
</div> </div>
<Switch <Switch

View file

@ -746,8 +746,8 @@
"nav_agent_models_desc": "Models with prompts & citations", "nav_agent_models_desc": "Models with prompts & citations",
"nav_system_instructions": "System Instructions", "nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions", "nav_system_instructions_desc": "SearchSpace-wide AI instructions",
"nav_public_links": "Public Chat Links", "nav_public_links": "Public Chats",
"nav_public_links_desc": "Manage publicly shared chat links", "nav_public_links_desc": "Manage publicly shared chats",
"nav_team_roles": "Team Roles", "nav_team_roles": "Team Roles",
"nav_team_roles_desc": "Manage team roles & permissions", "nav_team_roles_desc": "Manage team roles & permissions",
"general_name_label": "Name", "general_name_label": "Name",