e621 credentials: user fields, API endpoint, Account screen
Backend: - User model gains e621_username / e621_api_key / e621_base_url - GET/PUT /api/auth/e621/ for the owner's credentials; /me exposes only the username and a configured flag, never the key Frontend: - e621 client core: Basic auth, _client param (browsers cannot set a User-Agent), serialized queue throttled to 1 request/second, readable error mapping - Account screen (/account): username, API key with reveal toggle, base URL (e621 / e926 / custom), Save + Test connection - Credentials are fetched from the backend and held in memory only, cleared on logout
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { CheckCircle2, Eye, EyeOff, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Field, Spinner, inputClass } from "@/components/ui";
|
||||
import { errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
e621ErrorMessage,
|
||||
verifyE621Credentials,
|
||||
type E621Credentials,
|
||||
} from "@/lib/e621";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { useE621 } from "@/store/e621";
|
||||
|
||||
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 [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
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);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await save(values);
|
||||
setSaveMessage("Credentials saved.");
|
||||
} catch (error) {
|
||||
setSaveError(errorMessage(error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const user = await verifyE621Credentials({ ...values, configured: true });
|
||||
setTestResult({
|
||||
ok: true,
|
||||
message: `Connected as ${user.name}${
|
||||
typeof user.favorite_count === "number"
|
||||
? ` — ${user.favorite_count} favorites`
|
||||
: ""
|
||||
}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setTestResult({ ok: false, message: 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>
|
||||
|
||||
{saveError ? (
|
||||
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
|
||||
{saveError}
|
||||
</p>
|
||||
) : null}
|
||||
{saveMessage ? (
|
||||
<p className="text-sm text-ctp-green">{saveMessage}</p>
|
||||
) : null}
|
||||
{testResult ? (
|
||||
<p
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-2 text-sm",
|
||||
testResult.ok
|
||||
? "border-ctp-green/30 bg-ctp-green/10 text-ctp-green"
|
||||
: "border-ctp-red/30 bg-ctp-red/10 text-ctp-red",
|
||||
)}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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">
|
||||
Stored on your server and fetched by this app when needed. e621 requests
|
||||
are made directly from your browser, 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);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Account</h1>
|
||||
{user ? (
|
||||
<p className="mt-1 text-sm text-ctp-overlay0">
|
||||
Signed in as {user.username}.
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{loading && !credentials ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : (
|
||||
<AccountForm credentials={credentials} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user