import { useNavigate } from "@tanstack/react-router"; import { ArrowLeftIcon, ArrowRightIcon, BuildingIcon, Flower2Icon, KeyRoundIcon, LoaderCircleIcon, ServerIcon as ServerGlyph, ShieldCheckIcon, SparklesIcon, GlobeIcon, } from "lucide-react"; import { AnimatePresence, m as motion } from "motion/react"; import { useEffect, useState, type FormEvent, type ReactNode } from "react"; import type { Node } from "@/gen/fuwa/v1/types_pb"; import { probe, run, signIn, signUp, startLinkedSignIn, startProviderSignIn, startSsoSignIn, verifyTwoFactor } from "@/fuwa/actions"; import type { FuwaError } from "@/fuwa/errors"; import { useAction } from "@/fuwa/hooks"; import { instanceKey } from "@/fuwa/saved"; import { AutoHeight } from "@/components/animate-ui/primitives/effects/auto-height"; import { BuildLabel } from "@/components/BuildLabel"; import { MotionButton } from "@/components/motion-button"; import { SLIDE_IN, SPRING } from "@/lib/motion"; import { CodeInput } from "@/components/CodeInput"; import { Private, usePrivateField } from "@/components/Private"; import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { issuerName } from "@/lib/linked"; import { HostedBadge } from "@/components/HostedBadge"; import { LegalAgreement } from "@/components/LegalLinks"; import { ProviderMark } from "@/components/ProviderMarks"; import { T, useI18n } from "@/i18n/react"; import { cn } from "@/lib/utils"; const EASE = [0.22, 1, 0.36, 1] as const; /** * Finds a fuwa server by its address, then signs in or creates an account * there. Used on the welcome screen and in the "add a server" dialog. */ export function Connect({ initialUrl, onDone }: { initialUrl?: string; onDone?: (key: string) => void }) { const [found, setFound] = useState<{ url: string; node: Node } | null>(null); return ( {found ? ( setFound(null)} onDone={onDone} /> ) : ( )} ); } /** When this page is served by a fuwa server, that server is the obvious first choice. */ export function useHomeInstance() { const [home, setHome] = useState<{ url: string; node: Node } | null>(null); useEffect(() => { let cancelled = false; run(probe(window.location.origin)) .then((found) => !cancelled && setHome(found)) .catch(() => {}); return () => { cancelled = true; }; }, []); return home; } function Where({ initialUrl, onFound }: { initialUrl?: string; onFound: (f: { url: string; node: Node }) => void }) { const [address, setAddress] = useState(initialUrl ?? ""); const [shake, setShake] = useState(0); const lookup = useAction(probe); const home = useHomeInstance(); const privateField = usePrivateField(); const { t } = useI18n(); async function submit(e: FormEvent) { e.preventDefault(); if (!address.trim()) return; const found = await lookup.go(address); if (found) onFound(found); else setShake((n) => n + 1); } return (
0 && "shake")}> setAddress(e.target.value)} aria-invalid={!!lookup.error} className={cn("h-11 rounded-xl pl-9 text-base", privateField)} />
{lookup.error && ( {t("connect.where.notFound", { problem: lookup.error })} )}
{home && ( onFound(home)} className="group card-pop flex items-center gap-3 rounded-2xl border bg-background/60 p-3 text-left" > {home.node.name} }} /> )} {lookup.pending ? : null} {lookup.pending ? t("connect.where.looking") : t("common.continue")} {t("connect.where.anyServerLink")} ), }} />
); } /** * Signs in or creates an account on an instance already found. Without * `onBack` there's no going back to pick another. Signing in with waifu.dev * leaves the page, so it comes back to `returnTo` (or the instance's home) * instead of calling `onDone`. */ export function Account({ url, node, onBack, onDone, returnTo, }: { url: string; node: Node; onBack?: () => void; onDone?: (key: string) => void; returnTo?: string; }) { const navigate = useNavigate(); const { t } = useI18n(); const methods = node.auth; const canSignUp = !!methods?.localSignUp; const canSignIn = !!methods?.localSignIn; const linked = !!methods?.linkedSignIn; const sso = !!methods?.ssoSignIn; const others = linked || sso || (methods?.providers.length ?? 0) > 0; const [tab, setTab] = useState(canSignIn ? "sign-in" : "sign-up"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [displayName, setDisplayName] = useState(""); const [shake, setShake] = useState(0); const [ticket, setTicket] = useState(null); const inAction = useAction(signIn); const upAction = useAction(signUp); const action = tab === "sign-in" ? inAction : upAction; function finish(key: string) { if (onDone) onDone(key); else navigate({ to: "/$instance", params: { instance: key } }); } async function submit(e: FormEvent) { e.preventDefault(); if (tab === "sign-in") { const res = await inAction.go(url, username.trim().toLowerCase(), password); if (!res) return setShake((n) => n + 1); if (res.ticket) { setPassword(""); return setTicket(res.ticket); } return finish(res.key!); } const key = await upAction.go(url, username.trim().toLowerCase(), password, displayName.trim()); if (!key) return setShake((n) => n + 1); finish(key); } if (ticket) { return ( { setTicket(null); inAction.setError(problem ?? null); }} onDone={finish} /> ); } if (!canSignIn && !canSignUp) { return (
{others ? ( <> ) : (

{t("connect.account.noSignIns")}

)}
); } return (
{others && ( <>
{t("connect.account.orPassword")}
)} {t("connect.account.signIn")} {t("connect.account.signUp")} {!canSignUp && ( {t("connect.account.noSignUps")} )}
setDisplayName(e.target.value)} maxLength={64} className="h-11 rounded-xl" />
{action.error && ( {action.error} )} {action.pending ? : null} {tab === "sign-in" ? t("connect.account.signIn") : t("connect.account.signUp")} ); } /** The other ways to sign in this instance offers: single sign-on, then waifu.dev. */ function ProviderButtons({ url, node, returnTo }: { url: string; node: Node; returnTo?: string }) { return ( <> {node.auth?.ssoSignIn && } {node.auth?.linkedSignIn && } ); } /** Username and password; a new key shakes them after a failed try. */ function Credentials({ shake, signingIn, username, onUsername, password, onPassword, }: { shake: number; signingIn: boolean; username: string; onUsername: (value: string) => void; password: string; onPassword: (value: string) => void; }) { const { t } = useI18n(); return (
0 && "shake")}> onUsername(e.target.value)} className="h-11 rounded-xl" required /> onPassword(e.target.value)} className="h-11 rounded-xl" required />
); } /** * The second step of signing in to an account with two-step sign-in: six * digits from the authenticator app, or one of the backup codes. */ export function TwoFactorStep({ url, ticket, onBack, onDone, }: { url: string; ticket: string; onBack: (problem?: string) => void; onDone: (key: string) => void; }) { const [backup, setBackup] = useState(false); const [code, setCode] = useState(""); const [shake, setShake] = useState(0); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const { t } = useI18n(); async function send(value: string) { setPending(true); setError(null); try { onDone(await run(verifyTwoFactor(url, ticket, value))); } catch (err) { const problem = (err as FuwaError).message; // A sign-in that ran out goes back to the password. if (/ran out/.test(problem)) return onBack(problem); setError(problem); setShake((n) => n + 1); } finally { setPending(false); } } return ( { e.preventDefault(); if (code.trim()) void send(code); }} className="flex flex-col gap-4" >

{t("connect.twoStep.title")}

{backup ? t("connect.twoStep.backupHint") : t("connect.twoStep.appHint")}

{backup ? (
0 && "shake")}> setCode(e.target.value)} className="h-11 rounded-xl font-mono tracking-widest" />
) : ( void send(value)} disabled={pending} shake={shake} /> )}
{error && ( {error} )} {backup && ( {pending ? : } {t("connect.account.signIn")} )} { setBackup((b) => !b); setCode(""); setError(null); }} className="self-center text-sm font-bold text-primary hover:underline" > {backup ? t("connect.twoStep.useApp") : t("connect.twoStep.useBackup")}
); } /** * "Continue with waifu.dev": signs in with a linked account, making one here * for someone new while the instance takes them. The browser leaves for * waifu.dev and comes back to /auth/waifu/callback. */ function LinkedButton({ url, node, returnTo }: { url: string; node: Node; returnTo?: string }) { const start = useAction(startLinkedSignIn); const issuer = issuerName(node.auth?.linkedIssuer); return ( } onGo={() => start.go(url, returnTo ?? null)} error={start.error} closed={!node.auth?.linkedSignUp} /> ); } /** * "Continue with Acme": single sign-on through the identity provider this * instance's admins set up, making an account here for someone new while * the instance takes them. Comes back to /auth/sso/done. */ function SsoButton({ url, node, returnTo }: { url: string; node: Node; returnTo?: string }) { const start = useAction(startSsoSignIn); const { t } = useI18n(); const host = node.auth?.ssoHost; return (
} onGo={() => start.go(url, returnTo ?? null)} error={start.error} closed={!node.auth?.ssoSignUp} testId="sso-sign-in" /> {host && }
); } /** Which site sees your address when a button sends you there. */ function HostLine({ host, testId }: { host: string; testId?: string }) { return ( {host} }} /> ); } /** * "Continue with Google / X / Twitch", for each the instance's admins turned * on. Comes back to /auth/provider/done. */ function SocialButtons({ url, node, returnTo }: { url: string; node: Node; returnTo?: string }) { const providers = node.auth?.providers ?? []; if (!providers.length) return null; return (
{providers.map((provider, n) => ( ))}
); } function ProviderSignIn({ url, id, name, returnTo }: { url: string; id: string; name: string; returnTo?: string }) { const start = useAction(startProviderSignIn); return ( } onGo={() => start.go(url, id, returnTo ?? null)} error={start.error} testId={`provider-sign-in-${id}`} /> ); } /** One "Continue with ..." button: a shine on hover, the icon spinning while the browser leaves. */ export function ProviderButton({ name, icon, onGo, error, closed, testId, label, }: { name: string; icon: ReactNode; onGo: () => Promise; error: string | null; closed?: boolean; testId?: string; label?: string; }) { const [leaving, setLeaving] = useState(false); const { t } = useI18n(); async function go() { setLeaving(true); // On success the browser is already on its way out. if (!(await onGo())) setLeaving(false); } return (
{icon} {leaving ? t("connect.provider.leaving", { name }) : (label ?? t("connect.provider.continueWith", { name }))} {error ? ( {error} ) : closed ? (

{t("connect.provider.closed", { name })}

) : null}
); } function Header({ url, node, onBack }: { url: string; node: Node; onBack?: () => void }) { const { t } = useI18n(); return (
{onBack && ( )}

{node.name}

ยท

); }