+2
-2
frontend/src/App.svelte
+2
-2
frontend/src/App.svelte
+56
frontend/src/components/AccountTypeSwitcher.svelte
+56
frontend/src/components/AccountTypeSwitcher.svelte
···
1
+
<script lang="ts">
2
+
import { _ } from '../lib/i18n'
3
+
import { getFullUrl } from '../lib/router.svelte'
4
+
import { routes } from '../lib/types/routes'
5
+
6
+
interface Props {
7
+
active: 'passkey' | 'password'
8
+
}
9
+
10
+
let { active }: Props = $props()
11
+
</script>
12
+
13
+
<div class="account-type-switcher">
14
+
<a href={getFullUrl(routes.register)} class="switcher-option" class:active={active === 'passkey'}>
15
+
{$_('register.passkeyAccount')}
16
+
</a>
17
+
<a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}>
18
+
{$_('register.passwordAccount')}
19
+
</a>
20
+
</div>
21
+
22
+
<style>
23
+
.account-type-switcher {
24
+
display: flex;
25
+
gap: var(--space-2);
26
+
padding: var(--space-1);
27
+
background: var(--bg-secondary);
28
+
border-radius: var(--radius-lg);
29
+
margin-bottom: var(--space-6);
30
+
}
31
+
32
+
.switcher-option {
33
+
flex: 1;
34
+
display: flex;
35
+
align-items: center;
36
+
justify-content: center;
37
+
gap: var(--space-2);
38
+
padding: var(--space-3) var(--space-4);
39
+
border-radius: var(--radius-md);
40
+
text-decoration: none;
41
+
color: var(--text-secondary);
42
+
font-weight: var(--font-medium);
43
+
transition: all 0.15s ease;
44
+
}
45
+
46
+
.switcher-option:hover {
47
+
color: var(--text-primary);
48
+
background: var(--bg-tertiary);
49
+
}
50
+
51
+
.switcher-option.active {
52
+
background: var(--bg-primary);
53
+
color: var(--text-primary);
54
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
55
+
}
56
+
</style>
+1
-1
frontend/src/lib/types/routes.ts
+1
-1
frontend/src/lib/types/routes.ts
+9
-2
frontend/src/locales/en.json
+9
-2
frontend/src/locales/en.json
···
168
168
"createButton": "Create Account",
169
169
"alreadyHaveAccount": "Already have an account?",
170
170
"signIn": "Sign in",
171
-
"wantPasswordless": "Want passwordless security?",
172
-
"createPasskeyAccount": "Create a passkey account",
171
+
"passkeyAccount": "Passkey",
172
+
"passwordAccount": "Password",
173
173
"validation": {
174
174
"handleRequired": "Handle is required",
175
175
"handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.",
···
765
765
"verified": "Verified!",
766
766
"channelVerified": "Your {channel} has been verified successfully.",
767
767
"canNowSignIn": "You can now sign in to your account.",
768
+
"migrationContinue": "You can close this tab and continue your migration in the original window.",
768
769
"continue": "Continue",
769
770
"identifierLabel": "Email or Identifier",
770
771
"identifierPlaceholder": "you@example.com",
···
904
905
"whyPasskeyBullet1": "Cannot be phished or stolen in data breaches",
905
906
"whyPasskeyBullet2": "Use hardware-backed cryptographic keys",
906
907
"whyPasskeyBullet3": "Require your biometric or device PIN to use",
908
+
"infoWhyPasskey": "Why use a passkey?",
909
+
"infoWhyPasskeyDesc": "Passkeys are cryptographic credentials stored on your device. They cannot be phished, guessed, or stolen in data breaches like passwords can.",
910
+
"infoHowItWorks": "How it works",
911
+
"infoHowItWorksDesc": "When you sign in, your device will prompt you to verify with Face ID, Touch ID, or your device PIN. No password to remember or type.",
912
+
"infoAppAccess": "Using third-party apps",
913
+
"infoAppAccessDesc": "After creating your account, you will receive an app password. Use this to sign in to Bluesky apps and other AT Protocol clients.",
907
914
"passkeyNameLabel": "Passkey Name (optional)",
908
915
"passkeyNamePlaceholder": "e.g., MacBook Touch ID",
909
916
"passkeyNameHint": "A friendly name to identify this passkey",
+9
-2
frontend/src/locales/fi.json
+9
-2
frontend/src/locales/fi.json
···
168
168
"createButton": "Luo tili",
169
169
"alreadyHaveAccount": "Onko sinulla jo tili?",
170
170
"signIn": "Kirjaudu sisään",
171
-
"wantPasswordless": "Haluatko salasanattoman turvallisuuden?",
172
-
"createPasskeyAccount": "Luo pääsyavaintili",
171
+
"passkeyAccount": "Pääsyavain",
172
+
"passwordAccount": "Salasana",
173
173
"validation": {
174
174
"handleRequired": "Käyttäjänimi vaaditaan",
175
175
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
···
759
759
"verified": "Vahvistettu!",
760
760
"channelVerified": "{channel} on vahvistettu onnistuneesti.",
761
761
"canNowSignIn": "Voit nyt kirjautua tilillesi.",
762
+
"migrationContinue": "Voit sulkea tämän välilehden ja jatkaa siirtoa alkuperäisessä ikkunassa.",
762
763
"continue": "Jatka",
763
764
"identifierLabel": "Sähköposti tai tunniste",
764
765
"identifierPlaceholder": "sinä@esimerkki.fi",
···
886
887
"whyPasskeyBullet1": "Ei voi kalastella tai varastaa tietomurroissa",
887
888
"whyPasskeyBullet2": "Käyttää laitteistopohjaisia salausavaimia",
888
889
"whyPasskeyBullet3": "Vaatii biometrisen tunnistuksen tai laitteen PIN-koodin",
890
+
"infoWhyPasskey": "Miksi käyttää pääsyavainta?",
891
+
"infoWhyPasskeyDesc": "Pääsyavaimet ovat laitteellesi tallennettuja salattuja tunnistetietoja. Niitä ei voi kalastella, arvata tai varastaa tietomurroissa kuten salasanoja.",
892
+
"infoHowItWorks": "Miten se toimii",
893
+
"infoHowItWorksDesc": "Kirjautuessasi laitteesi pyytää sinua vahvistamaan Face ID:llä, Touch ID:llä tai laitteen PIN-koodilla. Ei salasanaa muistettavaksi tai kirjoitettavaksi.",
894
+
"infoAppAccess": "Kolmannen osapuolen sovellusten käyttö",
895
+
"infoAppAccessDesc": "Tilin luomisen jälkeen saat sovellussalasanan. Käytä sitä kirjautuaksesi Bluesky-sovelluksiin ja muihin AT Protocol -asiakkaisiin.",
889
896
"whyPasskeyOnly": "Miksi vain pääsyavain?",
890
897
"whyPasskeyOnlyDesc": "Pääsyavaintilit ovat turvallisempia kuin salasanapohjaiset tilit, koska ne:",
891
898
"subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.",
+9
-2
frontend/src/locales/ja.json
+9
-2
frontend/src/locales/ja.json
···
161
161
"createButton": "アカウントを作成",
162
162
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
163
163
"signIn": "サインイン",
164
-
"wantPasswordless": "パスワードレスをご希望ですか?",
165
-
"createPasskeyAccount": "パスキーアカウントを作成",
164
+
"passkeyAccount": "パスキー",
165
+
"passwordAccount": "パスワード",
166
166
"validation": {
167
167
"handleRequired": "ハンドルは必須です",
168
168
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
···
752
752
"verified": "確認完了!",
753
753
"channelVerified": "{channel} が正常に確認されました。",
754
754
"canNowSignIn": "アカウントにサインインできるようになりました。",
755
+
"migrationContinue": "このタブを閉じて、元のウィンドウで移行を続けてください。",
755
756
"continue": "続行",
756
757
"identifierLabel": "メールまたは識別子",
757
758
"identifierPlaceholder": "you@example.com",
···
879
880
"whyPasskeyBullet1": "フィッシングやデータ侵害で盗まれない",
880
881
"whyPasskeyBullet2": "ハードウェア支援の暗号鍵を使用",
881
882
"whyPasskeyBullet3": "生体認証またはデバイスPINが必要",
883
+
"infoWhyPasskey": "なぜパスキーを使うのですか?",
884
+
"infoWhyPasskeyDesc": "パスキーはデバイスに保存される暗号化資格情報です。パスワードのようにフィッシング、推測、データ侵害による盗難の被害を受けません。",
885
+
"infoHowItWorks": "仕組み",
886
+
"infoHowItWorksDesc": "サインイン時、デバイスがFace ID、Touch ID、またはデバイスPINでの確認を求めます。覚えたり入力したりするパスワードはありません。",
887
+
"infoAppAccess": "サードパーティアプリの使用",
888
+
"infoAppAccessDesc": "アカウント作成後、アプリパスワードが発行されます。Blueskyアプリやその他のAT Protocolクライアントへのサインインに使用してください。",
882
889
"whyPasskeyOnly": "なぜパスキーのみ?",
883
890
"whyPasskeyOnlyDesc": "パスキーアカウントはパスワードベースのアカウントより安全です:",
884
891
"subtitleInitialDidDoc": "続行するにはDIDドキュメントをアップロードしてください。",
+9
-2
frontend/src/locales/ko.json
+9
-2
frontend/src/locales/ko.json
···
161
161
"createButton": "계정 만들기",
162
162
"alreadyHaveAccount": "이미 계정이 있으신가요?",
163
163
"signIn": "로그인",
164
-
"wantPasswordless": "비밀번호 없는 보안을 원하시나요?",
165
-
"createPasskeyAccount": "패스키 계정 만들기",
164
+
"passkeyAccount": "패스키",
165
+
"passwordAccount": "비밀번호",
166
166
"validation": {
167
167
"handleRequired": "핸들은 필수입니다",
168
168
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
···
752
752
"verified": "인증 완료!",
753
753
"channelVerified": "{channel}이(가) 성공적으로 인증되었습니다.",
754
754
"canNowSignIn": "이제 계정에 로그인할 수 있습니다.",
755
+
"migrationContinue": "이 탭을 닫고 원래 창에서 마이그레이션을 계속할 수 있습니다.",
755
756
"continue": "계속",
756
757
"identifierLabel": "이메일 또는 식별자",
757
758
"identifierPlaceholder": "you@example.com",
···
879
880
"whyPasskeyBullet1": "피싱이나 데이터 유출로 도난당할 수 없음",
880
881
"whyPasskeyBullet2": "하드웨어 기반 암호화 키 사용",
881
882
"whyPasskeyBullet3": "생체 인식 또는 기기 PIN 필요",
883
+
"infoWhyPasskey": "왜 패스키를 사용하나요?",
884
+
"infoWhyPasskeyDesc": "패스키는 기기에 저장된 암호화 자격 증명입니다. 비밀번호처럼 피싱, 추측 또는 데이터 유출로 도난당할 수 없습니다.",
885
+
"infoHowItWorks": "작동 방식",
886
+
"infoHowItWorksDesc": "로그인할 때 기기에서 Face ID, Touch ID 또는 기기 PIN으로 인증하라는 메시지가 표시됩니다. 기억하거나 입력할 비밀번호가 없습니다.",
887
+
"infoAppAccess": "서드파티 앱 사용",
888
+
"infoAppAccessDesc": "계정 생성 후 앱 비밀번호를 받게 됩니다. Bluesky 앱 및 기타 AT Protocol 클라이언트에 로그인할 때 사용하세요.",
882
889
"whyPasskeyOnly": "왜 패스키만 사용하나요?",
883
890
"whyPasskeyOnlyDesc": "패스키 계정은 비밀번호 기반 계정보다 안전합니다:",
884
891
"subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.",
+9
-2
frontend/src/locales/sv.json
+9
-2
frontend/src/locales/sv.json
···
161
161
"createButton": "Skapa konto",
162
162
"alreadyHaveAccount": "Har du redan ett konto?",
163
163
"signIn": "Logga in",
164
-
"wantPasswordless": "Vill du ha lösenordsfri säkerhet?",
165
-
"createPasskeyAccount": "Skapa ett nyckelbaserat konto",
164
+
"passkeyAccount": "Nyckel",
165
+
"passwordAccount": "Lösenord",
166
166
"validation": {
167
167
"handleRequired": "Användarnamn krävs",
168
168
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
···
752
752
"verified": "Verifierad!",
753
753
"channelVerified": "Din {channel} har verifierats.",
754
754
"canNowSignIn": "Du kan nu logga in på ditt konto.",
755
+
"migrationContinue": "Du kan stänga denna flik och fortsätta migreringen i det ursprungliga fönstret.",
755
756
"continue": "Fortsätt",
756
757
"identifierLabel": "E-post eller identifierare",
757
758
"identifierPlaceholder": "du@exempel.se",
···
879
880
"whyPasskeyBullet1": "Kan inte nätfiskas eller stjälas vid dataintrång",
880
881
"whyPasskeyBullet2": "Använder hårdvarubaserade kryptografiska nycklar",
881
882
"whyPasskeyBullet3": "Kräver din biometri eller enhets-PIN för att använda",
883
+
"infoWhyPasskey": "Varfor anvanda nyckel?",
884
+
"infoWhyPasskeyDesc": "Nycklar ar kryptografiska uppgifter som lagras pa din enhet. De kan inte nätfiskas, gissas eller stjälas vid dataintrång som losenord kan.",
885
+
"infoHowItWorks": "Hur det fungerar",
886
+
"infoHowItWorksDesc": "När du loggar in kommer din enhet att be dig verifiera med Face ID, Touch ID eller din enhets-PIN. Inget lösenord att komma ihåg eller skriva.",
887
+
"infoAppAccess": "Använda tredjepartsappar",
888
+
"infoAppAccessDesc": "Efter att du skapat ditt konto får du ett applösenord. Använd detta för att logga in på Bluesky-appar och andra AT Protocol-klienter.",
882
889
"whyPasskeyOnly": "Varför endast nyckel?",
883
890
"whyPasskeyOnlyDesc": "Nyckelkonton är säkrare än lösenordsbaserade konton eftersom de:",
884
891
"subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.",
+9
-2
frontend/src/locales/zh.json
+9
-2
frontend/src/locales/zh.json
···
161
161
"createButton": "创建账户",
162
162
"alreadyHaveAccount": "已有账户?",
163
163
"signIn": "立即登录",
164
-
"wantPasswordless": "想要无密码登录?",
165
-
"createPasskeyAccount": "创建通行密钥账户",
164
+
"passkeyAccount": "通行密钥",
165
+
"passwordAccount": "密码",
166
166
"validation": {
167
167
"handleRequired": "请输入用户名",
168
168
"handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。",
···
758
758
"verified": "验证成功!",
759
759
"channelVerified": "您的{channel}已成功验证。",
760
760
"canNowSignIn": "您现在可以登录账户。",
761
+
"migrationContinue": "您可以关闭此标签页,在原窗口中继续迁移。",
761
762
"continue": "继续",
762
763
"identifierLabel": "邮箱或标识符",
763
764
"identifierPlaceholder": "you@example.com",
···
896
897
"whyPasskeyBullet1": "无法被钓鱼或在数据泄露中被盗",
897
898
"whyPasskeyBullet2": "使用硬件支持的加密密钥",
898
899
"whyPasskeyBullet3": "需要您的生物识别或设备 PIN 才能使用",
900
+
"infoWhyPasskey": "为什么使用通行密钥?",
901
+
"infoWhyPasskeyDesc": "通行密钥是存储在您设备上的加密凭证。与密码不同,它们无法被钓鱼、猜测或在数据泄露中被盗。",
902
+
"infoHowItWorks": "工作原理",
903
+
"infoHowItWorksDesc": "登录时,您的设备会提示您使用 Face ID、Touch ID 或设备 PIN 进行验证。无需记住或输入密码。",
904
+
"infoAppAccess": "使用第三方应用",
905
+
"infoAppAccessDesc": "创建账户后,您将收到一个应用密码。使用它登录 Bluesky 应用和其他 AT Protocol 客户端。",
899
906
"passkeyNameLabel": "通行密钥名称(可选)",
900
907
"passkeyNamePlaceholder": "如 MacBook Touch ID",
901
908
"passkeyNameHint": "用于识别此通行密钥的友好名称",
+1
-18
frontend/src/routes/Comms.svelte
+1
-18
frontend/src/routes/Comms.svelte
···
182
182
<div class="skeleton-section"></div>
183
183
</div>
184
184
{:else}
185
-
<div class="split-layout">
185
+
<div class="split-layout sidebar-right">
186
186
<div class="main-column">
187
187
<form onsubmit={handleSave}>
188
188
<section>
···
410
410
.description {
411
411
color: var(--text-secondary);
412
412
margin: var(--space-2) 0 0 0;
413
-
}
414
-
415
-
.split-layout {
416
-
display: grid;
417
-
grid-template-columns: 1fr;
418
-
gap: var(--space-6);
419
-
}
420
-
421
-
@media (min-width: 900px) {
422
-
.split-layout {
423
-
grid-template-columns: 1.5fr 1fr;
424
-
align-items: start;
425
-
}
426
-
}
427
-
428
-
.main-column, .side-column {
429
-
min-width: 0;
430
413
}
431
414
432
415
section {
+3
-3
frontend/src/routes/Register.svelte
+3
-3
frontend/src/routes/Register.svelte
···
8
8
KeyChoiceStep,
9
9
DidDocStep,
10
10
} from '../lib/registration'
11
+
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
11
12
12
13
let serverInfo = $state<{
13
14
availableUserDomains: string[]
···
178
179
</a>
179
180
</div>
180
181
</div>
182
+
183
+
<AccountTypeSwitcher active="password" />
181
184
182
185
<div class="split-layout sidebar-right">
183
186
<div class="form-section">
···
381
384
<div class="form-links">
382
385
<p class="link-text">
383
386
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
384
-
</p>
385
-
<p class="link-text">
386
-
{$_('register.wantPasswordless')} <a href={getFullUrl(routes.registerPasskey)}>{$_('register.createPasskeyAccount')}</a>
387
387
</p>
388
388
</div>
389
389
</div>
+174
-157
frontend/src/routes/RegisterPasskey.svelte
+174
-157
frontend/src/routes/RegisterPasskey.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
3
import { api, ApiError } from '../lib/api'
4
4
import { _ } from '../lib/i18n'
5
5
import {
···
14
14
serializeAttestationResponse,
15
15
type PublicKeyCredentialCreationOptionsJSON,
16
16
} from '../lib/webauthn'
17
+
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
17
18
18
19
let serverInfo = $state<{
19
20
availableUserDomains: string[]
···
215
216
</script>
216
217
217
218
<div class="register-page">
218
-
{#if flow?.state.step === 'info'}
219
+
<header class="page-header">
220
+
<h1>{$_('registerPasskey.title')}</h1>
221
+
<p class="subtitle">{getSubtitle()}</p>
222
+
</header>
223
+
224
+
{#if flow?.state.error}
225
+
<div class="message error">{flow.state.error}</div>
226
+
{/if}
227
+
228
+
{#if loadingServerInfo || !flow}
229
+
<div class="loading"></div>
230
+
231
+
{:else if flow.state.step === 'info'}
219
232
<div class="migrate-callout">
220
233
<div class="migrate-icon">↗</div>
221
234
<div class="migrate-content">
222
235
<strong>{$_('register.migrateTitle')}</strong>
223
236
<p>{$_('register.migrateDescription')}</p>
224
-
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
237
+
<a href={getFullUrl(routes.migrate)} class="migrate-link">
225
238
{$_('register.migrateLink')} →
226
239
</a>
227
240
</div>
228
241
</div>
229
-
{/if}
242
+
243
+
<AccountTypeSwitcher active="passkey" />
230
244
231
-
<h1>{$_('registerPasskey.title')}</h1>
232
-
<p class="subtitle">{getSubtitle()}</p>
245
+
<div class="split-layout sidebar-right">
246
+
<div class="form-section">
247
+
<form onsubmit={handleInfoSubmit}>
248
+
<div class="field">
249
+
<label for="handle">{$_('registerPasskey.handle')}</label>
250
+
<input
251
+
id="handle"
252
+
type="text"
253
+
bind:value={flow.info.handle}
254
+
placeholder={$_('registerPasskey.handlePlaceholder')}
255
+
disabled={flow.state.submitting}
256
+
required
257
+
/>
258
+
{#if flow.info.handle.includes('.')}
259
+
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
260
+
{:else if fullHandle()}
261
+
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
262
+
{/if}
263
+
</div>
233
264
234
-
{#if flow?.state.error}
235
-
<div class="message error">{flow.state.error}</div>
236
-
{/if}
265
+
<fieldset class="section-fieldset">
266
+
<legend>{$_('registerPasskey.contactMethod')}</legend>
267
+
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
268
+
<div class="field">
269
+
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
270
+
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
271
+
<option value="email">{$_('register.email')}</option>
272
+
<option value="discord" disabled={!isChannelAvailable('discord')}>
273
+
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
274
+
</option>
275
+
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
276
+
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
277
+
</option>
278
+
<option value="signal" disabled={!isChannelAvailable('signal')}>
279
+
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
280
+
</option>
281
+
</select>
282
+
</div>
283
+
{#if flow.info.verificationChannel === 'email'}
284
+
<div class="field">
285
+
<label for="email">{$_('registerPasskey.email')}</label>
286
+
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
287
+
</div>
288
+
{:else if flow.info.verificationChannel === 'discord'}
289
+
<div class="field">
290
+
<label for="discord-id">{$_('register.discordId')}</label>
291
+
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
292
+
<p class="hint">{$_('register.discordIdHint')}</p>
293
+
</div>
294
+
{:else if flow.info.verificationChannel === 'telegram'}
295
+
<div class="field">
296
+
<label for="telegram-username">{$_('register.telegramUsername')}</label>
297
+
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
298
+
</div>
299
+
{:else if flow.info.verificationChannel === 'signal'}
300
+
<div class="field">
301
+
<label for="signal-number">{$_('register.signalNumber')}</label>
302
+
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
303
+
<p class="hint">{$_('register.signalNumberHint')}</p>
304
+
</div>
305
+
{/if}
306
+
</fieldset>
237
307
238
-
{#if loadingServerInfo || !flow}
239
-
<p class="loading">{$_('registerPasskey.loading')}</p>
308
+
<fieldset class="section-fieldset">
309
+
<legend>{$_('registerPasskey.identityType')}</legend>
310
+
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
311
+
<div class="radio-group">
312
+
<label class="radio-label">
313
+
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
314
+
<span class="radio-content">
315
+
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
316
+
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
317
+
</span>
318
+
</label>
319
+
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
320
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
321
+
<span class="radio-content">
322
+
<strong>{$_('registerPasskey.didWeb')}</strong>
323
+
{#if serverInfo?.selfHostedDidWebEnabled === false}
324
+
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
325
+
{:else}
326
+
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
327
+
{/if}
328
+
</span>
329
+
</label>
330
+
<label class="radio-label">
331
+
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
332
+
<span class="radio-content">
333
+
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
334
+
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
335
+
</span>
336
+
</label>
337
+
</div>
338
+
{#if flow.info.didType === 'web'}
339
+
<div class="warning-box">
340
+
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
341
+
<ul>
342
+
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
343
+
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
344
+
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
345
+
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
346
+
</ul>
347
+
</div>
348
+
{/if}
349
+
{#if flow.info.didType === 'web-external'}
350
+
<div class="field">
351
+
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
352
+
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
353
+
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
354
+
</div>
355
+
{/if}
356
+
</fieldset>
240
357
241
-
{:else if flow.state.step === 'info'}
242
-
<form onsubmit={handleInfoSubmit}>
243
-
<div class="field">
244
-
<label for="handle">{$_('registerPasskey.handle')}</label>
245
-
<input
246
-
id="handle"
247
-
type="text"
248
-
bind:value={flow.info.handle}
249
-
placeholder={$_('registerPasskey.handlePlaceholder')}
250
-
disabled={flow.state.submitting}
251
-
required
252
-
/>
253
-
{#if flow.info.handle.includes('.')}
254
-
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
255
-
{:else if fullHandle()}
256
-
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
257
-
{/if}
258
-
</div>
358
+
{#if serverInfo?.inviteCodeRequired}
359
+
<div class="field">
360
+
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
361
+
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
362
+
</div>
363
+
{/if}
259
364
260
-
<fieldset class="section-fieldset">
261
-
<legend>{$_('registerPasskey.contactMethod')}</legend>
262
-
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
263
-
<div class="field">
264
-
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
265
-
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
266
-
<option value="email">{$_('register.email')}</option>
267
-
<option value="discord" disabled={!isChannelAvailable('discord')}>
268
-
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
269
-
</option>
270
-
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
271
-
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
272
-
</option>
273
-
<option value="signal" disabled={!isChannelAvailable('signal')}>
274
-
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
275
-
</option>
276
-
</select>
277
-
</div>
278
-
{#if flow.info.verificationChannel === 'email'}
279
-
<div class="field">
280
-
<label for="email">{$_('registerPasskey.email')}</label>
281
-
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
282
-
</div>
283
-
{:else if flow.info.verificationChannel === 'discord'}
284
-
<div class="field">
285
-
<label for="discord-id">{$_('register.discordId')}</label>
286
-
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
287
-
<p class="hint">{$_('register.discordIdHint')}</p>
288
-
</div>
289
-
{:else if flow.info.verificationChannel === 'telegram'}
290
-
<div class="field">
291
-
<label for="telegram-username">{$_('register.telegramUsername')}</label>
292
-
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
293
-
</div>
294
-
{:else if flow.info.verificationChannel === 'signal'}
295
-
<div class="field">
296
-
<label for="signal-number">{$_('register.signalNumber')}</label>
297
-
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
298
-
<p class="hint">{$_('register.signalNumberHint')}</p>
299
-
</div>
300
-
{/if}
301
-
</fieldset>
365
+
<button type="submit" disabled={flow.state.submitting}>
366
+
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
367
+
</button>
368
+
</form>
302
369
303
-
<fieldset class="section-fieldset">
304
-
<legend>{$_('registerPasskey.identityType')}</legend>
305
-
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
306
-
<div class="radio-group">
307
-
<label class="radio-label">
308
-
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
309
-
<span class="radio-content">
310
-
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
311
-
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
312
-
</span>
313
-
</label>
314
-
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
315
-
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
316
-
<span class="radio-content">
317
-
<strong>{$_('registerPasskey.didWeb')}</strong>
318
-
{#if serverInfo?.selfHostedDidWebEnabled === false}
319
-
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
320
-
{:else}
321
-
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
322
-
{/if}
323
-
</span>
324
-
</label>
325
-
<label class="radio-label">
326
-
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
327
-
<span class="radio-content">
328
-
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
329
-
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
330
-
</span>
331
-
</label>
370
+
<div class="form-links">
371
+
<p class="link-text">
372
+
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
373
+
</p>
332
374
</div>
333
-
{#if flow.info.didType === 'web'}
334
-
<div class="warning-box">
335
-
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
336
-
<ul>
337
-
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
338
-
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
339
-
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
340
-
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
341
-
</ul>
342
-
</div>
343
-
{/if}
344
-
{#if flow.info.didType === 'web-external'}
345
-
<div class="field">
346
-
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
347
-
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
348
-
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
349
-
</div>
350
-
{/if}
351
-
</fieldset>
375
+
</div>
352
376
353
-
{#if serverInfo?.inviteCodeRequired}
354
-
<div class="field">
355
-
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
356
-
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
357
-
</div>
358
-
{/if}
377
+
<aside class="info-panel">
378
+
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
379
+
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
359
380
360
-
<div class="info-box">
361
-
<strong>{$_('registerPasskey.whyPasskeyOnly')}</strong>
362
-
<p>{$_('registerPasskey.whyPasskeyOnlyDesc')}</p>
363
-
<ul>
364
-
<li>{$_('registerPasskey.whyPasskeyBullet1')}</li>
365
-
<li>{$_('registerPasskey.whyPasskeyBullet2')}</li>
366
-
<li>{$_('registerPasskey.whyPasskeyBullet3')}</li>
367
-
</ul>
368
-
</div>
381
+
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
382
+
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
369
383
370
-
<button type="submit" disabled={flow.state.submitting}>
371
-
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
372
-
</button>
373
-
</form>
384
+
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
385
+
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
386
+
</aside>
387
+
</div>
374
388
375
-
<p class="link-text">
376
-
{$_('registerPasskey.wantTraditional')} <a href="/app/register">{$_('registerPasskey.registerWithPassword')}</a>
377
-
</p>
378
389
379
390
{:else if flow.state.step === 'key-choice'}
380
391
<KeyChoiceStep {flow} />
···
436
447
437
448
<style>
438
449
.register-page {
439
-
max-width: var(--width-sm);
450
+
max-width: var(--width-lg);
440
451
margin: var(--space-9) auto;
441
452
padding: var(--space-7);
453
+
}
454
+
455
+
.page-header {
456
+
margin-bottom: var(--space-6);
457
+
}
458
+
459
+
.form-section {
460
+
min-width: 0;
461
+
}
462
+
463
+
.form-links {
464
+
margin-top: var(--space-6);
465
+
}
466
+
467
+
.link-text {
468
+
text-align: center;
469
+
color: var(--text-secondary);
470
+
}
471
+
472
+
.link-text a {
473
+
color: var(--accent);
442
474
}
443
475
444
476
.migrate-callout {
···
593
625
font-size: var(--text-sm);
594
626
}
595
627
596
-
.info-box strong {
597
-
display: block;
598
-
margin-bottom: var(--space-3);
599
-
}
600
-
601
628
.info-box p {
602
629
margin: 0 0 var(--space-3) 0;
603
630
color: var(--text-secondary);
···
616
643
.passkey-btn {
617
644
padding: var(--space-5);
618
645
font-size: var(--text-lg);
619
-
}
620
-
621
-
.link-text {
622
-
text-align: center;
623
-
margin-top: var(--space-6);
624
-
color: var(--text-secondary);
625
-
}
626
-
627
-
.link-text a {
628
-
color: var(--accent);
629
646
}
630
647
</style>
+4
-1
frontend/src/routes/Verify.svelte
+4
-1
frontend/src/routes/Verify.svelte
···
237
237
<div class="actions">
238
238
<a href="/app/settings" class="btn">{$_('common.backToSettings')}</a>
239
239
</div>
240
-
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
240
+
{:else if successPurpose === 'migration'}
241
+
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
242
+
<p class="info-text">{$_('verify.migrationContinue')}</p>
243
+
{:else if successPurpose === 'signup'}
241
244
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
242
245
<p class="info-text">{$_('verify.canNowSignIn')}</p>
243
246
<div class="actions">
+5
frontend/src/styles/base.css
+5
frontend/src/styles/base.css
···
431
431
@media (min-width: 800px) {
432
432
.split-layout {
433
433
grid-template-columns: 1fr 1fr;
434
+
align-items: start;
434
435
}
435
436
.split-layout.sidebar-right {
436
437
grid-template-columns: 1.5fr 1fr;
···
438
439
.split-layout.sidebar-left {
439
440
grid-template-columns: 1fr 1.5fr;
440
441
}
442
+
}
443
+
444
+
.split-layout > * {
445
+
min-width: 0;
441
446
}
442
447
443
448
.form-row {