aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 11:51:25 -0800
committerAdam Soltys <[email protected]>2026-02-16 11:51:25 -0800
commite39bf4f977bba2952a7de4a307b14c5ec862a3a7 (patch)
tree1eac038796d376ad073eae6246bf43f15454bd66 /src/routes
parentfe104c2364de35bcdb2a10ca469cd25c12b04289 (diff)
downloadcoinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.tar.gz
coinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.zip
passkey auth
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/(app)/+layout.svelte15
-rw-r--r--src/routes/(static)/login/+page.server.ts47
-rw-r--r--src/routes/(static)/login/+page.svelte44
-rw-r--r--src/routes/(static)/register/+page.server.ts4
-rw-r--r--src/routes/(static)/register/+page.svelte157
-rw-r--r--src/routes/passkey/[...path]/+server.ts18
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,
+ });
+}