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";
import { Check, Copy, Info, Plus, Trash2 } from "lucide-react";
import { Check, Copy, Info, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
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 { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -16,6 +26,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { usePats } from "@/hooks/use-pats";
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
@ -26,6 +37,7 @@ export function ApiKeyContent() {
const [label, setLabel] = useState("");
const [expiresInDays, setExpiresInDays] = useState("");
const [copiedToken, setCopiedToken] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<{ id: number; label: string } | null>(null);
const sortedTokens = useMemo(() => tokens, [tokens]);
@ -51,93 +63,112 @@ export function ApiKeyContent() {
}
}, [createdToken]);
const handleDelete = useCallback(
async (id: number, tokenLabel: string) => {
if (!window.confirm(`Delete personal access token "${tokenLabel}"? This cannot be undone.`)) {
return;
}
await deleteToken(id);
},
[deleteToken]
);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
await deleteToken(deleteTarget.id);
setDeleteTarget(null);
}, [deleteTarget, deleteToken]);
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="space-y-6 min-w-0">
<Alert>
<Info />
<AlertDescription>
Personal access tokens are long-lived credentials for extensions, Obsidian, and
programmatic API clients. Copy a token when you create it; it is shown only once.
API keys let extensions, Obsidian, and other apps connect to SurfSense.
</AlertDescription>
</Alert>
<div className="flex items-center justify-between gap-3">
<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">
Expired tokens stay listed until you delete them.
Expired API keys stay listed until you delete them.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create token
Create API key
</Button>
</div>
<div className="min-w-0 overflow-hidden rounded-lg border border-border/60">
{isLoading ? (
<div className="space-y-3 p-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : sortedTokens.length > 0 ? (
<div className="divide-y divide-border/60">
{sortedTokens.map((token) => {
const expiresAt = token.expires_at ? new Date(token.expires_at) : null;
const isExpired = expiresAt ? expiresAt.getTime() <= Date.now() : false;
return (
<div key={token.id} className="flex items-center gap-3 p-4">
{isLoading ? (
<div className="-m-1 grid grid-cols-1 gap-3 p-1">
{["skeleton-a", "skeleton-b"].map((key) => (
<Card
key={key}
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
>
<CardContent className="p-4 flex flex-col gap-3 h-full min-h-24">
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
<Skeleton className="h-3 w-full bg-accent" />
<Skeleton className="h-3 w-24 md:w-28 bg-accent" />
</CardContent>
</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="flex items-center gap-2">
<p className="truncate text-sm font-medium">{token.label}</p>
{isExpired ? <Badge variant="secondary">Expired</Badge> : null}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<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>
<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>
<Button
variant="ghost"
size="icon"
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>
</div>
);
})}
</div>
) : (
<p className="p-6 text-center text-sm text-muted-foreground">
No personal access tokens yet.
</p>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
No API keys yet.
</p>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create personal access token</DialogTitle>
<DialogTitle>Create API key</DialogTitle>
<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>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pat-label">Label</Label>
<Label htmlFor="pat-label">Name</Label>
<Input
id="pat-label"
value={label}
@ -158,11 +189,24 @@ export function ApiKeyContent() {
</div>
</div>
<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
</Button>
<Button disabled={isMutating || !label.trim()} onClick={handleCreate}>
Create token
<Button
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>
</DialogFooter>
</DialogContent>
@ -171,16 +215,21 @@ export function ApiKeyContent() {
<Dialog open={!!createdToken} onOpenChange={(open) => !open && setCreatedToken(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Copy your token now</DialogTitle>
<DialogTitle>Copy your API key now</DialogTitle>
<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>
</DialogHeader>
<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">
{createdToken?.token}
</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" />}
</Button>
</div>
@ -189,6 +238,41 @@ export function ApiKeyContent() {
</DialogFooter>
</DialogContent>
</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>
);
}

View file

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

View file

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

View file

@ -49,7 +49,7 @@ export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
toast.success("Public link deleted");
},
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");
},
}));

View file

@ -15,9 +15,9 @@ This update brings **public sharing, image generation**, a redesigned Documents
#### 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.
- **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

View file

@ -1,12 +1,10 @@
import { Link2Off } from "lucide-react";
interface PublicChatSnapshotsEmptyStateProps {
title?: string;
description?: string;
}
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.",
}: PublicChatSnapshotsEmptyStateProps) {
return (

View file

@ -115,7 +115,7 @@ export function PublicChatSnapshotsManager({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load public chat links. Please try again later.
Failed to load public chats. Please try again later.
</AlertDescription>
</Alert>
);
@ -127,7 +127,7 @@ export function PublicChatSnapshotsManager({
<Alert>
<Info />
<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>
</Alert>
);
@ -140,8 +140,8 @@ export function PublicChatSnapshotsManager({
<Alert>
<Info />
<AlertDescription>
Public chat links allow anyone with the URL to view a snapshot of a chat. These links do
not update when the original chat changes.
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update
when the original chat changes.
</AlertDescription>
</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="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">
Allow personal access tokens to use this search space. Web and desktop sessions are not
affected.
Allow API keys to access this search space.
</p>
</div>
<Switch

View file

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