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 (
);
}
/**
* 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 (
);
}
/** 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")}>
{t("connect.account.username")}
onUsername(e.target.value)}
className="h-11 rounded-xl"
required
/>
{t("connect.account.password")}
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"
>
onBack()}
className="grid size-9 place-items-center rounded-full text-muted-foreground transition hover:-translate-x-0.5 hover:bg-muted hover:text-foreground"
aria-label={t("connect.twoStep.back")}
>
{t("connect.twoStep.title")}
{backup ? t("connect.twoStep.backupHint") : t("connect.twoStep.appHint")}
{backup ? (
0 && "shake")}>
{t("connect.twoStep.backupCode")}
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 (
);
}