Frontend UX improvements

This commit is contained in:
lewis
2026-01-06 19:10:12 +02:00
parent 2fd72ab150
commit 3d0939c038
14 changed files with 307 additions and 201 deletions
+2 -2
View File
@@ -76,9 +76,9 @@
case '/login':
return Login
case '/register':
return Register
case '/register-passkey':
return RegisterPasskey
case '/register-password':
return Register
case '/verify':
return Verify
case '/reset-password':
@@ -0,0 +1,56 @@
<script lang="ts">
import { _ } from '../lib/i18n'
import { getFullUrl } from '../lib/router.svelte'
import { routes } from '../lib/types/routes'
interface Props {
active: 'passkey' | 'password'
}
let { active }: Props = $props()
</script>
<div class="account-type-switcher">
<a href={getFullUrl(routes.register)} class="switcher-option" class:active={active === 'passkey'}>
{$_('register.passkeyAccount')}
</a>
<a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}>
{$_('register.passwordAccount')}
</a>
</div>
<style>
.account-type-switcher {
display: flex;
gap: var(--space-2);
padding: var(--space-1);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
}
.switcher-option {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
text-decoration: none;
color: var(--text-secondary);
font-weight: var(--font-medium);
transition: all 0.15s ease;
}
.switcher-option:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.switcher-option.active {
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
export const routes = {
login: "/login",
register: "/register",
registerPasskey: "/register-passkey",
registerPassword: "/register-password",
dashboard: "/dashboard",
settings: "/settings",
security: "/security",
+9 -2
View File
@@ -168,8 +168,8 @@
"createButton": "Create Account",
"alreadyHaveAccount": "Already have an account?",
"signIn": "Sign in",
"wantPasswordless": "Want passwordless security?",
"createPasskeyAccount": "Create a passkey account",
"passkeyAccount": "Passkey",
"passwordAccount": "Password",
"validation": {
"handleRequired": "Handle is required",
"handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.",
@@ -765,6 +765,7 @@
"verified": "Verified!",
"channelVerified": "Your {channel} has been verified successfully.",
"canNowSignIn": "You can now sign in to your account.",
"migrationContinue": "You can close this tab and continue your migration in the original window.",
"continue": "Continue",
"identifierLabel": "Email or Identifier",
"identifierPlaceholder": "you@example.com",
@@ -904,6 +905,12 @@
"whyPasskeyBullet1": "Cannot be phished or stolen in data breaches",
"whyPasskeyBullet2": "Use hardware-backed cryptographic keys",
"whyPasskeyBullet3": "Require your biometric or device PIN to use",
"infoWhyPasskey": "Why use a passkey?",
"infoWhyPasskeyDesc": "Passkeys are cryptographic credentials stored on your device. They cannot be phished, guessed, or stolen in data breaches like passwords can.",
"infoHowItWorks": "How it works",
"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.",
"infoAppAccess": "Using third-party apps",
"infoAppAccessDesc": "After creating your account, you will receive an app password. Use this to sign in to Bluesky apps and other AT Protocol clients.",
"passkeyNameLabel": "Passkey Name (optional)",
"passkeyNamePlaceholder": "e.g., MacBook Touch ID",
"passkeyNameHint": "A friendly name to identify this passkey",
+9 -2
View File
@@ -168,8 +168,8 @@
"createButton": "Luo tili",
"alreadyHaveAccount": "Onko sinulla jo tili?",
"signIn": "Kirjaudu sisään",
"wantPasswordless": "Haluatko salasanattoman turvallisuuden?",
"createPasskeyAccount": "Luo pääsyavaintili",
"passkeyAccount": "Pääsyavain",
"passwordAccount": "Salasana",
"validation": {
"handleRequired": "Käyttäjänimi vaaditaan",
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
@@ -759,6 +759,7 @@
"verified": "Vahvistettu!",
"channelVerified": "{channel} on vahvistettu onnistuneesti.",
"canNowSignIn": "Voit nyt kirjautua tilillesi.",
"migrationContinue": "Voit sulkea tämän välilehden ja jatkaa siirtoa alkuperäisessä ikkunassa.",
"continue": "Jatka",
"identifierLabel": "Sähköposti tai tunniste",
"identifierPlaceholder": "sinä@esimerkki.fi",
@@ -886,6 +887,12 @@
"whyPasskeyBullet1": "Ei voi kalastella tai varastaa tietomurroissa",
"whyPasskeyBullet2": "Käyttää laitteistopohjaisia salausavaimia",
"whyPasskeyBullet3": "Vaatii biometrisen tunnistuksen tai laitteen PIN-koodin",
"infoWhyPasskey": "Miksi käyttää pääsyavainta?",
"infoWhyPasskeyDesc": "Pääsyavaimet ovat laitteellesi tallennettuja salattuja tunnistetietoja. Niitä ei voi kalastella, arvata tai varastaa tietomurroissa kuten salasanoja.",
"infoHowItWorks": "Miten se toimii",
"infoHowItWorksDesc": "Kirjautuessasi laitteesi pyytää sinua vahvistamaan Face ID:llä, Touch ID:llä tai laitteen PIN-koodilla. Ei salasanaa muistettavaksi tai kirjoitettavaksi.",
"infoAppAccess": "Kolmannen osapuolen sovellusten käyttö",
"infoAppAccessDesc": "Tilin luomisen jälkeen saat sovellussalasanan. Käytä sitä kirjautuaksesi Bluesky-sovelluksiin ja muihin AT Protocol -asiakkaisiin.",
"whyPasskeyOnly": "Miksi vain pääsyavain?",
"whyPasskeyOnlyDesc": "Pääsyavaintilit ovat turvallisempia kuin salasanapohjaiset tilit, koska ne:",
"subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.",
+9 -2
View File
@@ -161,8 +161,8 @@
"createButton": "アカウントを作成",
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"signIn": "サインイン",
"wantPasswordless": "パスワードレスをご希望ですか?",
"createPasskeyAccount": "パスキーアカウントを作成",
"passkeyAccount": "パスキー",
"passwordAccount": "パスワード",
"validation": {
"handleRequired": "ハンドルは必須です",
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
@@ -752,6 +752,7 @@
"verified": "確認完了!",
"channelVerified": "{channel} が正常に確認されました。",
"canNowSignIn": "アカウントにサインインできるようになりました。",
"migrationContinue": "このタブを閉じて、元のウィンドウで移行を続けてください。",
"continue": "続行",
"identifierLabel": "メールまたは識別子",
"identifierPlaceholder": "you@example.com",
@@ -879,6 +880,12 @@
"whyPasskeyBullet1": "フィッシングやデータ侵害で盗まれない",
"whyPasskeyBullet2": "ハードウェア支援の暗号鍵を使用",
"whyPasskeyBullet3": "生体認証またはデバイスPINが必要",
"infoWhyPasskey": "なぜパスキーを使うのですか?",
"infoWhyPasskeyDesc": "パスキーはデバイスに保存される暗号化資格情報です。パスワードのようにフィッシング、推測、データ侵害による盗難の被害を受けません。",
"infoHowItWorks": "仕組み",
"infoHowItWorksDesc": "サインイン時、デバイスがFace ID、Touch ID、またはデバイスPINでの確認を求めます。覚えたり入力したりするパスワードはありません。",
"infoAppAccess": "サードパーティアプリの使用",
"infoAppAccessDesc": "アカウント作成後、アプリパスワードが発行されます。Blueskyアプリやその他のAT Protocolクライアントへのサインインに使用してください。",
"whyPasskeyOnly": "なぜパスキーのみ?",
"whyPasskeyOnlyDesc": "パスキーアカウントはパスワードベースのアカウントより安全です:",
"subtitleInitialDidDoc": "続行するにはDIDドキュメントをアップロードしてください。",
+9 -2
View File
@@ -161,8 +161,8 @@
"createButton": "계정 만들기",
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"signIn": "로그인",
"wantPasswordless": "비밀번호 없는 보안을 원하시나요?",
"createPasskeyAccount": "패스키 계정 만들기",
"passkeyAccount": "패스키",
"passwordAccount": "비밀번호",
"validation": {
"handleRequired": "핸들은 필수입니다",
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
@@ -752,6 +752,7 @@
"verified": "인증 완료!",
"channelVerified": "{channel}이(가) 성공적으로 인증되었습니다.",
"canNowSignIn": "이제 계정에 로그인할 수 있습니다.",
"migrationContinue": "이 탭을 닫고 원래 창에서 마이그레이션을 계속할 수 있습니다.",
"continue": "계속",
"identifierLabel": "이메일 또는 식별자",
"identifierPlaceholder": "you@example.com",
@@ -879,6 +880,12 @@
"whyPasskeyBullet1": "피싱이나 데이터 유출로 도난당할 수 없음",
"whyPasskeyBullet2": "하드웨어 기반 암호화 키 사용",
"whyPasskeyBullet3": "생체 인식 또는 기기 PIN 필요",
"infoWhyPasskey": "왜 패스키를 사용하나요?",
"infoWhyPasskeyDesc": "패스키는 기기에 저장된 암호화 자격 증명입니다. 비밀번호처럼 피싱, 추측 또는 데이터 유출로 도난당할 수 없습니다.",
"infoHowItWorks": "작동 방식",
"infoHowItWorksDesc": "로그인할 때 기기에서 Face ID, Touch ID 또는 기기 PIN으로 인증하라는 메시지가 표시됩니다. 기억하거나 입력할 비밀번호가 없습니다.",
"infoAppAccess": "서드파티 앱 사용",
"infoAppAccessDesc": "계정 생성 후 앱 비밀번호를 받게 됩니다. Bluesky 앱 및 기타 AT Protocol 클라이언트에 로그인할 때 사용하세요.",
"whyPasskeyOnly": "왜 패스키만 사용하나요?",
"whyPasskeyOnlyDesc": "패스키 계정은 비밀번호 기반 계정보다 안전합니다:",
"subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.",
+9 -2
View File
@@ -161,8 +161,8 @@
"createButton": "Skapa konto",
"alreadyHaveAccount": "Har du redan ett konto?",
"signIn": "Logga in",
"wantPasswordless": "Vill du ha lösenordsfri säkerhet?",
"createPasskeyAccount": "Skapa ett nyckelbaserat konto",
"passkeyAccount": "Nyckel",
"passwordAccount": "Lösenord",
"validation": {
"handleRequired": "Användarnamn krävs",
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
@@ -752,6 +752,7 @@
"verified": "Verifierad!",
"channelVerified": "Din {channel} har verifierats.",
"canNowSignIn": "Du kan nu logga in på ditt konto.",
"migrationContinue": "Du kan stänga denna flik och fortsätta migreringen i det ursprungliga fönstret.",
"continue": "Fortsätt",
"identifierLabel": "E-post eller identifierare",
"identifierPlaceholder": "du@exempel.se",
@@ -879,6 +880,12 @@
"whyPasskeyBullet1": "Kan inte nätfiskas eller stjälas vid dataintrång",
"whyPasskeyBullet2": "Använder hårdvarubaserade kryptografiska nycklar",
"whyPasskeyBullet3": "Kräver din biometri eller enhets-PIN för att använda",
"infoWhyPasskey": "Varfor anvanda nyckel?",
"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.",
"infoHowItWorks": "Hur det fungerar",
"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.",
"infoAppAccess": "Använda tredjepartsappar",
"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.",
"whyPasskeyOnly": "Varför endast nyckel?",
"whyPasskeyOnlyDesc": "Nyckelkonton är säkrare än lösenordsbaserade konton eftersom de:",
"subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.",
+9 -2
View File
@@ -161,8 +161,8 @@
"createButton": "创建账户",
"alreadyHaveAccount": "已有账户?",
"signIn": "立即登录",
"wantPasswordless": "想要无密码登录?",
"createPasskeyAccount": "创建通行密钥账户",
"passkeyAccount": "通行密钥",
"passwordAccount": "密码",
"validation": {
"handleRequired": "请输入用户名",
"handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。",
@@ -758,6 +758,7 @@
"verified": "验证成功!",
"channelVerified": "您的{channel}已成功验证。",
"canNowSignIn": "您现在可以登录账户。",
"migrationContinue": "您可以关闭此标签页,在原窗口中继续迁移。",
"continue": "继续",
"identifierLabel": "邮箱或标识符",
"identifierPlaceholder": "you@example.com",
@@ -896,6 +897,12 @@
"whyPasskeyBullet1": "无法被钓鱼或在数据泄露中被盗",
"whyPasskeyBullet2": "使用硬件支持的加密密钥",
"whyPasskeyBullet3": "需要您的生物识别或设备 PIN 才能使用",
"infoWhyPasskey": "为什么使用通行密钥?",
"infoWhyPasskeyDesc": "通行密钥是存储在您设备上的加密凭证。与密码不同,它们无法被钓鱼、猜测或在数据泄露中被盗。",
"infoHowItWorks": "工作原理",
"infoHowItWorksDesc": "登录时,您的设备会提示您使用 Face ID、Touch ID 或设备 PIN 进行验证。无需记住或输入密码。",
"infoAppAccess": "使用第三方应用",
"infoAppAccessDesc": "创建账户后,您将收到一个应用密码。使用它登录 Bluesky 应用和其他 AT Protocol 客户端。",
"passkeyNameLabel": "通行密钥名称(可选)",
"passkeyNamePlaceholder": "如 MacBook Touch ID",
"passkeyNameHint": "用于识别此通行密钥的友好名称",
+1 -18
View File
@@ -182,7 +182,7 @@
<div class="skeleton-section"></div>
</div>
{:else}
<div class="split-layout">
<div class="split-layout sidebar-right">
<div class="main-column">
<form onsubmit={handleSave}>
<section>
@@ -412,23 +412,6 @@
margin: var(--space-2) 0 0 0;
}
.split-layout {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
}
@media (min-width: 900px) {
.split-layout {
grid-template-columns: 1.5fr 1fr;
align-items: start;
}
}
.main-column, .side-column {
min-width: 0;
}
section {
background: var(--bg-secondary);
padding: var(--space-6);
+3 -3
View File
@@ -8,6 +8,7 @@
KeyChoiceStep,
DidDocStep,
} from '../lib/registration'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
let serverInfo = $state<{
availableUserDomains: string[]
@@ -179,6 +180,8 @@
</div>
</div>
<AccountTypeSwitcher active="password" />
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleInfoSubmit}>
@@ -382,9 +385,6 @@
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
</p>
<p class="link-text">
{$_('register.wantPasswordless')} <a href={getFullUrl(routes.registerPasskey)}>{$_('register.createPasskeyAccount')}</a>
</p>
</div>
</div>
+181 -164
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
import {
@@ -14,6 +14,7 @@
serializeAttestationResponse,
type PublicKeyCredentialCreationOptionsJSON,
} from '../lib/webauthn'
import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
let serverInfo = $state<{
availableUserDomains: string[]
@@ -215,166 +216,176 @@
</script>
<div class="register-page">
{#if flow?.state.step === 'info'}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
{/if}
<h1>{$_('registerPasskey.title')}</h1>
<p class="subtitle">{getSubtitle()}</p>
<header class="page-header">
<h1>{$_('registerPasskey.title')}</h1>
<p class="subtitle">{getSubtitle()}</p>
</header>
{#if flow?.state.error}
<div class="message error">{flow.state.error}</div>
{/if}
{#if loadingServerInfo || !flow}
<p class="loading">{$_('registerPasskey.loading')}</p>
<div class="loading"></div>
{:else if flow.state.step === 'info'}
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('registerPasskey.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
placeholder={$_('registerPasskey.handlePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
{:else if fullHandle()}
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
<div class="migrate-callout">
<div class="migrate-icon"></div>
<div class="migrate-content">
<strong>{$_('register.migrateTitle')}</strong>
<p>{$_('register.migrateDescription')}</p>
<a href={getFullUrl(routes.migrate)} class="migrate-link">
{$_('register.migrateLink')}
</a>
</div>
</div>
<AccountTypeSwitcher active="passkey" />
<div class="split-layout sidebar-right">
<div class="form-section">
<form onsubmit={handleInfoSubmit}>
<div class="field">
<label for="handle">{$_('registerPasskey.handle')}</label>
<input
id="handle"
type="text"
bind:value={flow.info.handle}
placeholder={$_('registerPasskey.handlePlaceholder')}
disabled={flow.state.submitting}
required
/>
{#if flow.info.handle.includes('.')}
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
{:else if fullHandle()}
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
{/if}
</div>
<fieldset class="section-fieldset">
<legend>{$_('registerPasskey.contactMethod')}</legend>
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
<div class="field">
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('registerPasskey.email')}</label>
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.signalNumberHint')}</p>
</div>
{/if}
</fieldset>
<fieldset class="section-fieldset">
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
</div>
{/if}
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
</button>
</form>
<div class="form-links">
<p class="link-text">
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
</p>
</div>
</div>
<fieldset class="section-fieldset">
<legend>{$_('registerPasskey.contactMethod')}</legend>
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
<div class="field">
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if flow.info.verificationChannel === 'email'}
<div class="field">
<label for="email">{$_('registerPasskey.email')}</label>
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'discord'}
<div class="field">
<label for="discord-id">{$_('register.discordId')}</label>
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.discordIdHint')}</p>
</div>
{:else if flow.info.verificationChannel === 'telegram'}
<div class="field">
<label for="telegram-username">{$_('register.telegramUsername')}</label>
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
</div>
{:else if flow.info.verificationChannel === 'signal'}
<div class="field">
<label for="signal-number">{$_('register.signalNumber')}</label>
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('register.signalNumberHint')}</p>
</div>
{/if}
</fieldset>
<aside class="info-panel">
<h3>{$_('registerPasskey.infoWhyPasskey')}</h3>
<p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p>
<fieldset class="section-fieldset">
<legend>{$_('registerPasskey.identityType')}</legend>
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
{#if flow.info.didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if flow.info.didType === 'web-external'}
<div class="field">
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}
</fieldset>
<h3>{$_('registerPasskey.infoHowItWorks')}</h3>
<p>{$_('registerPasskey.infoHowItWorksDesc')}</p>
{#if serverInfo?.inviteCodeRequired}
<div class="field">
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
</div>
{/if}
<h3>{$_('registerPasskey.infoAppAccess')}</h3>
<p>{$_('registerPasskey.infoAppAccessDesc')}</p>
</aside>
</div>
<div class="info-box">
<strong>{$_('registerPasskey.whyPasskeyOnly')}</strong>
<p>{$_('registerPasskey.whyPasskeyOnlyDesc')}</p>
<ul>
<li>{$_('registerPasskey.whyPasskeyBullet1')}</li>
<li>{$_('registerPasskey.whyPasskeyBullet2')}</li>
<li>{$_('registerPasskey.whyPasskeyBullet3')}</li>
</ul>
</div>
<button type="submit" disabled={flow.state.submitting}>
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
</button>
</form>
<p class="link-text">
{$_('registerPasskey.wantTraditional')} <a href="/app/register">{$_('registerPasskey.registerWithPassword')}</a>
</p>
{:else if flow.state.step === 'key-choice'}
<KeyChoiceStep {flow} />
@@ -436,11 +447,32 @@
<style>
.register-page {
max-width: var(--width-sm);
max-width: var(--width-lg);
margin: var(--space-9) auto;
padding: var(--space-7);
}
.page-header {
margin-bottom: var(--space-6);
}
.form-section {
min-width: 0;
}
.form-links {
margin-top: var(--space-6);
}
.link-text {
text-align: center;
color: var(--text-secondary);
}
.link-text a {
color: var(--accent);
}
.migrate-callout {
display: flex;
gap: var(--space-4);
@@ -593,11 +625,6 @@
font-size: var(--text-sm);
}
.info-box strong {
display: block;
margin-bottom: var(--space-3);
}
.info-box p {
margin: 0 0 var(--space-3) 0;
color: var(--text-secondary);
@@ -617,14 +644,4 @@
padding: var(--space-5);
font-size: var(--text-lg);
}
.link-text {
text-align: center;
margin-top: var(--space-6);
color: var(--text-secondary);
}
.link-text a {
color: var(--accent);
}
</style>
+4 -1
View File
@@ -237,7 +237,10 @@
<div class="actions">
<a href="/app/settings" class="btn">{$_('common.backToSettings')}</a>
</div>
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
{:else if successPurpose === 'migration'}
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
<p class="info-text">{$_('verify.migrationContinue')}</p>
{:else if successPurpose === 'signup'}
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
<p class="info-text">{$_('verify.canNowSignIn')}</p>
<div class="actions">
+5
View File
@@ -431,6 +431,7 @@ hr {
@media (min-width: 800px) {
.split-layout {
grid-template-columns: 1fr 1fr;
align-items: start;
}
.split-layout.sidebar-right {
grid-template-columns: 1.5fr 1fr;
@@ -440,6 +441,10 @@ hr {
}
}
.split-layout > * {
min-width: 0;
}
.form-row {
display: grid;
grid-template-columns: 1fr;