+10
frontend/deno.lock
+10
frontend/deno.lock
···
1
1
{
2
2
"version": "5",
3
3
"specifiers": {
4
+
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
4
5
"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",
5
6
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
6
7
"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",
7
8
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
8
9
"npm:jsdom@^25.0.1": "25.0.1",
10
+
"npm:multiformats@^13.3.1": "13.4.2",
9
11
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
10
12
"npm:svelte@5": "5.45.10_acorn@8.15.0",
11
13
"npm:vite@*": "6.4.1_picomatch@4.0.3",
···
491
493
"@jridgewell/resolve-uri",
492
494
"@jridgewell/sourcemap-codec"
493
495
]
496
+
},
497
+
"@noble/secp256k1@2.3.0": {
498
+
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
494
499
},
495
500
"@rollup/rollup-android-arm-eabi@4.53.3": {
496
501
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
···
1281
1286
"ms@2.1.3": {
1282
1287
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
1283
1288
},
1289
+
"multiformats@13.4.2": {
1290
+
"integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ=="
1291
+
},
1284
1292
"nanoid@3.3.11": {
1285
1293
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1286
1294
"bin": true
···
1636
1644
"workspace": {
1637
1645
"packageJson": {
1638
1646
"dependencies": [
1647
+
"npm:@noble/secp256k1@^2.1.0",
1639
1648
"npm:@sveltejs/vite-plugin-svelte@5",
1640
1649
"npm:@testing-library/jest-dom@^6.6.3",
1641
1650
"npm:@testing-library/svelte@^5.2.6",
1642
1651
"npm:@testing-library/user-event@^14.5.2",
1643
1652
"npm:jsdom@^25.0.1",
1653
+
"npm:multiformats@^13.3.1",
1644
1654
"npm:svelte-i18n@^4.0.1",
1645
1655
"npm:svelte@5",
1646
1656
"npm:vite@6",
+2
frontend/package.json
+2
frontend/package.json
+56
-7
frontend/src/lib/api.ts
+56
-7
frontend/src/lib/api.ts
···
95
95
inviteCode?: string
96
96
didType?: DidType
97
97
did?: string
98
+
signingKey?: string
98
99
verificationChannel?: VerificationChannel
99
100
discordId?: string
100
101
telegramUsername?: string
···
120
121
}
121
122
122
123
export const api = {
123
-
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
124
-
return xrpc('com.atproto.server.createAccount', {
124
+
async createAccount(params: CreateAccountParams, byodToken?: string): Promise<CreateAccountResult> {
125
+
const url = `${API_BASE}/com.atproto.server.createAccount`
126
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
127
+
if (byodToken) {
128
+
headers['Authorization'] = `Bearer ${byodToken}`
129
+
}
130
+
const response = await fetch(url, {
125
131
method: 'POST',
126
-
body: {
132
+
headers,
133
+
body: JSON.stringify({
127
134
handle: params.handle,
128
135
email: params.email,
129
136
password: params.password,
130
137
inviteCode: params.inviteCode,
131
138
didType: params.didType,
132
139
did: params.did,
140
+
signingKey: params.signingKey,
133
141
verificationChannel: params.verificationChannel,
134
142
discordId: params.discordId,
135
143
telegramUsername: params.telegramUsername,
136
144
signalNumber: params.signalNumber,
137
-
},
145
+
}),
138
146
})
147
+
const data = await response.json()
148
+
if (!response.ok) {
149
+
throw new ApiError(data.error, data.message, response.status)
150
+
}
151
+
return data
139
152
},
140
153
141
154
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
···
750
763
})
751
764
},
752
765
766
+
async reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
767
+
return xrpc('com.atproto.server.reserveSigningKey', {
768
+
method: 'POST',
769
+
body: { did },
770
+
})
771
+
},
772
+
773
+
async getRecommendedDidCredentials(token: string): Promise<{
774
+
rotationKeys?: string[]
775
+
alsoKnownAs?: string[]
776
+
verificationMethods?: { atproto?: string }
777
+
services?: { atproto_pds?: { type: string; endpoint: string } }
778
+
}> {
779
+
return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token })
780
+
},
781
+
782
+
async activateAccount(token: string): Promise<void> {
783
+
await xrpc('com.atproto.server.activateAccount', {
784
+
method: 'POST',
785
+
token,
786
+
})
787
+
},
788
+
753
789
async createPasskeyAccount(params: {
754
790
handle: string
755
791
email?: string
···
761
797
discordId?: string
762
798
telegramUsername?: string
763
799
signalNumber?: string
764
-
}): Promise<{
800
+
}, byodToken?: string): Promise<{
765
801
did: string
766
802
handle: string
767
803
setupToken: string
768
804
setupExpiresAt: string
769
805
}> {
770
-
return xrpc('com.tranquil.account.createPasskeyAccount', {
806
+
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`
807
+
const headers: Record<string, string> = {
808
+
'Content-Type': 'application/json'
809
+
}
810
+
if (byodToken) {
811
+
headers['Authorization'] = `Bearer ${byodToken}`
812
+
}
813
+
const res = await fetch(url, {
771
814
method: 'POST',
772
-
body: params,
815
+
headers,
816
+
body: JSON.stringify(params),
773
817
})
818
+
if (!res.ok) {
819
+
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
820
+
throw new ApiError(res.status, err.error, err.message)
821
+
}
822
+
return res.json()
774
823
},
775
824
776
825
async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
+12
frontend/src/lib/auth.svelte.ts
+12
frontend/src/lib/auth.svelte.ts
···
265
265
}
266
266
}
267
267
268
+
export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void {
269
+
const newSession: Session = {
270
+
did: session.did,
271
+
handle: session.handle,
272
+
accessJwt: session.accessJwt,
273
+
refreshJwt: session.refreshJwt,
274
+
}
275
+
state.session = newSession
276
+
saveSession(newSession)
277
+
addOrUpdateSavedAccount(newSession)
278
+
}
279
+
268
280
export async function logout(): Promise<void> {
269
281
if (state.session) {
270
282
try {
+106
frontend/src/lib/crypto.ts
+106
frontend/src/lib/crypto.ts
···
1
+
import * as secp from '@noble/secp256k1'
2
+
import { base58btc } from 'multiformats/bases/base58'
3
+
4
+
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01])
5
+
6
+
export interface Keypair {
7
+
privateKey: Uint8Array
8
+
publicKey: Uint8Array
9
+
publicKeyMultibase: string
10
+
publicKeyDidKey: string
11
+
}
12
+
13
+
export async function generateKeypair(): Promise<Keypair> {
14
+
const privateKey = secp.utils.randomPrivateKey()
15
+
const publicKey = secp.getPublicKey(privateKey, true)
16
+
17
+
const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length)
18
+
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0)
19
+
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length)
20
+
21
+
const publicKeyMultibase = base58btc.encode(multicodecKey)
22
+
const publicKeyDidKey = `did:key:${publicKeyMultibase}`
23
+
24
+
return {
25
+
privateKey,
26
+
publicKey,
27
+
publicKeyMultibase,
28
+
publicKeyDidKey,
29
+
}
30
+
}
31
+
32
+
function base64UrlEncode(data: Uint8Array | string): string {
33
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
34
+
let binary = ''
35
+
for (let i = 0; i < bytes.length; i++) {
36
+
binary += String.fromCharCode(bytes[i])
37
+
}
38
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
39
+
}
40
+
41
+
export async function createServiceJwt(
42
+
privateKey: Uint8Array,
43
+
issuerDid: string,
44
+
audienceDid: string,
45
+
lxm: string
46
+
): Promise<string> {
47
+
const header = {
48
+
alg: 'ES256K',
49
+
typ: 'JWT',
50
+
}
51
+
52
+
const now = Math.floor(Date.now() / 1000)
53
+
const payload = {
54
+
iss: issuerDid,
55
+
sub: issuerDid,
56
+
aud: audienceDid,
57
+
exp: now + 180,
58
+
iat: now,
59
+
lxm: lxm,
60
+
}
61
+
62
+
const headerEncoded = base64UrlEncode(JSON.stringify(header))
63
+
const payloadEncoded = base64UrlEncode(JSON.stringify(payload))
64
+
const message = `${headerEncoded}.${payloadEncoded}`
65
+
66
+
const msgBytes = new TextEncoder().encode(message)
67
+
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes)
68
+
const msgHash = new Uint8Array(hashBuffer)
69
+
const signature = await secp.signAsync(msgHash, privateKey)
70
+
const sigBytes = signature.toCompactRawBytes()
71
+
const signatureEncoded = base64UrlEncode(sigBytes)
72
+
73
+
return `${message}.${signatureEncoded}`
74
+
}
75
+
76
+
export function generateDidDocument(
77
+
did: string,
78
+
publicKeyMultibase: string,
79
+
handle: string,
80
+
pdsEndpoint: string
81
+
): object {
82
+
return {
83
+
'@context': [
84
+
'https://www.w3.org/ns/did/v1',
85
+
'https://w3id.org/security/multikey/v1',
86
+
'https://w3id.org/security/suites/secp256k1-2019/v1',
87
+
],
88
+
id: did,
89
+
alsoKnownAs: [`at://${handle}`],
90
+
verificationMethod: [
91
+
{
92
+
id: `${did}#atproto`,
93
+
type: 'Multikey',
94
+
controller: did,
95
+
publicKeyMultibase: publicKeyMultibase,
96
+
},
97
+
],
98
+
service: [
99
+
{
100
+
id: '#atproto_pds',
101
+
type: 'AtprotoPersonalDataServer',
102
+
serviceEndpoint: pdsEndpoint,
103
+
},
104
+
],
105
+
}
106
+
}
+121
frontend/src/lib/registration/AppPasswordStep.svelte
+121
frontend/src/lib/registration/AppPasswordStep.svelte
···
1
+
<script lang="ts">
2
+
import type { RegistrationFlow } from './flow.svelte'
3
+
4
+
interface Props {
5
+
flow: RegistrationFlow
6
+
}
7
+
8
+
let { flow }: Props = $props()
9
+
10
+
let copied = $state(false)
11
+
let acknowledged = $state(false)
12
+
13
+
function copyToClipboard() {
14
+
if (flow.account?.appPassword) {
15
+
navigator.clipboard.writeText(flow.account.appPassword)
16
+
copied = true
17
+
}
18
+
}
19
+
</script>
20
+
21
+
<div class="app-password-step">
22
+
<div class="warning-box">
23
+
<strong>Important: Save this app password!</strong>
24
+
<p>
25
+
This app password is required to sign into apps that don't support passkeys yet (like bsky.app).
26
+
You will only see this password once.
27
+
</p>
28
+
</div>
29
+
30
+
<div class="app-password-display">
31
+
<div class="app-password-label">
32
+
App Password for: <strong>{flow.account?.appPasswordName}</strong>
33
+
</div>
34
+
<code class="app-password-code">{flow.account?.appPassword}</code>
35
+
<button type="button" class="copy-btn" onclick={copyToClipboard}>
36
+
{copied ? 'Copied!' : 'Copy to Clipboard'}
37
+
</button>
38
+
</div>
39
+
40
+
<div class="field">
41
+
<label class="checkbox-label">
42
+
<input type="checkbox" bind:checked={acknowledged} />
43
+
<span>I have saved my app password in a secure location</span>
44
+
</label>
45
+
</div>
46
+
47
+
<button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}>
48
+
Continue
49
+
</button>
50
+
</div>
51
+
52
+
<style>
53
+
.app-password-step {
54
+
display: flex;
55
+
flex-direction: column;
56
+
gap: var(--space-4);
57
+
}
58
+
59
+
.warning-box {
60
+
padding: var(--space-5);
61
+
background: var(--warning-bg);
62
+
border: 1px solid var(--warning-border);
63
+
border-radius: var(--radius-lg);
64
+
font-size: var(--text-sm);
65
+
}
66
+
67
+
.warning-box strong {
68
+
display: block;
69
+
margin-bottom: var(--space-3);
70
+
color: var(--warning-text);
71
+
}
72
+
73
+
.warning-box p {
74
+
margin: 0;
75
+
color: var(--warning-text);
76
+
}
77
+
78
+
.app-password-display {
79
+
background: var(--bg-card);
80
+
border: 2px solid var(--accent);
81
+
border-radius: var(--radius-xl);
82
+
padding: var(--space-6);
83
+
text-align: center;
84
+
}
85
+
86
+
.app-password-label {
87
+
font-size: var(--text-sm);
88
+
color: var(--text-secondary);
89
+
margin-bottom: var(--space-4);
90
+
}
91
+
92
+
.app-password-code {
93
+
display: block;
94
+
font-size: var(--text-xl);
95
+
font-family: ui-monospace, monospace;
96
+
letter-spacing: 0.1em;
97
+
padding: var(--space-5);
98
+
background: var(--bg-input);
99
+
border-radius: var(--radius-md);
100
+
margin-bottom: var(--space-4);
101
+
user-select: all;
102
+
}
103
+
104
+
.copy-btn {
105
+
padding: var(--space-3) var(--space-5);
106
+
font-size: var(--text-sm);
107
+
}
108
+
109
+
.checkbox-label {
110
+
display: flex;
111
+
align-items: center;
112
+
gap: var(--space-3);
113
+
cursor: pointer;
114
+
font-weight: var(--font-normal);
115
+
}
116
+
117
+
.checkbox-label input[type="checkbox"] {
118
+
width: auto;
119
+
padding: 0;
120
+
}
121
+
</style>
+166
frontend/src/lib/registration/DidDocStep.svelte
+166
frontend/src/lib/registration/DidDocStep.svelte
···
1
+
<script lang="ts">
2
+
import type { RegistrationFlow } from './flow.svelte'
3
+
4
+
interface Props {
5
+
flow: RegistrationFlow
6
+
type: 'initial' | 'updated'
7
+
onConfirm: () => void
8
+
onBack?: () => void
9
+
}
10
+
11
+
let { flow, type, onConfirm, onBack }: Props = $props()
12
+
13
+
let copied = $state(false)
14
+
let confirmed = $state(false)
15
+
16
+
const didDocument = $derived(
17
+
type === 'initial'
18
+
? flow.externalDidWeb.initialDidDocument
19
+
: flow.externalDidWeb.updatedDidDocument
20
+
)
21
+
22
+
const title = $derived(
23
+
type === 'initial'
24
+
? 'Step 1: Upload your DID document'
25
+
: 'Step 2: Update your DID document'
26
+
)
27
+
28
+
const description = $derived(
29
+
type === 'initial'
30
+
? 'Copy the JSON below and save it at:'
31
+
: 'The PDS has assigned a new signing key for your account. Update your DID document with this new key:'
32
+
)
33
+
34
+
const confirmLabel = $derived(
35
+
type === 'initial'
36
+
? 'I have uploaded the DID document to my domain'
37
+
: 'I have updated the DID document on my domain'
38
+
)
39
+
40
+
const buttonLabel = $derived(
41
+
type === 'initial' ? 'Continue' : 'Activate Account'
42
+
)
43
+
44
+
function copyToClipboard() {
45
+
if (didDocument) {
46
+
navigator.clipboard.writeText(didDocument)
47
+
copied = true
48
+
}
49
+
}
50
+
51
+
function handleConfirm() {
52
+
if (!confirmed) {
53
+
flow.setError(`Please confirm you have ${type === 'initial' ? 'uploaded' : 'updated'} the DID document`)
54
+
return
55
+
}
56
+
onConfirm()
57
+
}
58
+
</script>
59
+
60
+
<div class="did-doc-step">
61
+
<div class="warning-box">
62
+
<strong>{title}</strong>
63
+
<p>{description}</p>
64
+
<code class="did-url">https://{flow.extractDomain(flow.info.externalDid || '')}/.well-known/did.json</code>
65
+
</div>
66
+
67
+
<div class="did-doc-display">
68
+
<pre class="did-doc-code">{didDocument}</pre>
69
+
<button type="button" class="copy-btn" onclick={copyToClipboard}>
70
+
{copied ? 'Copied!' : 'Copy to Clipboard'}
71
+
</button>
72
+
</div>
73
+
74
+
<div class="field">
75
+
<label class="checkbox-label">
76
+
<input type="checkbox" bind:checked={confirmed} />
77
+
<span>{confirmLabel}</span>
78
+
</label>
79
+
</div>
80
+
81
+
<button onclick={handleConfirm} disabled={flow.state.submitting || !confirmed}>
82
+
{flow.state.submitting ? (type === 'initial' ? 'Creating account...' : 'Activating...') : buttonLabel}
83
+
</button>
84
+
85
+
{#if onBack}
86
+
<button type="button" class="secondary" onclick={onBack} disabled={flow.state.submitting}>
87
+
Back
88
+
</button>
89
+
{/if}
90
+
</div>
91
+
92
+
<style>
93
+
.did-doc-step {
94
+
display: flex;
95
+
flex-direction: column;
96
+
gap: var(--space-4);
97
+
}
98
+
99
+
.warning-box {
100
+
padding: var(--space-5);
101
+
background: var(--warning-bg);
102
+
border: 1px solid var(--warning-border);
103
+
border-radius: var(--radius-lg);
104
+
font-size: var(--text-sm);
105
+
}
106
+
107
+
.warning-box strong {
108
+
display: block;
109
+
margin-bottom: var(--space-3);
110
+
color: var(--warning-text);
111
+
}
112
+
113
+
.warning-box p {
114
+
margin: 0;
115
+
color: var(--warning-text);
116
+
}
117
+
118
+
.did-url {
119
+
display: block;
120
+
margin-top: var(--space-3);
121
+
padding: var(--space-3);
122
+
background: var(--bg-input);
123
+
border-radius: var(--radius-md);
124
+
font-size: var(--text-sm);
125
+
word-break: break-all;
126
+
}
127
+
128
+
.did-doc-display {
129
+
background: var(--bg-card);
130
+
border: 1px solid var(--border-color);
131
+
border-radius: var(--radius-lg);
132
+
overflow: hidden;
133
+
}
134
+
135
+
.did-doc-code {
136
+
margin: 0;
137
+
padding: var(--space-4);
138
+
background: var(--bg-input);
139
+
font-size: var(--text-xs);
140
+
overflow-x: auto;
141
+
white-space: pre;
142
+
max-height: 300px;
143
+
overflow-y: auto;
144
+
}
145
+
146
+
.copy-btn {
147
+
width: 100%;
148
+
border-radius: 0;
149
+
margin: 0;
150
+
padding: var(--space-3) var(--space-5);
151
+
font-size: var(--text-sm);
152
+
}
153
+
154
+
.checkbox-label {
155
+
display: flex;
156
+
align-items: center;
157
+
gap: var(--space-3);
158
+
cursor: pointer;
159
+
font-weight: var(--font-normal);
160
+
}
161
+
162
+
.checkbox-label input[type="checkbox"] {
163
+
width: auto;
164
+
padding: 0;
165
+
}
166
+
</style>
+117
frontend/src/lib/registration/KeyChoiceStep.svelte
+117
frontend/src/lib/registration/KeyChoiceStep.svelte
···
1
+
<script lang="ts">
2
+
import type { RegistrationFlow } from './flow.svelte'
3
+
4
+
interface Props {
5
+
flow: RegistrationFlow
6
+
}
7
+
8
+
let { flow }: Props = $props()
9
+
</script>
10
+
11
+
<div class="key-choice-step">
12
+
<div class="info-box">
13
+
<strong>External did:web Setup</strong>
14
+
<p>
15
+
To use your own domain ({flow.extractDomain(flow.info.externalDid || '')}) as your identity,
16
+
you'll need to host a DID document. Choose how you'd like to set up the signing key:
17
+
</p>
18
+
</div>
19
+
20
+
<div class="key-choice-options">
21
+
<button
22
+
class="key-choice-btn"
23
+
onclick={() => flow.selectKeyMode('reserved')}
24
+
disabled={flow.state.submitting}
25
+
>
26
+
<span class="key-choice-title">Let the PDS generate a key</span>
27
+
<span class="key-choice-desc">Simpler setup - we'll provide the public key for your DID document</span>
28
+
</button>
29
+
30
+
<button
31
+
class="key-choice-btn"
32
+
onclick={() => flow.selectKeyMode('byod')}
33
+
disabled={flow.state.submitting}
34
+
>
35
+
<span class="key-choice-title">I'll provide my own key</span>
36
+
<span class="key-choice-desc">Advanced - generate a key in your browser for initial authentication</span>
37
+
</button>
38
+
</div>
39
+
40
+
{#if flow.state.submitting}
41
+
<p class="loading">Generating key...</p>
42
+
{/if}
43
+
44
+
<button type="button" class="secondary" onclick={() => flow.goBack()} disabled={flow.state.submitting}>
45
+
Back
46
+
</button>
47
+
</div>
48
+
49
+
<style>
50
+
.key-choice-step {
51
+
display: flex;
52
+
flex-direction: column;
53
+
gap: var(--space-4);
54
+
}
55
+
56
+
.info-box {
57
+
background: var(--bg-secondary);
58
+
border: 1px solid var(--border-color);
59
+
border-radius: var(--radius-lg);
60
+
padding: var(--space-5);
61
+
font-size: var(--text-sm);
62
+
}
63
+
64
+
.info-box strong {
65
+
display: block;
66
+
margin-bottom: var(--space-3);
67
+
}
68
+
69
+
.info-box p {
70
+
margin: 0;
71
+
color: var(--text-secondary);
72
+
}
73
+
74
+
.key-choice-options {
75
+
display: flex;
76
+
flex-direction: column;
77
+
gap: var(--space-3);
78
+
}
79
+
80
+
.key-choice-btn {
81
+
display: flex;
82
+
flex-direction: column;
83
+
align-items: flex-start;
84
+
gap: var(--space-2);
85
+
padding: var(--space-5);
86
+
background: var(--bg-card);
87
+
border: 2px solid var(--border-color);
88
+
border-radius: var(--radius-lg);
89
+
text-align: left;
90
+
cursor: pointer;
91
+
transition: border-color 0.2s;
92
+
}
93
+
94
+
.key-choice-btn:hover:not(:disabled) {
95
+
border-color: var(--accent);
96
+
}
97
+
98
+
.key-choice-btn:disabled {
99
+
opacity: 0.6;
100
+
cursor: not-allowed;
101
+
}
102
+
103
+
.key-choice-title {
104
+
font-weight: var(--font-semibold);
105
+
color: var(--text-primary);
106
+
}
107
+
108
+
.key-choice-desc {
109
+
font-size: var(--text-sm);
110
+
color: var(--text-secondary);
111
+
}
112
+
113
+
.loading {
114
+
text-align: center;
115
+
color: var(--text-secondary);
116
+
}
117
+
</style>
+103
frontend/src/lib/registration/VerificationStep.svelte
+103
frontend/src/lib/registration/VerificationStep.svelte
···
1
+
<script lang="ts">
2
+
import { api, ApiError } from '../api'
3
+
import type { RegistrationFlow } from './flow.svelte'
4
+
5
+
interface Props {
6
+
flow: RegistrationFlow
7
+
}
8
+
9
+
let { flow }: Props = $props()
10
+
11
+
let verificationCode = $state('')
12
+
let resending = $state(false)
13
+
let resendMessage = $state<string | null>(null)
14
+
15
+
function channelLabel(ch: string): string {
16
+
switch (ch) {
17
+
case 'email': return 'email'
18
+
case 'discord': return 'Discord'
19
+
case 'telegram': return 'Telegram'
20
+
case 'signal': return 'Signal'
21
+
default: return ch
22
+
}
23
+
}
24
+
25
+
async function handleSubmit(e: Event) {
26
+
e.preventDefault()
27
+
if (!verificationCode.trim()) return
28
+
resendMessage = null
29
+
await flow.verifyAccount(verificationCode)
30
+
}
31
+
32
+
async function handleResend() {
33
+
if (resending || !flow.account) return
34
+
resending = true
35
+
resendMessage = null
36
+
flow.clearError()
37
+
38
+
try {
39
+
const { resendVerification } = await import('../auth.svelte')
40
+
await resendVerification(flow.account.did)
41
+
resendMessage = 'Verification code resent!'
42
+
} catch (err) {
43
+
if (err instanceof ApiError) {
44
+
flow.setError(err.message || 'Failed to resend code')
45
+
} else if (err instanceof Error) {
46
+
flow.setError(err.message || 'Failed to resend code')
47
+
} else {
48
+
flow.setError('Failed to resend code')
49
+
}
50
+
} finally {
51
+
resending = false
52
+
}
53
+
}
54
+
</script>
55
+
56
+
<div class="verification-step">
57
+
<p class="info-text">
58
+
We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}.
59
+
Enter it below to continue.
60
+
</p>
61
+
62
+
{#if resendMessage}
63
+
<div class="message success">{resendMessage}</div>
64
+
{/if}
65
+
66
+
<form onsubmit={handleSubmit}>
67
+
<div class="field">
68
+
<label for="verification-code">Verification Code</label>
69
+
<input
70
+
id="verification-code"
71
+
type="text"
72
+
bind:value={verificationCode}
73
+
placeholder="Enter 6-digit code"
74
+
disabled={flow.state.submitting}
75
+
required
76
+
maxlength="6"
77
+
inputmode="numeric"
78
+
autocomplete="one-time-code"
79
+
/>
80
+
</div>
81
+
82
+
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
83
+
{flow.state.submitting ? 'Verifying...' : 'Verify'}
84
+
</button>
85
+
86
+
<button type="button" class="secondary" onclick={handleResend} disabled={resending}>
87
+
{resending ? 'Resending...' : 'Resend Code'}
88
+
</button>
89
+
</form>
90
+
</div>
91
+
92
+
<style>
93
+
.verification-step {
94
+
display: flex;
95
+
flex-direction: column;
96
+
gap: var(--space-4);
97
+
}
98
+
99
+
.info-text {
100
+
color: var(--text-secondary);
101
+
margin: 0;
102
+
}
103
+
</style>
+340
frontend/src/lib/registration/flow.svelte.ts
+340
frontend/src/lib/registration/flow.svelte.ts
···
1
+
import { api, ApiError } from '../api'
2
+
import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto'
3
+
import type {
4
+
RegistrationMode,
5
+
RegistrationStep,
6
+
RegistrationInfo,
7
+
ExternalDidWebState,
8
+
AccountResult,
9
+
SessionState,
10
+
} from './types'
11
+
12
+
export interface RegistrationFlowState {
13
+
mode: RegistrationMode
14
+
step: RegistrationStep
15
+
info: RegistrationInfo
16
+
externalDidWeb: ExternalDidWebState
17
+
account: AccountResult | null
18
+
session: SessionState | null
19
+
error: string | null
20
+
submitting: boolean
21
+
pdsHostname: string
22
+
}
23
+
24
+
export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) {
25
+
let state = $state<RegistrationFlowState>({
26
+
mode,
27
+
step: 'info',
28
+
info: {
29
+
handle: '',
30
+
email: '',
31
+
password: '',
32
+
inviteCode: '',
33
+
didType: 'plc',
34
+
externalDid: '',
35
+
verificationChannel: 'email',
36
+
discordId: '',
37
+
telegramUsername: '',
38
+
signalNumber: '',
39
+
},
40
+
externalDidWeb: {
41
+
keyMode: 'reserved',
42
+
},
43
+
account: null,
44
+
session: null,
45
+
error: null,
46
+
submitting: false,
47
+
pdsHostname,
48
+
})
49
+
50
+
function getPdsEndpoint(): string {
51
+
return `https://${state.pdsHostname}`
52
+
}
53
+
54
+
function getPdsDid(): string {
55
+
return `did:web:${state.pdsHostname}`
56
+
}
57
+
58
+
function getFullHandle(): string {
59
+
return `${state.info.handle.trim()}.${state.pdsHostname}`
60
+
}
61
+
62
+
function extractDomain(did: string): string {
63
+
return did.replace('did:web:', '').replace(/%3A/g, ':')
64
+
}
65
+
66
+
function setError(err: unknown) {
67
+
if (err instanceof ApiError) {
68
+
state.error = err.message || 'An error occurred'
69
+
} else if (err instanceof Error) {
70
+
state.error = err.message || 'An error occurred'
71
+
} else {
72
+
state.error = 'An error occurred'
73
+
}
74
+
}
75
+
76
+
async function proceedFromInfo() {
77
+
state.error = null
78
+
if (state.info.didType === 'web-external') {
79
+
state.step = 'key-choice'
80
+
} else {
81
+
state.step = 'creating'
82
+
}
83
+
}
84
+
85
+
async function selectKeyMode(keyMode: 'reserved' | 'byod') {
86
+
state.submitting = true
87
+
state.error = null
88
+
state.externalDidWeb.keyMode = keyMode
89
+
90
+
try {
91
+
let publicKeyMultibase: string
92
+
93
+
if (keyMode === 'reserved') {
94
+
const result = await api.reserveSigningKey(state.info.externalDid!.trim())
95
+
state.externalDidWeb.reservedSigningKey = result.signingKey
96
+
publicKeyMultibase = result.signingKey.replace('did:key:', '')
97
+
} else {
98
+
const keypair = await generateKeypair()
99
+
state.externalDidWeb.byodPrivateKey = keypair.privateKey
100
+
state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase
101
+
publicKeyMultibase = keypair.publicKeyMultibase
102
+
}
103
+
104
+
const didDoc = generateDidDocument(
105
+
state.info.externalDid!.trim(),
106
+
publicKeyMultibase,
107
+
getFullHandle(),
108
+
getPdsEndpoint()
109
+
)
110
+
state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t')
111
+
state.step = 'initial-did-doc'
112
+
} catch (err) {
113
+
setError(err)
114
+
} finally {
115
+
state.submitting = false
116
+
}
117
+
}
118
+
119
+
async function confirmInitialDidDoc() {
120
+
state.step = 'creating'
121
+
}
122
+
123
+
async function createPasswordAccount() {
124
+
state.submitting = true
125
+
state.error = null
126
+
127
+
try {
128
+
let byodToken: string | undefined
129
+
130
+
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
131
+
byodToken = await createServiceJwt(
132
+
state.externalDidWeb.byodPrivateKey,
133
+
state.info.externalDid!.trim(),
134
+
getPdsDid(),
135
+
'com.atproto.server.createAccount'
136
+
)
137
+
}
138
+
139
+
const result = await api.createAccount({
140
+
handle: state.info.handle.trim(),
141
+
email: state.info.email.trim(),
142
+
password: state.info.password!,
143
+
inviteCode: state.info.inviteCode?.trim() || undefined,
144
+
didType: state.info.didType,
145
+
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
146
+
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
147
+
? state.externalDidWeb.reservedSigningKey
148
+
: undefined,
149
+
verificationChannel: state.info.verificationChannel,
150
+
discordId: state.info.discordId?.trim() || undefined,
151
+
telegramUsername: state.info.telegramUsername?.trim() || undefined,
152
+
signalNumber: state.info.signalNumber?.trim() || undefined,
153
+
}, byodToken)
154
+
155
+
state.account = {
156
+
did: result.did,
157
+
handle: result.handle,
158
+
}
159
+
state.step = 'verify'
160
+
} catch (err) {
161
+
setError(err)
162
+
} finally {
163
+
state.submitting = false
164
+
}
165
+
}
166
+
167
+
async function createPasskeyAccount() {
168
+
state.submitting = true
169
+
state.error = null
170
+
171
+
try {
172
+
let byodToken: string | undefined
173
+
174
+
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
175
+
byodToken = await createServiceJwt(
176
+
state.externalDidWeb.byodPrivateKey,
177
+
state.info.externalDid!.trim(),
178
+
getPdsDid(),
179
+
'com.atproto.server.createAccount'
180
+
)
181
+
}
182
+
183
+
const result = await api.createPasskeyAccount({
184
+
handle: state.info.handle.trim(),
185
+
email: state.info.email?.trim() || undefined,
186
+
inviteCode: state.info.inviteCode?.trim() || undefined,
187
+
didType: state.info.didType,
188
+
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
189
+
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
190
+
? state.externalDidWeb.reservedSigningKey
191
+
: undefined,
192
+
verificationChannel: state.info.verificationChannel,
193
+
discordId: state.info.discordId?.trim() || undefined,
194
+
telegramUsername: state.info.telegramUsername?.trim() || undefined,
195
+
signalNumber: state.info.signalNumber?.trim() || undefined,
196
+
}, byodToken)
197
+
198
+
state.account = {
199
+
did: result.did,
200
+
handle: result.handle,
201
+
setupToken: result.setupToken,
202
+
}
203
+
state.step = 'passkey'
204
+
} catch (err) {
205
+
setError(err)
206
+
} finally {
207
+
state.submitting = false
208
+
}
209
+
}
210
+
211
+
function setPasskeyComplete(appPassword: string, appPasswordName: string) {
212
+
if (state.account) {
213
+
state.account.appPassword = appPassword
214
+
state.account.appPasswordName = appPasswordName
215
+
}
216
+
state.step = 'app-password'
217
+
}
218
+
219
+
function proceedFromAppPassword() {
220
+
state.step = 'verify'
221
+
}
222
+
223
+
async function verifyAccount(code: string) {
224
+
state.submitting = true
225
+
state.error = null
226
+
227
+
try {
228
+
const confirmResult = await api.confirmSignup(state.account!.did, code.trim())
229
+
230
+
if (state.info.didType === 'web-external') {
231
+
const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password!
232
+
const session = await api.createSession(state.account!.did, password)
233
+
state.session = {
234
+
accessJwt: session.accessJwt,
235
+
refreshJwt: session.refreshJwt,
236
+
}
237
+
238
+
if (state.externalDidWeb.keyMode === 'byod') {
239
+
const credentials = await api.getRecommendedDidCredentials(session.accessJwt)
240
+
const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || ''
241
+
242
+
const didDoc = generateDidDocument(
243
+
state.info.externalDid!.trim(),
244
+
newPublicKeyMultibase,
245
+
state.account!.handle,
246
+
getPdsEndpoint()
247
+
)
248
+
state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t')
249
+
state.step = 'updated-did-doc'
250
+
} else {
251
+
await api.activateAccount(session.accessJwt)
252
+
await finalizeSession()
253
+
state.step = 'redirect-to-dashboard'
254
+
}
255
+
} else {
256
+
state.session = {
257
+
accessJwt: confirmResult.accessJwt,
258
+
refreshJwt: confirmResult.refreshJwt,
259
+
}
260
+
await finalizeSession()
261
+
state.step = 'redirect-to-dashboard'
262
+
}
263
+
} catch (err) {
264
+
setError(err)
265
+
} finally {
266
+
state.submitting = false
267
+
}
268
+
}
269
+
270
+
async function activateAccount() {
271
+
state.submitting = true
272
+
state.error = null
273
+
274
+
try {
275
+
await api.activateAccount(state.session!.accessJwt)
276
+
await finalizeSession()
277
+
state.step = 'redirect-to-dashboard'
278
+
} catch (err) {
279
+
setError(err)
280
+
} finally {
281
+
state.submitting = false
282
+
}
283
+
}
284
+
285
+
function goBack() {
286
+
switch (state.step) {
287
+
case 'key-choice':
288
+
state.step = 'info'
289
+
break
290
+
case 'initial-did-doc':
291
+
state.step = 'key-choice'
292
+
break
293
+
case 'passkey':
294
+
state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info'
295
+
break
296
+
}
297
+
}
298
+
299
+
async function finalizeSession() {
300
+
if (!state.session || !state.account) return
301
+
const { setSession } = await import('../auth.svelte')
302
+
setSession({
303
+
did: state.account.did,
304
+
handle: state.account.handle,
305
+
accessJwt: state.session.accessJwt,
306
+
refreshJwt: state.session.refreshJwt,
307
+
})
308
+
}
309
+
310
+
return {
311
+
get state() { return state },
312
+
get info() { return state.info },
313
+
get externalDidWeb() { return state.externalDidWeb },
314
+
get account() { return state.account },
315
+
get session() { return state.session },
316
+
317
+
getPdsEndpoint,
318
+
getPdsDid,
319
+
getFullHandle,
320
+
extractDomain,
321
+
322
+
proceedFromInfo,
323
+
selectKeyMode,
324
+
confirmInitialDidDoc,
325
+
createPasswordAccount,
326
+
createPasskeyAccount,
327
+
setPasskeyComplete,
328
+
proceedFromAppPassword,
329
+
verifyAccount,
330
+
activateAccount,
331
+
finalizeSession,
332
+
goBack,
333
+
334
+
setError(msg: string) { state.error = msg },
335
+
clearError() { state.error = null },
336
+
setSubmitting(val: boolean) { state.submitting = val },
337
+
}
338
+
}
339
+
340
+
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>
+6
frontend/src/lib/registration/index.ts
+6
frontend/src/lib/registration/index.ts
···
1
+
export * from './types'
2
+
export * from './flow.svelte'
3
+
export { default as VerificationStep } from './VerificationStep.svelte'
4
+
export { default as KeyChoiceStep } from './KeyChoiceStep.svelte'
5
+
export { default as DidDocStep } from './DidDocStep.svelte'
6
+
export { default as AppPasswordStep } from './AppPasswordStep.svelte'
+50
frontend/src/lib/registration/types.ts
+50
frontend/src/lib/registration/types.ts
···
1
+
import type { VerificationChannel, DidType } from '../api'
2
+
3
+
export type RegistrationMode = 'password' | 'passkey'
4
+
5
+
export type RegistrationStep =
6
+
| 'info'
7
+
| 'key-choice'
8
+
| 'initial-did-doc'
9
+
| 'creating'
10
+
| 'passkey'
11
+
| 'app-password'
12
+
| 'verify'
13
+
| 'updated-did-doc'
14
+
| 'activating'
15
+
| 'redirect-to-dashboard'
16
+
17
+
export interface RegistrationInfo {
18
+
handle: string
19
+
email: string
20
+
password?: string
21
+
inviteCode?: string
22
+
didType: DidType
23
+
externalDid?: string
24
+
verificationChannel: VerificationChannel
25
+
discordId?: string
26
+
telegramUsername?: string
27
+
signalNumber?: string
28
+
}
29
+
30
+
export interface ExternalDidWebState {
31
+
keyMode: 'reserved' | 'byod'
32
+
reservedSigningKey?: string
33
+
byodPrivateKey?: Uint8Array
34
+
byodPublicKeyMultibase?: string
35
+
initialDidDocument?: string
36
+
updatedDidDocument?: string
37
+
}
38
+
39
+
export interface AccountResult {
40
+
did: string
41
+
handle: string
42
+
setupToken?: string
43
+
appPassword?: string
44
+
appPasswordName?: string
45
+
}
46
+
47
+
export interface SessionState {
48
+
accessJwt: string
49
+
refreshJwt: string
50
+
}
+169
-128
frontend/src/routes/Register.svelte
+169
-128
frontend/src/routes/Register.svelte
···
1
1
<script lang="ts">
2
-
import { register, getAuthState } from '../lib/auth.svelte'
3
2
import { navigate } from '../lib/router.svelte'
4
-
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
3
+
import { api, ApiError } from '../lib/api'
5
4
import { _ } from '../lib/i18n'
5
+
import {
6
+
createRegistrationFlow,
7
+
VerificationStep,
8
+
KeyChoiceStep,
9
+
DidDocStep,
10
+
} from '../lib/registration'
6
11
7
-
const STORAGE_KEY = 'tranquil_pds_pending_verification'
8
-
9
-
let handle = $state('')
10
-
let email = $state('')
11
-
let password = $state('')
12
-
let confirmPassword = $state('')
13
-
let inviteCode = $state('')
14
-
let verificationChannel = $state<VerificationChannel>('email')
15
-
let discordId = $state('')
16
-
let telegramUsername = $state('')
17
-
let signalNumber = $state('')
18
-
let didType = $state<DidType>('plc')
19
-
let externalDid = $state('')
20
-
let submitting = $state(false)
21
-
let error = $state<string | null>(null)
22
12
let serverInfo = $state<{
23
13
availableUserDomains: string[]
24
14
inviteCodeRequired: boolean
···
27
17
let loadingServerInfo = $state(true)
28
18
let serverInfoLoaded = false
29
19
30
-
const auth = getAuthState()
20
+
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
21
+
let confirmPassword = $state('')
31
22
32
23
$effect(() => {
33
24
if (!serverInfoLoaded) {
···
36
27
}
37
28
})
38
29
30
+
$effect(() => {
31
+
if (flow?.state.step === 'redirect-to-dashboard') {
32
+
navigate('/dashboard')
33
+
}
34
+
})
35
+
39
36
async function loadServerInfo() {
40
37
try {
41
38
serverInfo = await api.describeServer()
39
+
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
40
+
flow = createRegistrationFlow('password', hostname)
42
41
} catch (e) {
43
42
console.error('Failed to load server info:', e)
44
43
} finally {
···
46
45
}
47
46
}
48
47
49
-
let handleHasDot = $derived(handle.includes('.'))
50
-
51
-
function isChannelAvailable(channel: string): boolean {
52
-
const available = serverInfo?.availableCommsChannels ?? ['email']
53
-
return available.includes(channel)
54
-
}
55
-
56
-
function validateForm(): string | null {
57
-
if (!handle.trim()) return $_('register.validation.handleRequired')
58
-
if (handle.includes('.')) return $_('register.validation.handleNoDots')
59
-
if (!password) return $_('register.validation.passwordRequired')
60
-
if (password.length < 8) return $_('register.validation.passwordLength')
61
-
if (password !== confirmPassword) return $_('register.validation.passwordsMismatch')
62
-
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
48
+
function validateInfoStep(): string | null {
49
+
if (!flow) return 'Flow not initialized'
50
+
const info = flow.info
51
+
if (!info.handle.trim()) return $_('register.validation.handleRequired')
52
+
if (info.handle.includes('.')) return $_('register.validation.handleNoDots')
53
+
if (!info.password) return $_('register.validation.passwordRequired')
54
+
if (info.password.length < 8) return $_('register.validation.passwordLength')
55
+
if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch')
56
+
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
63
57
return $_('register.validation.inviteCodeRequired')
64
58
}
65
-
if (didType === 'web-external') {
66
-
if (!externalDid.trim()) return $_('register.validation.externalDidRequired')
67
-
if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
59
+
if (info.didType === 'web-external') {
60
+
if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired')
61
+
if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
68
62
}
69
-
switch (verificationChannel) {
63
+
switch (info.verificationChannel) {
70
64
case 'email':
71
-
if (!email.trim()) return $_('register.validation.emailRequired')
65
+
if (!info.email.trim()) return $_('register.validation.emailRequired')
72
66
break
73
67
case 'discord':
74
-
if (!discordId.trim()) return $_('register.validation.discordIdRequired')
68
+
if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired')
75
69
break
76
70
case 'telegram':
77
-
if (!telegramUsername.trim()) return $_('register.validation.telegramRequired')
71
+
if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired')
78
72
break
79
73
case 'signal':
80
-
if (!signalNumber.trim()) return $_('register.validation.signalRequired')
74
+
if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired')
81
75
break
82
76
}
83
77
return null
84
78
}
85
79
86
-
async function handleSubmit(e: Event) {
80
+
async function handleInfoSubmit(e: Event) {
87
81
e.preventDefault()
88
-
const validationError = validateForm()
82
+
if (!flow) return
83
+
84
+
const validationError = validateInfoStep()
89
85
if (validationError) {
90
-
error = validationError
86
+
flow.setError(validationError)
91
87
return
92
88
}
93
-
submitting = true
94
-
error = null
95
-
try {
96
-
const result = await register({
97
-
handle: handle.trim(),
98
-
email: email.trim(),
99
-
password,
100
-
inviteCode: inviteCode.trim() || undefined,
101
-
didType,
102
-
did: didType === 'web-external' ? externalDid.trim() : undefined,
103
-
verificationChannel,
104
-
discordId: discordId.trim() || undefined,
105
-
telegramUsername: telegramUsername.trim() || undefined,
106
-
signalNumber: signalNumber.trim() || undefined,
107
-
})
108
-
if (result.verificationRequired) {
109
-
localStorage.setItem(STORAGE_KEY, JSON.stringify({
110
-
did: result.did,
111
-
handle: result.handle,
112
-
channel: result.verificationChannel,
113
-
}))
114
-
navigate('/verify')
115
-
} else {
116
-
navigate('/dashboard')
117
-
}
118
-
} catch (err: any) {
119
-
if (err instanceof ApiError) {
120
-
error = err.message || 'Registration failed'
121
-
} else if (err instanceof Error) {
122
-
error = err.message || 'Registration failed'
123
-
} else {
124
-
error = 'Registration failed'
125
-
}
126
-
} finally {
127
-
submitting = false
89
+
90
+
flow.clearError()
91
+
flow.proceedFromInfo()
92
+
}
93
+
94
+
async function handleCreateAccount() {
95
+
if (!flow) return
96
+
await flow.createPasswordAccount()
97
+
}
98
+
99
+
async function handleComplete() {
100
+
if (flow) {
101
+
await flow.finalizeSession()
102
+
}
103
+
navigate('/dashboard')
104
+
}
105
+
106
+
function isChannelAvailable(ch: string): boolean {
107
+
const available = serverInfo?.availableCommsChannels ?? ['email']
108
+
return available.includes(ch)
109
+
}
110
+
111
+
function channelLabel(ch: string): string {
112
+
switch (ch) {
113
+
case 'email': return $_('register.email')
114
+
case 'discord': return $_('register.discord')
115
+
case 'telegram': return $_('register.telegram')
116
+
case 'signal': return $_('register.signal')
117
+
default: return ch
128
118
}
129
119
}
130
120
131
121
let fullHandle = $derived(() => {
132
-
if (!handle.trim()) return ''
133
-
if (handle.includes('.')) return handle.trim()
122
+
if (!flow?.info.handle.trim()) return ''
123
+
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
134
124
const domain = serverInfo?.availableUserDomains?.[0]
135
-
if (domain) return `${handle.trim()}.${domain}`
136
-
return handle.trim()
125
+
if (domain) return `${flow.info.handle.trim()}.${domain}`
126
+
return flow.info.handle.trim()
137
127
})
128
+
129
+
function extractDomain(did: string): string {
130
+
return did.replace('did:web:', '').replace(/%3A/g, ':')
131
+
}
132
+
133
+
function getSubtitle(): string {
134
+
if (!flow) return ''
135
+
switch (flow.state.step) {
136
+
case 'info': return $_('register.subtitle')
137
+
case 'key-choice': return 'Choose how to set up your external did:web identity.'
138
+
case 'initial-did-doc': return 'Upload your DID document to continue.'
139
+
case 'creating': return $_('register.creating')
140
+
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
141
+
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
142
+
case 'activating': return 'Activating your account...'
143
+
case 'complete': return 'Your account has been created successfully!'
144
+
default: return ''
145
+
}
146
+
}
138
147
</script>
139
148
140
149
<div class="register-page">
141
-
<div class="migrate-callout">
142
-
<div class="migrate-icon">↗</div>
143
-
<div class="migrate-content">
144
-
<strong>{$_('register.migrateTitle')}</strong>
145
-
<p>{$_('register.migrateDescription')}</p>
146
-
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
147
-
{$_('register.migrateLink')} →
148
-
</a>
150
+
{#if flow?.state.step === 'info'}
151
+
<div class="migrate-callout">
152
+
<div class="migrate-icon">↗</div>
153
+
<div class="migrate-content">
154
+
<strong>{$_('register.migrateTitle')}</strong>
155
+
<p>{$_('register.migrateDescription')}</p>
156
+
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
157
+
{$_('register.migrateLink')} →
158
+
</a>
159
+
</div>
149
160
</div>
150
-
</div>
151
-
152
-
{#if error}
153
-
<div class="message error">{error}</div>
154
161
{/if}
155
162
156
163
<h1>{$_('register.title')}</h1>
157
-
<p class="subtitle">{$_('register.subtitle')}</p>
164
+
<p class="subtitle">{getSubtitle()}</p>
165
+
166
+
{#if flow?.state.error}
167
+
<div class="message error">{flow.state.error}</div>
168
+
{/if}
158
169
159
-
{#if loadingServerInfo}
170
+
{#if loadingServerInfo || !flow}
160
171
<p class="loading">{$_('common.loading')}</p>
161
-
{:else}
162
-
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
172
+
173
+
{:else if flow.state.step === 'info'}
174
+
<form onsubmit={handleInfoSubmit}>
163
175
<div class="field">
164
176
<label for="handle">{$_('register.handle')}</label>
165
177
<input
166
178
id="handle"
167
179
type="text"
168
-
bind:value={handle}
180
+
bind:value={flow.info.handle}
169
181
placeholder={$_('register.handlePlaceholder')}
170
-
disabled={submitting}
182
+
disabled={flow.state.submitting}
171
183
required
172
184
/>
173
-
{#if handleHasDot}
185
+
{#if flow.info.handle.includes('.')}
174
186
<p class="hint warning">{$_('register.handleDotWarning')}</p>
175
187
{:else if fullHandle()}
176
188
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
···
182
194
<input
183
195
id="password"
184
196
type="password"
185
-
bind:value={password}
197
+
bind:value={flow.info.password}
186
198
placeholder={$_('register.passwordPlaceholder')}
187
-
disabled={submitting}
199
+
disabled={flow.state.submitting}
188
200
required
189
201
minlength="8"
190
202
/>
···
197
209
type="password"
198
210
bind:value={confirmPassword}
199
211
placeholder={$_('register.confirmPasswordPlaceholder')}
200
-
disabled={submitting}
212
+
disabled={flow.state.submitting}
201
213
required
202
214
/>
203
215
</div>
···
208
220
209
221
<div class="radio-group">
210
222
<label class="radio-label">
211
-
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
223
+
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
212
224
<span class="radio-content">
213
225
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
214
226
<span class="radio-hint">{$_('register.didPlcHint')}</span>
···
216
228
</label>
217
229
218
230
<label class="radio-label">
219
-
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
231
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
220
232
<span class="radio-content">
221
233
<strong>{$_('register.didWeb')}</strong>
222
234
<span class="radio-hint">{$_('register.didWebHint')}</span>
···
224
236
</label>
225
237
226
238
<label class="radio-label">
227
-
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
239
+
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
228
240
<span class="radio-content">
229
241
<strong>{$_('register.didWebBYOD')}</strong>
230
242
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
···
232
244
</label>
233
245
</div>
234
246
235
-
{#if didType === 'web'}
247
+
{#if flow.info.didType === 'web'}
236
248
<div class="warning-box">
237
249
<strong>{$_('register.didWebWarningTitle')}</strong>
238
250
<ul>
···
244
256
</div>
245
257
{/if}
246
258
247
-
{#if didType === 'web-external'}
259
+
{#if flow.info.didType === 'web-external'}
248
260
<div class="field">
249
261
<label for="external-did">{$_('register.externalDid')}</label>
250
262
<input
251
263
id="external-did"
252
264
type="text"
253
-
bind:value={externalDid}
265
+
bind:value={flow.info.externalDid}
254
266
placeholder={$_('register.externalDidPlaceholder')}
255
-
disabled={submitting}
267
+
disabled={flow.state.submitting}
256
268
required
257
269
/>
258
270
<p class="hint">{$_('register.externalDidHint')}</p>
···
266
278
267
279
<div class="field">
268
280
<label for="verification-channel">{$_('register.verificationMethod')}</label>
269
-
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
281
+
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
270
282
<option value="email">{$_('register.email')}</option>
271
283
<option value="discord" disabled={!isChannelAvailable('discord')}>
272
284
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
···
280
292
</select>
281
293
</div>
282
294
283
-
{#if verificationChannel === 'email'}
295
+
{#if flow.info.verificationChannel === 'email'}
284
296
<div class="field">
285
297
<label for="email">{$_('register.emailAddress')}</label>
286
298
<input
287
299
id="email"
288
300
type="email"
289
-
bind:value={email}
301
+
bind:value={flow.info.email}
290
302
placeholder={$_('register.emailPlaceholder')}
291
-
disabled={submitting}
303
+
disabled={flow.state.submitting}
292
304
required
293
305
/>
294
306
</div>
295
-
{:else if verificationChannel === 'discord'}
307
+
{:else if flow.info.verificationChannel === 'discord'}
296
308
<div class="field">
297
309
<label for="discord-id">{$_('register.discordId')}</label>
298
310
<input
299
311
id="discord-id"
300
312
type="text"
301
-
bind:value={discordId}
313
+
bind:value={flow.info.discordId}
302
314
placeholder={$_('register.discordIdPlaceholder')}
303
-
disabled={submitting}
315
+
disabled={flow.state.submitting}
304
316
required
305
317
/>
306
318
<p class="hint">{$_('register.discordIdHint')}</p>
307
319
</div>
308
-
{:else if verificationChannel === 'telegram'}
320
+
{:else if flow.info.verificationChannel === 'telegram'}
309
321
<div class="field">
310
322
<label for="telegram-username">{$_('register.telegramUsername')}</label>
311
323
<input
312
324
id="telegram-username"
313
325
type="text"
314
-
bind:value={telegramUsername}
326
+
bind:value={flow.info.telegramUsername}
315
327
placeholder={$_('register.telegramUsernamePlaceholder')}
316
-
disabled={submitting}
328
+
disabled={flow.state.submitting}
317
329
required
318
330
/>
319
331
</div>
320
-
{:else if verificationChannel === 'signal'}
332
+
{:else if flow.info.verificationChannel === 'signal'}
321
333
<div class="field">
322
334
<label for="signal-number">{$_('register.signalNumber')}</label>
323
335
<input
324
336
id="signal-number"
325
337
type="tel"
326
-
bind:value={signalNumber}
338
+
bind:value={flow.info.signalNumber}
327
339
placeholder={$_('register.signalNumberPlaceholder')}
328
-
disabled={submitting}
340
+
disabled={flow.state.submitting}
329
341
required
330
342
/>
331
343
<p class="hint">{$_('register.signalNumberHint')}</p>
···
339
351
<input
340
352
id="invite-code"
341
353
type="text"
342
-
bind:value={inviteCode}
354
+
bind:value={flow.info.inviteCode}
343
355
placeholder={$_('register.inviteCodePlaceholder')}
344
-
disabled={submitting}
356
+
disabled={flow.state.submitting}
345
357
required
346
358
/>
347
359
</div>
348
360
{/if}
349
361
350
-
<button type="submit" disabled={submitting}>
351
-
{submitting ? $_('register.creating') : $_('register.createButton')}
362
+
<button type="submit" disabled={flow.state.submitting}>
363
+
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
352
364
</button>
353
365
</form>
354
366
···
358
370
<p class="link-text">
359
371
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
360
372
</p>
373
+
374
+
{:else if flow.state.step === 'key-choice'}
375
+
<KeyChoiceStep {flow} />
376
+
377
+
{:else if flow.state.step === 'initial-did-doc'}
378
+
<DidDocStep
379
+
{flow}
380
+
type="initial"
381
+
onConfirm={handleCreateAccount}
382
+
onBack={() => flow?.goBack()}
383
+
/>
384
+
385
+
{:else if flow.state.step === 'creating'}
386
+
{#await flow.createPasswordAccount()}
387
+
<p class="loading">{$_('register.creating')}</p>
388
+
{/await}
389
+
390
+
{:else if flow.state.step === 'verify'}
391
+
<VerificationStep {flow} />
392
+
393
+
{:else if flow.state.step === 'updated-did-doc'}
394
+
<DidDocStep
395
+
{flow}
396
+
type="updated"
397
+
onConfirm={() => flow?.activateAccount()}
398
+
/>
399
+
400
+
{:else if flow.state.step === 'redirect-to-dashboard'}
401
+
<p class="loading">Redirecting to dashboard...</p>
361
402
{/if}
362
403
</div>
363
404
+157
-328
frontend/src/routes/RegisterPasskey.svelte
+157
-328
frontend/src/routes/RegisterPasskey.svelte
···
1
1
<script lang="ts">
2
2
import { navigate } from '../lib/router.svelte'
3
-
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
4
-
import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte'
3
+
import { api, ApiError } from '../lib/api'
5
4
import { _ } from '../lib/i18n'
5
+
import {
6
+
createRegistrationFlow,
7
+
VerificationStep,
8
+
KeyChoiceStep,
9
+
DidDocStep,
10
+
AppPasswordStep,
11
+
} from '../lib/registration'
6
12
7
-
const auth = getAuthState()
8
-
9
-
let step = $state<'info' | 'passkey' | 'app-password' | 'verify' | 'success'>('info')
10
-
let handle = $state('')
11
-
let email = $state('')
12
-
let inviteCode = $state('')
13
-
let didType = $state<DidType>('plc')
14
-
let externalDid = $state('')
15
-
let verificationChannel = $state<VerificationChannel>('email')
16
-
let discordId = $state('')
17
-
let telegramUsername = $state('')
18
-
let signalNumber = $state('')
19
-
let passkeyName = $state('')
20
-
let submitting = $state(false)
21
-
let error = $state<string | null>(null)
22
-
let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null)
13
+
let serverInfo = $state<{
14
+
availableUserDomains: string[]
15
+
inviteCodeRequired: boolean
16
+
availableCommsChannels?: string[]
17
+
} | null>(null)
23
18
let loadingServerInfo = $state(true)
24
19
let serverInfoLoaded = false
25
20
26
-
let setupData = $state<{ did: string; handle: string; setupToken: string } | null>(null)
27
-
let appPasswordResult = $state<{ appPassword: string; appPasswordName: string } | null>(null)
28
-
let appPasswordAcknowledged = $state(false)
29
-
let appPasswordCopied = $state(false)
30
-
let verificationCode = $state('')
31
-
let resendingCode = $state(false)
32
-
let resendMessage = $state<string | null>(null)
21
+
let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null)
22
+
let passkeyName = $state('')
33
23
34
24
$effect(() => {
35
25
if (!serverInfoLoaded) {
36
26
serverInfoLoaded = true
37
27
loadServerInfo()
28
+
}
29
+
})
30
+
31
+
$effect(() => {
32
+
if (flow?.state.step === 'redirect-to-dashboard') {
33
+
navigate('/dashboard')
38
34
}
39
35
})
40
36
41
37
async function loadServerInfo() {
42
38
try {
43
39
serverInfo = await api.describeServer()
40
+
const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname
41
+
flow = createRegistrationFlow('passkey', hostname)
44
42
} catch (e) {
45
43
console.error('Failed to load server info:', e)
46
44
} finally {
···
49
47
}
50
48
51
49
function validateInfoStep(): string | null {
52
-
if (!handle.trim()) return 'Handle is required'
53
-
if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
54
-
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
50
+
if (!flow) return 'Flow not initialized'
51
+
const info = flow.info
52
+
if (!info.handle.trim()) return 'Handle is required'
53
+
if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
54
+
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
55
55
return 'Invite code is required'
56
56
}
57
-
if (didType === 'web-external') {
58
-
if (!externalDid.trim()) return 'External did:web is required'
59
-
if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
57
+
if (info.didType === 'web-external') {
58
+
if (!info.externalDid?.trim()) return 'External did:web is required'
59
+
if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
60
60
}
61
-
switch (verificationChannel) {
61
+
switch (info.verificationChannel) {
62
62
case 'email':
63
-
if (!email.trim()) return 'Email is required for email verification'
63
+
if (!info.email.trim()) return 'Email is required for email verification'
64
64
break
65
65
case 'discord':
66
-
if (!discordId.trim()) return 'Discord ID is required for Discord verification'
66
+
if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
67
67
break
68
68
case 'telegram':
69
-
if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
69
+
if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
70
70
break
71
71
case 'signal':
72
-
if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
72
+
if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
73
73
break
74
74
}
75
75
return null
···
112
112
113
113
async function handleInfoSubmit(e: Event) {
114
114
e.preventDefault()
115
+
if (!flow) return
116
+
115
117
const validationError = validateInfoStep()
116
118
if (validationError) {
117
-
error = validationError
119
+
flow.setError(validationError)
118
120
return
119
121
}
120
122
121
123
if (!window.PublicKeyCredential) {
122
-
error = 'Passkeys are not supported in this browser. Please use a different browser or register with a password instead.'
124
+
flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
123
125
return
124
126
}
125
127
126
-
submitting = true
127
-
error = null
128
+
flow.clearError()
129
+
flow.proceedFromInfo()
130
+
}
128
131
129
-
try {
130
-
const result = await api.createPasskeyAccount({
131
-
handle: handle.trim(),
132
-
email: email.trim() || undefined,
133
-
inviteCode: inviteCode.trim() || undefined,
134
-
didType,
135
-
did: didType === 'web-external' ? externalDid.trim() : undefined,
136
-
verificationChannel,
137
-
discordId: discordId.trim() || undefined,
138
-
telegramUsername: telegramUsername.trim() || undefined,
139
-
signalNumber: signalNumber.trim() || undefined,
140
-
})
141
-
142
-
setupData = {
143
-
did: result.did,
144
-
handle: result.handle,
145
-
setupToken: result.setupToken,
146
-
}
147
-
148
-
step = 'passkey'
149
-
} catch (err) {
150
-
if (err instanceof ApiError) {
151
-
error = err.message || 'Registration failed'
152
-
} else if (err instanceof Error) {
153
-
error = err.message || 'Registration failed'
154
-
} else {
155
-
error = 'Registration failed'
156
-
}
157
-
} finally {
158
-
submitting = false
159
-
}
132
+
async function handleCreateAccount() {
133
+
if (!flow) return
134
+
await flow.createPasskeyAccount()
160
135
}
161
136
162
137
async function handlePasskeyRegistration() {
163
-
if (!setupData) return
138
+
if (!flow || !flow.account) return
164
139
165
-
submitting = true
166
-
error = null
140
+
flow.setSubmitting(true)
141
+
flow.clearError()
167
142
168
143
try {
169
144
const { options } = await api.startPasskeyRegistrationForSetup(
170
-
setupData.did,
171
-
setupData.setupToken,
145
+
flow.account.did,
146
+
flow.account.setupToken!,
172
147
passkeyName || undefined
173
148
)
174
149
···
178
153
})
179
154
180
155
if (!credential) {
181
-
error = 'Passkey creation was cancelled'
182
-
submitting = false
156
+
flow.setError('Passkey creation was cancelled')
157
+
flow.setSubmitting(false)
183
158
return
184
159
}
185
160
···
196
171
}
197
172
198
173
const result = await api.completePasskeySetup(
199
-
setupData.did,
200
-
setupData.setupToken,
174
+
flow.account.did,
175
+
flow.account.setupToken!,
201
176
credentialResponse,
202
177
passkeyName || undefined
203
178
)
204
179
205
-
appPasswordResult = {
206
-
appPassword: result.appPassword,
207
-
appPasswordName: result.appPasswordName,
208
-
}
209
-
210
-
step = 'app-password'
180
+
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
211
181
} catch (err) {
212
182
if (err instanceof DOMException && err.name === 'NotAllowedError') {
213
-
error = 'Passkey creation was cancelled'
183
+
flow.setError('Passkey creation was cancelled')
214
184
} else if (err instanceof ApiError) {
215
-
error = err.message || 'Passkey registration failed'
185
+
flow.setError(err.message || 'Passkey registration failed')
216
186
} else if (err instanceof Error) {
217
-
error = err.message || 'Passkey registration failed'
187
+
flow.setError(err.message || 'Passkey registration failed')
218
188
} else {
219
-
error = 'Passkey registration failed'
189
+
flow.setError('Passkey registration failed')
220
190
}
221
191
} finally {
222
-
submitting = false
192
+
flow.setSubmitting(false)
223
193
}
224
194
}
225
195
226
-
function copyAppPassword() {
227
-
if (appPasswordResult) {
228
-
navigator.clipboard.writeText(appPasswordResult.appPassword)
229
-
appPasswordCopied = true
196
+
async function handleComplete() {
197
+
if (flow) {
198
+
await flow.finalizeSession()
230
199
}
200
+
navigate('/dashboard')
231
201
}
232
202
233
-
function handleFinish() {
234
-
step = 'verify'
235
-
}
236
-
237
-
async function handleVerification() {
238
-
if (!setupData || !verificationCode.trim()) return
239
-
240
-
submitting = true
241
-
error = null
242
-
243
-
try {
244
-
await confirmSignup(setupData.did, verificationCode.trim())
245
-
navigate('/dashboard')
246
-
} catch (err) {
247
-
if (err instanceof ApiError) {
248
-
error = err.message || 'Verification failed'
249
-
} else if (err instanceof Error) {
250
-
error = err.message || 'Verification failed'
251
-
} else {
252
-
error = 'Verification failed'
253
-
}
254
-
} finally {
255
-
submitting = false
256
-
}
257
-
}
258
-
259
-
async function handleResendCode() {
260
-
if (!setupData || resendingCode) return
261
-
262
-
resendingCode = true
263
-
resendMessage = null
264
-
error = null
265
-
266
-
try {
267
-
await resendVerification(setupData.did)
268
-
resendMessage = 'Verification code resent!'
269
-
} catch (err) {
270
-
if (err instanceof ApiError) {
271
-
error = err.message || 'Failed to resend code'
272
-
} else if (err instanceof Error) {
273
-
error = err.message || 'Failed to resend code'
274
-
} else {
275
-
error = 'Failed to resend code'
276
-
}
277
-
} finally {
278
-
resendingCode = false
279
-
}
203
+
function isChannelAvailable(ch: string): boolean {
204
+
const available = serverInfo?.availableCommsChannels ?? ['email']
205
+
return available.includes(ch)
280
206
}
281
207
282
208
function channelLabel(ch: string): string {
···
289
215
}
290
216
}
291
217
292
-
function isChannelAvailable(ch: string): boolean {
293
-
const available = serverInfo?.availableCommsChannels ?? ['email']
294
-
return available.includes(ch)
295
-
}
218
+
let fullHandle = $derived(() => {
219
+
if (!flow?.info.handle.trim()) return ''
220
+
if (flow.info.handle.includes('.')) return flow.info.handle.trim()
221
+
const domain = serverInfo?.availableUserDomains?.[0]
222
+
if (domain) return `${flow.info.handle.trim()}.${domain}`
223
+
return flow.info.handle.trim()
224
+
})
296
225
297
-
function goToLogin() {
298
-
navigate('/login')
226
+
function extractDomain(did: string): string {
227
+
return did.replace('did:web:', '').replace(/%3A/g, ':')
299
228
}
300
229
301
-
let fullHandle = $derived(() => {
302
-
if (!handle.trim()) return ''
303
-
if (handle.includes('.')) return handle.trim()
304
-
const domain = serverInfo?.availableUserDomains?.[0]
305
-
if (domain) return `${handle.trim()}.${domain}`
306
-
return handle.trim()
307
-
})
230
+
function getSubtitle(): string {
231
+
if (!flow) return ''
232
+
switch (flow.state.step) {
233
+
case 'info': return 'Create an ultra-secure account using a passkey instead of a password.'
234
+
case 'key-choice': return 'Choose how to set up your external did:web identity.'
235
+
case 'initial-did-doc': return 'Upload your DID document to continue.'
236
+
case 'creating': return 'Creating your account...'
237
+
case 'passkey': return 'Register your passkey to secure your account.'
238
+
case 'app-password': return 'Save your app password for third-party apps.'
239
+
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
240
+
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
241
+
case 'activating': return 'Activating your account...'
242
+
case 'complete': return 'Your account has been created successfully!'
243
+
default: return ''
244
+
}
245
+
}
308
246
</script>
309
247
310
248
<div class="register-page">
311
-
{#if step === 'info'}
249
+
{#if flow?.state.step === 'info'}
312
250
<div class="migrate-callout">
313
251
<div class="migrate-icon">↗</div>
314
252
<div class="migrate-content">
···
322
260
{/if}
323
261
324
262
<h1>Create Passkey Account</h1>
325
-
<p class="subtitle">
326
-
{#if step === 'info'}
327
-
Create an ultra-secure account using a passkey instead of a password.
328
-
{:else if step === 'passkey'}
329
-
Register your passkey to secure your account.
330
-
{:else if step === 'app-password'}
331
-
Save your app password for third-party apps.
332
-
{:else if step === 'verify'}
333
-
Verify your {channelLabel(verificationChannel)} to complete registration.
334
-
{:else}
335
-
Your account has been created successfully!
336
-
{/if}
337
-
</p>
263
+
<p class="subtitle">{getSubtitle()}</p>
338
264
339
-
{#if error}
340
-
<div class="message error">{error}</div>
265
+
{#if flow?.state.error}
266
+
<div class="message error">{flow.state.error}</div>
341
267
{/if}
342
268
343
-
{#if loadingServerInfo}
269
+
{#if loadingServerInfo || !flow}
344
270
<p class="loading">Loading...</p>
345
-
{:else if step === 'info'}
271
+
272
+
{:else if flow.state.step === 'info'}
346
273
<form onsubmit={handleInfoSubmit}>
347
274
<div class="field">
348
275
<label for="handle">Handle</label>
349
276
<input
350
277
id="handle"
351
278
type="text"
352
-
bind:value={handle}
279
+
bind:value={flow.info.handle}
353
280
placeholder="yourname"
354
-
disabled={submitting}
281
+
disabled={flow.state.submitting}
355
282
required
356
283
/>
357
-
{#if handle.includes('.')}
284
+
{#if flow.info.handle.includes('.')}
358
285
<p class="hint warning">Custom domain handles can be set up after account creation.</p>
359
286
{:else if fullHandle()}
360
287
<p class="hint">Your full handle will be: @{fullHandle()}</p>
···
366
293
<p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p>
367
294
<div class="field">
368
295
<label for="verification-channel">Verification Method</label>
369
-
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
296
+
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
370
297
<option value="email">Email</option>
371
298
<option value="discord" disabled={!isChannelAvailable('discord')}>
372
299
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
···
379
306
</option>
380
307
</select>
381
308
</div>
382
-
{#if verificationChannel === 'email'}
309
+
{#if flow.info.verificationChannel === 'email'}
383
310
<div class="field">
384
311
<label for="email">Email Address</label>
385
-
<input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required />
312
+
<input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required />
386
313
</div>
387
-
{:else if verificationChannel === 'discord'}
314
+
{:else if flow.info.verificationChannel === 'discord'}
388
315
<div class="field">
389
316
<label for="discord-id">Discord User ID</label>
390
-
<input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required />
317
+
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required />
391
318
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
392
319
</div>
393
-
{:else if verificationChannel === 'telegram'}
320
+
{:else if flow.info.verificationChannel === 'telegram'}
394
321
<div class="field">
395
322
<label for="telegram-username">Telegram Username</label>
396
-
<input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required />
323
+
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required />
397
324
</div>
398
-
{:else if verificationChannel === 'signal'}
325
+
{:else if flow.info.verificationChannel === 'signal'}
399
326
<div class="field">
400
327
<label for="signal-number">Signal Phone Number</label>
401
-
<input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required />
328
+
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required />
402
329
<p class="hint">Include country code (e.g., +1 for US)</p>
403
330
</div>
404
331
{/if}
···
409
336
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
410
337
<div class="radio-group">
411
338
<label class="radio-label">
412
-
<input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
339
+
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
413
340
<span class="radio-content">
414
341
<strong>did:plc</strong> (Recommended)
415
342
<span class="radio-hint">Portable identity managed by PLC Directory</span>
416
343
</span>
417
344
</label>
418
345
<label class="radio-label">
419
-
<input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
346
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
420
347
<span class="radio-content">
421
348
<strong>did:web</strong>
422
349
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
423
350
</span>
424
351
</label>
425
352
<label class="radio-label">
426
-
<input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
353
+
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
427
354
<span class="radio-content">
428
355
<strong>did:web (BYOD)</strong>
429
356
<span class="radio-hint">Bring your own domain</span>
430
357
</span>
431
358
</label>
432
359
</div>
433
-
{#if didType === 'web'}
360
+
{#if flow.info.didType === 'web'}
434
361
<div class="warning-box">
435
362
<strong>Important: Understand the trade-offs</strong>
436
363
<ul>
···
441
368
</ul>
442
369
</div>
443
370
{/if}
444
-
{#if didType === 'web-external'}
371
+
{#if flow.info.didType === 'web-external'}
445
372
<div class="field">
446
373
<label for="external-did">Your did:web</label>
447
-
<input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required />
448
-
<p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
374
+
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required />
375
+
<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>
449
376
</div>
450
377
{/if}
451
378
</fieldset>
···
453
380
{#if serverInfo?.inviteCodeRequired}
454
381
<div class="field">
455
382
<label for="invite-code">Invite Code <span class="required">*</span></label>
456
-
<input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required />
383
+
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required />
457
384
</div>
458
385
{/if}
459
386
···
467
394
</ul>
468
395
</div>
469
396
470
-
<button type="submit" disabled={submitting}>
471
-
{submitting ? 'Creating account...' : 'Continue'}
397
+
<button type="submit" disabled={flow.state.submitting}>
398
+
{flow.state.submitting ? 'Creating account...' : 'Continue'}
472
399
</button>
473
400
</form>
474
401
475
402
<p class="link-text">
476
403
Want a traditional password? <a href="#/register">Register with password</a>
477
404
</p>
478
-
{:else if step === 'passkey'}
405
+
406
+
{:else if flow.state.step === 'key-choice'}
407
+
<KeyChoiceStep {flow} />
408
+
409
+
{:else if flow.state.step === 'initial-did-doc'}
410
+
<DidDocStep
411
+
{flow}
412
+
type="initial"
413
+
onConfirm={handleCreateAccount}
414
+
onBack={() => flow?.goBack()}
415
+
/>
416
+
417
+
{:else if flow.state.step === 'creating'}
418
+
{#await flow.createPasskeyAccount()}
419
+
<p class="loading">Creating your account...</p>
420
+
{/await}
421
+
422
+
{:else if flow.state.step === 'passkey'}
479
423
<div class="step-content">
480
424
<div class="field">
481
425
<label for="passkey-name">Passkey Name (optional)</label>
482
-
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} />
426
+
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} />
483
427
<p class="hint">A friendly name to identify this passkey</p>
484
428
</div>
485
429
···
492
436
</ul>
493
437
</div>
494
438
495
-
<button onclick={handlePasskeyRegistration} disabled={submitting} class="passkey-btn">
496
-
{submitting ? 'Creating Passkey...' : 'Create Passkey'}
439
+
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
440
+
{flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
497
441
</button>
498
442
499
-
<button type="button" class="secondary" onclick={() => step = 'info'} disabled={submitting}>
443
+
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
500
444
Back
501
445
</button>
502
446
</div>
503
-
{:else if step === 'app-password'}
504
-
<div class="step-content">
505
-
<div class="warning-box">
506
-
<strong>Important: Save this app password!</strong>
507
-
<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>
508
-
</div>
509
447
510
-
<div class="app-password-display">
511
-
<div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div>
512
-
<code class="app-password-code">{appPasswordResult?.appPassword}</code>
513
-
<button type="button" class="copy-btn" onclick={copyAppPassword}>
514
-
{appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'}
515
-
</button>
516
-
</div>
517
-
518
-
<div class="field">
519
-
<label class="checkbox-label">
520
-
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
521
-
<span>I have saved my app password in a secure location</span>
522
-
</label>
523
-
</div>
448
+
{:else if flow.state.step === 'app-password'}
449
+
<AppPasswordStep {flow} />
524
450
525
-
<button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button>
526
-
</div>
527
-
{:else if step === 'verify'}
528
-
<div class="step-content">
529
-
<p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p>
451
+
{:else if flow.state.step === 'verify'}
452
+
<VerificationStep {flow} />
530
453
531
-
{#if resendMessage}
532
-
<div class="message success">{resendMessage}</div>
533
-
{/if}
454
+
{:else if flow.state.step === 'updated-did-doc'}
455
+
<DidDocStep
456
+
{flow}
457
+
type="updated"
458
+
onConfirm={() => flow?.activateAccount()}
459
+
/>
534
460
535
-
<form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}>
536
-
<div class="field">
537
-
<label for="verification-code">Verification Code</label>
538
-
<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" />
539
-
</div>
540
-
541
-
<button type="submit" disabled={submitting || !verificationCode.trim()}>
542
-
{submitting ? 'Verifying...' : 'Verify Account'}
543
-
</button>
544
-
545
-
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
546
-
{resendingCode ? 'Resending...' : 'Resend Code'}
547
-
</button>
548
-
</form>
549
-
</div>
550
-
{:else if step === 'success'}
551
-
<div class="success-content">
552
-
<div class="success-icon">✔</div>
553
-
<h2>Account Created!</h2>
554
-
<p>Your passkey-only account has been created successfully.</p>
555
-
<p class="handle-display">@{setupData?.handle}</p>
556
-
<button onclick={goToLogin}>Sign In</button>
557
-
</div>
461
+
{:else if flow.state.step === 'redirect-to-dashboard'}
462
+
<p class="loading">Redirecting to dashboard...</p>
558
463
{/if}
559
464
</div>
560
465
···
609
514
text-decoration: underline;
610
515
}
611
516
612
-
h1, h2 {
517
+
h1 {
613
518
margin: 0 0 var(--space-3) 0;
614
519
}
615
520
···
697
602
color: var(--warning-text);
698
603
}
699
604
700
-
.warning-box p {
701
-
margin: 0;
702
-
color: var(--warning-text);
703
-
}
704
-
705
605
.warning-box ul {
706
606
margin: var(--space-4) 0 0 0;
707
607
padding-left: var(--space-5);
···
747
647
.passkey-btn {
748
648
padding: var(--space-5);
749
649
font-size: var(--text-lg);
750
-
}
751
-
752
-
.app-password-display {
753
-
background: var(--bg-card);
754
-
border: 2px solid var(--accent);
755
-
border-radius: var(--radius-xl);
756
-
padding: var(--space-6);
757
-
text-align: center;
758
-
}
759
-
760
-
.app-password-label {
761
-
font-size: var(--text-sm);
762
-
color: var(--text-secondary);
763
-
margin-bottom: var(--space-4);
764
-
}
765
-
766
-
.app-password-code {
767
-
display: block;
768
-
font-size: var(--text-xl);
769
-
font-family: ui-monospace, monospace;
770
-
letter-spacing: 0.1em;
771
-
padding: var(--space-5);
772
-
background: var(--bg-input);
773
-
border-radius: var(--radius-md);
774
-
margin-bottom: var(--space-4);
775
-
user-select: all;
776
-
}
777
-
778
-
.copy-btn {
779
-
margin-top: 0;
780
-
padding: var(--space-3) var(--space-5);
781
-
font-size: var(--text-sm);
782
-
}
783
-
784
-
.checkbox-label {
785
-
display: flex;
786
-
align-items: center;
787
-
gap: var(--space-3);
788
-
cursor: pointer;
789
-
font-weight: var(--font-normal);
790
-
}
791
-
792
-
.checkbox-label input[type="checkbox"] {
793
-
width: auto;
794
-
padding: 0;
795
-
}
796
-
797
-
.success-content {
798
-
text-align: center;
799
-
}
800
-
801
-
.success-icon {
802
-
font-size: var(--text-4xl);
803
-
color: var(--success-text);
804
-
margin-bottom: var(--space-4);
805
-
}
806
-
807
-
.success-content p {
808
-
color: var(--text-secondary);
809
-
}
810
-
811
-
.handle-display {
812
-
font-size: var(--text-xl);
813
-
font-weight: var(--font-semibold);
814
-
color: var(--text-primary);
815
-
margin: var(--space-4) 0;
816
-
}
817
-
818
-
.info-text {
819
-
color: var(--text-secondary);
820
-
margin: 0;
821
650
}
822
651
823
652
.link-text {
+14
-12
src/api/identity/account.rs
+14
-12
src/api/identity/account.rs
···
118
118
None
119
119
};
120
120
121
-
let is_migration = migration_auth.is_some()
121
+
let is_did_web_byod = migration_auth.is_some()
122
122
&& input
123
123
.did
124
124
.as_ref()
125
-
.map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:"))
125
+
.map(|d| d.starts_with("did:web:"))
126
126
.unwrap_or(false);
127
127
128
-
let is_did_web_byod = migration_auth.is_some()
128
+
let is_migration = migration_auth.is_some()
129
129
&& input
130
130
.did
131
131
.as_ref()
132
-
.map(|d| d.starts_with("did:web:"))
132
+
.map(|d| d.starts_with("did:plc:"))
133
133
.unwrap_or(false);
134
134
135
-
if is_migration {
136
-
if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
135
+
if is_migration || is_did_web_byod {
136
+
if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
137
137
{
138
-
if migration_did != auth_did {
138
+
if provided_did != auth_did {
139
139
return (
140
140
StatusCode::FORBIDDEN,
141
141
Json(json!({
142
142
"error": "AuthorizationError",
143
-
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
143
+
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
144
144
})),
145
145
)
146
146
.into_response();
147
147
}
148
148
if is_did_web_byod {
149
-
info!(did = %migration_did, "Processing did:web BYOD account creation");
149
+
info!(did = %provided_did, "Processing did:web BYOD account creation");
150
150
} else {
151
-
info!(did = %migration_did, "Processing account migration");
151
+
info!(did = %provided_did, "Processing account migration");
152
152
}
153
153
}
154
154
}
···
717
717
.await
718
718
.map(|c| c.unwrap_or(0) == 0)
719
719
.unwrap_or(false);
720
-
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration {
720
+
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod {
721
721
Some(chrono::Utc::now())
722
722
} else {
723
723
None
···
946
946
)
947
947
.into_response();
948
948
}
949
-
if !is_migration {
949
+
if !is_migration && !is_did_web_byod {
950
950
if let Err(e) =
951
951
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
952
952
{
···
972
972
{
973
973
warn!("Failed to create default profile for {}: {}", did, e);
974
974
}
975
+
}
976
+
if !is_migration {
975
977
if let Some(ref recipient) = verification_recipient
976
978
&& let Err(e) = crate::comms::enqueue_signup_verification(
977
979
&state.db,
+105
-40
src/api/server/passkey_account.rs
+105
-40
src/api/server/passkey_account.rs
···
12
12
use serde::{Deserialize, Serialize};
13
13
use serde_json::json;
14
14
use std::sync::Arc;
15
-
use tracing::{error, info, warn};
15
+
use tracing::{debug, error, info, warn};
16
16
use uuid::Uuid;
17
17
18
18
use crate::api::repo::record::utils::create_signed_commit;
19
+
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
19
20
use crate::state::{AppState, RateLimitKind};
20
21
use crate::validation::validate_password;
21
22
···
105
106
)
106
107
.into_response();
107
108
}
109
+
110
+
let byod_auth = if let Some(token) =
111
+
extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
112
+
{
113
+
if is_service_token(&token) {
114
+
let verifier = ServiceTokenVerifier::new();
115
+
match verifier
116
+
.verify_service_token(&token, Some("com.atproto.server.createAccount"))
117
+
.await
118
+
{
119
+
Ok(claims) => {
120
+
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
121
+
Some(claims.iss)
122
+
}
123
+
Err(e) => {
124
+
error!("Service token verification failed: {:?}", e);
125
+
return (
126
+
StatusCode::UNAUTHORIZED,
127
+
Json(json!({
128
+
"error": "AuthenticationFailed",
129
+
"message": format!("Service token verification failed: {}", e)
130
+
})),
131
+
)
132
+
.into_response();
133
+
}
134
+
}
135
+
} else {
136
+
None
137
+
}
138
+
} else {
139
+
None
140
+
};
141
+
142
+
let is_byod_did_web = byod_auth.is_some()
143
+
&& input
144
+
.did
145
+
.as_ref()
146
+
.map(|d| d.starts_with("did:web:"))
147
+
.unwrap_or(false);
108
148
109
149
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
110
150
let pds_suffix = format!(".{}", hostname);
···
301
341
)
302
342
.into_response();
303
343
}
304
-
if let Err(e) = crate::api::identity::did::verify_did_web(
305
-
d,
306
-
&hostname,
307
-
&input.handle,
308
-
input.signing_key.as_deref(),
309
-
)
310
-
.await
311
-
{
312
-
return (
313
-
StatusCode::BAD_REQUEST,
314
-
Json(json!({"error": "InvalidDid", "message": e})),
344
+
if is_byod_did_web {
345
+
if let Some(ref auth_did) = byod_auth {
346
+
if d != auth_did {
347
+
return (
348
+
StatusCode::FORBIDDEN,
349
+
Json(json!({
350
+
"error": "AuthorizationError",
351
+
"message": format!("Service token issuer {} does not match DID {}", auth_did, d)
352
+
})),
353
+
)
354
+
.into_response();
355
+
}
356
+
}
357
+
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
358
+
} else {
359
+
if let Err(e) = crate::api::identity::did::verify_did_web(
360
+
d,
361
+
&hostname,
362
+
&input.handle,
363
+
input.signing_key.as_deref(),
315
364
)
316
-
.into_response();
365
+
.await
366
+
{
367
+
return (
368
+
StatusCode::BAD_REQUEST,
369
+
Json(json!({"error": "InvalidDid", "message": e})),
370
+
)
371
+
.into_response();
372
+
}
373
+
info!(did = %d, "Creating external did:web passkey account (reserved key)");
317
374
}
318
-
info!(did = %d, "Creating external did:web passkey account");
319
375
d.to_string()
320
376
}
321
377
_ => {
···
398
454
.map(|c| c.unwrap_or(0) == 0)
399
455
.unwrap_or(false);
400
456
457
+
let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web {
458
+
Some(Utc::now())
459
+
} else {
460
+
None
461
+
};
462
+
401
463
let user_insert: Result<(Uuid,), _> = sqlx::query_as(
402
464
r#"INSERT INTO users (
403
465
handle, email, did, password_hash, password_required,
404
466
preferred_comms_channel,
405
467
discord_id, telegram_username, signal_number,
406
468
recovery_token, recovery_token_expires_at,
407
-
is_admin
408
-
) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10) RETURNING id"#,
469
+
is_admin, deactivated_at
470
+
) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
409
471
)
410
472
.bind(&handle)
411
473
.bind(&email)
···
435
497
.bind(&setup_token_hash)
436
498
.bind(setup_expires_at)
437
499
.bind(is_first_user)
500
+
.bind(deactivated_at)
438
501
.fetch_one(&mut *tx)
439
502
.await;
440
503
···
612
675
.into_response();
613
676
}
614
677
615
-
if let Err(e) =
616
-
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
617
-
{
618
-
warn!("Failed to sequence identity event for {}: {}", did, e);
619
-
}
620
-
if let Err(e) =
621
-
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
622
-
{
623
-
warn!("Failed to sequence account event for {}: {}", did, e);
624
-
}
625
-
let profile_record = serde_json::json!({
626
-
"$type": "app.bsky.actor.profile",
627
-
"displayName": handle
628
-
});
629
-
if let Err(e) = crate::api::repo::record::create_record_internal(
630
-
&state,
631
-
&did,
632
-
"app.bsky.actor.profile",
633
-
"self",
634
-
&profile_record,
635
-
)
636
-
.await
637
-
{
638
-
warn!("Failed to create default profile for {}: {}", did, e);
678
+
if !is_byod_did_web {
679
+
if let Err(e) =
680
+
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
681
+
{
682
+
warn!("Failed to sequence identity event for {}: {}", did, e);
683
+
}
684
+
if let Err(e) =
685
+
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
686
+
{
687
+
warn!("Failed to sequence account event for {}: {}", did, e);
688
+
}
689
+
let profile_record = serde_json::json!({
690
+
"$type": "app.bsky.actor.profile",
691
+
"displayName": handle
692
+
});
693
+
if let Err(e) = crate::api::repo::record::create_record_internal(
694
+
&state,
695
+
&did,
696
+
"app.bsky.actor.profile",
697
+
"self",
698
+
&profile_record,
699
+
)
700
+
.await
701
+
{
702
+
warn!("Failed to create default profile for {}: {}", did, e);
703
+
}
639
704
}
640
705
641
706
if let Err(e) = crate::comms::enqueue_signup_verification(