this repo has no description
1<script lang="ts">
2 import { register, getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
5 import { _ } from '../lib/i18n'
6
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 let serverInfo = $state<{
23 availableUserDomains: string[]
24 inviteCodeRequired: boolean
25 } | null>(null)
26 let loadingServerInfo = $state(true)
27 let serverInfoLoaded = false
28
29 const auth = getAuthState()
30
31 $effect(() => {
32 if (!serverInfoLoaded) {
33 serverInfoLoaded = true
34 loadServerInfo()
35 }
36 })
37
38 async function loadServerInfo() {
39 try {
40 serverInfo = await api.describeServer()
41 } catch (e) {
42 console.error('Failed to load server info:', e)
43 } finally {
44 loadingServerInfo = false
45 }
46 }
47
48 let handleHasDot = $derived(handle.includes('.'))
49
50 function validateForm(): string | null {
51 if (!handle.trim()) return $_('register.validation.handleRequired')
52 if (handle.includes('.')) return $_('register.validation.handleNoDots')
53 if (!password) return $_('register.validation.passwordRequired')
54 if (password.length < 8) return $_('register.validation.passwordLength')
55 if (password !== confirmPassword) return $_('register.validation.passwordsMismatch')
56 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
57 return $_('register.validation.inviteCodeRequired')
58 }
59 if (didType === 'web-external') {
60 if (!externalDid.trim()) return $_('register.validation.externalDidRequired')
61 if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat')
62 }
63 switch (verificationChannel) {
64 case 'email':
65 if (!email.trim()) return $_('register.validation.emailRequired')
66 break
67 case 'discord':
68 if (!discordId.trim()) return $_('register.validation.discordIdRequired')
69 break
70 case 'telegram':
71 if (!telegramUsername.trim()) return $_('register.validation.telegramRequired')
72 break
73 case 'signal':
74 if (!signalNumber.trim()) return $_('register.validation.signalRequired')
75 break
76 }
77 return null
78 }
79
80 async function handleSubmit(e: Event) {
81 e.preventDefault()
82 const validationError = validateForm()
83 if (validationError) {
84 error = validationError
85 return
86 }
87 submitting = true
88 error = null
89 try {
90 const result = await register({
91 handle: handle.trim(),
92 email: email.trim(),
93 password,
94 inviteCode: inviteCode.trim() || undefined,
95 didType,
96 did: didType === 'web-external' ? externalDid.trim() : undefined,
97 verificationChannel,
98 discordId: discordId.trim() || undefined,
99 telegramUsername: telegramUsername.trim() || undefined,
100 signalNumber: signalNumber.trim() || undefined,
101 })
102 if (result.verificationRequired) {
103 localStorage.setItem(STORAGE_KEY, JSON.stringify({
104 did: result.did,
105 handle: result.handle,
106 channel: result.verificationChannel,
107 }))
108 navigate('/verify')
109 } else {
110 navigate('/dashboard')
111 }
112 } catch (err: any) {
113 if (err instanceof ApiError) {
114 error = err.message || 'Registration failed'
115 } else if (err instanceof Error) {
116 error = err.message || 'Registration failed'
117 } else {
118 error = 'Registration failed'
119 }
120 } finally {
121 submitting = false
122 }
123 }
124
125 let fullHandle = $derived(() => {
126 if (!handle.trim()) return ''
127 if (handle.includes('.')) return handle.trim()
128 const domain = serverInfo?.availableUserDomains?.[0]
129 if (domain) return `${handle.trim()}.${domain}`
130 return handle.trim()
131 })
132</script>
133
134<div class="register-page">
135 {#if error}
136 <div class="message error">{error}</div>
137 {/if}
138
139 <h1>{$_('register.title')}</h1>
140 <p class="subtitle">{$_('register.subtitle')}</p>
141
142 {#if loadingServerInfo}
143 <p class="loading">{$_('common.loading')}</p>
144 {:else}
145 <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
146 <div class="field">
147 <label for="handle">{$_('register.handle')}</label>
148 <input
149 id="handle"
150 type="text"
151 bind:value={handle}
152 placeholder={$_('register.handlePlaceholder')}
153 disabled={submitting}
154 required
155 />
156 {#if handleHasDot}
157 <p class="hint warning">{$_('register.handleDotWarning')}</p>
158 {:else if fullHandle()}
159 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
160 {/if}
161 </div>
162
163 <div class="field">
164 <label for="password">{$_('register.password')}</label>
165 <input
166 id="password"
167 type="password"
168 bind:value={password}
169 placeholder={$_('register.passwordPlaceholder')}
170 disabled={submitting}
171 required
172 minlength="8"
173 />
174 </div>
175
176 <div class="field">
177 <label for="confirm-password">{$_('register.confirmPassword')}</label>
178 <input
179 id="confirm-password"
180 type="password"
181 bind:value={confirmPassword}
182 placeholder={$_('register.confirmPasswordPlaceholder')}
183 disabled={submitting}
184 required
185 />
186 </div>
187
188 <fieldset class="section-fieldset">
189 <legend>{$_('register.identityType')}</legend>
190 <p class="section-hint">{$_('register.identityHint')}</p>
191
192 <div class="radio-group">
193 <label class="radio-label">
194 <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} />
195 <span class="radio-content">
196 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
197 <span class="radio-hint">{$_('register.didPlcHint')}</span>
198 </span>
199 </label>
200
201 <label class="radio-label">
202 <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} />
203 <span class="radio-content">
204 <strong>{$_('register.didWeb')}</strong>
205 <span class="radio-hint">{$_('register.didWebHint')}</span>
206 </span>
207 </label>
208
209 <label class="radio-label">
210 <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} />
211 <span class="radio-content">
212 <strong>{$_('register.didWebBYOD')}</strong>
213 <span class="radio-hint">{$_('register.didWebBYODHint')}</span>
214 </span>
215 </label>
216 </div>
217
218 {#if didType === 'web'}
219 <div class="warning-box">
220 <strong>{$_('register.didWebWarningTitle')}</strong>
221 <ul>
222 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
223 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
224 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
225 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
226 </ul>
227 </div>
228 {/if}
229
230 {#if didType === 'web-external'}
231 <div class="field">
232 <label for="external-did">{$_('register.externalDid')}</label>
233 <input
234 id="external-did"
235 type="text"
236 bind:value={externalDid}
237 placeholder={$_('register.externalDidPlaceholder')}
238 disabled={submitting}
239 required
240 />
241 <p class="hint">{$_('register.externalDidHint')}</p>
242 </div>
243 {/if}
244 </fieldset>
245
246 <fieldset class="section-fieldset">
247 <legend>{$_('register.contactMethod')}</legend>
248 <p class="section-hint">{$_('register.contactMethodHint')}</p>
249
250 <div class="field">
251 <label for="verification-channel">{$_('register.verificationMethod')}</label>
252 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
253 <option value="email">{$_('register.email')}</option>
254 <option value="discord">{$_('register.discord')}</option>
255 <option value="telegram">{$_('register.telegram')}</option>
256 <option value="signal">{$_('register.signal')}</option>
257 </select>
258 </div>
259
260 {#if verificationChannel === 'email'}
261 <div class="field">
262 <label for="email">{$_('register.emailAddress')}</label>
263 <input
264 id="email"
265 type="email"
266 bind:value={email}
267 placeholder={$_('register.emailPlaceholder')}
268 disabled={submitting}
269 required
270 />
271 </div>
272 {:else if verificationChannel === 'discord'}
273 <div class="field">
274 <label for="discord-id">{$_('register.discordId')}</label>
275 <input
276 id="discord-id"
277 type="text"
278 bind:value={discordId}
279 placeholder={$_('register.discordIdPlaceholder')}
280 disabled={submitting}
281 required
282 />
283 <p class="hint">{$_('register.discordIdHint')}</p>
284 </div>
285 {:else if verificationChannel === 'telegram'}
286 <div class="field">
287 <label for="telegram-username">{$_('register.telegramUsername')}</label>
288 <input
289 id="telegram-username"
290 type="text"
291 bind:value={telegramUsername}
292 placeholder={$_('register.telegramUsernamePlaceholder')}
293 disabled={submitting}
294 required
295 />
296 </div>
297 {:else if verificationChannel === 'signal'}
298 <div class="field">
299 <label for="signal-number">{$_('register.signalNumber')}</label>
300 <input
301 id="signal-number"
302 type="tel"
303 bind:value={signalNumber}
304 placeholder={$_('register.signalNumberPlaceholder')}
305 disabled={submitting}
306 required
307 />
308 <p class="hint">{$_('register.signalNumberHint')}</p>
309 </div>
310 {/if}
311 </fieldset>
312
313 {#if serverInfo?.inviteCodeRequired}
314 <div class="field">
315 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
316 <input
317 id="invite-code"
318 type="text"
319 bind:value={inviteCode}
320 placeholder={$_('register.inviteCodePlaceholder')}
321 disabled={submitting}
322 required
323 />
324 </div>
325 {/if}
326
327 <button type="submit" disabled={submitting}>
328 {submitting ? $_('register.creating') : $_('register.createButton')}
329 </button>
330 </form>
331
332 <p class="link-text">
333 {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
334 </p>
335 <p class="link-text">
336 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
337 </p>
338 {/if}
339</div>
340
341<style>
342 .register-page {
343 max-width: var(--width-sm);
344 margin: var(--space-9) auto;
345 padding: var(--space-7);
346 }
347
348 h1 {
349 margin: 0 0 var(--space-3) 0;
350 }
351
352 .subtitle {
353 color: var(--text-secondary);
354 margin: 0 0 var(--space-7) 0;
355 }
356
357 .loading {
358 text-align: center;
359 color: var(--text-secondary);
360 }
361
362 form {
363 display: flex;
364 flex-direction: column;
365 gap: var(--space-5);
366 }
367
368 .required {
369 color: var(--error-text);
370 }
371
372 .section-fieldset {
373 border: 1px solid var(--border-color);
374 border-radius: var(--radius-lg);
375 padding: var(--space-5);
376 }
377
378 .section-fieldset legend {
379 font-weight: var(--font-semibold);
380 padding: 0 var(--space-3);
381 }
382
383 .section-hint {
384 font-size: var(--text-sm);
385 color: var(--text-secondary);
386 margin: 0 0 var(--space-5) 0;
387 }
388
389 .radio-group {
390 display: flex;
391 flex-direction: column;
392 gap: var(--space-4);
393 }
394
395 .radio-label {
396 display: flex;
397 align-items: flex-start;
398 gap: var(--space-3);
399 cursor: pointer;
400 font-size: var(--text-base);
401 font-weight: var(--font-normal);
402 margin-bottom: 0;
403 }
404
405 .radio-label input[type="radio"] {
406 margin-top: var(--space-1);
407 width: auto;
408 }
409
410 .radio-content {
411 display: flex;
412 flex-direction: column;
413 gap: var(--space-1);
414 }
415
416 .radio-hint {
417 font-size: var(--text-xs);
418 color: var(--text-secondary);
419 }
420
421 .warning-box {
422 margin-top: var(--space-5);
423 padding: var(--space-5);
424 background: var(--warning-bg);
425 border: 1px solid var(--warning-border);
426 border-radius: var(--radius-lg);
427 font-size: var(--text-sm);
428 }
429
430 .warning-box strong {
431 color: var(--warning-text);
432 }
433
434 .warning-box ul {
435 margin: var(--space-4) 0 0 0;
436 padding-left: var(--space-5);
437 }
438
439 .warning-box li {
440 margin-bottom: var(--space-3);
441 line-height: var(--leading-normal);
442 }
443
444 .warning-box li:last-child {
445 margin-bottom: 0;
446 }
447
448 button[type="submit"] {
449 margin-top: var(--space-3);
450 }
451
452 .link-text {
453 text-align: center;
454 margin-top: var(--space-6);
455 color: var(--text-secondary);
456 }
457
458 .link-text a {
459 color: var(--accent);
460 }
461</style>