Files
J621/frontend/src/features/account/AccountPage.tsx
T
JakeBreath 770b1e5ee6 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.
2026-09-18 13:37:29 -05:00

262 lines
8.5 KiB
TypeScript

import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { Button, Field, Spinner, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
verifyE621Credentials,
type E621Credentials,
} from "@/lib/e621";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621";
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" },
{ value: "https://e926.net", label: "e926.net — safe mirror" },
{ value: "custom", label: "Custom…" },
];
function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
const save = useE621((state) => state.save);
const [username, setUsername] = useState(credentials?.username ?? "");
const [apiKey, setApiKey] = useState(credentials?.api_key ?? "");
const savedBase = credentials?.base_url ?? "https://e621.net";
const savedBaseIsKnown = BASE_URL_OPTIONS.some(
(option) => option.value === savedBase,
);
const [baseChoice, setBaseChoice] = useState(
savedBaseIsKnown ? savedBase : "custom",
);
const [customBase, setCustomBase] = useState(savedBaseIsKnown ? "" : savedBase);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const baseUrl = (baseChoice === "custom" ? customBase.trim() : baseChoice).replace(
/\/+$/,
"",
);
const values = {
username: username.trim(),
api_key: apiKey.trim(),
base_url: baseUrl,
};
async function handleSave() {
setSaving(true);
try {
await save(values);
toast.ok("e621 credentials saved.");
} catch (error) {
toast.error(errorMessage(error));
} finally {
setSaving(false);
}
}
async function handleTest() {
setTesting(true);
try {
const user = await verifyE621Credentials({ ...values, configured: true });
toast.ok(
`Connected as ${user.name}${
typeof user.favorite_count === "number"
? ` — ${user.favorite_count} favorites`
: ""
}.`,
);
} catch (error) {
toast.error(e621ErrorMessage(error));
} finally {
setTesting(false);
}
}
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-ctp-subtext1">
e621 credentials
</h2>
<p className="mt-1 text-xs text-ctp-overlay0">
Generate an API key under Account → My profile on e621, then paste it
here.
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2.5 py-1 font-mono text-[11px]",
credentials?.configured
? "border-ctp-green/40 bg-ctp-green/15 text-ctp-green"
: "border-ctp-surface1 text-ctp-overlay0",
)}
>
{credentials?.configured ? "Configured" : "Not configured"}
</span>
</div>
<div className="mt-5 flex flex-col gap-4">
<Field label="e621 username">
<input
className={inputClass}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="off"
/>
</Field>
<Field label="API key">
<div className="relative">
<input
className={cn(inputClass, "pr-10 font-mono")}
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowKey((visible) => !visible)}
title={showKey ? "Hide API key" : "Show API key"}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
{showKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</Field>
<Field label="API base URL">
<div className="flex flex-col gap-2">
<select
className={inputClass}
value={baseChoice}
onChange={(event) => setBaseChoice(event.target.value)}
>
{BASE_URL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{baseChoice === "custom" ? (
<input
className={cn(inputClass, "font-mono")}
placeholder="https://example.com"
value={customBase}
onChange={(event) => setCustomBase(event.target.value)}
/>
) : null}
</div>
</Field>
<div className="flex items-center gap-3">
<Button onClick={() => void handleSave()} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
<Button
variant="secondary"
onClick={() => void handleTest()}
disabled={testing || !values.username || !values.api_key}
>
{testing ? "Testing…" : "Test connection"}
</Button>
</div>
<p className="text-xs text-ctp-overlay0">
{hasBackend()
? "Stored on your server and fetched by this app when needed. e621 requests are made directly from your browser, throttled to one per second."
: "Stored in this browser only (local mode). e621 requests are made directly from here, throttled to one per second."}
</p>
</div>
</section>
);
}
export default function AccountPage() {
const user = useAuth((state) => state.user);
const credentials = useE621((state) => state.credentials);
const loading = useE621((state) => state.loading);
const load = useE621((state) => state.load);
const backend = hasBackend();
useEffect(() => {
void load();
}, [load]);
// Local mode: the e621 credentials live in this browser.
if (!backend) {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">e621 account</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
No backend is connected, so these credentials are stored in this
browser only and sent straight to e621 from here.
</p>
</header>
{loading && !credentials ? (
<div className="flex justify-center py-12">
<Spinner className="h-6 w-6" />
</div>
) : (
<AccountForm credentials={credentials} />
)}
<p className="text-xs text-ctp-overlay0">
<Link to="/setup" className="text-ctp-blue hover:underline">
Connect a backend
</Link>{" "}
to unlock the library, follows and account settings.
</p>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Account</h1>
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-ctp-overlay0">
<span>Signed in as {user.username}.</span>
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0">
{user.role}
</span>
{user.is_superuser ? (
<span className="rounded-full border border-ctp-red/40 bg-ctp-red/10 px-2 py-0.5 font-mono text-[11px] text-ctp-red">
admin
</span>
) : null}
</p>
</header>
<AvatarCard />
<PreferencesCard />
<TokensCard />
{loading && !credentials ? (
<div className="flex justify-center py-12">
<Spinner className="h-6 w-6" />
</div>
) : (
<AccountForm credentials={credentials} />
)}
</div>
);
}