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:
@@ -27,6 +27,7 @@ import RandomPage from "@/features/random/RandomPage";
|
||||
import SimilarPage from "@/features/similar/SimilarPage";
|
||||
import { SetupPage } from "@/features/setup/SetupPage";
|
||||
import StatsPage from "@/features/stats/StatsPage";
|
||||
import TokensPage from "@/features/tokens/TokensPage";
|
||||
import UploadPage from "@/features/upload/UploadPage";
|
||||
import UsersPage from "@/features/users/UsersPage";
|
||||
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
||||
@@ -183,6 +184,16 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route
|
||||
path="/tokens"
|
||||
element={
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<TokensPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
<Route
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Globe,
|
||||
History,
|
||||
Images,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -220,6 +221,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
icon: Settings,
|
||||
run: () => navigate("/account"),
|
||||
});
|
||||
list.push({
|
||||
id: "tokens",
|
||||
label: "API tokens",
|
||||
icon: KeyRound,
|
||||
run: () => navigate("/tokens"),
|
||||
});
|
||||
if (user.is_staff || user.is_superuser || user.role === "staff") {
|
||||
list.push({
|
||||
id: "users",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,16 @@
|
||||
export interface GreetingToken {
|
||||
id: number;
|
||||
prefix: string;
|
||||
label: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface GreetingTokenCreated extends GreetingToken {
|
||||
/** Only ever returned by the creation request. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RandomItem {
|
||||
j_id: string;
|
||||
md5: string;
|
||||
|
||||
Reference in New Issue
Block a user