this repo has no description

Frontend UX improvements

lewis e86f5eb5 59030b49

+2 -2
frontend/src/App.svelte
··· 76 76 case '/login': 77 77 return Login 78 78 case '/register': 79 - return Register 80 - case '/register-passkey': 81 79 return RegisterPasskey 80 + case '/register-password': 81 + return Register 82 82 case '/verify': 83 83 return Verify 84 84 case '/reset-password':
+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 export const routes = { 2 2 login: "/login", 3 3 register: "/register", 4 - registerPasskey: "/register-passkey", 4 + registerPassword: "/register-password", 5 5 dashboard: "/dashboard", 6 6 settings: "/settings", 7 7 security: "/security",
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 {