aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-27 14:46:27 -0800
committerAdam Soltys <[email protected]>2026-02-27 14:46:27 -0800
commit8ffcf769052461e03a2c69220392cb37a623f3f4 (patch)
tree5b0b16269a729fbe9db44c65b3a0da3d95c11b78 /src
parent6ba952f16fc60fc56f0834e5b1d08671885618c5 (diff)
downloadcoinos-ui-8ffcf769052461e03a2c69220392cb37a623f3f4.tar.gz
coinos-ui-8ffcf769052461e03a2c69220392cb37a623f3f4.zip
2 step register
Diffstat (limited to 'src')
-rw-r--r--src/lib/deriveAuthKey.ts5
-rw-r--r--src/lib/keyWrapping.ts61
-rw-r--r--src/lib/passkey.ts7
-rw-r--r--src/lib/utils.ts2
-rw-r--r--src/lib/walletEntropy.ts10
-rw-r--r--src/routes/(app)/settings/+layout.svelte20
-rw-r--r--src/routes/(app)/settings/security/+page.svelte19
-rw-r--r--src/routes/(static)/login/+page.server.ts24
-rw-r--r--src/routes/(static)/login/+page.svelte91
-rw-r--r--src/routes/(static)/register/+page.server.ts107
-rw-r--r--src/routes/(static)/register/+page.svelte374
-rw-r--r--src/routes/(static)/register/auth/+page.server.ts115
-rw-r--r--src/routes/(static)/register/auth/+page.svelte393
13 files changed, 724 insertions, 504 deletions
diff --git a/src/lib/deriveAuthKey.ts b/src/lib/deriveAuthKey.ts
index 9215b515..eb498e0a 100644
--- a/src/lib/deriveAuthKey.ts
+++ b/src/lib/deriveAuthKey.ts
@@ -1,7 +1,6 @@
import { scryptAsync } from "@noble/hashes/scrypt.js";
import { bytesToHex } from "@noble/hashes/utils.js";
import { getPublicKey } from "nostr-tools";
-import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
export async function deriveAuthKeypair(username: string, password: string) {
const salt = `coinos:auth:${username.toLowerCase().replace(/\s/g, "")}`;
@@ -17,7 +16,5 @@ export async function deriveWalletEntropy(sk: Uint8Array): Promise<ArrayBuffer>
const sigBytes = new Uint8Array(signed.sig.length / 2);
for (let i = 0; i < signed.sig.length; i += 2)
sigBytes[i / 2] = parseInt(signed.sig.slice(i, i + 2), 16);
- const entropy = await crypto.subtle.digest("SHA-256", sigBytes);
- rememberPrfKey(entropy, defaultRememberForMs);
- return entropy;
+ return crypto.subtle.digest("SHA-256", sigBytes);
}
diff --git a/src/lib/keyWrapping.ts b/src/lib/keyWrapping.ts
new file mode 100644
index 00000000..b176e741
--- /dev/null
+++ b/src/lib/keyWrapping.ts
@@ -0,0 +1,61 @@
+import { prfEncrypt, prfDecrypt } from "$lib/crypto";
+import { rememberPrfKey, defaultRememberForMs, getCachedPrfKey } from "$lib/passwordCache";
+import { post } from "$lib/utils";
+
+export function generateCanonicalKey(): ArrayBuffer {
+ return crypto.getRandomValues(new Uint8Array(32)).buffer as ArrayBuffer;
+}
+
+export async function wrapCanonicalKey(
+ wrappingKey: ArrayBuffer,
+ canonicalKey: ArrayBuffer,
+): Promise<string> {
+ return prfEncrypt(wrappingKey, new Uint8Array(canonicalKey));
+}
+
+export async function unwrapCanonicalKey(
+ wrappingKey: ArrayBuffer,
+ encrypted: string,
+): Promise<ArrayBuffer> {
+ const bytes = await prfDecrypt(wrappingKey, encrypted);
+ return bytes.buffer as ArrayBuffer;
+}
+
+export async function resolveCanonicalKey(
+ methodId: string,
+ wrappingKey: ArrayBuffer,
+ encryptedKeys: Record<string, string> | null | undefined,
+ oldPrfKey: ArrayBuffer,
+): Promise<{ canonicalKey: ArrayBuffer; encryptedKeys: Record<string, string> }> {
+ if (encryptedKeys && encryptedKeys[methodId]) {
+ const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]);
+ rememberPrfKey(canonicalKey, defaultRememberForMs);
+ return { canonicalKey, encryptedKeys };
+ }
+
+ // Migration: old PRF key becomes canonical key
+ const canonicalKey = oldPrfKey;
+ const wrapped = await wrapCanonicalKey(wrappingKey, canonicalKey);
+ const updated = { ...(encryptedKeys || {}), [methodId]: wrapped };
+ await saveEncryptedKeys(updated);
+ rememberPrfKey(canonicalKey, defaultRememberForMs);
+ return { canonicalKey, encryptedKeys: updated };
+}
+
+export async function saveEncryptedKeys(
+ encryptedKeys: Record<string, string>,
+): Promise<void> {
+ await post("/post/user", { encryptedKeys });
+}
+
+export function passwordMethodId(): string {
+ return "password";
+}
+
+export function passkeyMethodId(credentialId: string): string {
+ return `passkey:${credentialId}`;
+}
+
+export function nostrMethodId(pubkey: string): string {
+ return `nostr:${pubkey}`;
+}
diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts
index 053eb06a..7c9e98cc 100644
--- a/src/lib/passkey.ts
+++ b/src/lib/passkey.ts
@@ -10,12 +10,15 @@ function authHeader(token: string) {
return { authorization: `Bearer ${token}` };
}
-export async function registerPasskey(token?: string): Promise<ArrayBuffer> {
+export async function registerPasskey(
+ token?: string,
+): Promise<{ prfKey: ArrayBuffer; credentialId: string }> {
const headers = token ? authHeader(token) : undefined;
const options = await post("/passkey/register/options", {}, headers);
const registration = await startRegistration({ optionsJSON: options });
await post("/passkey/register/verify", registration, headers);
- return deriveKey(registration.id);
+ const prfKey = await deriveKey(registration.id);
+ return { prfKey, credentialId: registration.id };
}
export async function loginWithPasskey(): Promise<{
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index eee6903e..e37556e3 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -214,6 +214,8 @@ export const login = async (
});
} catch {}
}
+
+ return u;
};
export const auth = (cookies) => ({
diff --git a/src/lib/walletEntropy.ts b/src/lib/walletEntropy.ts
index 25bd49b8..95b3e903 100644
--- a/src/lib/walletEntropy.ts
+++ b/src/lib/walletEntropy.ts
@@ -1,5 +1,5 @@
import { browser } from "$app/environment";
-import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
+import { getCachedPrfKey } from "$lib/passwordCache";
const DERIVATION_EVENT = {
kind: 1,
@@ -27,9 +27,7 @@ export async function tryDeriveFromNsec(): Promise<ArrayBuffer | null> {
const { nip19, finalizeEvent } = await import("nostr-tools");
const sk = nip19.decode(nsec).data as Uint8Array;
const signed = finalizeEvent({ ...DERIVATION_EVENT } as any, sk);
- const entropy = await hashSignature(signed.sig);
- rememberPrfKey(entropy, defaultRememberForMs);
- return entropy;
+ return await hashSignature(signed.sig);
} catch (e) {
console.log("nsec entropy derivation failed", e);
return null;
@@ -60,7 +58,5 @@ export async function deriveNostrEntropy(): Promise<ArrayBuffer> {
const { sign } = await import("$lib/nostr");
const signed = await sign({ ...DERIVATION_EVENT });
- const entropy = await hashSignature(signed.sig);
- rememberPrfKey(entropy, defaultRememberForMs);
- return entropy;
+ return hashSignature(signed.sig);
}
diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte
index ce45f915..99bdc4ce 100644
--- a/src/routes/(app)/settings/+layout.svelte
+++ b/src/routes/(app)/settings/+layout.svelte
@@ -13,6 +13,8 @@
import { sign, send, getPrivateKey } from "$lib/nostr";
import { invalidateAll } from "$app/navigation";
import { bytesToHex } from "@noble/hashes/utils.js";
+ import { getCachedPrfKey } from "$lib/passwordCache";
+ import { wrapCanonicalKey, saveEncryptedKeys, passwordMethodId } from "$lib/keyWrapping";
import { SimplePool } from "nostr-tools/pool";
import { finalizeEvent } from "nostr-tools/pure";
@@ -201,14 +203,21 @@
}
}
- // If password is changing, derive new authPubkey
+ // If password is changing, derive new authPubkey and re-wrap canonical key
const pw = body.get("password") as string;
const un = (body.get("username") as string) || user.username;
+ let pendingEncryptedKeys: Record<string, string> | null = null;
if (pw && pw === body.get("confirm")) {
try {
const { deriveAuthKeypair } = await import("$lib/deriveAuthKey");
- const { pubkey: authPubkey } = await deriveAuthKeypair(un, pw);
+ const { sk, pubkey: authPubkey } = await deriveAuthKeypair(un, pw);
body.set("authPubkey", authPubkey);
+
+ const canonicalKey = getCachedPrfKey();
+ if (canonicalKey) {
+ const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey);
+ pendingEncryptedKeys = { ...(user.encryptedKeys || {}), [passwordMethodId()]: wrapped };
+ }
} catch (e) {
console.log("Failed to derive authPubkey for password change", e);
}
@@ -222,6 +231,13 @@
const result = deserialize(await response.text());
if (result.type === "success") {
+ if (pendingEncryptedKeys) {
+ try {
+ await saveEncryptedKeys(pendingEncryptedKeys);
+ } catch (e) {
+ console.log("Failed to save re-wrapped encryptedKeys", e);
+ }
+ }
await invalidateAll();
if (pw) $password = pw;
}
diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte
index 0991c679..9ab16ded 100644
--- a/src/routes/(app)/settings/security/+page.svelte
+++ b/src/routes/(app)/settings/security/+page.svelte
@@ -9,7 +9,12 @@
import { save, pin as current } from "$lib/store";
import { getNsec } from "$lib/nostr";
import { registerPasskey } from "$lib/passkey";
- import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
+ import { getCachedPrfKey, rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
+ import {
+ wrapCanonicalKey,
+ saveEncryptedKeys,
+ passkeyMethodId,
+ } from "$lib/keyWrapping";
import { invalidate } from "$app/navigation";
import { page } from "$app/stores";
@@ -155,8 +160,16 @@
let addPasskey = async () => {
passkeyLoading = true;
try {
- const prfKey = await registerPasskey();
- rememberPrfKey(prfKey, defaultRememberForMs);
+ const { prfKey, credentialId } = await registerPasskey();
+ const canonicalKey = getCachedPrfKey();
+ if (canonicalKey) {
+ const wrapped = await wrapCanonicalKey(prfKey, canonicalKey);
+ const updated = { ...(user.encryptedKeys || {}), [passkeyMethodId(credentialId)]: wrapped };
+ await saveEncryptedKeys(updated);
+ user.encryptedKeys = updated;
+ } else {
+ rememberPrfKey(prfKey, defaultRememberForMs);
+ }
success("Passkey added");
} catch (e: any) {
if (e.name !== "NotAllowedError") {
diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts
index eb0bf079..68143d83 100644
--- a/src/routes/(static)/login/+page.server.ts
+++ b/src/routes/(static)/login/+page.server.ts
@@ -26,18 +26,21 @@ export const actions = {
if (loginRedirect === "undefined") loginRedirect = undefined;
try {
- await login(
+ const u = await login(
user,
cookies,
request.headers.get("cf-connecting-ip") ?? "",
request.headers.get("host") ?? "",
);
+
+ return {
+ encryptedKeys: u?.encryptedKeys || null,
+ redirectUrl: loginRedirect || `/${user.username}`,
+ };
} catch (e) {
const { message } = e as Error;
return fail(400, { error: "Login failed", message, ...form });
}
-
- redirect(307, loginRedirect || `/${user.username}`);
},
passwordAuth: async ({ cookies, fetch, request }) => {
@@ -88,7 +91,10 @@ export const actions = {
cookies.set("username", uname, opts);
cookies.set("token", token, opts);
- redirect(307, loginRedirect || `/${uname}`);
+ return {
+ encryptedKeys: user.encryptedKeys || null,
+ redirectUrl: loginRedirect || `/${uname}`,
+ };
},
passkey: async ({ cookies, fetch, request }) => {
@@ -134,7 +140,10 @@ export const actions = {
cookies.set("username", username, opts);
cookies.set("token", token, opts);
- redirect(307, loginRedirect || `/${username}`);
+ return {
+ encryptedKeys: user.encryptedKeys || null,
+ redirectUrl: loginRedirect || `/${username}`,
+ };
},
nostr: async ({ cookies, fetch, request }) => {
@@ -175,6 +184,9 @@ export const actions = {
cookies.set("username", username, opts);
cookies.set("token", token, opts);
- redirect(307, loginRedirect || `/${username}`);
+ return {
+ encryptedKeys: user.encryptedKeys || null,
+ redirectUrl: loginRedirect || `/${username}`,
+ };
},
};
diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte
index 6d7a6e19..96a03ef5 100644
--- a/src/routes/(static)/login/+page.svelte
+++ b/src/routes/(static)/login/+page.svelte
@@ -11,15 +11,17 @@
import Spinner from "$comp/Spinner.svelte";
import { focus, fail } from "$lib/utils";
import { loginWithPasskey } from "$lib/passkey";
- import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
import { password, signer, pin, loginRedirect } from "$lib/store";
+ import {
+ resolveCanonicalKey,
+ passwordMethodId,
+ passkeyMethodId,
+ nostrMethodId,
+ } from "$lib/keyWrapping";
import { t } from "$lib/translations";
import { page } from "$app/stores";
import { sign } from "$lib/nostr";
- import { invalidateAll } from "$app/navigation";
- import { sha256 } from "@noble/hashes/sha2.js";
- import { bytesToHex } from "@noble/hashes/utils.js";
-
+ import { goto, invalidateAll } from "$app/navigation";
let { data, form }: any = $props();
onMount(() => {
@@ -134,18 +136,25 @@
body: fd,
});
- const result = deserialize(await response.text());
+ const result: any = deserialize(await response.text());
- if (result.type === "success" || result.type === "redirect") {
+ if (result.type === "success") {
try {
- await deriveWalletEntropy(sk);
+ const oldPrfKey = await deriveWalletEntropy(sk);
+ await resolveCanonicalKey(
+ passwordMethodId(),
+ sk.buffer as ArrayBuffer,
+ result.data?.encryptedKeys,
+ oldPrfKey,
+ );
} catch (e) {
- console.log("Wallet entropy derivation failed", e);
+ console.log("Canonical key resolution failed", e);
}
await invalidateAll();
+ goto(result.data?.redirectUrl || `/${uname}`);
+ } else {
+ applyAction(result);
}
-
- applyAction(result);
return;
}
@@ -153,24 +162,33 @@
formData.set("authPubkey", pubkey);
return async ({ result }: any) => {
- if (result.type === "success" || result.type === "redirect") {
+ if (result.type === "success") {
try {
- await deriveWalletEntropy(sk);
+ const oldPrfKey = await deriveWalletEntropy(sk);
+ await resolveCanonicalKey(
+ passwordMethodId(),
+ sk.buffer as ArrayBuffer,
+ result.data?.encryptedKeys,
+ oldPrfKey,
+ );
} catch (e) {
- console.log("Wallet entropy derivation failed", e);
+ console.log("Canonical key resolution failed", e);
}
await invalidateAll();
+ goto(result.data?.redirectUrl || `/${uname}`);
+ } else {
+ applyAction(result);
}
- applyAction(result);
};
} catch (e: any) {
console.log("Auth key derivation failed, falling back to legacy login", e);
- // Fallback: plain password login without migration
return async ({ result }: any) => {
if (result.type === "success") {
await invalidateAll();
+ goto(result.data?.redirectUrl || "/");
+ } else {
+ applyAction(result);
}
- applyAction(result);
};
}
};
@@ -201,14 +219,24 @@
body: formData,
});
- const result = deserialize(await response.text());
+ const result: any = deserialize(await response.text());
- if (result.type === "success" || result.type === "redirect") {
- rememberPrfKey(prfKey, defaultRememberForMs);
+ if (result.type === "success") {
+ try {
+ await resolveCanonicalKey(
+ passkeyMethodId(credential.id),
+ prfKey,
+ result.data?.encryptedKeys,
+ prfKey,
+ );
+ } catch (e) {
+ console.log("Canonical key resolution failed", e);
+ }
await invalidateAll();
+ goto(result.data?.redirectUrl || "/");
+ } else {
+ applyAction(result);
}
-
- applyAction(result);
} catch (e: any) {
if (e.name === "NotAllowedError") {
// user cancelled the browser prompt
@@ -248,19 +276,26 @@
body: formData,
});
- const result = deserialize(await response.text());
+ const result: any = deserialize(await response.text());
- if (result.type === "success" || result.type === "redirect") {
+ if (result.type === "success") {
try {
const { deriveNostrEntropy } = await import("$lib/walletEntropy");
- await deriveNostrEntropy();
+ const oldPrfKey = await deriveNostrEntropy();
+ await resolveCanonicalKey(
+ nostrMethodId(signedEvent.pubkey),
+ oldPrfKey,
+ result.data?.encryptedKeys,
+ oldPrfKey,
+ );
} catch (e) {
- console.log("Nostr entropy derivation failed", e);
+ console.log("Nostr canonical key resolution failed", e);
}
await invalidateAll();
+ goto(result.data?.redirectUrl || "/");
+ } else {
+ applyAction(result);
}
-
- applyAction(result);
} catch (e: any) {
fail(e.message);
}
diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts
index 55331c5a..e56f4e14 100644
--- a/src/routes/(static)/register/+page.server.ts
+++ b/src/routes/(static)/register/+page.server.ts
@@ -1,113 +1,10 @@
-import { fd, get, login, post } from "$lib/utils";
-import { fail, redirect } from "@sveltejs/kit";
+import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const { user } = await parent();
if (user) redirect(307, `/${user.username}`);
const index = Math.floor(Math.random() * 64) + 1;
- const { challenge } = await get("/challenge");
- return { index, challenge };
-};
-
-export const actions = {
- register: async ({ cookies, request }) => {
- 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, authPubkey } = form;
- let { loginRedirect } = form;
- if (loginRedirect === "undefined") loginRedirect = undefined;
-
- const user = {
- picture,
- username,
- password,
- challenge,
- recaptcha,
- authPubkey: authPubkey || undefined,
- };
-
- const headers: Record<string, string> = {};
- if (ip) headers["cf-connecting-ip"] = ip;
-
- try {
- const { sk, token } = await post("/register", { user }, headers);
-
- // Log the user in and set cookies
- await login(user, cookies, ip, host);
-
- // Set sk cookie if server generated one
- if (sk) {
- const skExpires = new Date();
- skExpires.setSeconds(skExpires.getSeconds() + 21000000);
- cookies.set("sk", sk, {
- path: "/",
- expires: skExpires,
- httpOnly: false,
- sameSite: "lax",
- });
- }
- } catch (e) {
- const { message } = e as Error;
- return fail(400, { error: message });
- }
-
- redirect(303, loginRedirect || `/${username}`);
- },
-
- passkeyCreate: async ({ request }) => {
- const ip = request.headers.get("cf-connecting-ip") ?? "";
- const form = await fd(request);
- const { username, password, challenge, recaptcha, picture } = form;
-
- const user = { picture, username, password, challenge, recaptcha };
- const headers: Record<string, string> = {};
- if (ip) headers["cf-connecting-ip"] = ip;
-
- try {
- const { sk, token } = await post("/register", { user }, headers);
- return { token, username, sk };
- } catch (e) {
- const { message } = e as Error;
- return fail(400, { error: message });
- }
- },
-
- activate: async ({ cookies, request }) => {
- const form = await fd(request);
- const { token, username, sk, pubkey } = form;
- let { loginRedirect } = form;
- if (loginRedirect === "undefined") loginRedirect = undefined;
-
- const maxAge = 380 * 24 * 60 * 60;
- const expires = new Date();
- expires.setSeconds(expires.getSeconds() + maxAge);
- const opts = { path: "/", expires };
-
- cookies.set("username", username, opts);
- cookies.set("token", token, opts);
-
- if (sk) {
- const skExpires = new Date();
- skExpires.setSeconds(skExpires.getSeconds() + 21000000);
- cookies.set("sk", sk, {
- path: "/",
- expires: skExpires,
- httpOnly: false,
- sameSite: "lax",
- });
- }
-
- if (pubkey) {
- try {
- await post("/user", { pubkey }, { authorization: `Bearer ${token}` });
- } catch (e) {
- console.log("Failed to update pubkey:", e);
- }
- }
-
- redirect(303, loginRedirect || `/${username}`);
- },
+ return { index };
};
diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte
index e2d53fd0..0d32cf21 100644
--- a/src/routes/(static)/register/+page.svelte
+++ b/src/routes/(static)/register/+page.svelte
@@ -1,57 +1,18 @@
<script lang="ts">
- import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
- import { onDestroy, onMount } from "svelte";
- import { browser } from "$app/environment";
+ import { goto } from "$app/navigation";
+ import { page } from "$app/stores";
import punks from "$lib/punks";
- import { upload } from "$lib/upload";
- import { afterNavigate, invalidateAll } from "$app/navigation";
- import { applyAction, deserialize } from "$app/forms";
- import { tick } from "svelte";
- import { fly } from "svelte/transition";
- import { enhance } from "$app/forms";
-
- import Pin from "$comp/Pin.svelte";
- import PasswordInput from "$comp/PasswordInput.svelte";
- import Spinner from "$comp/Spinner.svelte";
-
- import { focus, fail, versions, post } from "$lib/utils";
- import { registerPasskey } from "$lib/passkey";
- import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
- import { avatar, signer, password, pin, loginRedirect } from "$lib/store";
+ import { avatar } from "$lib/store";
+ import { focus } from "$lib/utils";
import { t } from "$lib/translations";
- import { page } from "$app/stores";
import { NumberDictionary, uniqueNamesGenerator, animals } from "unique-names-generator";
- import { sign } from "$lib/nostr";
-
- let { form, data }: any = $props();
- let { challenge } = $derived(data);
- let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
- let isTor = browser && location.hostname.endsWith(".onion");
- onMount(() => {
- if (!isTor && recaptchaSiteKey) {
- let s = document.createElement("script");
- s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
- document.head.appendChild(s);
- }
- });
-
- onMount(() => {
- if (browser) {
- setTimeout(() => {
- password.set(undefined);
- pin.set(undefined);
- signer.set(undefined);
- localStorage.clear();
- sessionStorage.clear();
- }, 50);
- }
- });
+ let { data }: any = $props();
let username: string | undefined = $state();
let index = $state(data.index);
- let cleared;
+ let cleared: boolean;
let clear = () => {
if (!cleared) {
cleared = true;
@@ -70,197 +31,18 @@
});
};
- afterNavigate(async () => {
- try {
- await invalidateAll();
- } catch (e) {
- console.log(e);
- }
- });
-
- let token: string = $state(""),
- formElement: any;
- let redirect: any;
-
- let cancel: any = () => {
- need2fa = false;
- };
-
- let btn: HTMLButtonElement = $state() as any;
-
- let loading = $state(false);
- let passkeyLoading = $state(false);
-
- const getRecaptchaToken = () =>
- new Promise((resolve, reject) => {
- if (isTor) return resolve("");
- if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
- grecaptcha.ready(() => {
- grecaptcha.execute(recaptchaSiteKey, { action: "register" }).then(resolve).catch(reject);
- });
- });
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault();
-
- loading = true;
-
- let data = new FormData(e.target as HTMLFormElement);
- let user: Record<string, any> = Object.fromEntries(data);
- user.username = (user.username as string).replace(/\s*/g, "");
-
- if (!user.password) {
- fail("Password is required");
- loading = false;
- return;
- }
-
- for (let k in user) {
- data.set(k, user[k]);
- }
-
- try {
- const recaptcha = await getRecaptchaToken();
- data.set("recaptcha", recaptcha as string);
- } catch (err: any) {
- fail(err.message || "captcha failed");
- loading = false;
- return;
- }
-
- data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
- if ($avatar) {
- try {
- let { hash } = JSON.parse(
- (await upload(
- ($avatar as any).file,
- ($avatar as any).type,
- ($avatar as any).progress,
- )) as string,
- );
-
- let url = `${$page.url.origin}/api/public/${hash}.webp`;
- data.set("picture", url);
- await fetch(url, {
- cache: "reload",
- mode: "no-cors",
- });
- } catch (e) {
- console.log("problem uploading avatar", e);
- }
- }
-
- // Derive authPubkey from username + password
- try {
- const { deriveAuthKeypair } = await import("$lib/deriveAuthKey");
- const { pubkey: authPubkey } = await deriveAuthKeypair(user.username, user.password);
- data.set("authPubkey", authPubkey);
- } catch (e) {
- console.log("Failed to derive authPubkey during registration", e);
- }
-
- const response = await fetch("/register?/register", {
- method: "POST",
- body: data,
- });
-
- const result = deserialize(await response.text());
-
- if (result.type === "success") {
- // Derive wallet entropy from password
- try {
- const { deriveAuthKeypair, deriveWalletEntropy } = await import("$lib/deriveAuthKey");
- const { sk } = await deriveAuthKeypair(user.username, user.password);
- await deriveWalletEntropy(sk);
- } catch (e) {
- console.log("Wallet entropy derivation failed", e);
- }
-
- await invalidateAll();
- }
-
- applyAction(result);
- loading = false;
- }
-
- let passkeyRegister = async () => {
- passkeyLoading = true;
- try {
- let name =
- username ||
- uniqueNamesGenerator({
- dictionaries: [animals, NumberDictionary.generate({ min: 10, max: 99 })],
- length: 2,
- separator: "",
- });
-
- let pw = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
- b.toString(16).padStart(2, "0"),
- ).join("");
-
- const recaptcha = await getRecaptchaToken();
-
- const formData = new FormData();
- formData.append("username", name);
- formData.append("password", pw);
- formData.append("challenge", challenge);
- formData.append("recaptcha", recaptcha as string);
- formData.append("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
-
- const response = await fetch("/register?/passkeyCreate", {
- method: "POST",
- body: formData,
- });
-
- const result: any = deserialize(await response.text());
- if (result.type !== "success") {
- fail(result.data?.error || "Account creation failed");
- passkeyLoading = false;
- return;
- }
-
- const { token, sk } = result.data;
- const prfKey = await registerPasskey(token);
-
- const activateData = new FormData();
- activateData.append("token", token);
- activateData.append("username", name);
- if (sk) activateData.append("sk", sk);
- activateData.append("loginRedirect", (redirect ?? "") as string);
-
- const activateResponse = await fetch("/register?/activate", {
- method: "POST",
- body: activateData,
- });
-
- const activateResult = deserialize(await activateResponse.text());
-
- if (activateResult.type === "success" || activateResult.type === "redirect") {
- rememberPrfKey(prfKey, defaultRememberForMs);
- await invalidateAll();
- }
-
- applyAction(activateResult);
- } catch (e: any) {
- if (e.name !== "NotAllowedError") {
- fail(e.message || "Passkey registration failed");
- }
- }
- passkeyLoading = false;
- };
-
let avatarInput: HTMLInputElement = $state() as any;
let decr = () => (index = index <= 0 ? 63 : index - 1);
let incr = () => (index = index >= 63 ? 0 : index + 1);
let selectAvatar = () => avatarInput.click();
- let progress;
+ let progress: any;
let handleFile = async ({ target }: { target: HTMLInputElement }) => {
let type = "picture";
let file = target.files![0];
if (!file) return;
- if (file.size > 10000000) form!.error = "File too large";
+ if (file.size > 10000000) return;
$avatar = { file, type, progress };
var reader = new FileReader();
@@ -271,87 +53,18 @@
reader.readAsDataURL(file);
};
- let need2fa: any = $derived(form?.message === "2fa");
let src: string = $derived(`/api/public/${punks[index]}.webp`);
- $effect(() => {
- if (need2fa && form?.token === token) token = "";
- });
-
- $effect(() => {
- token && token.length === 6 && tick().then(() => btn.click());
- });
-
- let nostrLogin = async () => {
- let event = {
- kind: 27235,
- created_at: Date.now(),
- content: "",
- tags: [
- ["u", `${$page.url.origin}/api/nostrAuth`],
- ["method", "POST"],
- ["challenge", challenge],
- ],
- };
-
- let signedEvent = await sign(event);
-
- const formData = new FormData();
-
- try {
- const recaptcha = await getRecaptchaToken();
- formData.append("loginRedirect", redirect ?? "");
- formData.append("token", token ?? "");
- formData.append("event", JSON.stringify(signedEvent));
- formData.append("challenge", challenge);
- formData.append("recaptcha", recaptcha as string);
-
- let response = await fetch("/login?/nostr", {
- method: "POST",
- body: formData,
- });
-
- const result = deserialize(await response.text());
-
- if (result.type === "success" || result.type === "redirect") {
- try {
- const { deriveNostrEntropy } = await import("$lib/walletEntropy");
- await deriveNostrEntropy();
- } catch (e) {
- console.log("Nostr entropy derivation failed", e);
- }
- await invalidateAll();
- }
-
- applyAction(result);
- } catch (e: any) {
- fail(e.message);
- }
+ let continueToAuth = () => {
+ let name = (username || "").replace(/\s*/g, "");
+ if (!name) return;
+ goto(`/register/auth?username=${encodeURIComponent(name)}&index=${index}`);
};
-
- onDestroy(() => {
- if (!browser) return;
- const nodeBadge = document.querySelector(".grecaptcha-badge");
- if (nodeBadge) {
- document.body.removeChild(nodeBadge.parentNode!);
- }
-
- const scriptSelector =
- "script[src='https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey + "']";
- const script = document.querySelector(scriptSelector);
- if (script) {
- script.remove();
- }
- });
</script>
-{#if need2fa}
- <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} />
-{/if}
-
<div class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 space-y-5 mb-20">
<h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1>
- <input type="file" class="hidden!" bind:this={avatarInput} onchange={(e: any) => handleFile(e)} />
+ <input type="file" accept="image/*" class="hidden!" bind:this={avatarInput} onchange={(e: any) => handleFile(e)} />
<div class="relative">
<button
@@ -371,8 +84,9 @@
alt={username}
/>
</div>
- <div class="absolute bg-base-100 rounded-full p-2 mx-auto right-0 bottom-0 z-10 w-12">
- <iconify-icon noobserver icon="ph:upload-simple-bold" width="24"></iconify-icon>
+ <div class="absolute bg-base-100 rounded-full px-3 py-1 mx-auto -right-6 -bottom-2 z-10 flex items-center gap-1 text-base">
+ <iconify-icon noobserver icon="ph:camera-bold" width="24"></iconify-icon>
+ Upload
</div>
</button>
<button
@@ -384,22 +98,10 @@
</button>
</div>
- {#if form?.error || form?.message}
- <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">
+ <div class="space-y-3">
+ <label for="username" class="input flex items-center gap-2 w-full pr-0! overflow-hidden">
<input
- class="clean"
+ class="clean min-w-0 shrink"
use:focus
name="username"
type="text"
@@ -408,40 +110,18 @@
onfocus={clear}
autocapitalize="none"
placeholder={$t("login.username")}
+ onkeydown={(e: KeyboardEvent) => e.key === "Enter" && continueToAuth()}
/>
- <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>
+ <span class="bg-base-300 whitespace-nowrap select-none self-stretch flex items-center px-3">@coinos.io</span>
</label>
- <PasswordInput bind:value={$password} placeholder={$t("login.password")} />
-
- <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}>
- {#if loading}
- <Spinner />
- {:else}
- {$t("login.register")}
- {/if}
- </button>
-
- <button type="button" class="btn" onclick={passkeyRegister} disabled={passkeyLoading}>
- {#if passkeyLoading}
- <Spinner />
- {:else}
- <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon>
- {/if}
- <div class="my-auto">Register with Passkey</div>
+ <button type="button" class="btn gap-2" onclick={refresh}>
+ <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon>
+ Random username
</button>
- <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 class="btn btn-accent" onclick={continueToAuth}>
+ Continue
</button>
<p class="text-secondary text-center font-medium">
@@ -453,5 +133,5 @@
{$t("login.signIn")}
</a>
</p>
- </form>
+ </div>
</div>
diff --git a/src/routes/(static)/register/auth/+page.server.ts b/src/routes/(static)/register/auth/+page.server.ts
new file mode 100644
index 00000000..06735396
--- /dev/null
+++ b/src/routes/(static)/register/auth/+page.server.ts
@@ -0,0 +1,115 @@
+import { fd, get, login, post } from "$lib/utils";
+import { fail, redirect } from "@sveltejs/kit";
+
+export const load = async ({ parent, url }) => {
+ const { user } = await parent();
+ if (user) redirect(307, `/${user.username}`);
+
+ const username = url.searchParams.get("username");
+ if (!username) redirect(307, "/register");
+
+ const { challenge } = await get("/challenge");
+
+ return { username, challenge };
+};
+
+export const actions = {
+ register: async ({ cookies, request }) => {
+ 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, authPubkey } = form;
+ let { loginRedirect } = form;
+ if (loginRedirect === "undefined") loginRedirect = undefined;
+
+ const user = {
+ picture,
+ username,
+ password,
+ challenge,
+ recaptcha,
+ authPubkey: authPubkey || undefined,
+ };
+
+ const headers: Record<string, string> = {};
+ if (ip) headers["cf-connecting-ip"] = ip;
+
+ try {
+ const { sk, token } = await post("/register", { user }, headers);
+
+ // Log the user in and set cookies
+ await login(user, cookies, ip, host);
+
+ // Set sk cookie if server generated one
+ if (sk) {
+ const skExpires = new Date();
+ skExpires.setSeconds(skExpires.getSeconds() + 21000000);
+ cookies.set("sk", sk, {
+ path: "/",
+ expires: skExpires,
+ httpOnly: false,
+ sameSite: "lax",
+ });
+ }
+ } catch (e) {
+ const { message } = e as Error;
+ return fail(400, { error: message });
+ }
+
+ redirect(303, loginRedirect || `/${username}`);
+ },
+
+ passkeyCreate: async ({ request }) => {
+ const ip = request.headers.get("cf-connecting-ip") ?? "";
+ const form = await fd(request);
+ const { username, password, challenge, recaptcha, picture } = form;
+
+ const user = { picture, username, password, challenge, recaptcha };
+ const headers: Record<string, string> = {};
+ if (ip) headers["cf-connecting-ip"] = ip;
+
+ try {
+ const { sk, token } = await post("/register", { user }, headers);
+ return { token, username, sk };
+ } catch (e) {
+ const { message } = e as Error;
+ return fail(400, { error: message });
+ }
+ },
+
+ activate: async ({ cookies, request }) => {
+ const form = await fd(request);
+ const { token, username, sk, pubkey } = form;
+ let { loginRedirect } = form;
+ if (loginRedirect === "undefined") loginRedirect = undefined;
+
+ const maxAge = 380 * 24 * 60 * 60;
+ const expires = new Date();
+ expires.setSeconds(expires.getSeconds() + maxAge);
+ const opts = { path: "/", expires };
+
+ cookies.set("username", username, opts);
+ cookies.set("token", token, opts);
+
+ if (sk) {
+ const skExpires = new Date();
+ skExpires.setSeconds(skExpires.getSeconds() + 21000000);
+ cookies.set("sk", sk, {
+ path: "/",
+ expires: skExpires,
+ httpOnly: false,
+ sameSite: "lax",
+ });
+ }
+
+ if (pubkey) {
+ try {
+ await post("/user", { pubkey }, { authorization: `Bearer ${token}` });
+ } catch (e) {
+ console.log("Failed to update pubkey:", e);
+ }
+ }
+
+ redirect(303, loginRedirect || `/${username}`);
+ },
+};
diff --git a/src/routes/(static)/register/auth/+page.svelte b/src/routes/(static)/register/auth/+page.svelte
new file mode 100644
index 00000000..d285e0af
--- /dev/null
+++ b/src/routes/(static)/register/auth/+page.svelte
@@ -0,0 +1,393 @@
+<script lang="ts">
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
+ import { onDestroy, onMount } from "svelte";
+ import { browser } from "$app/environment";
+ import punks from "$lib/punks";
+ import { upload } from "$lib/upload";
+ import { invalidateAll } from "$app/navigation";
+ import { applyAction, deserialize } from "$app/forms";
+ import { tick } from "svelte";
+ import { fly } from "svelte/transition";
+
+ import Pin from "$comp/Pin.svelte";
+ import PasswordInput from "$comp/PasswordInput.svelte";
+ import Spinner from "$comp/Spinner.svelte";
+
+ import { fail, focus } from "$lib/utils";
+ import { registerPasskey } from "$lib/passkey";
+ import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
+ import { avatar, signer, password, pin, loginRedirect } from "$lib/store";
+ import {
+ generateCanonicalKey,
+ wrapCanonicalKey,
+ saveEncryptedKeys,
+ resolveCanonicalKey,
+ passwordMethodId,
+ passkeyMethodId,
+ nostrMethodId,
+ } from "$lib/keyWrapping";
+ import { t } from "$lib/translations";
+ import { page } from "$app/stores";
+ import { sign } from "$lib/nostr";
+
+ let { form, data }: any = $props();
+ let { challenge, username } = $derived(data);
+ let index = $derived(Number($page.url.searchParams.get("index") || "1"));
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
+
+ onMount(() => {
+ if (browser) {
+ setTimeout(() => {
+ password.set(undefined);
+ pin.set(undefined);
+ signer.set(undefined);
+ localStorage.clear();
+ sessionStorage.clear();
+ }, 50);
+ }
+ });
+
+ let token: string = $state(""),
+ formElement: any;
+ let redirect: any = $derived($loginRedirect || $page.url.searchParams.get("redirect"));
+
+ let cancel: any = () => {
+ need2fa = false;
+ };
+
+ let btn: HTMLButtonElement = $state() as any;
+
+ let loading = $state(false);
+ let passkeyLoading = $state(false);
+
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha.execute(recaptchaSiteKey, { action: "register" }).then(resolve).catch(reject);
+ });
+ });
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault();
+
+ loading = true;
+
+ let data = new FormData(e.target as HTMLFormElement);
+ let user: Record<string, any> = Object.fromEntries(data);
+ user.username = username.replace(/\s*/g, "");
+
+ if (!user.password) {
+ fail("Password is required");
+ loading = false;
+ return;
+ }
+
+ for (let k in user) {
+ data.set(k, user[k]);
+ }
+
+ try {
+ const recaptcha = await getRecaptchaToken();
+ data.set("recaptcha", recaptcha as string);
+ } catch (err: any) {
+ fail(err.message || "captcha failed");
+ loading = false;
+ return;
+ }
+
+ data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
+ if ($avatar) {
+ try {
+ let { hash } = JSON.parse(
+ (await upload(
+ ($avatar as any).file,
+ ($avatar as any).type,
+ ($avatar as any).progress,
+ )) as string,
+ );
+
+ let url = `${$page.url.origin}/api/public/${hash}.webp`;
+ data.set("picture", url);
+ await fetch(url, {
+ cache: "reload",
+ mode: "no-cors",
+ });
+ } catch (e) {
+ console.log("problem uploading avatar", e);
+ }
+ }
+
+ // Derive authPubkey from username + password
+ try {
+ const { deriveAuthKeypair } = await import("$lib/deriveAuthKey");
+ const { pubkey: authPubkey } = await deriveAuthKeypair(user.username, user.password);
+ data.set("authPubkey", authPubkey);
+ } catch (e) {
+ console.log("Failed to derive authPubkey during registration", e);
+ }
+
+ const response = await fetch("/register/auth?/register", {
+ method: "POST",
+ body: data,
+ });
+
+ const result = deserialize(await response.text());
+
+ if (result.type === "success") {
+ try {
+ const { deriveAuthKeypair } = await import("$lib/deriveAuthKey");
+ const { sk } = await deriveAuthKeypair(user.username, user.password);
+ const canonicalKey = generateCanonicalKey();
+ const wrapped = await wrapCanonicalKey(sk.buffer as ArrayBuffer, canonicalKey);
+ await saveEncryptedKeys({ [passwordMethodId()]: wrapped });
+ rememberPrfKey(canonicalKey, defaultRememberForMs);
+ } catch (e) {
+ console.log("Canonical key generation failed", e);
+ }
+
+ await invalidateAll();
+ }
+
+ applyAction(result);
+ loading = false;
+ }
+
+ let passkeyRegister = async () => {
+ passkeyLoading = true;
+ try {
+ let pw = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
+ b.toString(16).padStart(2, "0"),
+ ).join("");
+
+ const recaptcha = await getRecaptchaToken();
+
+ const formData = new FormData();
+ formData.append("username", username);
+ formData.append("password", pw);
+ formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha as string);
+ formData.append("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
+
+ const response = await fetch("/register/auth?/passkeyCreate", {
+ method: "POST",
+ body: formData,
+ });
+
+ const result: any = deserialize(await response.text());
+ if (result.type !== "success") {
+ fail(result.data?.error || "Account creation failed");
+ passkeyLoading = false;
+ return;
+ }
+
+ const { token, sk } = result.data;
+ const { prfKey, credentialId } = await registerPasskey(token);
+
+ const activateData = new FormData();
+ activateData.append("token", token);
+ activateData.append("username", username);
+ if (sk) activateData.append("sk", sk);
+ activateData.append("loginRedirect", (redirect ?? "") as string);
+
+ const activateResponse = await fetch("/register/auth?/activate", {
+ method: "POST",
+ body: activateData,
+ });
+
+ const activateResult = deserialize(await activateResponse.text());
+
+ if (activateResult.type === "success" || activateResult.type === "redirect") {
+ try {
+ const canonicalKey = generateCanonicalKey();
+ const wrapped = await wrapCanonicalKey(prfKey, canonicalKey);
+ await saveEncryptedKeys({ [passkeyMethodId(credentialId)]: wrapped });
+ rememberPrfKey(canonicalKey, defaultRememberForMs);
+ } catch (e) {
+ console.log("Canonical key generation failed", e);
+ }
+ await invalidateAll();
+ }
+
+ applyAction(activateResult);
+ } catch (e: any) {
+ if (e.name !== "NotAllowedError") {
+ fail(e.message || "Passkey registration failed");
+ }
+ }
+ passkeyLoading = false;
+ };
+
+ let need2fa: any = $derived(form?.message === "2fa");
+
+ let src: string = $derived(
+ $avatar ? URL.createObjectURL(($avatar as any).file) : `/api/public/${punks[index]}.webp`,
+ );
+
+ $effect(() => {
+ if (need2fa && form?.token === token) token = "";
+ });
+
+ $effect(() => {
+ token && token.length === 6 && tick().then(() => btn.click());
+ });
+
+ let nostrLogin = async () => {
+ let event = {
+ kind: 27235,
+ created_at: Date.now(),
+ content: "",
+ tags: [
+ ["u", `${$page.url.origin}/api/nostrAuth`],
+ ["method", "POST"],
+ ["challenge", challenge],
+ ],
+ };
+
+ let signedEvent = await sign(event);
+
+ const formData = new FormData();
+
+ try {
+ const recaptcha = await getRecaptchaToken();
+ formData.append("loginRedirect", redirect ?? "");
+ formData.append("token", token ?? "");
+ formData.append("event", JSON.stringify(signedEvent));
+ formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha as string);
+
+ let response = await fetch("/login?/nostr", {
+ method: "POST",
+ body: formData,
+ });
+
+ const result: any = deserialize(await response.text());
+
+ if (result.type === "success" || result.type === "redirect") {
+ try {
+ const { deriveNostrEntropy } = await import("$lib/walletEntropy");
+ const oldPrfKey = await deriveNostrEntropy();
+ await resolveCanonicalKey(
+ nostrMethodId(signedEvent.pubkey),
+ oldPrfKey,
+ result.data?.encryptedKeys,
+ oldPrfKey,
+ );
+ } catch (e) {
+ console.log("Nostr canonical key resolution failed", e);
+ }
+ await invalidateAll();
+ }
+
+ applyAction(result);
+ } catch (e: any) {
+ fail(e.message);
+ }
+ };
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode!);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey + "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
+</script>
+
+{#if need2fa}
+ <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} />
+{/if}
+
+<div class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 space-y-5 mb-20">
+ <h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1>
+
+ <div class="flex flex-col items-center gap-2">
+ <div class="w-24 h-24 rounded-full overflow-hidden">
+ <img {src} class="w-full h-full object-cover" alt={username} />
+ </div>
+ <span class="text-xl font-medium">{username}</span>
+ </div>
+
+ {#if form?.error || form?.message}
+ <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={redirect}
+ />
+ <input type="hidden" name="token" value={token} />
+ <input type="hidden" name="challenge" value={challenge} />
+ <input type="hidden" name="username" value={username} />
+
+ <PasswordInput bind:value={$password} placeholder={$t("login.password")} {focus} />
+
+ <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}>
+ {#if loading}
+ <Spinner />
+ {:else}
+ {$t("login.register")}
+ {/if}
+ </button>
+
+ <div class="flex items-center gap-3">
+ <div class="h-px bg-base-300 flex-1"></div>
+ <span class="text-secondary text-lg">or</span>
+ <div class="h-px bg-base-300 flex-1"></div>
+ </div>
+
+ <button type="button" class="btn" onclick={passkeyRegister} disabled={passkeyLoading}>
+ {#if passkeyLoading}
+ <Spinner />
+ {:else}
+ <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon>
+ {/if}
+ <div class="my-auto">Register with Passkey</div>
+ </button>
+
+ <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">Register with 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>
+
+ <a href="/register" class="btn btn-ghost gap-2">
+ <iconify-icon noobserver icon="ph:arrow-left-bold" width="20"></iconify-icon>
+ Back
+ </a>
+ </form>
+</div>