1import { createEffect, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
2import { authReason, authRequest } from "../auth.ts";
3import ArrowLeft from "lucide-solid/icons/arrow-left";
4import Stone from "lucide-solid/icons/stone";
5import githubIcon from "../sso/github.svg";
6import theme from "../sso/css/styles.css?inline";
7import adjustments from "./SignIn.css?inline";
8
9export function SignIn() {
10 const params = new URLSearchParams(window.location.search);
11 const setup = params.get("setup");
12 const statusQuery = new URLSearchParams();
13 if (setup) statusQuery.set("setup", setup);
14 if (params.get("flow")) statusQuery.set("flow", params.get("flow")!);
15 if (params.get("next")) statusQuery.set("next", params.get("next")!);
16 const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; service: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`));
17 const [username, setUsername] = createSignal("");
18 const [password, setPassword] = createSignal("");
19 const [email, setEmail] = createSignal("");
20 const [passwordStep, setPasswordStep] = createSignal(false);
21 const [visible, setVisible] = createSignal(false);
22 const [busy, setBusy] = createSignal(false);
23 const [error, setError] = createSignal(params.has("guest_error") ? "Guest sign-in wasn't completed. Choose a provider to try again." : "");
24 const [notice, setNotice] = createSignal("");
25 let conditional: AbortController | undefined;
26 let disposed = false;
27 const body = () => ({ csrf: status()!.csrf, username: username(), password: password(), email: email(), setup,
28 remember: false, flow: params.get("flow") ?? "", next: params.get("next") ?? "/" });
29 const passkey = async (automatic = false) => {
30 if (!status() || busy()) return;
31 conditional?.abort();
32 if (!automatic) { setBusy(true); setError(""); setNotice(""); }
33 const controller = new AbortController();
34 conditional = controller;
35 let selected = false;
36 try {
37 const request = { ...body(), username: automatic ? "" : username() };
38 const { options, token } = await authRequest<{ options: { publicKey: PublicKeyCredentialRequestOptionsJSON }; token: string }>("passkey/start", request);
39 if (controller.signal.aborted || disposed) return;
40 const credential = await navigator.credentials.get({
41 publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey),
42 mediation: automatic ? "conditional" : "optional", signal: controller.signal,
43 });
44 if (!(credential instanceof PublicKeyCredential)) throw new Error("Passkey sign-in was canceled. Try again or use your password.");
45 selected = true; setBusy(true);
46 const result = await authRequest<{ next: string }>("passkey/finish", { csrf: request.csrf, token, remember: false, credential: credential.toJSON() });
47 window.location.assign(result.next);
48 } catch (failure) {
49 if ((!automatic || selected) && !controller.signal.aborted) setError(authReason(failure));
50 } finally { if (!automatic || selected) setBusy(false); }
51 };
52 createEffect(() => {
53 if (!setup && !passwordStep() && status()) void (async () => {
54 if (await PublicKeyCredential.isConditionalMediationAvailable?.() && !disposed && !passwordStep()) await passkey(true);
55 })().catch(() => {});
56 });
57 const complete = async () => {
58 if (!status() || busy()) return;
59 conditional?.abort(); setError(""); setNotice("");
60 if (!setup && !passwordStep()) {
61 if (!username().trim()) return;
62 setUsername(username().trim());
63 setPasswordStep(true);
64 return;
65 }
66 setBusy(true);
67 try {
68 const result = await authRequest<{ next: string }>(setup ? "setup" : "password", body());
69 window.location.assign(result.next);
70 } catch (failure) { setError(authReason(failure)); }
71 finally { setBusy(false); }
72 };
73 onMount(() => {
74 const title = document.title;
75 document.title = "sso (snow sign on)";
76 onCleanup(() => { document.title = title; });
77 });
78 onCleanup(() => { disposed = true; conditional?.abort(); });
79 return (
80 <>
81 <style>{theme + adjustments}</style>
82 <main class="pf-v5-c-login__main">
83 <div id="kc-header"><div id="kc-header-wrapper"><div class="kc-logo-text"><span>snow sign on</span></div></div></div>
84 <div class="card-pf">
85 <header><h1 id="kc-page-title">{setup ? "welcome" : `sign in to ${status()?.service?.toLowerCase() ?? "snow globe"}`}</h1></header>
86 <div id="kc-content"><div id="kc-content-wrapper">
87 <Show when={status.error || error()}><div class="alert-error pf-v5-c-alert" role="alert"><span class="pf-v5-c-alert__title">{status.error ? authReason(status.error) : error()}</span></div></Show>
88 <Show when={notice()}><div class="alert-info pf-v5-c-alert" role="status"><span class="pf-v5-c-alert__title">{notice()}</span></div></Show>
89 <Show when={!status.error} fallback={<button class="pf-v5-c-button pf-m-primary pf-m-block" onClick={() => refetch()}>try again</button>}>
90 <Show when={!setup && !passwordStep() && status()?.providers?.length}>
91 <div id="kc-social-providers" class="guest-providers">
92 <For each={status()?.providers}>{(provider) => <a rel="external" data-provider={provider.id} class="pf-v5-c-button pf-m-primary pf-m-block" href={`/auth/guest/start/${provider.id}?next=${encodeURIComponent(params.get("next") ?? "")}`}>{provider.id === "astheno" ? <Stone size={18} aria-hidden="true" /> : <img src={githubIcon} width="18" height="18" alt="" />}<span>continue with {provider.name}</span></a>}</For>
93 </div>
94 </Show>
95 <div id="kc-form"><div id="kc-form-wrapper">
96 <form id="kc-form-login" onSubmit={(event) => { event.preventDefault(); void complete(); }}>
97 <Show when={setup} fallback={
98 <Show when={!passwordStep()} fallback={<div class="sign-in-account"><button type="button" onClick={() => { setPassword(""); setVisible(false); setError(""); setNotice(""); setPasswordStep(false); }}><ArrowLeft size={18} aria-hidden="true" />back</button><h2>{username()}</h2><input name="username" type="hidden" autocomplete="username" value={username()} /></div>}>
99 <div class="pf-v5-c-form__group">
100 <label for="username" class="pf-v5-c-form__label">username or email</label>
101 <input id="username" class="pf-v5-c-form-control" name="username" type="text" required autocomplete="username webauthn" value={username()} onInput={(event) => setUsername(event.currentTarget.value)} autofocus ref={(input) => queueMicrotask(() => input.focus())} />
102 </div>
103 </Show>
104 }>
105 <p class="setup-intro">Your account is <b>{status()?.setup ?? "…"}</b>. Add your email and choose a password.</p>
106 <div class="pf-v5-c-form__group">
107 <label for="email" class="pf-v5-c-form__label">email</label>
108 <input id="email" class="pf-v5-c-form-control" name="email" type="email" required autocomplete="email" value={email()} onInput={(event) => setEmail(event.currentTarget.value)} autofocus />
109 </div>
110 </Show>
111 <Show when={setup || passwordStep()}>
112 <div class="pf-v5-c-form__group">
113 <label for="password" class="pf-v5-c-form__label">{setup ? "choose a password" : "password"}</label>
114 <div class="pf-v5-c-input-group">
115 <input id="password" class="pf-v5-c-form-control" name="password" type={visible() ? "text" : "password"} required minLength={setup ? 8 : undefined} maxLength={1024} autocomplete={setup ? "new-password" : "current-password"} value={password()} onInput={(event) => setPassword(event.currentTarget.value)} ref={(input) => { if (!setup) queueMicrotask(() => input.focus()); }} />
116 <button class="pf-v5-c-button pf-m-control" type="button" aria-label={visible() ? "Hide password" : "Show password"} aria-controls="password" data-password-toggle onClick={() => setVisible(!visible())}><i aria-hidden="true" /></button>
117 </div>
118 <Show when={setup}><span class="pf-v5-c-helper-text__item-text">Use at least 8 characters. You can add a passkey next.</span></Show>
119 </div>
120 <Show when={!setup}><div class="pf-v5-c-form__group">
121 <div><span><a href="#" onClick={(event) => { event.preventDefault(); setNotice("Ask Clover for a password reset link."); }}>forgot password?</a></span></div>
122 </div></Show>
123 </Show>
124 <div id="kc-form-buttons" class="pf-v5-c-form__group">
125 <input class="pf-v5-c-button pf-m-primary pf-m-block" name="login" id="kc-login" type="submit" value={setup ? "create account" : "sign in"} disabled={busy() || !status()} aria-busy={busy()} />
126 </div>
127 </form>
128 </div></div>
129 <Show when={!setup}><a id="authenticateWebAuthnButton" href="#" class="pf-v5-c-button pf-m-secondary pf-m-block" aria-disabled={busy() || !status()} onClick={(event) => { event.preventDefault(); void passkey(); }}>sign in with passkey</a></Show>
130
131 </Show>
132 </div></div>
133 </div>
134 </main>
135 <div id="credit"><a rel="noopener noreferrer" target="_blank" href="https://www.pixiv.net/en/artworks/109102745">art by 紺屋</a></div>
136 </>
137 );
138}