895 lines
35 KiB
TypeScript
895 lines
35 KiB
TypeScript
import { useState, useRef, useEffect, type FormEvent } from "react";
|
|
import { ArrowRightLeft, CheckCircle2, Link2, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Loading, ErrorState } from "@/components/States";
|
|
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
|
import { MessageLine, InlineError } from "@/components/MessageLine";
|
|
import { PageHeader } from "@/components/PageHeader";
|
|
import { api, clientError, humanizeError } from "@/lib/api";
|
|
import { formatAbsolute } from "@/lib/format";
|
|
import type { PasskeyCredential } from "@/lib/types";
|
|
import { useAsync } from "@/lib/hooks";
|
|
import { AccountSessionsCard } from "@/pages/AccountSessions";
|
|
import { isReauthCancelled, isReauthRequired, useReauth } from "@/components/ReauthDialog";
|
|
import { useTier } from "@/lib/tier";
|
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
|
import { requestAssertion } from "@/lib/passkey";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
|
|
// The Account page is the web half of the §10 link flow. A code is born in-game
|
|
// (online-mode auth proves the UUID) and consumed here (the session proves the
|
|
// user) — so this page only ever reports status and redeems a code; it can never
|
|
// originate a binding. The Minecraft-link card is a small state machine: checking
|
|
// → linked, or → the two-step "get a code in-game, enter it here" form.
|
|
|
|
export function Account() {
|
|
const status = useAsync(() => api.linkStatus(), []);
|
|
const { identity, refresh } = useTier();
|
|
const { t, i18n } = useTranslation("account");
|
|
// Adding or removing a passkey and changing the email ask for a fresh proof
|
|
// of a factor first (ReauthDialog).
|
|
const reauth = useReauth();
|
|
|
|
// Email verification state. A verified address is changed through the same
|
|
// two steps, opened with changingEmail.
|
|
const [emailInput, setEmailInput] = useState("");
|
|
const [otpCodeInput, setOtpCodeInput] = useState("");
|
|
const [emailSending, setEmailSending] = useState(false);
|
|
const [emailVerifying, setEmailVerifying] = useState(false);
|
|
const [emailError, setEmailError] = useState<string | null>(null);
|
|
const [emailSent, setEmailSent] = useState(false);
|
|
const [sentEmailAddress, setSentEmailAddress] = useState("");
|
|
const [initializedEmail, setInitializedEmail] = useState(false);
|
|
const [changingEmail, setChangingEmail] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (identity?.email && !initializedEmail) {
|
|
setEmailInput(identity.email);
|
|
setInitializedEmail(true);
|
|
}
|
|
}, [identity, initializedEmail]);
|
|
|
|
async function sendEmailOtp(e: FormEvent) {
|
|
e.preventDefault();
|
|
const trimmed = emailInput.trim();
|
|
if (!trimmed || emailSending) return;
|
|
setEmailSending(true);
|
|
setEmailError(null);
|
|
try {
|
|
await reauth.guard(() => api.emailStart(trimmed));
|
|
setEmailSent(true);
|
|
setSentEmailAddress(trimmed);
|
|
} catch (err) {
|
|
if (!isReauthCancelled(err)) setEmailError(humanizeError(err));
|
|
} finally {
|
|
setEmailSending(false);
|
|
}
|
|
}
|
|
|
|
function startChangeEmail() {
|
|
setChangingEmail(true);
|
|
setEmailInput("");
|
|
setEmailSent(false);
|
|
setOtpCodeInput("");
|
|
setEmailError(null);
|
|
}
|
|
|
|
function cancelChangeEmail() {
|
|
setChangingEmail(false);
|
|
setEmailSent(false);
|
|
setEmailError(null);
|
|
}
|
|
|
|
async function verifyEmailOtp(e: FormEvent) {
|
|
e.preventDefault();
|
|
const trimmedCode = otpCodeInput.trim();
|
|
if (!trimmedCode || emailVerifying) return;
|
|
setEmailVerifying(true);
|
|
setEmailError(null);
|
|
try {
|
|
await api.emailVerify(trimmedCode);
|
|
await refresh();
|
|
if (changingEmail) {
|
|
// Replacing a verified address signs the other devices out.
|
|
setChangingEmail(false);
|
|
setSessionsVersion((v) => v + 1);
|
|
}
|
|
setEmailSent(false);
|
|
setEmailInput("");
|
|
setOtpCodeInput("");
|
|
} catch (err) {
|
|
setEmailError(humanizeError(err));
|
|
} finally {
|
|
setEmailVerifying(false);
|
|
}
|
|
}
|
|
|
|
// Passkeys list
|
|
const passkeys = useAsync(() => api.passkeyList(), []);
|
|
|
|
// Passkey registration state
|
|
const [passkeyNickname, setPasskeyNickname] = useState("");
|
|
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
|
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
|
const [passkeyNotice, setPasskeyNotice] = useState<string | null>(null);
|
|
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
|
// Deleting a passkey goes through a confirm dialog that names it. The API
|
|
// refuses to remove the only passkey of an account whose email is unverified
|
|
// (it would be left with no way back in); the button mirrors that rule so the
|
|
// refusal is explained up front instead of after a round trip.
|
|
const [pendingDelete, setPendingDelete] = useState<PasskeyCredential | null>(null);
|
|
const [sessionsVersion, setSessionsVersion] = useState(0);
|
|
const [deletingPasskey, setDeletingPasskey] = useState(false);
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
const credentials = passkeys.data?.credentials ?? [];
|
|
const keepLastPasskey = credentials.length === 1 && !identity?.email_verified;
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
function cancelRegistration() {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
setRegisterDialogOpen(false);
|
|
setPasskeyNickname("");
|
|
setPasskeyError(null);
|
|
setPasskeyNotice(null);
|
|
setRegisteringPasskey(false);
|
|
}
|
|
|
|
async function handleRegisterPasskey(e: FormEvent) {
|
|
e.preventDefault();
|
|
const name = passkeyNickname.trim();
|
|
if (!name || registeringPasskey) return;
|
|
setRegisteringPasskey(true);
|
|
setPasskeyError(null);
|
|
setPasskeyNotice(null);
|
|
|
|
const controller = new AbortController();
|
|
abortControllerRef.current = controller;
|
|
|
|
try {
|
|
let options;
|
|
try {
|
|
options = await api.passkeyRegisterBegin();
|
|
} catch (err) {
|
|
if (!isReauthRequired(err)) throw err;
|
|
// The browser lets navigator.credentials.create run only right after a
|
|
// click, which the confirmation used up: the user presses Continue again.
|
|
if (await reauth.confirm()) setPasskeyNotice(t("reauth_done_continue"));
|
|
return;
|
|
}
|
|
const publicKey: PublicKeyCredentialCreationOptions = {
|
|
...options,
|
|
challenge: base64urlToBytes(options.challenge),
|
|
user: {
|
|
...options.user,
|
|
id: base64urlToBytes(options.user.id),
|
|
},
|
|
excludeCredentials: options.excludeCredentials?.map((cred: any) => ({
|
|
...cred,
|
|
id: base64urlToBytes(cred.id),
|
|
})),
|
|
};
|
|
|
|
const credential = (await navigator.credentials.create({
|
|
publicKey,
|
|
signal: controller.signal,
|
|
})) as PublicKeyCredential;
|
|
|
|
if (!credential) {
|
|
throw clientError("passkey_no_credential");
|
|
}
|
|
|
|
const response = credential.response as AuthenticatorAttestationResponse;
|
|
const attestation = {
|
|
id: credential.id,
|
|
rawId: bytesToBase64url(credential.rawId),
|
|
type: credential.type,
|
|
response: {
|
|
clientDataJSON: bytesToBase64url(response.clientDataJSON),
|
|
attestationObject: bytesToBase64url(response.attestationObject),
|
|
transports: typeof response.getTransports === "function" ? response.getTransports() : [],
|
|
},
|
|
};
|
|
|
|
await api.passkeyRegisterFinish(name, attestation);
|
|
await passkeys.reload();
|
|
setRegisterDialogOpen(false);
|
|
setPasskeyNickname("");
|
|
} catch (err: any) {
|
|
setPasskeyError(humanizeError(err));
|
|
} finally {
|
|
setRegisteringPasskey(false);
|
|
abortControllerRef.current = null;
|
|
}
|
|
}
|
|
|
|
function askDeletePasskey(cred: PasskeyCredential) {
|
|
setDeleteError(null);
|
|
setPendingDelete(cred);
|
|
}
|
|
|
|
async function confirmDeletePasskey() {
|
|
if (!pendingDelete || deletingPasskey) return;
|
|
setDeletingPasskey(true);
|
|
setDeleteError(null);
|
|
try {
|
|
await reauth.guard(() => api.passkeyDelete(pendingDelete.id));
|
|
setPendingDelete(null);
|
|
// The server signed the other devices out along with the passkey.
|
|
setSessionsVersion((v) => v + 1);
|
|
await passkeys.reload();
|
|
} catch (err) {
|
|
if (isReauthCancelled(err)) return;
|
|
// Another device may have changed the list meanwhile: refresh it. A 404
|
|
// means the passkey is already gone, which is what was asked for.
|
|
void passkeys.reload();
|
|
if ((err as { code?: string }).code === "not_found") {
|
|
setPendingDelete(null);
|
|
} else {
|
|
setDeleteError(humanizeError(err));
|
|
}
|
|
} finally {
|
|
setDeletingPasskey(false);
|
|
}
|
|
}
|
|
|
|
// Sign-out ends a local session (passkey / email-OTP / bind-code / op-login):
|
|
// clear it server-side, then refresh /me. For a local session that read now 401s
|
|
// → the tier model flips to
|
|
// `unauthenticated` and RequireAuth bounces this page to /login, so no explicit
|
|
// navigation is needed. (On a Zero-Trust proxied session there is no local cookie
|
|
// to drop and /me still succeeds — sign-out is a no-op, which is the honest
|
|
// outcome: you cannot sign out of your org's access proxy from here.)
|
|
const [signingOut, setSigningOut] = useState(false);
|
|
async function signOut() {
|
|
if (signingOut) return;
|
|
setSigningOut(true);
|
|
try {
|
|
await api.logout();
|
|
} finally {
|
|
await refresh();
|
|
}
|
|
}
|
|
|
|
// Verify is a mutation, not a read, so it is hand-rolled (the useAsync producer
|
|
// is for the status read). On success we flip linked locally and keep the echoed
|
|
// UUID — the start endpoint never returns it, so this is the only confirmation
|
|
// of *which* account bound, shown without a refetch.
|
|
const [code, setCode] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [verifiedUUID, setVerifiedUUID] = useState<string | null>(null);
|
|
|
|
const linked = verifiedUUID !== null || status.data?.linked === true;
|
|
|
|
async function submit(e: FormEvent) {
|
|
e.preventDefault();
|
|
const trimmed = code.trim().toUpperCase();
|
|
if (!trimmed || submitting) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const res = await api.linkVerify(trimmed);
|
|
setVerifiedUUID(res.mc_uuid);
|
|
setCode("");
|
|
} catch (err) {
|
|
setError(humanizeError(err));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader icon={UserRound} title={t("title")} subtitle={t("subtitle")} />
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Link2 className="h-4 w-4 text-primary" /> {t("minecraft_link")}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm">
|
|
{linked ? (
|
|
<LinkedState uuid={verifiedUUID} />
|
|
) : status.loading && !status.data ? (
|
|
<Loading label={t("checking_link")} />
|
|
) : status.error ? (
|
|
<ErrorState error={status.error} onRetry={status.reload} />
|
|
) : (
|
|
<LinkForm
|
|
code={code}
|
|
setCode={setCode}
|
|
submitting={submitting}
|
|
error={error}
|
|
onSubmit={submit}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Mail className="h-4 w-4 text-primary" /> {t("email_verification")}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm">
|
|
{identity?.email_verified && !changingEmail ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
{t("email_verified")}
|
|
</div>
|
|
<p className="text-muted-foreground">{t("email_desc")}</p>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
|
{identity.email}
|
|
</code>
|
|
<Button variant="link" size="sm" onClick={startChangeEmail} className="h-auto p-0 font-normal">
|
|
{t("change_email")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ol className="space-y-4">
|
|
<li className="flex gap-3">
|
|
<StepBadge n={1} />
|
|
<div className="w-full space-y-2">
|
|
<p className="font-medium text-foreground">{t("email_step1")}</p>
|
|
<p className="text-muted-foreground">{t(changingEmail ? "email_change_desc" : "email_step1_desc")}</p>
|
|
{!emailSent ? (
|
|
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
|
|
<Input
|
|
type="email"
|
|
placeholder="[email protected]"
|
|
value={emailInput}
|
|
onChange={(e) => setEmailInput(e.target.value)}
|
|
disabled={emailSending}
|
|
required
|
|
className="max-w-[18rem]"
|
|
/>
|
|
<Button type="submit" disabled={emailSending || !emailInput}>
|
|
{emailSending ? t("sending_code") : t("send_code")}
|
|
</Button>
|
|
{changingEmail && (
|
|
<Button type="button" variant="ghost" onClick={cancelChangeEmail} disabled={emailSending}>
|
|
{t("common:cancel")}
|
|
</Button>
|
|
)}
|
|
</form>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
<span>{t("email_otp_sent")} ({sentEmailAddress})</span>
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
onClick={() => setEmailSent(false)}
|
|
className="h-auto p-0 font-normal"
|
|
>
|
|
{t("change_email")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
{emailSent && (
|
|
<li className="flex gap-3">
|
|
<StepBadge n={2} />
|
|
<div className="w-full space-y-2">
|
|
<p className="font-medium text-foreground">{t("email_step2")}</p>
|
|
<p className="text-muted-foreground">{t("email_step2_desc")}</p>
|
|
<form onSubmit={verifyEmailOtp} className="flex gap-2 max-w-md">
|
|
<Input
|
|
type="text"
|
|
placeholder={t("otp_code_placeholder")}
|
|
value={otpCodeInput}
|
|
onChange={(e) => setOtpCodeInput(e.target.value)}
|
|
disabled={emailVerifying}
|
|
maxLength={6}
|
|
required
|
|
className="max-w-[12rem] font-mono text-center tracking-[0.2em]"
|
|
/>
|
|
<Button type="submit" disabled={emailVerifying || otpCodeInput.trim().length !== 6}>
|
|
{emailVerifying ? t("email_verifying") : t("email_verify_btn")}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
</li>
|
|
)}
|
|
<InlineError message={emailError} className="ml-9" />
|
|
</ol>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Fingerprint className="h-4 w-4 text-primary" /> {t("passkeys")}
|
|
</CardTitle>
|
|
<Dialog open={registerDialogOpen} onOpenChange={(open) => {
|
|
if (!open) {
|
|
cancelRegistration();
|
|
} else {
|
|
setRegisterDialogOpen(true);
|
|
}
|
|
}}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" variant="outline">
|
|
<Fingerprint className="mr-1 h-3.5 w-3.5" />
|
|
{t("add_passkey")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent hideClose={registeringPasskey}>
|
|
<form onSubmit={handleRegisterPasskey}>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("add_passkey")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("passkeys_desc")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="pk-name">{t("passkey_name")}</Label>
|
|
<Input
|
|
id="pk-name"
|
|
placeholder={t("passkey_name_placeholder")}
|
|
value={passkeyNickname}
|
|
onChange={(e) => setPasskeyNickname(e.target.value)}
|
|
disabled={registeringPasskey}
|
|
required
|
|
/>
|
|
</div>
|
|
{passkeyNotice && <MessageLine kind="success" message={passkeyNotice} />}
|
|
<InlineError message={passkeyError} />
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={cancelRegistration}
|
|
disabled={registeringPasskey}
|
|
>
|
|
{t("common:cancel")}
|
|
</Button>
|
|
<Button type="submit" disabled={registeringPasskey || !passkeyNickname.trim()}>
|
|
{registeringPasskey ? t("registering_passkey") : t("continue_btn")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardHeader>
|
|
<CardContent className="text-sm space-y-4">
|
|
<p className="text-muted-foreground">{t("passkeys_desc")}</p>
|
|
{passkeys.loading && !passkeys.data ? (
|
|
<Loading label={t("loading_passkeys")} />
|
|
) : passkeys.error ? (
|
|
<ErrorState error={passkeys.error} onRetry={passkeys.reload} />
|
|
) : credentials.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground py-2 italic">{t("no_passkeys")}</p>
|
|
) : (
|
|
<>
|
|
<ul className="border rounded-md divide-y bg-background/50">
|
|
{credentials.map((cred) => (
|
|
<li key={cred.id} className="flex items-center justify-between gap-3 p-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<p className="font-medium text-foreground flex items-center gap-1.5">
|
|
<KeyRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="truncate">{cred.name}</span>
|
|
</p>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
<span>
|
|
{t("created_at")}
|
|
{formatAbsolute(cred.created_at, i18n.language)}
|
|
</span>
|
|
<span>
|
|
{t("last_used")}
|
|
{cred.last_used_at ? formatAbsolute(cred.last_used_at, i18n.language) : t("never")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => askDeletePasskey(cred)}
|
|
disabled={keepLastPasskey}
|
|
aria-label={t("passkey_delete_aria", { name: cred.name })}
|
|
title={keepLastPasskey ? t("passkey_last_hint") : t("passkey_delete_aria", { name: cred.name })}
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{keepLastPasskey && (
|
|
<p className="text-xs text-muted-foreground">{t("passkey_last_hint")}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
<Dialog
|
|
open={pendingDelete !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open && !deletingPasskey) setPendingDelete(null);
|
|
}}
|
|
>
|
|
<DialogContent hideClose={deletingPasskey}>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("passkey_delete_title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{pendingDelete &&
|
|
t("passkey_delete_desc", {
|
|
name: pendingDelete.name,
|
|
created: formatAbsolute(pendingDelete.created_at, i18n.language),
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{deleteError && <MessageLine kind="error" message={deleteError} />}
|
|
<ConfirmFooter
|
|
onCancel={() => setPendingDelete(null)}
|
|
onConfirm={() => void confirmDeletePasskey()}
|
|
loading={deletingPasskey}
|
|
disabled={deletingPasskey || keepLastPasskey}
|
|
cancelLabel={t("common:cancel")}
|
|
confirmLabel={t("passkey_delete_confirm")}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<MigrationCard
|
|
userId={identity?.user_id}
|
|
hasPasskey={credentials.length > 0}
|
|
/>
|
|
|
|
<AccountSessionsCard
|
|
version={sessionsVersion}
|
|
staff={identity !== null && identity.role !== "user"}
|
|
onSignOut={() => void signOut()}
|
|
signingOut={signingOut}
|
|
/>
|
|
|
|
{reauth.dialog}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** LinkedState confirms the binding. The UUID is shown only when this session
|
|
* just verified it (start does not return it), so a pre-existing link renders
|
|
* the confirmation without a UUID rather than inventing one. */
|
|
function LinkedState({ uuid }: { uuid: string | null }) {
|
|
const { t } = useTranslation("account");
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
{t("linked_title")}
|
|
</div>
|
|
<p className="text-muted-foreground">{t("linked_desc")}</p>
|
|
{uuid && (
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<span className="text-xs uppercase tracking-wide">{t("uuid_label")}</span>
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
|
{uuid}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** LinkForm is the two-step redemption UX: get a code in-game, then enter it. The
|
|
* input auto-uppercases for instant feedback; the server also trims + uppercases,
|
|
* so this is cosmetic, not the source of truth. */
|
|
function LinkForm({
|
|
code,
|
|
setCode,
|
|
submitting,
|
|
error,
|
|
onSubmit,
|
|
}: {
|
|
code: string;
|
|
setCode: (v: string) => void;
|
|
submitting: boolean;
|
|
error: string | null;
|
|
onSubmit: (e: FormEvent) => void;
|
|
}) {
|
|
const { t } = useTranslation("account");
|
|
return (
|
|
<ol className="space-y-4">
|
|
<li className="flex gap-3">
|
|
<StepBadge n={1} />
|
|
<div className="space-y-1">
|
|
<p className="font-medium text-foreground">{t("step1_title")}</p>
|
|
<p className="text-muted-foreground">
|
|
{t("step1_desc_prefix")}
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
|
/link
|
|
</code>
|
|
{t("step1_desc_suffix")}
|
|
</p>
|
|
</div>
|
|
</li>
|
|
<li className="flex gap-3">
|
|
<StepBadge n={2} />
|
|
<div className="w-full space-y-2">
|
|
<p className="font-medium text-foreground">{t("step2_title")}</p>
|
|
<form onSubmit={onSubmit} className="space-y-2">
|
|
<Label htmlFor="link-code">{t("link_code")}</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="link-code"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
placeholder={t("link_code_placeholder")}
|
|
autoComplete="off"
|
|
autoCapitalize="characters"
|
|
spellCheck={false}
|
|
maxLength={8}
|
|
className="max-w-[14rem] font-mono uppercase tracking-[0.3em]"
|
|
aria-invalid={error ? true : undefined}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
disabled={submitting || code.trim().length === 0}
|
|
>
|
|
{submitting ? t("verifying") : t("verify_btn")}
|
|
</Button>
|
|
</div>
|
|
<InlineError message={error} />
|
|
</form>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
/** MigrationCard is the web half of §B3 account migration (scenario A "inherit").
|
|
* The flow is born in-game (/felis migrate proves the player) and driven here:
|
|
* status → step-up confirm (passkey when one is enrolled — the server 409s the
|
|
* OTP door in that case — else email-OTP) → issue-code (the source names the
|
|
* target account and reads a one-time code) → redeem (the TARGET account spends
|
|
* the code; the source's servers move over and the source is retired). Both
|
|
* roles render on every account: the redeem form is always offered, and the
|
|
* account id is always shown so a target can hand it to the source. */
|
|
function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: boolean }) {
|
|
const { t } = useTranslation("account");
|
|
const mig = useAsync(() => api.migrateStatus(), []);
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const [otpSent, setOtpSent] = useState(false);
|
|
const [otpCode, setOtpCode] = useState("");
|
|
|
|
const [targetId, setTargetId] = useState("");
|
|
const [issued, setIssued] = useState<{ code: string; expires_at: string } | null>(null);
|
|
|
|
const [redeemCode, setRedeemCode] = useState("");
|
|
const [redeemed, setRedeemed] = useState<{ servers_moved: number; servers: string[] } | null>(null);
|
|
|
|
async function run(fn: () => Promise<void>) {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setErr(null);
|
|
try {
|
|
await fn();
|
|
} catch (e) {
|
|
setErr(humanizeError(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
// Step-up passkey confirm. Unlike the register/login begins (which strip the
|
|
// envelope server-side), the migrate begin returns go-webauthn's raw
|
|
// {"publicKey": {...}} document, so we descend into .publicKey here.
|
|
function confirmWithPasskey() {
|
|
void run(async () => {
|
|
const options = await api.migrateConfirmPasskeyBegin();
|
|
await api.migrateConfirmPasskeyFinish(await requestAssertion(options.publicKey));
|
|
await mig.reload();
|
|
});
|
|
}
|
|
|
|
const state = mig.data?.active ? mig.data.state : undefined;
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<ArrowRightLeft className="h-4 w-4 text-primary" /> {t("migration")}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 text-sm">
|
|
<p className="text-muted-foreground">{t("migration_desc")}</p>
|
|
{userId && (
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<span className="text-xs uppercase tracking-wide">{t("account_id")}</span>
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground select-all">
|
|
{userId}
|
|
</code>
|
|
</div>
|
|
)}
|
|
|
|
{mig.loading && !mig.data ? (
|
|
<Loading label={t("migration_checking")} />
|
|
) : mig.error ? (
|
|
<ErrorState error={mig.error} onRetry={mig.reload} />
|
|
) : (
|
|
<>
|
|
{!mig.data?.active && !redeemed && (
|
|
<p className="text-muted-foreground">
|
|
{t("migration_none_prefix")}
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
|
/felis migrate
|
|
</code>
|
|
{t("migration_none_suffix")}
|
|
</p>
|
|
)}
|
|
|
|
{state === "initiated" && (
|
|
<div className="space-y-2">
|
|
<p className="font-medium text-foreground">{t("migration_confirm_title")}</p>
|
|
<p className="text-muted-foreground">{t("migration_confirm_desc")}</p>
|
|
{hasPasskey ? (
|
|
<Button size="sm" onClick={confirmWithPasskey} disabled={busy}>
|
|
<Fingerprint className="mr-2 h-4 w-4" />
|
|
{busy ? t("migration_confirming") : t("migration_confirm_passkey_btn")}
|
|
</Button>
|
|
) : !otpSent ? (
|
|
<Button
|
|
size="sm"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void run(async () => {
|
|
await api.migrateConfirmOTPStart();
|
|
setOtpSent(true);
|
|
})
|
|
}
|
|
>
|
|
<Mail className="mr-2 h-4 w-4" />
|
|
{busy ? t("sending_code") : t("migration_confirm_otp_btn")}
|
|
</Button>
|
|
) : (
|
|
<form
|
|
className="flex gap-2 max-w-md"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void run(async () => {
|
|
await api.migrateConfirmOTPVerify(otpCode.trim());
|
|
await mig.reload();
|
|
});
|
|
}}
|
|
>
|
|
<Input
|
|
value={otpCode}
|
|
onChange={(e) => setOtpCode(e.target.value)}
|
|
placeholder={t("otp_code_placeholder")}
|
|
maxLength={6}
|
|
disabled={busy}
|
|
className="max-w-[12rem] font-mono text-center tracking-[0.2em]"
|
|
/>
|
|
<Button type="submit" size="sm" disabled={busy || otpCode.trim().length !== 6}>
|
|
{busy ? t("migration_confirming") : t("email_verify_btn")}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{state === "confirmed" && !issued && (
|
|
<form
|
|
className="space-y-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void run(async () => {
|
|
setIssued(await api.migrateIssueCode(targetId.trim()));
|
|
await mig.reload();
|
|
});
|
|
}}
|
|
>
|
|
<p className="font-medium text-foreground">{t("migration_issue_title")}</p>
|
|
<p className="text-muted-foreground">{t("migration_issue_desc")}</p>
|
|
<div className="flex gap-2 max-w-md">
|
|
<Input
|
|
value={targetId}
|
|
onChange={(e) => setTargetId(e.target.value)}
|
|
placeholder={t("migration_target_placeholder")}
|
|
disabled={busy}
|
|
className="font-mono"
|
|
/>
|
|
<Button type="submit" size="sm" disabled={busy || !targetId.trim()}>
|
|
{busy ? t("migration_issuing") : t("migration_issue_btn")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{issued && (
|
|
<div className="space-y-2">
|
|
<p className="font-medium text-foreground">{t("migration_code_title")}</p>
|
|
<code className="block w-fit rounded bg-muted px-3 py-2 font-mono text-base tracking-[0.2em] text-foreground select-all">
|
|
{issued.code}
|
|
</code>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("migration_code_desc")} ({new Date(issued.expires_at).toLocaleString()})
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{state === "code_issued" && !issued && (
|
|
<p className="text-muted-foreground">
|
|
{t("migration_code_pending")}{" "}
|
|
{mig.data?.code_expires_at &&
|
|
`(${new Date(mig.data.code_expires_at).toLocaleString()})`}
|
|
</p>
|
|
)}
|
|
|
|
{redeemed ? (
|
|
<div className="flex items-center gap-2 font-medium text-foreground">
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
{t("migration_redeemed", { count: redeemed.servers_moved })}
|
|
</div>
|
|
) : (
|
|
!mig.data?.active && (
|
|
<form
|
|
className="space-y-2 border-t pt-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void run(async () => {
|
|
setRedeemed(await api.migrateRedeem(redeemCode.trim()));
|
|
});
|
|
}}
|
|
>
|
|
<p className="font-medium text-foreground">{t("migration_redeem_title")}</p>
|
|
<p className="text-muted-foreground">{t("migration_redeem_desc")}</p>
|
|
<div className="flex gap-2 max-w-md">
|
|
<Input
|
|
value={redeemCode}
|
|
onChange={(e) => setRedeemCode(e.target.value)}
|
|
placeholder={t("migration_redeem_placeholder")}
|
|
disabled={busy}
|
|
className="font-mono"
|
|
/>
|
|
<Button type="submit" size="sm" disabled={busy || !redeemCode.trim()}>
|
|
{busy ? t("migration_redeeming") : t("migration_redeem_btn")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)
|
|
)}
|
|
|
|
<InlineError message={err} />
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function StepBadge({ n }: { n: number }) {
|
|
return (
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
|
{n}
|
|
</span>
|
|
);
|
|
}
|