this repo has no description

Attempt at better byod did:web registration

lewis d8ac571e 41feec8d

+1
frontend/src/lib/api.ts
··· 266 inviteCodeRequired: boolean 267 links?: { privacyPolicy?: string; termsOfService?: string } 268 version?: string 269 }> { 270 return xrpc('com.atproto.server.describeServer') 271 },
··· 266 inviteCodeRequired: boolean 267 links?: { privacyPolicy?: string; termsOfService?: string } 268 version?: string 269 + availableCommsChannels?: string[] 270 }> { 271 return xrpc('com.atproto.server.describeServer') 272 },
+2
frontend/src/locales/en.json
··· 96 "signalNumber": "Signal Phone Number", 97 "signalNumberPlaceholder": "+1234567890", 98 "signalNumberHint": "Include country code (e.g., +1 for US)", 99 "inviteCode": "Invite Code", 100 "inviteCodePlaceholder": "Enter your invite code", 101 "inviteCodeRequired": "required", ··· 388 "telegramVia": "Receive messages via Telegram", 389 "signalVia": "Receive messages via Signal", 390 "configureToEnable": "Configure below to enable", 391 "emailManagedInSettings": "Your email is managed in Account Settings", 392 "discordIdHint": "Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.", 393 "telegramHint": "Your Telegram username without the @ symbol",
··· 96 "signalNumber": "Signal Phone Number", 97 "signalNumberPlaceholder": "+1234567890", 98 "signalNumberHint": "Include country code (e.g., +1 for US)", 99 + "notConfigured": "not configured", 100 "inviteCode": "Invite Code", 101 "inviteCodePlaceholder": "Enter your invite code", 102 "inviteCodeRequired": "required", ··· 389 "telegramVia": "Receive messages via Telegram", 390 "signalVia": "Receive messages via Signal", 391 "configureToEnable": "Configure below to enable", 392 + "notConfiguredOnServer": "Not configured on this server", 393 "emailManagedInSettings": "Your email is managed in Account Settings", 394 "discordIdHint": "Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.", 395 "telegramHint": "Your Telegram username without the @ symbol",
+2
frontend/src/locales/fi.json
··· 96 "signalNumber": "Signal-puhelinnumero", 97 "signalNumberPlaceholder": "+358401234567", 98 "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", 99 "inviteCode": "Kutsukoodi", 100 "inviteCodePlaceholder": "Syötä kutsukoodisi", 101 "inviteCodeRequired": "vaaditaan", ··· 388 "telegramVia": "Vastaanota viestejä Telegramissa", 389 "signalVia": "Vastaanota viestejä Signalissa", 390 "configureToEnable": "Määritä alla ottaaksesi käyttöön", 391 "emailManagedInSettings": "Sähköpostisi hallinnoidaan Tilin asetuksissa", 392 "discordIdHint": "Discord-käyttäjätunnuksesi (ei käyttäjänimi). Ota Kehittäjätila käyttöön Discordissa kopioidaksesi sen.", 393 "telegramHint": "Telegram-käyttäjänimesi ilman @-merkkiä",
··· 96 "signalNumber": "Signal-puhelinnumero", 97 "signalNumberPlaceholder": "+358401234567", 98 "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", 99 + "notConfigured": "ei määritetty", 100 "inviteCode": "Kutsukoodi", 101 "inviteCodePlaceholder": "Syötä kutsukoodisi", 102 "inviteCodeRequired": "vaaditaan", ··· 389 "telegramVia": "Vastaanota viestejä Telegramissa", 390 "signalVia": "Vastaanota viestejä Signalissa", 391 "configureToEnable": "Määritä alla ottaaksesi käyttöön", 392 + "notConfiguredOnServer": "Ei määritetty tällä palvelimella", 393 "emailManagedInSettings": "Sähköpostisi hallinnoidaan Tilin asetuksissa", 394 "discordIdHint": "Discord-käyttäjätunnuksesi (ei käyttäjänimi). Ota Kehittäjätila käyttöön Discordissa kopioidaksesi sen.", 395 "telegramHint": "Telegram-käyttäjänimesi ilman @-merkkiä",
+2
frontend/src/locales/ja.json
··· 96 "signalNumber": "Signal 電話番号", 97 "signalNumberPlaceholder": "+81XXXXXXXXXX", 98 "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 99 "inviteCode": "招待コード", 100 "inviteCodePlaceholder": "招待コードを入力", 101 "inviteCodeRequired": "必須", ··· 388 "telegramVia": "Telegram でメッセージを受信", 389 "signalVia": "Signal でメッセージを受信", 390 "configureToEnable": "有効にするには下記で設定", 391 "emailManagedInSettings": "メールはアカウント設定で管理されています", 392 "discordIdHint": "Discord ユーザー ID(ユーザー名ではありません)。Discord で開発者モードを有効にしてコピーしてください。", 393 "telegramHint": "@ 記号なしの Telegram ユーザー名",
··· 96 "signalNumber": "Signal 電話番号", 97 "signalNumberPlaceholder": "+81XXXXXXXXXX", 98 "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 99 + "notConfigured": "未設定", 100 "inviteCode": "招待コード", 101 "inviteCodePlaceholder": "招待コードを入力", 102 "inviteCodeRequired": "必須", ··· 389 "telegramVia": "Telegram でメッセージを受信", 390 "signalVia": "Signal でメッセージを受信", 391 "configureToEnable": "有効にするには下記で設定", 392 + "notConfiguredOnServer": "このサーバーでは設定されていません", 393 "emailManagedInSettings": "メールはアカウント設定で管理されています", 394 "discordIdHint": "Discord ユーザー ID(ユーザー名ではありません)。Discord で開発者モードを有効にしてコピーしてください。", 395 "telegramHint": "@ 記号なしの Telegram ユーザー名",
+2
frontend/src/locales/ko.json
··· 96 "signalNumber": "Signal 전화번호", 97 "signalNumberPlaceholder": "+821012345678", 98 "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 99 "inviteCode": "초대 코드", 100 "inviteCodePlaceholder": "초대 코드 입력", 101 "inviteCodeRequired": "필수", ··· 388 "telegramVia": "Telegram으로 메시지 받기", 389 "signalVia": "Signal로 메시지 받기", 390 "configureToEnable": "활성화하려면 아래에서 설정", 391 "emailManagedInSettings": "이메일은 계정 설정에서 관리됩니다", 392 "discordIdHint": "Discord 사용자 ID (사용자 이름 아님). Discord에서 개발자 모드를 활성화하여 복사하세요.", 393 "telegramHint": "@ 기호 없이 Telegram 사용자 이름",
··· 96 "signalNumber": "Signal 전화번호", 97 "signalNumberPlaceholder": "+821012345678", 98 "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 99 + "notConfigured": "구성되지 않음", 100 "inviteCode": "초대 코드", 101 "inviteCodePlaceholder": "초대 코드 입력", 102 "inviteCodeRequired": "필수", ··· 389 "telegramVia": "Telegram으로 메시지 받기", 390 "signalVia": "Signal로 메시지 받기", 391 "configureToEnable": "활성화하려면 아래에서 설정", 392 + "notConfiguredOnServer": "이 서버에서 설정되지 않음", 393 "emailManagedInSettings": "이메일은 계정 설정에서 관리됩니다", 394 "discordIdHint": "Discord 사용자 ID (사용자 이름 아님). Discord에서 개발자 모드를 활성화하여 복사하세요.", 395 "telegramHint": "@ 기호 없이 Telegram 사용자 이름",
+2
frontend/src/locales/sv.json
··· 96 "signalNumber": "Signal-telefonnummer", 97 "signalNumberPlaceholder": "+46701234567", 98 "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", 99 "inviteCode": "Inbjudningskod", 100 "inviteCodePlaceholder": "Ange din inbjudningskod", 101 "inviteCodeRequired": "krävs", ··· 388 "telegramVia": "Ta emot meddelanden via Telegram", 389 "signalVia": "Ta emot meddelanden via Signal", 390 "configureToEnable": "Konfigurera nedan för att aktivera", 391 "emailManagedInSettings": "Din e-post hanteras i Kontoinställningar", 392 "discordIdHint": "Ditt Discord användar-ID (inte användarnamn). Aktivera Utvecklarläge i Discord för att kopiera det.", 393 "telegramHint": "Ditt Telegram-användarnamn utan @-symbolen",
··· 96 "signalNumber": "Signal-telefonnummer", 97 "signalNumberPlaceholder": "+46701234567", 98 "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", 99 + "notConfigured": "ej konfigurerad", 100 "inviteCode": "Inbjudningskod", 101 "inviteCodePlaceholder": "Ange din inbjudningskod", 102 "inviteCodeRequired": "krävs", ··· 389 "telegramVia": "Ta emot meddelanden via Telegram", 390 "signalVia": "Ta emot meddelanden via Signal", 391 "configureToEnable": "Konfigurera nedan för att aktivera", 392 + "notConfiguredOnServer": "Inte konfigurerat på denna server", 393 "emailManagedInSettings": "Din e-post hanteras i Kontoinställningar", 394 "discordIdHint": "Ditt Discord användar-ID (inte användarnamn). Aktivera Utvecklarläge i Discord för att kopiera det.", 395 "telegramHint": "Ditt Telegram-användarnamn utan @-symbolen",
+2
frontend/src/locales/zh.json
··· 96 "signalNumber": "Signal 电话号码", 97 "signalNumberPlaceholder": "+1234567890", 98 "signalNumberHint": "包含国家代码(例如中国为 +86)", 99 "inviteCode": "邀请码", 100 "inviteCodePlaceholder": "输入您的邀请码", 101 "inviteCodeRequired": "必填", ··· 388 "telegramVia": "通过 Telegram 接收消息", 389 "signalVia": "通过 Signal 接收消息", 390 "configureToEnable": "请先在下方配置", 391 "emailManagedInSettings": "邮箱在账户设置中管理", 392 "discordIdHint": "您的 Discord 数字用户 ID(非用户名)。在 Discord 中开启开发者模式即可复制。", 393 "telegramHint": "您的 Telegram 用户名,不含 @ 符号",
··· 96 "signalNumber": "Signal 电话号码", 97 "signalNumberPlaceholder": "+1234567890", 98 "signalNumberHint": "包含国家代码(例如中国为 +86)", 99 + "notConfigured": "未配置", 100 "inviteCode": "邀请码", 101 "inviteCodePlaceholder": "输入您的邀请码", 102 "inviteCodeRequired": "必填", ··· 389 "telegramVia": "通过 Telegram 接收消息", 390 "signalVia": "通过 Signal 接收消息", 391 "configureToEnable": "请先在下方配置", 392 + "notConfiguredOnServer": "此服务器未配置", 393 "emailManagedInSettings": "邮箱在账户设置中管理", 394 "discordIdHint": "您的 Discord 数字用户 ID(非用户名)。在 Discord 中开启开发者模式即可复制。", 395 "telegramHint": "您的 Telegram 用户名,不含 @ 符号",
+47 -12
frontend/src/routes/Comms.svelte
··· 10 let error = $state<string | null>(null) 11 let success = $state<string | null>(null) 12 let preferredChannel = $state('email') 13 let email = $state('') 14 let discordId = $state('') 15 let discordVerified = $state(false) ··· 47 loading = true 48 error = null 49 try { 50 - const prefs = await api.getNotificationPrefs(auth.session.accessJwt) 51 preferredChannel = prefs.preferredChannel 52 email = prefs.email 53 discordId = prefs.discordId ?? '' ··· 56 telegramVerified = prefs.telegramVerified 57 signalNumber = prefs.signalNumber ?? '' 58 signalVerified = prefs.signalVerified 59 } catch (e) { 60 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences' 61 } finally { ··· 135 default: return '' 136 } 137 } 138 function canSelectChannel(channelId: string): boolean { 139 if (channelId === 'email') return true 140 if (channelId === 'discord') return !!discordId 141 if (channelId === 'telegram') return !!telegramUsername ··· 174 </p> 175 <div class="channel-options"> 176 {#each channels as channelId} 177 - <label class="channel-option" class:disabled={!canSelectChannel(channelId)}> 178 <input 179 type="radio" 180 name="preferredChannel" ··· 185 <div class="channel-info"> 186 <span class="channel-name">{getChannelName(channelId)}</span> 187 <span class="channel-description">{getChannelDescription(channelId)}</span> 188 - {#if channelId !== 'email' && !canSelectChannel(channelId)} 189 <span class="channel-hint">{$_('comms.configureToEnable')}</span> 190 {/if} 191 </div> ··· 210 </div> 211 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 212 </div> 213 - <div class="config-item"> 214 <label for="discord">{$_('register.discordId')}</label> 215 <div class="config-input"> 216 <input ··· 218 type="text" 219 bind:value={discordId} 220 placeholder={$_('register.discordIdPlaceholder')} 221 - disabled={saving} 222 /> 223 - {#if discordId} 224 {#if discordVerified} 225 <span class="status verified">{$_('comms.verified')}</span> 226 {:else} ··· 243 </div> 244 {/if} 245 </div> 246 - <div class="config-item"> 247 <label for="telegram">{$_('register.telegramUsername')}</label> 248 <div class="config-input"> 249 <input ··· 251 type="text" 252 bind:value={telegramUsername} 253 placeholder={$_('register.telegramUsernamePlaceholder')} 254 - disabled={saving} 255 /> 256 - {#if telegramUsername} 257 {#if telegramVerified} 258 <span class="status verified">{$_('comms.verified')}</span> 259 {:else} ··· 276 </div> 277 {/if} 278 </div> 279 - <div class="config-item"> 280 <label for="signal">{$_('register.signalNumber')}</label> 281 <div class="config-input"> 282 <input ··· 284 type="tel" 285 bind:value={signalNumber} 286 placeholder={$_('register.signalNumberPlaceholder')} 287 - disabled={saving} 288 /> 289 - {#if signalNumber} 290 {#if signalVerified} 291 <span class="status verified">{$_('comms.verified')}</span> 292 {:else} ··· 439 cursor: not-allowed; 440 } 441 442 .channel-option input[type="radio"] { 443 flex-shrink: 0; 444 width: 16px; ··· 467 font-size: var(--text-xs); 468 color: var(--text-muted); 469 font-style: italic; 470 } 471 472 .channel-config { ··· 481 gap: var(--space-1); 482 } 483 484 .config-item label { 485 font-size: var(--text-sm); 486 font-weight: var(--font-medium); ··· 516 .status.unverified { 517 background: var(--warning-bg); 518 color: var(--warning-text); 519 } 520 521 .config-hint {
··· 10 let error = $state<string | null>(null) 11 let success = $state<string | null>(null) 12 let preferredChannel = $state('email') 13 + let availableCommsChannels = $state<string[]>(['email']) 14 let email = $state('') 15 let discordId = $state('') 16 let discordVerified = $state(false) ··· 48 loading = true 49 error = null 50 try { 51 + const [prefs, serverInfo] = await Promise.all([ 52 + api.getNotificationPrefs(auth.session.accessJwt), 53 + api.describeServer() 54 + ]) 55 preferredChannel = prefs.preferredChannel 56 email = prefs.email 57 discordId = prefs.discordId ?? '' ··· 60 telegramVerified = prefs.telegramVerified 61 signalNumber = prefs.signalNumber ?? '' 62 signalVerified = prefs.signalVerified 63 + availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] 64 } catch (e) { 65 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences' 66 } finally { ··· 140 default: return '' 141 } 142 } 143 + function isChannelAvailableOnServer(channelId: string): boolean { 144 + return availableCommsChannels.includes(channelId) 145 + } 146 function canSelectChannel(channelId: string): boolean { 147 + if (!isChannelAvailableOnServer(channelId)) return false 148 if (channelId === 'email') return true 149 if (channelId === 'discord') return !!discordId 150 if (channelId === 'telegram') return !!telegramUsername ··· 183 </p> 184 <div class="channel-options"> 185 {#each channels as channelId} 186 + <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}> 187 <input 188 type="radio" 189 name="preferredChannel" ··· 194 <div class="channel-info"> 195 <span class="channel-name">{getChannelName(channelId)}</span> 196 <span class="channel-description">{getChannelDescription(channelId)}</span> 197 + {#if !isChannelAvailableOnServer(channelId)} 198 + <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span> 199 + {:else if channelId !== 'email' && !canSelectChannel(channelId)} 200 <span class="channel-hint">{$_('comms.configureToEnable')}</span> 201 {/if} 202 </div> ··· 221 </div> 222 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 223 </div> 224 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}> 225 <label for="discord">{$_('register.discordId')}</label> 226 <div class="config-input"> 227 <input ··· 229 type="text" 230 bind:value={discordId} 231 placeholder={$_('register.discordIdPlaceholder')} 232 + disabled={saving || !isChannelAvailableOnServer('discord')} 233 /> 234 + {#if !isChannelAvailableOnServer('discord')} 235 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 236 + {:else if discordId} 237 {#if discordVerified} 238 <span class="status verified">{$_('comms.verified')}</span> 239 {:else} ··· 256 </div> 257 {/if} 258 </div> 259 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}> 260 <label for="telegram">{$_('register.telegramUsername')}</label> 261 <div class="config-input"> 262 <input ··· 264 type="text" 265 bind:value={telegramUsername} 266 placeholder={$_('register.telegramUsernamePlaceholder')} 267 + disabled={saving || !isChannelAvailableOnServer('telegram')} 268 /> 269 + {#if !isChannelAvailableOnServer('telegram')} 270 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 271 + {:else if telegramUsername} 272 {#if telegramVerified} 273 <span class="status verified">{$_('comms.verified')}</span> 274 {:else} ··· 291 </div> 292 {/if} 293 </div> 294 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}> 295 <label for="signal">{$_('register.signalNumber')}</label> 296 <div class="config-input"> 297 <input ··· 299 type="tel" 300 bind:value={signalNumber} 301 placeholder={$_('register.signalNumberPlaceholder')} 302 + disabled={saving || !isChannelAvailableOnServer('signal')} 303 /> 304 + {#if !isChannelAvailableOnServer('signal')} 305 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 306 + {:else if signalNumber} 307 {#if signalVerified} 308 <span class="status verified">{$_('comms.verified')}</span> 309 {:else} ··· 456 cursor: not-allowed; 457 } 458 459 + .channel-option.unavailable { 460 + opacity: 0.5; 461 + background: var(--bg-input-disabled); 462 + } 463 + 464 .channel-option input[type="radio"] { 465 flex-shrink: 0; 466 width: 16px; ··· 489 font-size: var(--text-xs); 490 color: var(--text-muted); 491 font-style: italic; 492 + } 493 + 494 + .channel-hint.server-unavailable { 495 + color: var(--warning-text); 496 } 497 498 .channel-config { ··· 507 gap: var(--space-1); 508 } 509 510 + .config-item.unavailable { 511 + opacity: 0.6; 512 + } 513 + 514 .config-item label { 515 font-size: var(--text-sm); 516 font-weight: var(--font-medium); ··· 546 .status.unverified { 547 background: var(--warning-bg); 548 color: var(--warning-text); 549 + } 550 + 551 + .status.unavailable { 552 + background: var(--bg-input-disabled); 553 + color: var(--text-muted); 554 } 555 556 .config-hint {
+3 -3
frontend/src/routes/Home.svelte
··· 153 154 <h2>Works with everything</h2> 155 156 - <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients, tools, and bots just work.</p> 157 158 <h2>Ready to try it?</h2> 159 ··· 170 </section> 171 172 <footer class="site-footer"> 173 - <span>Open Source</span> 174 - <span>Made with patience</span> 175 </footer> 176 </div> 177
··· 153 154 <h2>Works with everything</h2> 155 156 + <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p> 157 158 <h2>Ready to try it?</h2> 159 ··· 170 </section> 171 172 <footer class="site-footer"> 173 + <span>Made by people who don't take themselves too seriously</span> 174 + <span>Open Source: issues & PRs welcome</span> 175 </footer> 176 </div> 177
+1 -1
frontend/src/routes/Login.svelte
··· 144 </button> 145 146 <p class="link-text"> 147 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAcount')}</a> 148 </p> 149 150 {:else}
··· 144 </button> 145 146 <p class="link-text"> 147 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 148 </p> 149 150 {:else}
+15 -3
frontend/src/routes/Register.svelte
··· 22 let serverInfo = $state<{ 23 availableUserDomains: string[] 24 inviteCodeRequired: boolean 25 } | null>(null) 26 let loadingServerInfo = $state(true) 27 let serverInfoLoaded = false ··· 46 } 47 48 let handleHasDot = $derived(handle.includes('.')) 49 50 function validateForm(): string | null { 51 if (!handle.trim()) return $_('register.validation.handleRequired') ··· 262 <label for="verification-channel">{$_('register.verificationMethod')}</label> 263 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 264 <option value="email">{$_('register.email')}</option> 265 - <option value="discord">{$_('register.discord')}</option> 266 - <option value="telegram">{$_('register.telegram')}</option> 267 - <option value="signal">{$_('register.signal')}</option> 268 </select> 269 </div> 270
··· 22 let serverInfo = $state<{ 23 availableUserDomains: string[] 24 inviteCodeRequired: boolean 25 + availableCommsChannels?: string[] 26 } | null>(null) 27 let loadingServerInfo = $state(true) 28 let serverInfoLoaded = false ··· 47 } 48 49 let handleHasDot = $derived(handle.includes('.')) 50 + 51 + function isChannelAvailable(channel: string): boolean { 52 + const available = serverInfo?.availableCommsChannels ?? ['email'] 53 + return available.includes(channel) 54 + } 55 56 function validateForm(): string | null { 57 if (!handle.trim()) return $_('register.validation.handleRequired') ··· 268 <label for="verification-channel">{$_('register.verificationMethod')}</label> 269 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 270 <option value="email">{$_('register.email')}</option> 271 + <option value="discord" disabled={!isChannelAvailable('discord')}> 272 + {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 273 + </option> 274 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 275 + {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 276 + </option> 277 + <option value="signal" disabled={!isChannelAvailable('signal')}> 278 + {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 279 + </option> 280 </select> 281 </div> 282
+15 -4
frontend/src/routes/RegisterPasskey.svelte
··· 19 let passkeyName = $state('') 20 let submitting = $state(false) 21 let error = $state<string | null>(null) 22 - let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean } | null>(null) 23 let loadingServerInfo = $state(true) 24 let serverInfoLoaded = false 25 ··· 289 } 290 } 291 292 function goToLogin() { 293 navigate('/login') 294 } ··· 363 <label for="verification-channel">Verification Method</label> 364 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 365 <option value="email">Email</option> 366 - <option value="discord">Discord</option> 367 - <option value="telegram">Telegram</option> 368 - <option value="signal">Signal</option> 369 </select> 370 </div> 371 {#if verificationChannel === 'email'}
··· 19 let passkeyName = $state('') 20 let submitting = $state(false) 21 let error = $state<string | null>(null) 22 + let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null) 23 let loadingServerInfo = $state(true) 24 let serverInfoLoaded = false 25 ··· 289 } 290 } 291 292 + function isChannelAvailable(ch: string): boolean { 293 + const available = serverInfo?.availableCommsChannels ?? ['email'] 294 + return available.includes(ch) 295 + } 296 + 297 function goToLogin() { 298 navigate('/login') 299 } ··· 368 <label for="verification-channel">Verification Method</label> 369 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 370 <option value="email">Email</option> 371 + <option value="discord" disabled={!isChannelAvailable('discord')}> 372 + Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 373 + </option> 374 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 375 + Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 376 + </option> 377 + <option value="signal" disabled={!isChannelAvailable('signal')}> 378 + Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 379 + </option> 380 </select> 381 </div> 382 {#if verificationChannel === 'email'}
+34 -19
src/api/identity/account.rs
··· 122 && input 123 .did 124 .as_ref() 125 - .map(|d| d.starts_with("did:plc:")) 126 .unwrap_or(false); 127 128 if is_migration { ··· 138 ) 139 .into_response(); 140 } 141 - info!(did = %migration_did, "Processing account migration"); 142 } 143 } 144 ··· 337 ) 338 .into_response(); 339 } 340 - if let Err(e) = 341 - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 342 - { 343 - return ( 344 - StatusCode::BAD_REQUEST, 345 - Json(json!({"error": "InvalidDid", "message": e})), 346 - ) 347 - .into_response(); 348 } 349 info!(did = %d, "Creating external did:web account"); 350 d.clone() ··· 355 info!(did = %d, "Migration with existing did:plc"); 356 d.clone() 357 } else if d.starts_with("did:web:") { 358 - if let Err(e) = 359 - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 360 - .await 361 - { 362 - return ( 363 - StatusCode::BAD_REQUEST, 364 - Json(json!({"error": "InvalidDid", "message": e})), 365 - ) 366 - .into_response(); 367 } 368 d.clone() 369 } else if !d.trim().is_empty() {
··· 122 && input 123 .did 124 .as_ref() 125 + .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) 126 + .unwrap_or(false); 127 + 128 + let is_did_web_byod = migration_auth.is_some() 129 + && input 130 + .did 131 + .as_ref() 132 + .map(|d| d.starts_with("did:web:")) 133 .unwrap_or(false); 134 135 if is_migration { ··· 145 ) 146 .into_response(); 147 } 148 + if is_did_web_byod { 149 + info!(did = %migration_did, "Processing did:web BYOD account creation"); 150 + } else { 151 + info!(did = %migration_did, "Processing account migration"); 152 + } 153 } 154 } 155 ··· 348 ) 349 .into_response(); 350 } 351 + if !is_did_web_byod { 352 + if let Err(e) = 353 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 354 + { 355 + return ( 356 + StatusCode::BAD_REQUEST, 357 + Json(json!({"error": "InvalidDid", "message": e})), 358 + ) 359 + .into_response(); 360 + } 361 } 362 info!(did = %d, "Creating external did:web account"); 363 d.clone() ··· 368 info!(did = %d, "Migration with existing did:plc"); 369 d.clone() 370 } else if d.starts_with("did:web:") { 371 + if !is_did_web_byod { 372 + if let Err(e) = 373 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 374 + .await 375 + { 376 + return ( 377 + StatusCode::BAD_REQUEST, 378 + Json(json!({"error": "InvalidDid", "message": e})), 379 + ) 380 + .into_response(); 381 + } 382 } 383 d.clone() 384 } else if !d.trim().is_empty() {
+17 -1
src/api/server/meta.rs
··· 2 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 3 use serde_json::json; 4 use tracing::error; 5 pub async fn robots_txt() -> impl IntoResponse { 6 ( 7 StatusCode::OK, ··· 21 "availableUserDomains": domains, 22 "inviteCodeRequired": invite_code_required, 23 "did": format!("did:web:{}", pds_hostname), 24 - "version": env!("CARGO_PKG_VERSION") 25 })) 26 } 27 pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
··· 2 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 3 use serde_json::json; 4 use tracing::error; 5 + 6 + fn get_available_comms_channels() -> Vec<&'static str> { 7 + let mut channels = vec!["email"]; 8 + if std::env::var("DISCORD_WEBHOOK_URL").is_ok() { 9 + channels.push("discord"); 10 + } 11 + if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { 12 + channels.push("telegram"); 13 + } 14 + if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { 15 + channels.push("signal"); 16 + } 17 + channels 18 + } 19 + 20 pub async fn robots_txt() -> impl IntoResponse { 21 ( 22 StatusCode::OK, ··· 36 "availableUserDomains": domains, 37 "inviteCodeRequired": invite_code_required, 38 "did": format!("did:web:{}", pds_hostname), 39 + "version": env!("CARGO_PKG_VERSION"), 40 + "availableCommsChannels": get_available_comms_channels() 41 })) 42 } 43 pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+9 -8
src/auth/service.rs
··· 229 .strip_prefix("did:web:") 230 .ok_or_else(|| anyhow!("Invalid did:web format"))?; 231 232 - let decoded_host = host.replace("%3A", ":"); 233 - let (host_part, path_part) = if let Some(idx) = decoded_host.find('/') { 234 - (&decoded_host[..idx], &decoded_host[idx..]) 235 - } else { 236 - (decoded_host.as_str(), "") 237 - }; 238 239 let scheme = if host_part.starts_with("localhost") 240 || host_part.starts_with("127.0.0.1") ··· 245 "https" 246 }; 247 248 - let url = if path_part.is_empty() { 249 format!("{}://{}/.well-known/did.json", scheme, host_part) 250 } else { 251 - format!("{}://{}{}/did.json", scheme, host_part, path_part) 252 }; 253 254 debug!("Resolving did:web {} via {}", did, url);
··· 229 .strip_prefix("did:web:") 230 .ok_or_else(|| anyhow!("Invalid did:web format"))?; 231 232 + let parts: Vec<&str> = host.split(':').collect(); 233 + if parts.is_empty() { 234 + return Err(anyhow!("Invalid did:web format - no host")); 235 + } 236 + 237 + let host_part = parts[0].replace("%3A", ":"); 238 239 let scheme = if host_part.starts_with("localhost") 240 || host_part.starts_with("127.0.0.1") ··· 245 "https" 246 }; 247 248 + let url = if parts.len() == 1 { 249 format!("{}://{}/.well-known/did.json", scheme, host_part) 250 } else { 251 + let path = parts[1..].join("/"); 252 + format!("{}://{}/{}/did.json", scheme, host_part, path) 253 }; 254 255 debug!("Resolving did:web {} via {}", did, url);
+193
tests/did_web.rs
··· 1 mod common; 2 use common::*; 3 use reqwest::StatusCode; 4 use serde_json::{Value, json}; 5 use wiremock::matchers::{method, path}; ··· 348 body 349 ); 350 }
··· 1 mod common; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use base64::Engine; 4 use common::*; 5 + use k256::ecdsa::{SigningKey, signature::Signer}; 6 use reqwest::StatusCode; 7 use serde_json::{Value, json}; 8 use wiremock::matchers::{method, path}; ··· 351 body 352 ); 353 } 354 + 355 + fn signing_key_to_multibase(signing_key: &SigningKey) -> String { 356 + let verifying_key = signing_key.verifying_key(); 357 + let compressed = verifying_key.to_sec1_bytes(); 358 + let mut multicodec = vec![0xe7, 0x01]; 359 + multicodec.extend_from_slice(&compressed); 360 + multibase::encode(multibase::Base::Base58Btc, &multicodec) 361 + } 362 + 363 + fn create_service_jwt(signing_key: &SigningKey, did: &str, aud: &str) -> String { 364 + let header = json!({"alg": "ES256K", "typ": "jwt"}); 365 + let now = chrono::Utc::now().timestamp() as usize; 366 + let claims = json!({ 367 + "iss": did, 368 + "sub": did, 369 + "aud": aud, 370 + "exp": now + 300, 371 + "iat": now, 372 + "lxm": "com.atproto.server.createAccount", 373 + "jti": uuid::Uuid::new_v4().to_string() 374 + }); 375 + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); 376 + let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string()); 377 + let message = format!("{}.{}", header_b64, claims_b64); 378 + let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes()); 379 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 380 + format!("{}.{}", message, sig_b64) 381 + } 382 + 383 + #[tokio::test] 384 + async fn test_did_web_byod_flow() { 385 + let client = client(); 386 + let mock_server = MockServer::start().await; 387 + let mock_uri = mock_server.uri(); 388 + let mock_addr = mock_uri.trim_start_matches("http://"); 389 + let unique_id = uuid::Uuid::new_v4().to_string().replace("-", ""); 390 + let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id); 391 + let handle = format!("byod_{}", uuid::Uuid::new_v4()); 392 + let pds_endpoint = base_url().await.replace("http://", "https://"); 393 + let pds_did = format!( 394 + "did:web:{}", 395 + pds_endpoint.trim_start_matches("https://") 396 + ); 397 + 398 + let temp_key = SigningKey::random(&mut rand::thread_rng()); 399 + let public_key_multibase = signing_key_to_multibase(&temp_key); 400 + 401 + let did_doc = json!({ 402 + "@context": ["https://www.w3.org/ns/did/v1"], 403 + "id": did, 404 + "verificationMethod": [{ 405 + "id": format!("{}#atproto", did), 406 + "type": "Multikey", 407 + "controller": did, 408 + "publicKeyMultibase": public_key_multibase 409 + }], 410 + "service": [{ 411 + "id": "#atproto_pds", 412 + "type": "AtprotoPersonalDataServer", 413 + "serviceEndpoint": pds_endpoint 414 + }] 415 + }); 416 + Mock::given(method("GET")) 417 + .and(path(format!("/byod/{}/did.json", unique_id))) 418 + .respond_with(ResponseTemplate::new(200).set_body_json(&did_doc)) 419 + .mount(&mock_server) 420 + .await; 421 + 422 + let service_jwt = create_service_jwt(&temp_key, &did, &pds_did); 423 + let payload = json!({ 424 + "handle": handle, 425 + "email": format!("{}@example.com", handle), 426 + "password": "Testpass123!", 427 + "did": did 428 + }); 429 + let res = client 430 + .post(format!( 431 + "{}/xrpc/com.atproto.server.createAccount", 432 + base_url().await 433 + )) 434 + .header("Authorization", format!("Bearer {}", service_jwt)) 435 + .json(&payload) 436 + .send() 437 + .await 438 + .expect("Failed to send request"); 439 + if res.status() != StatusCode::OK { 440 + let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"})); 441 + panic!("createAccount BYOD failed: {:?}", body); 442 + } 443 + let body: Value = res.json().await.expect("Response was not JSON"); 444 + let returned_did = body["did"].as_str().expect("No DID in response"); 445 + assert_eq!(returned_did, did, "Returned DID should match requested DID"); 446 + let access_jwt = body["accessJwt"] 447 + .as_str() 448 + .expect("No accessJwt in response"); 449 + 450 + let res = client 451 + .get(format!( 452 + "{}/xrpc/com.atproto.server.checkAccountStatus", 453 + base_url().await 454 + )) 455 + .bearer_auth(access_jwt) 456 + .send() 457 + .await 458 + .expect("Failed to check account status"); 459 + assert_eq!(res.status(), StatusCode::OK); 460 + let status: Value = res.json().await.expect("Response was not JSON"); 461 + assert_eq!( 462 + status["activated"], false, 463 + "BYOD account should be deactivated initially" 464 + ); 465 + 466 + let res = client 467 + .get(format!( 468 + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", 469 + base_url().await 470 + )) 471 + .bearer_auth(access_jwt) 472 + .send() 473 + .await 474 + .expect("Failed to get recommended credentials"); 475 + assert_eq!(res.status(), StatusCode::OK); 476 + let creds: Value = res.json().await.expect("Response was not JSON"); 477 + assert!( 478 + creds["verificationMethods"]["atproto"].is_string(), 479 + "Should return PDS signing key" 480 + ); 481 + let pds_signing_key = creds["verificationMethods"]["atproto"] 482 + .as_str() 483 + .expect("No atproto verification method"); 484 + assert!( 485 + pds_signing_key.starts_with("did:key:"), 486 + "PDS signing key should be did:key format" 487 + ); 488 + 489 + let res = client 490 + .post(format!( 491 + "{}/xrpc/com.atproto.server.activateAccount", 492 + base_url().await 493 + )) 494 + .bearer_auth(access_jwt) 495 + .send() 496 + .await 497 + .expect("Failed to activate account"); 498 + assert_eq!( 499 + res.status(), 500 + StatusCode::OK, 501 + "activateAccount should succeed" 502 + ); 503 + 504 + let res = client 505 + .get(format!( 506 + "{}/xrpc/com.atproto.server.checkAccountStatus", 507 + base_url().await 508 + )) 509 + .bearer_auth(access_jwt) 510 + .send() 511 + .await 512 + .expect("Failed to check account status"); 513 + assert_eq!(res.status(), StatusCode::OK); 514 + let status: Value = res.json().await.expect("Response was not JSON"); 515 + assert_eq!( 516 + status["activated"], true, 517 + "Account should be activated after activateAccount call" 518 + ); 519 + 520 + let res = client 521 + .post(format!( 522 + "{}/xrpc/com.atproto.repo.createRecord", 523 + base_url().await 524 + )) 525 + .bearer_auth(access_jwt) 526 + .json(&json!({ 527 + "repo": did, 528 + "collection": "app.bsky.feed.post", 529 + "record": { 530 + "$type": "app.bsky.feed.post", 531 + "text": "Hello from BYOD did:web!", 532 + "createdAt": chrono::Utc::now().to_rfc3339() 533 + } 534 + })) 535 + .send() 536 + .await 537 + .expect("Failed to create post"); 538 + assert_eq!( 539 + res.status(), 540 + StatusCode::OK, 541 + "Activated BYOD account should be able to create records" 542 + ); 543 + }