Scoped API tokens for the random endpoint, with a management page

Backend: a GreetingToken model stores only a SHA-256 hash of a j621r_…
key (shown once at creation) plus label, prefix, created/last-used. A
dedicated GreetingTokenAuthentication understands the usual
'Authorization: Token …' header but is registered only on RandomItemView
(alongside the normal token auth), so a greeting token authenticates
/api/random/ and is rejected with 401 everywhere else — exactly the scope
shell greetings need. Endpoints: GET/POST /api/auth/greeting-tokens/ and
DELETE /api/auth/greeting-tokens/{id}/ (own tokens only; the list never
returns keys or hashes).

Frontend: /tokens page (Account → Shell tokens card, command palette entry)
lists tokens with label, prefix, created/last-used and revoke (shared
confirm dialog). Creating one shows the key with Copy and 'Copy for fish'
buttons plus a pointer to extras/fish_greeting.

Tests: apps/accounts/tests/test_greeting_tokens.py — 9 tests covering
create-once semantics and hashing, hidden keys in listings, the scope
guarantee (random 200 with a signed URL; 401 on files, storage, me, tags
cloud, delete and the token list itself), unknown/revoked keys, cross-user
revocation, last-used tracking and label limits.

Verified live: created a token, rolled /random (signed URL), got 401 from
four other endpoints, saw the list omit secrets, revoked it (204) and the
same key then 401'd on /random. Full suite: 39 tests green.
This commit is contained in:
2026-09-18 13:37:29 -05:00
parent 2d9493d9fe
commit 770b1e5ee6
19 changed files with 678 additions and 9 deletions
@@ -17,6 +17,7 @@ import { toast } from "@/store/toasts";
import { AvatarCard } from "./AvatarCard";
import { PreferencesCard } from "./PreferencesCard";
import { TokensCard } from "./TokensCard";
const BASE_URL_OPTIONS = [
{ value: "https://e621.net", label: "e621.net — main site" },
@@ -247,6 +248,7 @@ export default function AccountPage() {
</header>
<AvatarCard />
<PreferencesCard />
<TokensCard />
{loading && !credentials ? (
<div className="flex justify-center py-12">
<Spinner className="h-6 w-6" />
@@ -0,0 +1,23 @@
import { Link } from "react-router-dom";
import { linkButtonClass } from "@/components/ui";
export function TokensCard() {
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Shell tokens
</h2>
<p className="mt-1 text-xs leading-relaxed text-ctp-overlay0">
Long-lived tokens that only work with the random-image endpoint — for
shell greetings and small scripts. They cannot read the library,
upload or change your account.
</p>
<div className="mt-3">
<Link to="/tokens" className={linkButtonClass}>
Manage API tokens
</Link>
</div>
</section>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, KeyRound, Trash2 } from "lucide-react";
import { useState } from "react";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type { GreetingToken, GreetingTokenCreated } from "@/lib/types";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
export default function TokensPage() {
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [fresh, setFresh] = useState<GreetingTokenCreated | null>(null);
const query = useQuery({
queryKey: ["greeting-tokens"],
queryFn: () => api<GreetingToken[]>("/api/auth/greeting-tokens/"),
});
const createMutation = useMutation({
mutationFn: () =>
api<GreetingTokenCreated>("/api/auth/greeting-tokens/", {
method: "POST",
json: { label: label.trim() },
}),
onSuccess: (created) => {
setFresh(created);
setLabel("");
void queryClient.invalidateQueries({ queryKey: ["greeting-tokens"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
const revokeMutation = useMutation({
mutationFn: (id: number) =>
api(`/api/auth/greeting-tokens/${id}/`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["greeting-tokens"] });
toast.ok("Token revoked.");
},
onError: (error) => toast.error(errorMessage(error)),
});
async function copy(text: string, what: string) {
try {
await navigator.clipboard.writeText(text);
toast.ok(`${what} copied.`);
} catch {
toast.error("Could not copy — select the text and copy it manually.");
}
}
async function revoke(token: GreetingToken) {
const confirmed = await confirmAction({
title: `Revoke ${token.prefix}…?`,
description:
"Whatever uses this token (your shell greeting, a script) stops working immediately. You can create a new one any time.",
confirmLabel: "Revoke",
danger: true,
});
if (confirmed) revokeMutation.mutate(token.id);
}
const tokens = query.data ?? [];
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">API tokens</h1>
<p className="mt-1 text-sm leading-relaxed text-ctp-overlay0">
Tokens that only work with the random-image endpoint
(<span className="font-mono">/api/random/</span>) — made for shell
greetings and little scripts. They cannot read the library, upload,
delete or change your account, and only a hash is stored on the
server.
</p>
</header>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Create a token
</h2>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className={cn(inputClass, "max-w-xs flex-1")}
placeholder="Label, e.g. laptop greeting"
value={label}
onChange={(event) => setLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !createMutation.isPending) {
createMutation.mutate();
}
}}
/>
<Button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
<KeyRound className="h-4 w-4" />
{createMutation.isPending ? "Creating…" : "Create token"}
</Button>
</div>
{fresh ? (
<div className="mt-4 rounded-md border border-ctp-green/40 bg-ctp-green/10 p-3">
<p className="text-xs font-medium text-ctp-green">
Copy this key now — it is not shown again.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded bg-ctp-crust px-2 py-1.5 font-mono text-xs text-ctp-text">
{fresh.key}
</code>
<Button
variant="secondary"
className="px-2 py-1.5"
onClick={() => void copy(fresh.key, "Token")}
>
<Copy className="h-3.5 w-3.5" /> Copy
</Button>
<Button
variant="secondary"
className="px-2 py-1.5"
onClick={() =>
void copy(`set -g J621_TOKEN ${fresh.key}`, "fish line")
}
>
<Copy className="h-3.5 w-3.5" /> Copy for fish
</Button>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-ctp-overlay0">
Shell greetings: put that line in{" "}
<span className="font-mono">
~/.config/j621Greeting/config.fish
</span>{" "}
(see <span className="font-mono">extras/fish_greeting</span> in
the repository).
</p>
</div>
) : null}
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Your tokens
</h2>
{query.isPending ? (
<div className="flex justify-center py-12">
<Spinner className="h-5 w-5" />
</div>
) : query.isError ? (
<EmptyState
title="Could not load tokens"
description={errorMessage(query.error)}
/>
) : tokens.length === 0 ? (
<EmptyState
title="No tokens yet"
description="Create one above to use the random endpoint from a script."
/>
) : (
<ul className="flex flex-col gap-2">
{tokens.map((token) => (
<li
key={token.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-ctp-surface0 bg-ctp-base px-3 py-2"
>
<span className="font-mono text-xs text-ctp-subtext1">
{token.prefix}…
</span>
<span className="text-xs text-ctp-subtext0">
{token.label || "no label"}
</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
created {formatDate(token.created_at)}
</span>
<span className="font-mono text-[11px] text-ctp-overlay0">
{token.last_used_at
? `last used ${formatDate(token.last_used_at)}`
: "never used"}
</span>
<Button
variant="ghost"
className="ml-auto px-2 py-1 text-xs text-ctp-red hover:bg-ctp-red/15"
disabled={revokeMutation.isPending}
onClick={() => void revoke(token)}
title="Revoke this token"
>
<Trash2 className="h-3.5 w-3.5" /> Revoke
</Button>
</li>
))}
</ul>
)}
</section>
</div>
);
}