| 1 | import { createEffect, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; |
| 2 | import { authReason, authRequest } from "../auth.ts"; |
| 3 | import ArrowLeft from "lucide-solid/icons/arrow-left"; |
| 4 | import Stone from "lucide-solid/icons/stone"; |
| 5 | import githubIcon from "../sso/github.svg"; |
| 6 | import theme from "../sso/css/styles.css?inline"; |
| 7 | import adjustments from "./SignIn.css?inline"; |
| 8 | |
| 9 | export 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 | } |