mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-08 21:30:08 +00:00
Fixed up did:web account creation
This commit is contained in:
10
frontend/deno.lock
generated
10
frontend/deno.lock
generated
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
|
||||
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
|
||||
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
|
||||
"npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1",
|
||||
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
|
||||
"npm:jsdom@^25.0.1": "25.0.1",
|
||||
"npm:multiformats@^13.3.1": "13.4.2",
|
||||
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
|
||||
"npm:svelte@5": "5.45.10_acorn@8.15.0",
|
||||
"npm:vite@*": "6.4.1_picomatch@4.0.3",
|
||||
@@ -492,6 +494,9 @@
|
||||
"@jridgewell/sourcemap-codec"
|
||||
]
|
||||
},
|
||||
"@noble/secp256k1@2.3.0": {
|
||||
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
|
||||
},
|
||||
"@rollup/rollup-android-arm-eabi@4.53.3": {
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"os": ["android"],
|
||||
@@ -1281,6 +1286,9 @@
|
||||
"ms@2.1.3": {
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"multiformats@13.4.2": {
|
||||
"integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ=="
|
||||
},
|
||||
"nanoid@3.3.11": {
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"bin": true
|
||||
@@ -1636,11 +1644,13 @@
|
||||
"workspace": {
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@noble/secp256k1@^2.1.0",
|
||||
"npm:@sveltejs/vite-plugin-svelte@5",
|
||||
"npm:@testing-library/jest-dom@^6.6.3",
|
||||
"npm:@testing-library/svelte@^5.2.6",
|
||||
"npm:@testing-library/user-event@^14.5.2",
|
||||
"npm:jsdom@^25.0.1",
|
||||
"npm:multiformats@^13.3.1",
|
||||
"npm:svelte-i18n@^4.0.1",
|
||||
"npm:svelte@5",
|
||||
"npm:vite@6",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/secp256k1": "^2.1.0",
|
||||
"multiformats": "^13.3.1",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface CreateAccountParams {
|
||||
inviteCode?: string
|
||||
didType?: DidType
|
||||
did?: string
|
||||
signingKey?: string
|
||||
verificationChannel?: VerificationChannel
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
@@ -120,22 +121,34 @@ export interface ConfirmSignupResult {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
return xrpc('com.atproto.server.createAccount', {
|
||||
async createAccount(params: CreateAccountParams, byodToken?: string): Promise<CreateAccountResult> {
|
||||
const url = `${API_BASE}/com.atproto.server.createAccount`
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (byodToken) {
|
||||
headers['Authorization'] = `Bearer ${byodToken}`
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
handle: params.handle,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
inviteCode: params.inviteCode,
|
||||
didType: params.didType,
|
||||
did: params.did,
|
||||
signingKey: params.signingKey,
|
||||
verificationChannel: params.verificationChannel,
|
||||
discordId: params.discordId,
|
||||
telegramUsername: params.telegramUsername,
|
||||
signalNumber: params.signalNumber,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new ApiError(data.error, data.message, response.status)
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
|
||||
@@ -750,6 +763,29 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
async reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
|
||||
return xrpc('com.atproto.server.reserveSigningKey', {
|
||||
method: 'POST',
|
||||
body: { did },
|
||||
})
|
||||
},
|
||||
|
||||
async getRecommendedDidCredentials(token: string): Promise<{
|
||||
rotationKeys?: string[]
|
||||
alsoKnownAs?: string[]
|
||||
verificationMethods?: { atproto?: string }
|
||||
services?: { atproto_pds?: { type: string; endpoint: string } }
|
||||
}> {
|
||||
return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token })
|
||||
},
|
||||
|
||||
async activateAccount(token: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.activateAccount', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async createPasskeyAccount(params: {
|
||||
handle: string
|
||||
email?: string
|
||||
@@ -761,16 +797,29 @@ export const api = {
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
}): Promise<{
|
||||
}, byodToken?: string): Promise<{
|
||||
did: string
|
||||
handle: string
|
||||
setupToken: string
|
||||
setupExpiresAt: string
|
||||
}> {
|
||||
return xrpc('com.tranquil.account.createPasskeyAccount', {
|
||||
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (byodToken) {
|
||||
headers['Authorization'] = `Bearer ${byodToken}`
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers,
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
|
||||
throw new ApiError(res.status, err.error, err.message)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
|
||||
|
||||
@@ -265,6 +265,18 @@ export async function resendVerification(did: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void {
|
||||
const newSession: Session = {
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
}
|
||||
state.session = newSession
|
||||
saveSession(newSession)
|
||||
addOrUpdateSavedAccount(newSession)
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (state.session) {
|
||||
try {
|
||||
|
||||
106
frontend/src/lib/crypto.ts
Normal file
106
frontend/src/lib/crypto.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as secp from '@noble/secp256k1'
|
||||
import { base58btc } from 'multiformats/bases/base58'
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01])
|
||||
|
||||
export interface Keypair {
|
||||
privateKey: Uint8Array
|
||||
publicKey: Uint8Array
|
||||
publicKeyMultibase: string
|
||||
publicKeyDidKey: string
|
||||
}
|
||||
|
||||
export async function generateKeypair(): Promise<Keypair> {
|
||||
const privateKey = secp.utils.randomPrivateKey()
|
||||
const publicKey = secp.getPublicKey(privateKey, true)
|
||||
|
||||
const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length)
|
||||
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0)
|
||||
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length)
|
||||
|
||||
const publicKeyMultibase = base58btc.encode(multicodecKey)
|
||||
const publicKeyDidKey = `did:key:${publicKeyMultibase}`
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicKeyMultibase,
|
||||
publicKeyDidKey,
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Uint8Array | string): string {
|
||||
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
}
|
||||
|
||||
export async function createServiceJwt(
|
||||
privateKey: Uint8Array,
|
||||
issuerDid: string,
|
||||
audienceDid: string,
|
||||
lxm: string
|
||||
): Promise<string> {
|
||||
const header = {
|
||||
alg: 'ES256K',
|
||||
typ: 'JWT',
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload = {
|
||||
iss: issuerDid,
|
||||
sub: issuerDid,
|
||||
aud: audienceDid,
|
||||
exp: now + 180,
|
||||
iat: now,
|
||||
lxm: lxm,
|
||||
}
|
||||
|
||||
const headerEncoded = base64UrlEncode(JSON.stringify(header))
|
||||
const payloadEncoded = base64UrlEncode(JSON.stringify(payload))
|
||||
const message = `${headerEncoded}.${payloadEncoded}`
|
||||
|
||||
const msgBytes = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes)
|
||||
const msgHash = new Uint8Array(hashBuffer)
|
||||
const signature = await secp.signAsync(msgHash, privateKey)
|
||||
const sigBytes = signature.toCompactRawBytes()
|
||||
const signatureEncoded = base64UrlEncode(sigBytes)
|
||||
|
||||
return `${message}.${signatureEncoded}`
|
||||
}
|
||||
|
||||
export function generateDidDocument(
|
||||
did: string,
|
||||
publicKeyMultibase: string,
|
||||
handle: string,
|
||||
pdsEndpoint: string
|
||||
): object {
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/did/v1',
|
||||
'https://w3id.org/security/multikey/v1',
|
||||
'https://w3id.org/security/suites/secp256k1-2019/v1',
|
||||
],
|
||||
id: did,
|
||||
alsoKnownAs: [`at://${handle}`],
|
||||
verificationMethod: [
|
||||
{
|
||||
id: `${did}#atproto`,
|
||||
type: 'Multikey',
|
||||
controller: did,
|
||||
publicKeyMultibase: publicKeyMultibase,
|
||||
},
|
||||
],
|
||||
service: [
|
||||
{
|
||||
id: '#atproto_pds',
|
||||
type: 'AtprotoPersonalDataServer',
|
||||
serviceEndpoint: pdsEndpoint,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
121
frontend/src/lib/registration/AppPasswordStep.svelte
Normal file
121
frontend/src/lib/registration/AppPasswordStep.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: RegistrationFlow
|
||||
}
|
||||
|
||||
let { flow }: Props = $props()
|
||||
|
||||
let copied = $state(false)
|
||||
let acknowledged = $state(false)
|
||||
|
||||
function copyToClipboard() {
|
||||
if (flow.account?.appPassword) {
|
||||
navigator.clipboard.writeText(flow.account.appPassword)
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-password-step">
|
||||
<div class="warning-box">
|
||||
<strong>Important: Save this app password!</strong>
|
||||
<p>
|
||||
This app password is required to sign into apps that don't support passkeys yet (like bsky.app).
|
||||
You will only see this password once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="app-password-display">
|
||||
<div class="app-password-label">
|
||||
App Password for: <strong>{flow.account?.appPasswordName}</strong>
|
||||
</div>
|
||||
<code class="app-password-code">{flow.account?.appPassword}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyToClipboard}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={acknowledged} />
|
||||
<span>I have saved my app password in a secure location</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-password-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.app-password-display {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.app-password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
166
frontend/src/lib/registration/DidDocStep.svelte
Normal file
166
frontend/src/lib/registration/DidDocStep.svelte
Normal file
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: RegistrationFlow
|
||||
type: 'initial' | 'updated'
|
||||
onConfirm: () => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
let { flow, type, onConfirm, onBack }: Props = $props()
|
||||
|
||||
let copied = $state(false)
|
||||
let confirmed = $state(false)
|
||||
|
||||
const didDocument = $derived(
|
||||
type === 'initial'
|
||||
? flow.externalDidWeb.initialDidDocument
|
||||
: flow.externalDidWeb.updatedDidDocument
|
||||
)
|
||||
|
||||
const title = $derived(
|
||||
type === 'initial'
|
||||
? 'Step 1: Upload your DID document'
|
||||
: 'Step 2: Update your DID document'
|
||||
)
|
||||
|
||||
const description = $derived(
|
||||
type === 'initial'
|
||||
? 'Copy the JSON below and save it at:'
|
||||
: 'The PDS has assigned a new signing key for your account. Update your DID document with this new key:'
|
||||
)
|
||||
|
||||
const confirmLabel = $derived(
|
||||
type === 'initial'
|
||||
? 'I have uploaded the DID document to my domain'
|
||||
: 'I have updated the DID document on my domain'
|
||||
)
|
||||
|
||||
const buttonLabel = $derived(
|
||||
type === 'initial' ? 'Continue' : 'Activate Account'
|
||||
)
|
||||
|
||||
function copyToClipboard() {
|
||||
if (didDocument) {
|
||||
navigator.clipboard.writeText(didDocument)
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!confirmed) {
|
||||
flow.setError(`Please confirm you have ${type === 'initial' ? 'uploaded' : 'updated'} the DID document`)
|
||||
return
|
||||
}
|
||||
onConfirm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="did-doc-step">
|
||||
<div class="warning-box">
|
||||
<strong>{title}</strong>
|
||||
<p>{description}</p>
|
||||
<code class="did-url">https://{flow.extractDomain(flow.info.externalDid || '')}/.well-known/did.json</code>
|
||||
</div>
|
||||
|
||||
<div class="did-doc-display">
|
||||
<pre class="did-doc-code">{didDocument}</pre>
|
||||
<button type="button" class="copy-btn" onclick={copyToClipboard}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={confirmed} />
|
||||
<span>{confirmLabel}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onclick={handleConfirm} disabled={flow.state.submitting || !confirmed}>
|
||||
{flow.state.submitting ? (type === 'initial' ? 'Creating account...' : 'Activating...') : buttonLabel}
|
||||
</button>
|
||||
|
||||
{#if onBack}
|
||||
<button type="button" class="secondary" onclick={onBack} disabled={flow.state.submitting}>
|
||||
Back
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.did-doc-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.did-url {
|
||||
display: block;
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.did-doc-display {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.did-doc-code {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-input);
|
||||
font-size: var(--text-xs);
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
117
frontend/src/lib/registration/KeyChoiceStep.svelte
Normal file
117
frontend/src/lib/registration/KeyChoiceStep.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: RegistrationFlow
|
||||
}
|
||||
|
||||
let { flow }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="key-choice-step">
|
||||
<div class="info-box">
|
||||
<strong>External did:web Setup</strong>
|
||||
<p>
|
||||
To use your own domain ({flow.extractDomain(flow.info.externalDid || '')}) as your identity,
|
||||
you'll need to host a DID document. Choose how you'd like to set up the signing key:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="key-choice-options">
|
||||
<button
|
||||
class="key-choice-btn"
|
||||
onclick={() => flow.selectKeyMode('reserved')}
|
||||
disabled={flow.state.submitting}
|
||||
>
|
||||
<span class="key-choice-title">Let the PDS generate a key</span>
|
||||
<span class="key-choice-desc">Simpler setup - we'll provide the public key for your DID document</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="key-choice-btn"
|
||||
onclick={() => flow.selectKeyMode('byod')}
|
||||
disabled={flow.state.submitting}
|
||||
>
|
||||
<span class="key-choice-title">I'll provide my own key</span>
|
||||
<span class="key-choice-desc">Advanced - generate a key in your browser for initial authentication</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if flow.state.submitting}
|
||||
<p class="loading">Generating key...</p>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="secondary" onclick={() => flow.goBack()} disabled={flow.state.submitting}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.key-choice-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.key-choice-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.key-choice-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.key-choice-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.key-choice-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.key-choice-title {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.key-choice-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/lib/registration/VerificationStep.svelte
Normal file
103
frontend/src/lib/registration/VerificationStep.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '../api'
|
||||
import type { RegistrationFlow } from './flow.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: RegistrationFlow
|
||||
}
|
||||
|
||||
let { flow }: Props = $props()
|
||||
|
||||
let verificationCode = $state('')
|
||||
let resending = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return 'email'
|
||||
case 'discord': return 'Discord'
|
||||
case 'telegram': return 'Telegram'
|
||||
case 'signal': return 'Signal'
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!verificationCode.trim()) return
|
||||
resendMessage = null
|
||||
await flow.verifyAccount(verificationCode)
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (resending || !flow.account) return
|
||||
resending = true
|
||||
resendMessage = null
|
||||
flow.clearError()
|
||||
|
||||
try {
|
||||
const { resendVerification } = await import('../auth.svelte')
|
||||
await resendVerification(flow.account.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
flow.setError(err.message || 'Failed to resend code')
|
||||
} else if (err instanceof Error) {
|
||||
flow.setError(err.message || 'Failed to resend code')
|
||||
} else {
|
||||
flow.setError('Failed to resend code')
|
||||
}
|
||||
} finally {
|
||||
resending = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="verification-step">
|
||||
<p class="info-text">
|
||||
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
|
||||
Enter it below to continue.
|
||||
</p>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="message success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
|
||||
{flow.state.submitting ? 'Verifying...' : 'Verify'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
|
||||
{resending ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.verification-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
340
frontend/src/lib/registration/flow.svelte.ts
Normal file
340
frontend/src/lib/registration/flow.svelte.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { api, ApiError } from '../api'
|
||||
import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto'
|
||||
import type {
|
||||
RegistrationMode,
|
||||
RegistrationStep,
|
||||
RegistrationInfo,
|
||||
ExternalDidWebState,
|
||||
AccountResult,
|
||||
SessionState,
|
||||
} from './types'
|
||||
|
||||
export interface RegistrationFlowState {
|
||||
mode: RegistrationMode
|
||||
step: RegistrationStep
|
||||
info: RegistrationInfo
|
||||
externalDidWeb: ExternalDidWebState
|
||||
account: AccountResult | null
|
||||
session: SessionState | null
|
||||
error: string | null
|
||||
submitting: boolean
|
||||
pdsHostname: string
|
||||
}
|
||||
|
||||
export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) {
|
||||
let state = $state<RegistrationFlowState>({
|
||||
mode,
|
||||
step: 'info',
|
||||
info: {
|
||||
handle: '',
|
||||
email: '',
|
||||
password: '',
|
||||
inviteCode: '',
|
||||
didType: 'plc',
|
||||
externalDid: '',
|
||||
verificationChannel: 'email',
|
||||
discordId: '',
|
||||
telegramUsername: '',
|
||||
signalNumber: '',
|
||||
},
|
||||
externalDidWeb: {
|
||||
keyMode: 'reserved',
|
||||
},
|
||||
account: null,
|
||||
session: null,
|
||||
error: null,
|
||||
submitting: false,
|
||||
pdsHostname,
|
||||
})
|
||||
|
||||
function getPdsEndpoint(): string {
|
||||
return `https://${state.pdsHostname}`
|
||||
}
|
||||
|
||||
function getPdsDid(): string {
|
||||
return `did:web:${state.pdsHostname}`
|
||||
}
|
||||
|
||||
function getFullHandle(): string {
|
||||
return `${state.info.handle.trim()}.${state.pdsHostname}`
|
||||
}
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
function setError(err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
state.error = err.message || 'An error occurred'
|
||||
} else if (err instanceof Error) {
|
||||
state.error = err.message || 'An error occurred'
|
||||
} else {
|
||||
state.error = 'An error occurred'
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedFromInfo() {
|
||||
state.error = null
|
||||
if (state.info.didType === 'web-external') {
|
||||
state.step = 'key-choice'
|
||||
} else {
|
||||
state.step = 'creating'
|
||||
}
|
||||
}
|
||||
|
||||
async function selectKeyMode(keyMode: 'reserved' | 'byod') {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
state.externalDidWeb.keyMode = keyMode
|
||||
|
||||
try {
|
||||
let publicKeyMultibase: string
|
||||
|
||||
if (keyMode === 'reserved') {
|
||||
const result = await api.reserveSigningKey(state.info.externalDid!.trim())
|
||||
state.externalDidWeb.reservedSigningKey = result.signingKey
|
||||
publicKeyMultibase = result.signingKey.replace('did:key:', '')
|
||||
} else {
|
||||
const keypair = await generateKeypair()
|
||||
state.externalDidWeb.byodPrivateKey = keypair.privateKey
|
||||
state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase
|
||||
publicKeyMultibase = keypair.publicKeyMultibase
|
||||
}
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
publicKeyMultibase,
|
||||
getFullHandle(),
|
||||
getPdsEndpoint()
|
||||
)
|
||||
state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t')
|
||||
state.step = 'initial-did-doc'
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
state.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInitialDidDoc() {
|
||||
state.step = 'creating'
|
||||
}
|
||||
|
||||
async function createPasswordAccount() {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
|
||||
try {
|
||||
let byodToken: string | undefined
|
||||
|
||||
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
|
||||
byodToken = await createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
'com.atproto.server.createAccount'
|
||||
)
|
||||
}
|
||||
|
||||
const result = await api.createAccount({
|
||||
handle: state.info.handle.trim(),
|
||||
email: state.info.email.trim(),
|
||||
password: state.info.password!,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
|
||||
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordId: state.info.discordId?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalNumber: state.info.signalNumber?.trim() || undefined,
|
||||
}, byodToken)
|
||||
|
||||
state.account = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
}
|
||||
state.step = 'verify'
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
state.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createPasskeyAccount() {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
|
||||
try {
|
||||
let byodToken: string | undefined
|
||||
|
||||
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
|
||||
byodToken = await createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
'com.atproto.server.createAccount'
|
||||
)
|
||||
}
|
||||
|
||||
const result = await api.createPasskeyAccount({
|
||||
handle: state.info.handle.trim(),
|
||||
email: state.info.email?.trim() || undefined,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
|
||||
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordId: state.info.discordId?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalNumber: state.info.signalNumber?.trim() || undefined,
|
||||
}, byodToken)
|
||||
|
||||
state.account = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
setupToken: result.setupToken,
|
||||
}
|
||||
state.step = 'passkey'
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
state.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function setPasskeyComplete(appPassword: string, appPasswordName: string) {
|
||||
if (state.account) {
|
||||
state.account.appPassword = appPassword
|
||||
state.account.appPasswordName = appPasswordName
|
||||
}
|
||||
state.step = 'app-password'
|
||||
}
|
||||
|
||||
function proceedFromAppPassword() {
|
||||
state.step = 'verify'
|
||||
}
|
||||
|
||||
async function verifyAccount(code: string) {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
|
||||
try {
|
||||
const confirmResult = await api.confirmSignup(state.account!.did, code.trim())
|
||||
|
||||
if (state.info.didType === 'web-external') {
|
||||
const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password!
|
||||
const session = await api.createSession(state.account!.did, password)
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
}
|
||||
|
||||
if (state.externalDidWeb.keyMode === 'byod') {
|
||||
const credentials = await api.getRecommendedDidCredentials(session.accessJwt)
|
||||
const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || ''
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
newPublicKeyMultibase,
|
||||
state.account!.handle,
|
||||
getPdsEndpoint()
|
||||
)
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t')
|
||||
state.step = 'updated-did-doc'
|
||||
} else {
|
||||
await api.activateAccount(session.accessJwt)
|
||||
await finalizeSession()
|
||||
state.step = 'redirect-to-dashboard'
|
||||
}
|
||||
} else {
|
||||
state.session = {
|
||||
accessJwt: confirmResult.accessJwt,
|
||||
refreshJwt: confirmResult.refreshJwt,
|
||||
}
|
||||
await finalizeSession()
|
||||
state.step = 'redirect-to-dashboard'
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
state.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function activateAccount() {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
|
||||
try {
|
||||
await api.activateAccount(state.session!.accessJwt)
|
||||
await finalizeSession()
|
||||
state.step = 'redirect-to-dashboard'
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
} finally {
|
||||
state.submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
switch (state.step) {
|
||||
case 'key-choice':
|
||||
state.step = 'info'
|
||||
break
|
||||
case 'initial-did-doc':
|
||||
state.step = 'key-choice'
|
||||
break
|
||||
case 'passkey':
|
||||
state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeSession() {
|
||||
if (!state.session || !state.account) return
|
||||
const { setSession } = await import('../auth.svelte')
|
||||
setSession({
|
||||
did: state.account.did,
|
||||
handle: state.account.handle,
|
||||
accessJwt: state.session.accessJwt,
|
||||
refreshJwt: state.session.refreshJwt,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
get state() { return state },
|
||||
get info() { return state.info },
|
||||
get externalDidWeb() { return state.externalDidWeb },
|
||||
get account() { return state.account },
|
||||
get session() { return state.session },
|
||||
|
||||
getPdsEndpoint,
|
||||
getPdsDid,
|
||||
getFullHandle,
|
||||
extractDomain,
|
||||
|
||||
proceedFromInfo,
|
||||
selectKeyMode,
|
||||
confirmInitialDidDoc,
|
||||
createPasswordAccount,
|
||||
createPasskeyAccount,
|
||||
setPasskeyComplete,
|
||||
proceedFromAppPassword,
|
||||
verifyAccount,
|
||||
activateAccount,
|
||||
finalizeSession,
|
||||
goBack,
|
||||
|
||||
setError(msg: string) { state.error = msg },
|
||||
clearError() { state.error = null },
|
||||
setSubmitting(val: boolean) { state.submitting = val },
|
||||
}
|
||||
}
|
||||
|
||||
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>
|
||||
6
frontend/src/lib/registration/index.ts
Normal file
6
frontend/src/lib/registration/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
export * from './flow.svelte'
|
||||
export { default as VerificationStep } from './VerificationStep.svelte'
|
||||
export { default as KeyChoiceStep } from './KeyChoiceStep.svelte'
|
||||
export { default as DidDocStep } from './DidDocStep.svelte'
|
||||
export { default as AppPasswordStep } from './AppPasswordStep.svelte'
|
||||
50
frontend/src/lib/registration/types.ts
Normal file
50
frontend/src/lib/registration/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { VerificationChannel, DidType } from '../api'
|
||||
|
||||
export type RegistrationMode = 'password' | 'passkey'
|
||||
|
||||
export type RegistrationStep =
|
||||
| 'info'
|
||||
| 'key-choice'
|
||||
| 'initial-did-doc'
|
||||
| 'creating'
|
||||
| 'passkey'
|
||||
| 'app-password'
|
||||
| 'verify'
|
||||
| 'updated-did-doc'
|
||||
| 'activating'
|
||||
| 'redirect-to-dashboard'
|
||||
|
||||
export interface RegistrationInfo {
|
||||
handle: string
|
||||
email: string
|
||||
password?: string
|
||||
inviteCode?: string
|
||||
didType: DidType
|
||||
externalDid?: string
|
||||
verificationChannel: VerificationChannel
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
}
|
||||
|
||||
export interface ExternalDidWebState {
|
||||
keyMode: 'reserved' | 'byod'
|
||||
reservedSigningKey?: string
|
||||
byodPrivateKey?: Uint8Array
|
||||
byodPublicKeyMultibase?: string
|
||||
initialDidDocument?: string
|
||||
updatedDidDocument?: string
|
||||
}
|
||||
|
||||
export interface AccountResult {
|
||||
did: string
|
||||
handle: string
|
||||
setupToken?: string
|
||||
appPassword?: string
|
||||
appPasswordName?: string
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { register, getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
createRegistrationFlow,
|
||||
VerificationStep,
|
||||
KeyChoiceStep,
|
||||
DidDocStep,
|
||||
} from '../lib/registration'
|
||||
|
||||
const STORAGE_KEY = 'tranquil_pds_pending_verification'
|
||||
|
||||
let handle = $state('')
|
||||
let email = $state('')
|
||||
let password = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let inviteCode = $state('')
|
||||
let verificationChannel = $state<VerificationChannel>('email')
|
||||
let discordId = $state('')
|
||||
let telegramUsername = $state('')
|
||||
let signalNumber = $state('')
|
||||
let didType = $state<DidType>('plc')
|
||||
let externalDid = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
@@ -27,7 +17,8 @@
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
|
||||
const auth = getAuthState()
|
||||
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
|
||||
let confirmPassword = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
@@ -36,9 +27,17 @@
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow?.state.step === 'redirect-to-dashboard') {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
serverInfo = await api.describeServer()
|
||||
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
flow = createRegistrationFlow('password', hostname)
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
@@ -46,131 +45,144 @@
|
||||
}
|
||||
}
|
||||
|
||||
let handleHasDot = $derived(handle.includes('.'))
|
||||
|
||||
function isChannelAvailable(channel: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(channel)
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!handle.trim()) return $_('register.validation.handleRequired')
|
||||
if (handle.includes('.')) return $_('register.validation.handleNoDots')
|
||||
if (!password) return $_('register.validation.passwordRequired')
|
||||
if (password.length < 8) return $_('register.validation.passwordLength')
|
||||
if (password !== confirmPassword) return $_('register.validation.passwordsMismatch')
|
||||
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
|
||||
function validateInfoStep(): string | null {
|
||||
if (!flow) return 'Flow not initialized'
|
||||
const info = flow.info
|
||||
if (!info.handle.trim()) return $_('register.validation.handleRequired')
|
||||
if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
|
||||
if (!info.password) return $_('register.validation.passwordRequired')
|
||||
if (info.password.length < 8) return $_('register.validation.passwordLength')
|
||||
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
|
||||
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
|
||||
return $_('register.validation.inviteCodeRequired')
|
||||
}
|
||||
if (didType === 'web-external') {
|
||||
if (!externalDid.trim()) return $_('register.validation.externalDidRequired')
|
||||
if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
|
||||
if (info.didType === 'web-external') {
|
||||
if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
|
||||
if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
|
||||
}
|
||||
switch (verificationChannel) {
|
||||
switch (info.verificationChannel) {
|
||||
case 'email':
|
||||
if (!email.trim()) return $_('register.validation.emailRequired')
|
||||
if (!info.email.trim()) return $_('register.validation.emailRequired')
|
||||
break
|
||||
case 'discord':
|
||||
if (!discordId.trim()) return $_('register.validation.discordIdRequired')
|
||||
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
|
||||
break
|
||||
case 'telegram':
|
||||
if (!telegramUsername.trim()) return $_('register.validation.telegramRequired')
|
||||
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
|
||||
break
|
||||
case 'signal':
|
||||
if (!signalNumber.trim()) return $_('register.validation.signalRequired')
|
||||
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
async function handleInfoSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const validationError = validateForm()
|
||||
if (!flow) return
|
||||
|
||||
const validationError = validateInfoStep()
|
||||
if (validationError) {
|
||||
error = validationError
|
||||
flow.setError(validationError)
|
||||
return
|
||||
}
|
||||
submitting = true
|
||||
error = null
|
||||
try {
|
||||
const result = await register({
|
||||
handle: handle.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
inviteCode: inviteCode.trim() || undefined,
|
||||
didType,
|
||||
did: didType === 'web-external' ? externalDid.trim() : undefined,
|
||||
verificationChannel,
|
||||
discordId: discordId.trim() || undefined,
|
||||
telegramUsername: telegramUsername.trim() || undefined,
|
||||
signalNumber: signalNumber.trim() || undefined,
|
||||
})
|
||||
if (result.verificationRequired) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
channel: result.verificationChannel,
|
||||
}))
|
||||
navigate('/verify')
|
||||
} else {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else {
|
||||
error = 'Registration failed'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
|
||||
flow.clearError()
|
||||
flow.proceedFromInfo()
|
||||
}
|
||||
|
||||
async function handleCreateAccount() {
|
||||
if (!flow) return
|
||||
await flow.createPasswordAccount()
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (flow) {
|
||||
await flow.finalizeSession()
|
||||
}
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
function isChannelAvailable(ch: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(ch)
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return $_('register.email')
|
||||
case 'discord': return $_('register.discord')
|
||||
case 'telegram': return $_('register.telegram')
|
||||
case 'signal': return $_('register.signal')
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!handle.trim()) return ''
|
||||
if (handle.includes('.')) return handle.trim()
|
||||
if (!flow?.info.handle.trim()) return ''
|
||||
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
|
||||
const domain = serverInfo?.availableUserDomains?.[0]
|
||||
if (domain) return `${handle.trim()}.${domain}`
|
||||
return handle.trim()
|
||||
if (domain) return `${flow.info.handle.trim()}.${domain}`
|
||||
return flow.info.handle.trim()
|
||||
})
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
function getSubtitle(): string {
|
||||
if (!flow) return ''
|
||||
switch (flow.state.step) {
|
||||
case 'info': return $_('register.subtitle')
|
||||
case 'key-choice': return 'Choose how to set up your external did:web identity.'
|
||||
case 'initial-did-doc': return 'Upload your DID document to continue.'
|
||||
case 'creating': return $_('register.creating')
|
||||
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
|
||||
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
|
||||
case 'activating': return 'Activating your account...'
|
||||
case 'complete': return 'Your account has been created successfully!'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
{#if flow?.state.step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<h1>{$_('register.title')}</h1>
|
||||
<p class="subtitle">{$_('register.subtitle')}</p>
|
||||
<p class="subtitle">{getSubtitle()}</p>
|
||||
|
||||
{#if loadingServerInfo}
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingServerInfo || !flow}
|
||||
<p class="loading">{$_('common.loading')}</p>
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||
|
||||
{:else if flow.state.step === 'info'}
|
||||
<form onsubmit={handleInfoSubmit}>
|
||||
<div class="field">
|
||||
<label for="handle">{$_('register.handle')}</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
bind:value={flow.info.handle}
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if handleHasDot}
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
@@ -182,9 +194,9 @@
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
bind:value={flow.info.password}
|
||||
placeholder={$_('register.passwordPlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
@@ -197,7 +209,7 @@
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder={$_('register.confirmPasswordPlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -208,7 +220,7 @@
|
||||
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
|
||||
<span class="radio-hint">{$_('register.didPlcHint')}</span>
|
||||
@@ -216,7 +228,7 @@
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWeb')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebHint')}</span>
|
||||
@@ -224,7 +236,7 @@
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
|
||||
@@ -232,7 +244,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if didType === 'web'}
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('register.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
@@ -244,15 +256,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if didType === 'web-external'}
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('register.externalDid')}</label>
|
||||
<input
|
||||
id="external-did"
|
||||
type="text"
|
||||
bind:value={externalDid}
|
||||
bind:value={flow.info.externalDid}
|
||||
placeholder={$_('register.externalDidPlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.externalDidHint')}</p>
|
||||
@@ -266,7 +278,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label for="verification-channel">{$_('register.verificationMethod')}</label>
|
||||
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
|
||||
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
|
||||
<option value="email">{$_('register.email')}</option>
|
||||
<option value="discord" disabled={!isChannelAvailable('discord')}>
|
||||
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
@@ -280,52 +292,52 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if verificationChannel === 'email'}
|
||||
{#if flow.info.verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">{$_('register.emailAddress')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
bind:value={flow.info.email}
|
||||
placeholder={$_('register.emailPlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if verificationChannel === 'discord'}
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
bind:value={flow.info.discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if verificationChannel === 'telegram'}
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">{$_('register.telegramUsername')}</label>
|
||||
<input
|
||||
id="telegram-username"
|
||||
type="text"
|
||||
bind:value={telegramUsername}
|
||||
bind:value={flow.info.telegramUsername}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if verificationChannel === 'signal'}
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
bind:value={flow.info.signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
@@ -339,16 +351,16 @@
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={inviteCode}
|
||||
bind:value={flow.info.inviteCode}
|
||||
placeholder={$_('register.inviteCodePlaceholder')}
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? $_('register.creating') : $_('register.createButton')}
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -358,6 +370,35 @@
|
||||
<p class="link-text">
|
||||
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
</p>
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
<KeyChoiceStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'initial-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="initial"
|
||||
onConfirm={handleCreateAccount}
|
||||
onBack={() => flow?.goBack()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'creating'}
|
||||
{#await flow.createPasswordAccount()}
|
||||
<p class="loading">{$_('register.creating')}</p>
|
||||
{/await}
|
||||
|
||||
{:else if flow.state.step === 'verify'}
|
||||
<VerificationStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'updated-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="updated"
|
||||
onConfirm={() => flow?.activateAccount()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'redirect-to-dashboard'}
|
||||
<p class="loading">Redirecting to dashboard...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
|
||||
import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
createRegistrationFlow,
|
||||
VerificationStep,
|
||||
KeyChoiceStep,
|
||||
DidDocStep,
|
||||
AppPasswordStep,
|
||||
} from '../lib/registration'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let step = $state<'info' | 'passkey' | 'app-password' | 'verify' | 'success'>('info')
|
||||
let handle = $state('')
|
||||
let email = $state('')
|
||||
let inviteCode = $state('')
|
||||
let didType = $state<DidType>('plc')
|
||||
let externalDid = $state('')
|
||||
let verificationChannel = $state<VerificationChannel>('email')
|
||||
let discordId = $state('')
|
||||
let telegramUsername = $state('')
|
||||
let signalNumber = $state('')
|
||||
let passkeyName = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null)
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
availableCommsChannels?: string[]
|
||||
} | null>(null)
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
|
||||
let setupData = $state<{ did: string; handle: string; setupToken: string } | null>(null)
|
||||
let appPasswordResult = $state<{ appPassword: string; appPasswordName: string } | null>(null)
|
||||
let appPasswordAcknowledged = $state(false)
|
||||
let appPasswordCopied = $state(false)
|
||||
let verificationCode = $state('')
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
|
||||
let passkeyName = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
@@ -38,9 +28,17 @@
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow?.state.step === 'redirect-to-dashboard') {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
serverInfo = await api.describeServer()
|
||||
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
|
||||
flow = createRegistrationFlow('passkey', hostname)
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
@@ -49,27 +47,29 @@
|
||||
}
|
||||
|
||||
function validateInfoStep(): string | null {
|
||||
if (!handle.trim()) return 'Handle is required'
|
||||
if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
|
||||
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
|
||||
if (!flow) return 'Flow not initialized'
|
||||
const info = flow.info
|
||||
if (!info.handle.trim()) return 'Handle is required'
|
||||
if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
|
||||
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
|
||||
return 'Invite code is required'
|
||||
}
|
||||
if (didType === 'web-external') {
|
||||
if (!externalDid.trim()) return 'External did:web is required'
|
||||
if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
|
||||
if (info.didType === 'web-external') {
|
||||
if (!info.externalDid?.trim()) return 'External did:web is required'
|
||||
if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
|
||||
}
|
||||
switch (verificationChannel) {
|
||||
switch (info.verificationChannel) {
|
||||
case 'email':
|
||||
if (!email.trim()) return 'Email is required for email verification'
|
||||
if (!info.email.trim()) return 'Email is required for email verification'
|
||||
break
|
||||
case 'discord':
|
||||
if (!discordId.trim()) return 'Discord ID is required for Discord verification'
|
||||
if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
|
||||
break
|
||||
case 'telegram':
|
||||
if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
|
||||
if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
|
||||
break
|
||||
case 'signal':
|
||||
if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
|
||||
if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
|
||||
break
|
||||
}
|
||||
return null
|
||||
@@ -112,63 +112,38 @@
|
||||
|
||||
async function handleInfoSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!flow) return
|
||||
|
||||
const validationError = validateInfoStep()
|
||||
if (validationError) {
|
||||
error = validationError
|
||||
flow.setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.'
|
||||
flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
flow.clearError()
|
||||
flow.proceedFromInfo()
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.createPasskeyAccount({
|
||||
handle: handle.trim(),
|
||||
email: email.trim() || undefined,
|
||||
inviteCode: inviteCode.trim() || undefined,
|
||||
didType,
|
||||
did: didType === 'web-external' ? externalDid.trim() : undefined,
|
||||
verificationChannel,
|
||||
discordId: discordId.trim() || undefined,
|
||||
telegramUsername: telegramUsername.trim() || undefined,
|
||||
signalNumber: signalNumber.trim() || undefined,
|
||||
})
|
||||
|
||||
setupData = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
setupToken: result.setupToken,
|
||||
}
|
||||
|
||||
step = 'passkey'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else {
|
||||
error = 'Registration failed'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
async function handleCreateAccount() {
|
||||
if (!flow) return
|
||||
await flow.createPasskeyAccount()
|
||||
}
|
||||
|
||||
async function handlePasskeyRegistration() {
|
||||
if (!setupData) return
|
||||
if (!flow || !flow.account) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
flow.setSubmitting(true)
|
||||
flow.clearError()
|
||||
|
||||
try {
|
||||
const { options } = await api.startPasskeyRegistrationForSetup(
|
||||
setupData.did,
|
||||
setupData.setupToken,
|
||||
flow.account.did,
|
||||
flow.account.setupToken!,
|
||||
passkeyName || undefined
|
||||
)
|
||||
|
||||
@@ -178,8 +153,8 @@
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
error = 'Passkey creation was cancelled'
|
||||
submitting = false
|
||||
flow.setError('Passkey creation was cancelled')
|
||||
flow.setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -196,87 +171,38 @@
|
||||
}
|
||||
|
||||
const result = await api.completePasskeySetup(
|
||||
setupData.did,
|
||||
setupData.setupToken,
|
||||
flow.account.did,
|
||||
flow.account.setupToken!,
|
||||
credentialResponse,
|
||||
passkeyName || undefined
|
||||
)
|
||||
|
||||
appPasswordResult = {
|
||||
appPassword: result.appPassword,
|
||||
appPasswordName: result.appPasswordName,
|
||||
}
|
||||
|
||||
step = 'app-password'
|
||||
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'NotAllowedError') {
|
||||
error = 'Passkey creation was cancelled'
|
||||
flow.setError('Passkey creation was cancelled')
|
||||
} else if (err instanceof ApiError) {
|
||||
error = err.message || 'Passkey registration failed'
|
||||
flow.setError(err.message || 'Passkey registration failed')
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Passkey registration failed'
|
||||
flow.setError(err.message || 'Passkey registration failed')
|
||||
} else {
|
||||
error = 'Passkey registration failed'
|
||||
flow.setError('Passkey registration failed')
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
flow.setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function copyAppPassword() {
|
||||
if (appPasswordResult) {
|
||||
navigator.clipboard.writeText(appPasswordResult.appPassword)
|
||||
appPasswordCopied = true
|
||||
async function handleComplete() {
|
||||
if (flow) {
|
||||
await flow.finalizeSession()
|
||||
}
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
function handleFinish() {
|
||||
step = 'verify'
|
||||
}
|
||||
|
||||
async function handleVerification() {
|
||||
if (!setupData || !verificationCode.trim()) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await confirmSignup(setupData.did, verificationCode.trim())
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Verification failed'
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Verification failed'
|
||||
} else {
|
||||
error = 'Verification failed'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
if (!setupData || resendingCode) return
|
||||
|
||||
resendingCode = true
|
||||
resendMessage = null
|
||||
error = null
|
||||
|
||||
try {
|
||||
await resendVerification(setupData.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Failed to resend code'
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Failed to resend code'
|
||||
} else {
|
||||
error = 'Failed to resend code'
|
||||
}
|
||||
} finally {
|
||||
resendingCode = false
|
||||
}
|
||||
function isChannelAvailable(ch: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(ch)
|
||||
}
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
@@ -289,26 +215,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isChannelAvailable(ch: string): boolean {
|
||||
const available = serverInfo?.availableCommsChannels ?? ['email']
|
||||
return available.includes(ch)
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!handle.trim()) return ''
|
||||
if (handle.includes('.')) return handle.trim()
|
||||
if (!flow?.info.handle.trim()) return ''
|
||||
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
|
||||
const domain = serverInfo?.availableUserDomains?.[0]
|
||||
if (domain) return `${handle.trim()}.${domain}`
|
||||
return handle.trim()
|
||||
if (domain) return `${flow.info.handle.trim()}.${domain}`
|
||||
return flow.info.handle.trim()
|
||||
})
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
}
|
||||
|
||||
function getSubtitle(): string {
|
||||
if (!flow) return ''
|
||||
switch (flow.state.step) {
|
||||
case 'info': return 'Create an ultra-secure account using a passkey instead of a password.'
|
||||
case 'key-choice': return 'Choose how to set up your external did:web identity.'
|
||||
case 'initial-did-doc': return 'Upload your DID document to continue.'
|
||||
case 'creating': return 'Creating your account...'
|
||||
case 'passkey': return 'Register your passkey to secure your account.'
|
||||
case 'app-password': return 'Save your app password for third-party apps.'
|
||||
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
|
||||
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
|
||||
case 'activating': return 'Activating your account...'
|
||||
case 'complete': return 'Your account has been created successfully!'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
{#if step === 'info'}
|
||||
{#if flow?.state.step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
@@ -322,39 +260,28 @@
|
||||
{/if}
|
||||
|
||||
<h1>Create Passkey Account</h1>
|
||||
<p class="subtitle">
|
||||
{#if step === 'info'}
|
||||
Create an ultra-secure account using a passkey instead of a password.
|
||||
{:else if step === 'passkey'}
|
||||
Register your passkey to secure your account.
|
||||
{:else if step === 'app-password'}
|
||||
Save your app password for third-party apps.
|
||||
{:else if step === 'verify'}
|
||||
Verify your {channelLabel(verificationChannel)} to complete registration.
|
||||
{:else}
|
||||
Your account has been created successfully!
|
||||
{/if}
|
||||
</p>
|
||||
<p class="subtitle">{getSubtitle()}</p>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingServerInfo}
|
||||
{#if loadingServerInfo || !flow}
|
||||
<p class="loading">Loading...</p>
|
||||
{:else if step === 'info'}
|
||||
|
||||
{:else if flow.state.step === 'info'}
|
||||
<form onsubmit={handleInfoSubmit}>
|
||||
<div class="field">
|
||||
<label for="handle">Handle</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
bind:value={flow.info.handle}
|
||||
placeholder="yourname"
|
||||
disabled={submitting}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if handle.includes('.')}
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">Custom domain handles can be set up after account creation.</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">Your full handle will be: @{fullHandle()}</p>
|
||||
@@ -366,7 +293,7 @@
|
||||
<p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p>
|
||||
<div class="field">
|
||||
<label for="verification-channel">Verification Method</label>
|
||||
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
|
||||
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
|
||||
<option value="email">Email</option>
|
||||
<option value="discord" disabled={!isChannelAvailable('discord')}>
|
||||
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
@@ -379,26 +306,26 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if verificationChannel === 'email'}
|
||||
{#if flow.info.verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">Email Address</label>
|
||||
<input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required />
|
||||
<input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required />
|
||||
</div>
|
||||
{:else if verificationChannel === 'discord'}
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">Discord User ID</label>
|
||||
<input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required />
|
||||
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required />
|
||||
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
|
||||
</div>
|
||||
{:else if verificationChannel === 'telegram'}
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">Telegram Username</label>
|
||||
<input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required />
|
||||
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required />
|
||||
</div>
|
||||
{:else if verificationChannel === 'signal'}
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">Signal Phone Number</label>
|
||||
<input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required />
|
||||
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required />
|
||||
<p class="hint">Include country code (e.g., +1 for US)</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -409,28 +336,28 @@
|
||||
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>did:plc</strong> (Recommended)
|
||||
<span class="radio-hint">Portable identity managed by PLC Directory</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>did:web</strong>
|
||||
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>did:web (BYOD)</strong>
|
||||
<span class="radio-hint">Bring your own domain</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{#if didType === 'web'}
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>Important: Understand the trade-offs</strong>
|
||||
<ul>
|
||||
@@ -441,11 +368,11 @@
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if didType === 'web-external'}
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">Your did:web</label>
|
||||
<input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required />
|
||||
<p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
|
||||
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required />
|
||||
<p class="hint">You'll need to serve a DID document at <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
@@ -453,7 +380,7 @@
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite-code">Invite Code <span class="required">*</span></label>
|
||||
<input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required />
|
||||
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -467,19 +394,36 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating account...' : 'Continue'}
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? 'Creating account...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
Want a traditional password? <a href="#/register">Register with password</a>
|
||||
</p>
|
||||
{:else if step === 'passkey'}
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
<KeyChoiceStep {flow} />
|
||||
|
||||
{:else if flow.state.step === 'initial-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="initial"
|
||||
onConfirm={handleCreateAccount}
|
||||
onBack={() => flow?.goBack()}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'creating'}
|
||||
{#await flow.createPasskeyAccount()}
|
||||
<p class="loading">Creating your account...</p>
|
||||
{/await}
|
||||
|
||||
{:else if flow.state.step === 'passkey'}
|
||||
<div class="step-content">
|
||||
<div class="field">
|
||||
<label for="passkey-name">Passkey Name (optional)</label>
|
||||
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} />
|
||||
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} />
|
||||
<p class="hint">A friendly name to identify this passkey</p>
|
||||
</div>
|
||||
|
||||
@@ -492,69 +436,30 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn">
|
||||
{submitting ? 'Creating Passkey...' : 'Create Passkey'}
|
||||
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
|
||||
{flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}>
|
||||
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
{:else if step === 'app-password'}
|
||||
<div class="step-content">
|
||||
<div class="warning-box">
|
||||
<strong>Important: Save this app password!</strong>
|
||||
<p>This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.</p>
|
||||
</div>
|
||||
|
||||
<div class="app-password-display">
|
||||
<div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div>
|
||||
<code class="app-password-code">{appPasswordResult?.appPassword}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyAppPassword}>
|
||||
{appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
{:else if flow.state.step === 'app-password'}
|
||||
<AppPasswordStep {flow} />
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
|
||||
<span>I have saved my app password in a secure location</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if flow.state.step === 'verify'}
|
||||
<VerificationStep {flow} />
|
||||
|
||||
<button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button>
|
||||
</div>
|
||||
{:else if step === 'verify'}
|
||||
<div class="step-content">
|
||||
<p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p>
|
||||
{:else if flow.state.step === 'updated-did-doc'}
|
||||
<DidDocStep
|
||||
{flow}
|
||||
type="updated"
|
||||
onConfirm={() => flow?.activateAccount()}
|
||||
/>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="message success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input id="verification-code" type="text" bind:value={verificationCode} placeholder="Enter 6-digit code" disabled={submitting} required maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? 'Verifying...' : 'Verify Account'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else if step === 'success'}
|
||||
<div class="success-content">
|
||||
<div class="success-icon">✔</div>
|
||||
<h2>Account Created!</h2>
|
||||
<p>Your passkey-only account has been created successfully.</p>
|
||||
<p class="handle-display">@{setupData?.handle}</p>
|
||||
<button onclick={goToLogin}>Sign In</button>
|
||||
</div>
|
||||
{:else if flow.state.step === 'redirect-to-dashboard'}
|
||||
<p class="loading">Redirecting to dashboard...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -609,7 +514,7 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
h1 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
@@ -697,11 +602,6 @@
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
margin: var(--space-4) 0 0 0;
|
||||
padding-left: var(--space-5);
|
||||
@@ -749,77 +649,6 @@
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.app-password-display {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.app-password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
margin-top: 0;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: var(--text-4xl);
|
||||
color: var(--success-text);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.success-content p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.handle-display {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
text-align: center;
|
||||
margin-top: var(--space-6);
|
||||
|
||||
@@ -118,13 +118,6 @@ pub async fn create_account(
|
||||
None
|
||||
};
|
||||
|
||||
let is_migration = migration_auth.is_some()
|
||||
&& input
|
||||
.did
|
||||
.as_ref()
|
||||
.map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let is_did_web_byod = migration_auth.is_some()
|
||||
&& input
|
||||
.did
|
||||
@@ -132,23 +125,30 @@ pub async fn create_account(
|
||||
.map(|d| d.starts_with("did:web:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_migration {
|
||||
if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
|
||||
let is_migration = migration_auth.is_some()
|
||||
&& input
|
||||
.did
|
||||
.as_ref()
|
||||
.map(|d| d.starts_with("did:plc:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_migration || is_did_web_byod {
|
||||
if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
|
||||
{
|
||||
if migration_did != auth_did {
|
||||
if provided_did != auth_did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if is_did_web_byod {
|
||||
info!(did = %migration_did, "Processing did:web BYOD account creation");
|
||||
info!(did = %provided_did, "Processing did:web BYOD account creation");
|
||||
} else {
|
||||
info!(did = %migration_did, "Processing account migration");
|
||||
info!(did = %provided_did, "Processing account migration");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,7 +717,7 @@ pub async fn create_account(
|
||||
.await
|
||||
.map(|c| c.unwrap_or(0) == 0)
|
||||
.unwrap_or(false);
|
||||
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration {
|
||||
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod {
|
||||
Some(chrono::Utc::now())
|
||||
} else {
|
||||
None
|
||||
@@ -946,7 +946,7 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !is_migration {
|
||||
if !is_migration && !is_did_web_byod {
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
@@ -972,6 +972,8 @@ pub async fn create_account(
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
}
|
||||
if !is_migration {
|
||||
if let Some(ref recipient) = verification_recipient
|
||||
&& let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
&state.db,
|
||||
|
||||
@@ -12,10 +12,11 @@ use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::repo::record::utils::create_signed_commit;
|
||||
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::validation::validate_password;
|
||||
|
||||
@@ -106,6 +107,45 @@ pub async fn create_passkey_account(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let byod_auth = if let Some(token) =
|
||||
extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
|
||||
{
|
||||
if is_service_token(&token) {
|
||||
let verifier = ServiceTokenVerifier::new();
|
||||
match verifier
|
||||
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
|
||||
.await
|
||||
{
|
||||
Ok(claims) => {
|
||||
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
|
||||
Some(claims.iss)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Service token verification failed: {:?}", e);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "AuthenticationFailed",
|
||||
"message": format!("Service token verification failed: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let is_byod_did_web = byod_auth.is_some()
|
||||
&& input
|
||||
.did
|
||||
.as_ref()
|
||||
.map(|d| d.starts_with("did:web:"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_suffix = format!(".{}", hostname);
|
||||
|
||||
@@ -301,21 +341,37 @@ pub async fn create_passkey_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::api::identity::did::verify_did_web(
|
||||
d,
|
||||
&hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
if is_byod_did_web {
|
||||
if let Some(ref auth_did) = byod_auth {
|
||||
if d != auth_did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, d)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
|
||||
} else {
|
||||
if let Err(e) = crate::api::identity::did::verify_did_web(
|
||||
d,
|
||||
&hostname,
|
||||
&input.handle,
|
||||
input.signing_key.as_deref(),
|
||||
)
|
||||
.into_response();
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidDid", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account (reserved key)");
|
||||
}
|
||||
info!(did = %d, "Creating external did:web passkey account");
|
||||
d.to_string()
|
||||
}
|
||||
_ => {
|
||||
@@ -398,14 +454,20 @@ pub async fn create_passkey_account(
|
||||
.map(|c| c.unwrap_or(0) == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web {
|
||||
Some(Utc::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_insert: Result<(Uuid,), _> = sqlx::query_as(
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash, password_required,
|
||||
preferred_comms_channel,
|
||||
discord_id, telegram_username, signal_number,
|
||||
recovery_token, recovery_token_expires_at,
|
||||
is_admin
|
||||
) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10) RETURNING id"#,
|
||||
is_admin, deactivated_at
|
||||
) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
|
||||
)
|
||||
.bind(&handle)
|
||||
.bind(&email)
|
||||
@@ -435,6 +497,7 @@ pub async fn create_passkey_account(
|
||||
.bind(&setup_token_hash)
|
||||
.bind(setup_expires_at)
|
||||
.bind(is_first_user)
|
||||
.bind(deactivated_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
@@ -612,30 +675,32 @@ pub async fn create_passkey_account(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
"app.bsky.actor.profile",
|
||||
"self",
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
if !is_byod_did_web {
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = serde_json::json!({
|
||||
"$type": "app.bsky.actor.profile",
|
||||
"displayName": handle
|
||||
});
|
||||
if let Err(e) = crate::api::repo::record::create_record_internal(
|
||||
&state,
|
||||
&did,
|
||||
"app.bsky.actor.profile",
|
||||
"self",
|
||||
&profile_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = crate::comms::enqueue_signup_verification(
|
||||
|
||||
Reference in New Issue
Block a user