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.
262 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|