diff options
| author | Adam Soltys <[email protected]> | 2026-02-16 11:51:25 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-16 11:51:25 -0800 |
| commit | e39bf4f977bba2952a7de4a307b14c5ec862a3a7 (patch) | |
| tree | 1eac038796d376ad073eae6246bf43f15454bd66 /src/routes | |
| parent | fe104c2364de35bcdb2a10ca469cd25c12b04289 (diff) | |
| download | coinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.tar.gz coinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.zip | |
passkey auth
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 15 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.server.ts | 47 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 44 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.server.ts | 4 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 157 | ||||
| -rw-r--r-- | src/routes/passkey/[...path]/+server.ts | 18 |
6 files changed, 211 insertions, 74 deletions
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 2eda8072..732ac61e 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { getWallet, syncTransactions, settle, refresh, arkkey, arkaid, sendArk } from "$lib/ark"; + import { getWallet, syncTransactions, settle, refresh, arkkey, arkaid, sendArk, arkSending } from "$lib/ark"; import { SvelteToast } from "@zerodevx/svelte-toast"; import { onDestroy, onMount } from "svelte"; import { close, connect, send, socket } from "$lib/socket"; @@ -55,7 +55,7 @@ let arkForwarding = false; let arkSync = async () => { - if (arkSyncing) return; + if (arkSyncing || arkSending) return; arkSyncing = true; try { @@ -75,7 +75,10 @@ arkForwarding = false; } } else if (result?.received > 0) { - if ($fiat && $rateStore && user?.currency) { + const paidPayment = result.payments?.find((p: any) => p.iid && p.amount > 0); + if (paidPayment) { + goto(`/invoice/${paidPayment.iid}`); + } else if ($fiat && $rateStore && user?.currency) { success(`Received ${f(toFiat(result.received, $rateStore as number), user.currency)}!`); } else { success(`Received ${s(result.received)}!`); @@ -92,6 +95,7 @@ let arkInitializedKey: string | undefined; let arkRefreshTimer: ReturnType<typeof setInterval> | undefined; + let arkSyncTimer: ReturnType<typeof setInterval> | undefined; const initArk = async () => { const key = $arkkey; @@ -106,6 +110,10 @@ refresh().catch((e) => console.error("Ark refresh failed:", e)); settle().catch((e) => console.error("Ark settle failed:", e)); + // Poll for incoming ark transactions (fallback for SSE) + if (arkSyncTimer) clearInterval(arkSyncTimer); + arkSyncTimer = setInterval(() => arkSync(), 15_000); + // Periodically recover expired VTXOs if (arkRefreshTimer) clearInterval(arkRefreshTimer); arkRefreshTimer = setInterval(() => { @@ -183,6 +191,7 @@ if (browser) { close(); clearTimeout(checkTimer); + if (arkSyncTimer) clearInterval(arkSyncTimer); if (arkRefreshTimer) clearInterval(arkRefreshTimer); } }); diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index eb0ad0bf..18f9cb20 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -39,6 +39,53 @@ export const actions = { redirect(307, loginRedirect || `/${user.username}`); }, + passkey: async ({ cookies, fetch, request }) => { + const form = await fd(request); + let { credential, challengeId, loginRedirect } = form; + if (loginRedirect === "null" || loginRedirect === "undefined") + loginRedirect = undefined; + credential = JSON.parse(credential); + + const maxAge = 380 * 24 * 60 * 60; + + const origin = new URL(request.url).origin; + const res = await fetch(`${PUBLIC_COINOS_URL}/passkey/login/verify`, { + method: "POST", + body: JSON.stringify({ credential, challengeId, origin }), + headers: { + "content-type": "application/json", + accept: "application/json", + "cf-connecting-ip": request.headers.get("cf-connecting-ip") ?? "", + "x-forwarded-host": request.headers.get("host") ?? "", + }, + }); + + const text = await res.text(); + if (!res.ok) { + let message; + try { + ({ message } = JSON.parse(text)); + } catch { + message = text; + } + return fail(400, { error: message || "Passkey login failed" }); + } + + const { user, token } = JSON.parse(text); + if (!token) return fail(400, { error: "Login failed" }); + const { username, language } = user; + + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + + const opts = { path: "/", expires }; + if (language) cookies.set("lang", language, opts); + cookies.set("username", username, opts); + cookies.set("token", token, opts); + + redirect(307, loginRedirect || `/${username}`); + }, + nostr: async ({ cookies, fetch, request }) => { const form = await fd(request); let { challenge, event, loginRedirect, recaptcha } = form; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 22e49e65..74699c05 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -10,6 +10,7 @@ import PasswordInput from "$comp/PasswordInput.svelte"; import Spinner from "$comp/Spinner.svelte"; import { focus, fail } from "$lib/utils"; + import { loginWithPasskey } from "$lib/passkey"; import { password, signer, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; @@ -105,6 +106,40 @@ let redirect = $derived($loginRedirect || $page.url.searchParams.get("redirect")); + let passkeyLoading = $state(false); + + let passkeyLogin = async () => { + passkeyLoading = true; + try { + const { credential, challengeId } = await loginWithPasskey(); + + const formData = new FormData(); + formData.append("credential", JSON.stringify(credential)); + formData.append("challengeId", challengeId); + formData.append("loginRedirect", (redirect ?? "") as string); + + const response = await fetch("/login?/passkey", { + method: "POST", + body: formData, + }); + + const result = deserialize(await response.text()); + + if (result.type === "success") { + await invalidateAll(); + } + + applyAction(result); + } catch (e: any) { + if (e.name === "NotAllowedError") { + // user cancelled the browser prompt + } else { + fail(e.message || "Passkey login failed"); + } + } + passkeyLoading = false; + }; + let nostrLogin = async () => { let event = { kind: 27235, @@ -191,6 +226,15 @@ {$t("login.signIn")} </button> + <button type="button" class="btn" onclick={passkeyLogin} disabled={passkeyLoading}> + {#if passkeyLoading} + <Spinner /> + {:else} + <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon> + {/if} + <div class="my-auto">Sign in with Passkey</div> + </button> + <button type="button" class="btn" onclick={nostrLogin}> {#if $signer?.ready} <div class="shrink"> diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 7fde82aa..43a0df80 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -16,11 +16,11 @@ export const actions = { const ip = request.headers.get("cf-connecting-ip"); const host = request.headers.get("host"); const form = await fd(request); - const { picture, username, password, challenge, recaptcha } = form; + const { picture, username, challenge, recaptcha } = form; let { loginRedirect } = form; if (loginRedirect === "undefined") loginRedirect = undefined; - const user = { picture, username, password, challenge, recaptcha }; + const user = { picture, username, challenge, recaptcha }; return register(user, ip, cookies, loginRedirect, host); }, }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index 843fb129..e74ddde6 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -12,13 +12,13 @@ import Pin from "$comp/Pin.svelte"; import Spinner from "$comp/Spinner.svelte"; - import PasswordInput from "$comp/PasswordInput.svelte"; import { focus, fail } from "$lib/utils"; + import { registerPasskey } from "$lib/passkey"; import { avatar, signer, password, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; - import { NumberDictionary, uniqueNamesGenerator, colors, animals } from "unique-names-generator"; + import { NumberDictionary, uniqueNamesGenerator, animals } from "unique-names-generator"; import { sign } from "$lib/nostr"; let { form, data }: any = $props(); @@ -48,15 +48,13 @@ let username: string | undefined = $state(); let index = $state(data.index); - let revealPassword = $state(false); + let needsPasskey = $state(false); let cleared; let clear = () => { if (!cleared) { cleared = true; username = ""; - $password = ""; - revealPassword = false; } }; @@ -69,14 +67,6 @@ length: 2, separator: "", }); - - $password = uniqueNamesGenerator({ - dictionaries: [colors, NumberDictionary.generate({ min: 100, max: 999 })], - length: 2, - separator: "", - }); - - revealPassword = true; }; afterNavigate(async () => { @@ -160,7 +150,16 @@ const result = deserialize(await response.text()); - if (result.type === "success") { + if (result.type === "redirect") { + try { + await registerPasskey(); + } catch (e: any) { + console.log("passkey creation cancelled or failed", e); + needsPasskey = true; + loading = false; + return; + } + await invalidateAll(); } @@ -168,6 +167,19 @@ loading = false; } + let retryPasskey = async () => { + loading = true; + try { + await registerPasskey(); + needsPasskey = false; + await invalidateAll(); + applyAction({ type: "redirect", status: 303, location: `/${username}` }); + } catch (e: any) { + fail("Passkey creation failed. Please try again."); + } + loading = false; + } + let avatarInput: HTMLInputElement = $state() as any; let decr = () => (index = index <= 0 ? 63 : index - 1); let incr = () => (index = index >= 63 ? 0 : index + 1); @@ -301,65 +313,72 @@ <div class="text-red-600 text-center" in:fly>{form?.message}{form?.error}</div> {/if} - <form class="space-y-5" onsubmit={handleSubmit} method="POST"> - <input - type="hidden" - name="loginRedirect" - value={$loginRedirect || $page.url.searchParams.get("redirect")} - /> - <input type="hidden" name="token" value={token} /> - <input type="hidden" name="challenge" value={challenge} /> - - <label for="username" class="input flex items-center justify-center gap-2 w-full"> + {#if needsPasskey} + <div class="space-y-5 text-center"> + <p>Account created! You need to set up a passkey to continue.</p> + <button class="btn btn-accent" onclick={retryPasskey} disabled={loading}> + {#if loading} + <Spinner /> + {:else} + Set up Passkey + {/if} + </button> + </div> + {:else} + <form class="space-y-5" onsubmit={handleSubmit} method="POST"> <input - class="clean" - use:focus - name="username" - type="text" - required - bind:value={username} - onfocus={clear} - autocapitalize="none" - placeholder={$t("login.username")} + type="hidden" + name="loginRedirect" + value={$loginRedirect || $page.url.searchParams.get("redirect")} /> - <button type="button" tabindex="-1" onclick={refresh} aria-label="Randomize" class="contents"> - <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon> - </button> - </label> - - <PasswordInput - bind:value={$password} - placeholder={$t("login.password")} - bind:show={revealPassword} - /> + <input type="hidden" name="token" value={token} /> + <input type="hidden" name="challenge" value={challenge} /> - <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}> - {#if loading} - <Spinner /> - {:else} - {$t("login.register")} - {/if} - </button> + <label for="username" class="input flex items-center justify-center gap-2 w-full"> + <input + class="clean" + use:focus + name="username" + type="text" + required + bind:value={username} + onfocus={clear} + autocapitalize="none" + placeholder={$t("login.username")} + /> + <button type="button" tabindex="-1" onclick={refresh} aria-label="Randomize" class="contents"> + <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon> + </button> + </label> - <button type="button" class="btn" onclick={nostrLogin}> - {#if $signer?.ready} - <div class="shrink"> + <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}> + {#if loading} <Spinner /> - </div> - {:else} - <img src="/images/nostr.png" class="w-8" alt="Nostr" /> - {/if} - <div class="my-auto">{$t("login.nostr")}</div> - </button> + {:else} + {$t("login.register")} + {/if} + </button> - <p class="text-secondary text-center font-medium"> - {$t("login.haveAccount")} - <a - href={"/login" + $page.url.search} - class="block md:inline text-secondary underline underline-offset-4 hover:opacity-80" - > - {$t("login.signIn")} - </a> - </p> - </form> + <button type="button" class="btn" onclick={nostrLogin}> + {#if $signer?.ready} + <div class="shrink"> + <Spinner /> + </div> + {:else} + <img src="/images/nostr.png" class="w-8" alt="Nostr" /> + {/if} + <div class="my-auto">{$t("login.nostr")}</div> + </button> + + <p class="text-secondary text-center font-medium"> + {$t("login.haveAccount")} + <a + href={"/login" + $page.url.search} + class="block md:inline text-secondary underline underline-offset-4 hover:opacity-80" + > + {$t("login.signIn")} + </a> + </p> + </form> + {/if} </div> diff --git a/src/routes/passkey/[...path]/+server.ts b/src/routes/passkey/[...path]/+server.ts new file mode 100644 index 00000000..0f94c605 --- /dev/null +++ b/src/routes/passkey/[...path]/+server.ts @@ -0,0 +1,18 @@ +import { PUBLIC_COINOS_URL as base } from "$env/static/public"; +import { auth } from "$lib/utils"; + +export async function POST({ cookies, fetch, params, request, url }) { + const { path } = params; + const token = cookies.get("token"); + const headers: Record<string, string> = { "Content-Type": "application/json" }; + if (token) Object.assign(headers, auth(cookies)); + + const body = await request.json(); + body.origin = url.origin; + + return fetch(`${base}/passkey/${path}`, { + method: "POST", + body: JSON.stringify(body), + headers, + }); +} |
