Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: update routes, backend verification tweaks, and restyle

authored by did:plc:mb5to35neicxt4gemstoro… and committed by tangled.org 5c8894d5 81fc03c7

+562 -845
+20 -20
Cargo.lock
··· 6094 6094 6095 6095 [[package]] 6096 6096 name = "tranquil-api" 6097 - version = "0.4.4" 6097 + version = "0.4.5" 6098 6098 dependencies = [ 6099 6099 "anyhow", 6100 6100 "axum", ··· 6142 6142 6143 6143 [[package]] 6144 6144 name = "tranquil-auth" 6145 - version = "0.4.4" 6145 + version = "0.4.5" 6146 6146 dependencies = [ 6147 6147 "anyhow", 6148 6148 "base32", ··· 6165 6165 6166 6166 [[package]] 6167 6167 name = "tranquil-cache" 6168 - version = "0.4.4" 6168 + version = "0.4.5" 6169 6169 dependencies = [ 6170 6170 "async-trait", 6171 6171 "base64 0.22.1", ··· 6179 6179 6180 6180 [[package]] 6181 6181 name = "tranquil-comms" 6182 - version = "0.4.4" 6182 + version = "0.4.5" 6183 6183 dependencies = [ 6184 6184 "async-trait", 6185 6185 "base64 0.22.1", ··· 6194 6194 6195 6195 [[package]] 6196 6196 name = "tranquil-config" 6197 - version = "0.4.4" 6197 + version = "0.4.5" 6198 6198 dependencies = [ 6199 6199 "confique", 6200 6200 "serde", ··· 6202 6202 6203 6203 [[package]] 6204 6204 name = "tranquil-crypto" 6205 - version = "0.4.4" 6205 + version = "0.4.5" 6206 6206 dependencies = [ 6207 6207 "aes-gcm", 6208 6208 "base64 0.22.1", ··· 6218 6218 6219 6219 [[package]] 6220 6220 name = "tranquil-db" 6221 - version = "0.4.4" 6221 + version = "0.4.5" 6222 6222 dependencies = [ 6223 6223 "async-trait", 6224 6224 "chrono", ··· 6235 6235 6236 6236 [[package]] 6237 6237 name = "tranquil-db-traits" 6238 - version = "0.4.4" 6238 + version = "0.4.5" 6239 6239 dependencies = [ 6240 6240 "async-trait", 6241 6241 "base64 0.22.1", ··· 6251 6251 6252 6252 [[package]] 6253 6253 name = "tranquil-infra" 6254 - version = "0.4.4" 6254 + version = "0.4.5" 6255 6255 dependencies = [ 6256 6256 "async-trait", 6257 6257 "bytes", ··· 6262 6262 6263 6263 [[package]] 6264 6264 name = "tranquil-lexicon" 6265 - version = "0.4.4" 6265 + version = "0.4.5" 6266 6266 dependencies = [ 6267 6267 "chrono", 6268 6268 "hickory-resolver", ··· 6280 6280 6281 6281 [[package]] 6282 6282 name = "tranquil-oauth" 6283 - version = "0.4.4" 6283 + version = "0.4.5" 6284 6284 dependencies = [ 6285 6285 "anyhow", 6286 6286 "axum", ··· 6303 6303 6304 6304 [[package]] 6305 6305 name = "tranquil-oauth-server" 6306 - version = "0.4.4" 6306 + version = "0.4.5" 6307 6307 dependencies = [ 6308 6308 "axum", 6309 6309 "base64 0.22.1", ··· 6336 6336 6337 6337 [[package]] 6338 6338 name = "tranquil-pds" 6339 - version = "0.4.4" 6339 + version = "0.4.5" 6340 6340 dependencies = [ 6341 6341 "aes-gcm", 6342 6342 "anyhow", ··· 6424 6424 6425 6425 [[package]] 6426 6426 name = "tranquil-repo" 6427 - version = "0.4.4" 6427 + version = "0.4.5" 6428 6428 dependencies = [ 6429 6429 "bytes", 6430 6430 "cid", ··· 6436 6436 6437 6437 [[package]] 6438 6438 name = "tranquil-ripple" 6439 - version = "0.4.4" 6439 + version = "0.4.5" 6440 6440 dependencies = [ 6441 6441 "async-trait", 6442 6442 "backon", ··· 6461 6461 6462 6462 [[package]] 6463 6463 name = "tranquil-scopes" 6464 - version = "0.4.4" 6464 + version = "0.4.5" 6465 6465 dependencies = [ 6466 6466 "axum", 6467 6467 "futures", ··· 6477 6477 6478 6478 [[package]] 6479 6479 name = "tranquil-server" 6480 - version = "0.4.4" 6480 + version = "0.4.5" 6481 6481 dependencies = [ 6482 6482 "axum", 6483 6483 "clap", ··· 6497 6497 6498 6498 [[package]] 6499 6499 name = "tranquil-storage" 6500 - version = "0.4.4" 6500 + version = "0.4.5" 6501 6501 dependencies = [ 6502 6502 "async-trait", 6503 6503 "aws-config", ··· 6514 6514 6515 6515 [[package]] 6516 6516 name = "tranquil-sync" 6517 - version = "0.4.4" 6517 + version = "0.4.5" 6518 6518 dependencies = [ 6519 6519 "anyhow", 6520 6520 "axum", ··· 6536 6536 6537 6537 [[package]] 6538 6538 name = "tranquil-types" 6539 - version = "0.4.4" 6539 + version = "0.4.5" 6540 6540 dependencies = [ 6541 6541 "chrono", 6542 6542 "cid",
+1 -1
Cargo.toml
··· 24 24 ] 25 25 26 26 [workspace.package] 27 - version = "0.4.4" 27 + version = "0.4.5" 28 28 edition = "2024" 29 29 license = "AGPL-3.0-or-later" 30 30
+15 -12
crates/tranquil-api/src/identity/account.rs
··· 153 153 let verification_channel = input 154 154 .verification_channel 155 155 .unwrap_or(tranquil_db_traits::CommsChannel::Email); 156 - let verification_recipient = if is_migration { 157 - None 158 - } else { 156 + let verification_recipient = { 159 157 Some(match verification_channel { 160 158 tranquil_db_traits::CommsChannel::Email => match &input.email { 161 159 Some(email) if !email.trim().is_empty() => email.trim().to_string(), ··· 372 370 return ApiError::InternalError(None).into_response(); 373 371 } 374 372 let hostname = &tranquil_config::get().server.hostname; 375 - let verification_required = if let Some(ref user_email) = email { 373 + let verification_required = if let Some(ref recipient) = verification_recipient { 376 374 let token = tranquil_pds::auth::verification_token::generate_migration_token( 377 - &did_typed, user_email, 375 + &did_typed, 376 + verification_channel, 377 + recipient, 378 378 ); 379 379 let formatted_token = 380 380 tranquil_pds::auth::verification_token::format_token_for_display(&token); ··· 382 382 state.user_repo.as_ref(), 383 383 state.infra_repo.as_ref(), 384 384 reactivated.user_id, 385 - user_email, 385 + verification_channel, 386 + recipient, 386 387 &formatted_token, 387 388 hostname, 388 389 ) 389 390 .await 390 391 { 391 - warn!("Failed to enqueue migration verification email: {:?}", e); 392 + warn!("Failed to enqueue migration verification: {:?}", e); 392 393 } 393 394 true 394 395 } else { ··· 403 404 access_jwt: access_meta.token, 404 405 refresh_jwt: refresh_meta.token, 405 406 verification_required, 406 - verification_channel: tranquil_db_traits::CommsChannel::Email, 407 + verification_channel, 407 408 }), 408 409 ) 409 410 .into_response(); ··· 668 669 ); 669 670 } 670 671 } 671 - } else if let Some(ref user_email) = email { 672 + } else if let Some(ref recipient) = verification_recipient { 672 673 let token = tranquil_pds::auth::verification_token::generate_migration_token( 673 674 &did_for_commit, 674 - user_email, 675 + verification_channel, 676 + recipient, 675 677 ); 676 678 let formatted_token = 677 679 tranquil_pds::auth::verification_token::format_token_for_display(&token); ··· 679 681 state.user_repo.as_ref(), 680 682 state.infra_repo.as_ref(), 681 683 user_id, 682 - user_email, 684 + verification_channel, 685 + recipient, 683 686 &formatted_token, 684 687 hostname, 685 688 ) 686 689 .await 687 690 { 688 - warn!("Failed to enqueue migration verification email: {:?}", e); 691 + warn!("Failed to enqueue migration verification: {:?}", e); 689 692 } 690 693 } 691 694
-4
crates/tranquil-api/src/lib.rs
··· 216 216 post(server::check_email_in_use), 217 217 ) 218 218 .route( 219 - "/_account.checkCommsChannelInUse", 220 - post(server::check_comms_channel_in_use), 221 - ) 222 - .route( 223 219 "/com.atproto.server.reserveSigningKey", 224 220 post(server::reserve_signing_key), 225 221 )
-34
crates/tranquil-api/src/server/email.rs
··· 606 606 })) 607 607 .into_response() 608 608 } 609 - 610 - #[derive(Deserialize)] 611 - pub struct CheckCommsChannelInUseInput { 612 - pub channel: CommsChannel, 613 - pub identifier: String, 614 - } 615 - 616 - pub async fn check_comms_channel_in_use( 617 - State(state): State<AppState>, 618 - _rate_limit: RateLimited<VerificationCheckLimit>, 619 - Json(input): Json<CheckCommsChannelInUseInput>, 620 - ) -> Response { 621 - let identifier = input.identifier.trim(); 622 - if identifier.is_empty() { 623 - return ApiError::InvalidRequest("identifier is required".into()).into_response(); 624 - } 625 - 626 - let count = match state 627 - .user_repo 628 - .count_accounts_by_comms_identifier(input.channel, identifier) 629 - .await 630 - { 631 - Ok(c) => c, 632 - Err(e) => { 633 - error!("DB error checking comms channel usage: {:?}", e); 634 - return ApiError::InternalError(None).into_response(); 635 - } 636 - }; 637 - 638 - Json(json!({ 639 - "inUse": count > 0, 640 - })) 641 - .into_response() 642 - }
+1 -1
crates/tranquil-api/src/server/mod.rs
··· 23 23 }; 24 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 25 pub use email::{ 26 - authorize_email_update, check_channel_verified, check_comms_channel_in_use, check_email_in_use, 26 + authorize_email_update, check_channel_verified, check_email_in_use, 27 27 check_email_update_status, check_email_verified, confirm_email, request_email_update, 28 28 update_email, 29 29 };
+16 -7
crates/tranquil-api/src/server/verify_email.rs
··· 40 40 #[derive(Deserialize)] 41 41 #[serde(rename_all = "camelCase")] 42 42 pub struct ResendMigrationVerificationInput { 43 - pub email: String, 43 + pub channel: Option<tranquil_db_traits::CommsChannel>, 44 + pub identifier: String, 44 45 } 45 46 46 47 #[derive(Serialize)] ··· 53 54 State(state): State<AppState>, 54 55 Json(input): Json<ResendMigrationVerificationInput>, 55 56 ) -> Result<Json<ResendMigrationVerificationOutput>, ApiError> { 56 - let email = input.email.trim().to_lowercase(); 57 + let channel = input 58 + .channel 59 + .unwrap_or(tranquil_db_traits::CommsChannel::Email); 60 + let identifier = input.identifier.trim().to_lowercase(); 57 61 58 - let user = match state.user_repo.get_by_email(&email).await { 62 + let user = match state.user_repo.get_by_email(&identifier).await { 59 63 Ok(Some(u)) => u, 60 64 Ok(None) => { 61 65 return Ok(Json(ResendMigrationVerificationOutput { sent: true })); ··· 71 75 } 72 76 73 77 let hostname = &tranquil_config::get().server.hostname; 74 - let token = tranquil_pds::auth::verification_token::generate_migration_token(&user.did, &email); 78 + let token = tranquil_pds::auth::verification_token::generate_migration_token( 79 + &user.did, 80 + channel, 81 + &identifier, 82 + ); 75 83 let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(&token); 76 84 77 85 if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification( 78 86 state.user_repo.as_ref(), 79 87 state.infra_repo.as_ref(), 80 88 user.id, 81 - &email, 89 + channel, 90 + &identifier, 82 91 &formatted_token, 83 92 hostname, 84 93 ) 85 94 .await 86 95 { 87 - warn!(error = ?e, "Failed to enqueue migration verification email"); 96 + warn!(error = ?e, channel = ?channel, "Failed to enqueue migration verification"); 88 97 } 89 98 90 - info!(did = %user.did, "Resent migration verification email"); 99 + info!(did = %user.did, channel = ?channel, "Resent migration verification"); 91 100 92 101 Ok(Json(ResendMigrationVerificationOutput { sent: true })) 93 102 }
+36 -16
crates/tranquil-api/src/server/verify_token.rs
··· 72 72 channel: CommsChannel, 73 73 identifier: &str, 74 74 ) -> Result<Json<VerifyTokenOutput>, ApiError> { 75 - if channel != CommsChannel::Email { 76 - return Err(ApiError::InvalidChannel); 77 - } 78 - 79 75 let user = state 80 76 .user_repo 81 77 .get_verification_info(did) ··· 83 79 .log_db_err("during migration verification")? 84 80 .ok_or(ApiError::AccountNotFound)?; 85 81 86 - if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) { 87 - return Err(ApiError::IdentifierMismatch); 88 - } 89 - 90 - if !user.channel_verification.email { 91 - state 92 - .user_repo 93 - .set_email_verified_flag(user.id) 94 - .await 95 - .log_db_err("updating email_verified status")?; 96 - } 82 + match channel { 83 + CommsChannel::Email => { 84 + if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) { 85 + return Err(ApiError::IdentifierMismatch); 86 + } 87 + if !user.channel_verification.email { 88 + state 89 + .user_repo 90 + .set_email_verified_flag(user.id) 91 + .await 92 + .log_db_err("updating email_verified status")?; 93 + } 94 + } 95 + CommsChannel::Discord => { 96 + state 97 + .user_repo 98 + .set_discord_verified_flag(user.id) 99 + .await 100 + .log_db_err("updating discord verified status")?; 101 + } 102 + CommsChannel::Telegram => { 103 + state 104 + .user_repo 105 + .set_telegram_verified_flag(user.id) 106 + .await 107 + .log_db_err("updating telegram verified status")?; 108 + } 109 + CommsChannel::Signal => { 110 + state 111 + .user_repo 112 + .set_signal_verified_flag(user.id) 113 + .await 114 + .log_db_err("updating signal verified status")?; 115 + } 116 + }; 97 117 98 - info!(did = %did, "Migration email verified successfully"); 118 + info!(did = %did, channel = ?channel, "Migration verification completed successfully"); 99 119 100 120 Ok(Json(VerifyTokenOutput { 101 121 success: true,
-6
crates/tranquil-db-traits/src/user.rs
··· 429 429 430 430 async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>; 431 431 432 - async fn count_accounts_by_comms_identifier( 433 - &self, 434 - channel: CommsChannel, 435 - identifier: &str, 436 - ) -> Result<i64, DbError>; 437 - 438 432 async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError>; 439 433 440 434 async fn set_password_reset_code(
-27
crates/tranquil-db/src/postgres/user.rs
··· 1654 1654 .map_err(map_sqlx_error) 1655 1655 } 1656 1656 1657 - async fn count_accounts_by_comms_identifier( 1658 - &self, 1659 - channel: CommsChannel, 1660 - identifier: &str, 1661 - ) -> Result<i64, DbError> { 1662 - let query = match channel { 1663 - CommsChannel::Email => { 1664 - "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL" 1665 - } 1666 - CommsChannel::Discord => { 1667 - "SELECT COUNT(*) FROM users WHERE LOWER(discord_username) = LOWER($1) AND deactivated_at IS NULL" 1668 - } 1669 - CommsChannel::Telegram => { 1670 - "SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL" 1671 - } 1672 - CommsChannel::Signal => { 1673 - "SELECT COUNT(*) FROM users WHERE signal_username = $1 AND deactivated_at IS NULL" 1674 - } 1675 - }; 1676 - sqlx::query_scalar(query) 1677 - .bind(identifier) 1678 - .fetch_one(&self.pool) 1679 - .await 1680 - .map(|c: Option<i64>| c.unwrap_or(0)) 1681 - .map_err(map_sqlx_error) 1682 - } 1683 - 1684 1657 async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> { 1685 1658 sqlx::query_scalar!( 1686 1659 "SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC",
+9 -2
crates/tranquil-oauth-server/src/endpoints/authorize.rs
··· 323 323 .await 324 324 && !accounts.is_empty() 325 325 { 326 + let login_hint_param = request_data 327 + .parameters 328 + .login_hint 329 + .as_ref() 330 + .map(|h| format!("&login_hint={}", url_encode(h))) 331 + .unwrap_or_default(); 326 332 return redirect_see_other(&format!( 327 - "/app/oauth/accounts?request_uri={}", 328 - url_encode(&request_uri) 333 + "/app/oauth/accounts?request_uri={}{}", 334 + url_encode(&request_uri), 335 + login_hint_param 329 336 )); 330 337 } 331 338 redirect_see_other(&format!(
+9 -13
crates/tranquil-pds/src/auth/verification_token.rs
··· 80 80 generate_token(did, VerificationPurpose::Signup, channel, identifier) 81 81 } 82 82 83 - pub fn generate_migration_token(did: &Did, email: &str) -> String { 84 - generate_token( 85 - did, 86 - VerificationPurpose::Migration, 87 - CommsChannel::Email, 88 - email, 89 - ) 83 + pub fn generate_migration_token(did: &Did, channel: CommsChannel, identifier: &str) -> String { 84 + generate_token(did, VerificationPurpose::Migration, channel, identifier) 90 85 } 91 86 92 87 pub fn generate_channel_update_token(did: &Did, channel: CommsChannel, identifier: &str) -> String { ··· 196 191 197 192 pub fn verify_migration_token( 198 193 token: &str, 199 - expected_email: &str, 194 + expected_channel: CommsChannel, 195 + expected_identifier: &str, 200 196 ) -> Result<VerificationToken, VerifyError> { 201 197 let parsed = verify_token_signature(token)?; 202 198 if parsed.purpose != VerificationPurpose::Migration { 203 199 return Err(VerifyError::PurposeMismatch); 204 200 } 205 - if parsed.channel != CommsChannel::Email { 201 + if parsed.channel != expected_channel { 206 202 return Err(VerifyError::ChannelMismatch); 207 203 } 208 - let expected_hash = hash_identifier(expected_email); 204 + let expected_hash = hash_identifier(expected_identifier); 209 205 if parsed.identifier_hash != expected_hash { 210 206 return Err(VerifyError::IdentifierMismatch); 211 207 } ··· 345 341 init(); 346 342 let did: Did = "did:plc:test123".parse().unwrap(); 347 343 let email = "test@example.com"; 348 - let token = generate_migration_token(&did, email); 349 - let result = verify_migration_token(&token, email); 344 + let token = generate_migration_token(&did, CommsChannel::Email, email); 345 + let result = verify_migration_token(&token, CommsChannel::Email, email); 350 346 assert!(result.is_ok(), "Expected Ok, got {:?}", result); 351 347 let parsed = result.unwrap(); 352 348 assert_eq!(parsed.did, did); ··· 409 405 let did: Did = "did:plc:test123".parse().unwrap(); 410 406 let email = "test@example.com"; 411 407 let signup_token = generate_signup_token(&did, CommsChannel::Email, email); 412 - let result = verify_migration_token(&signup_token, email); 408 + let result = verify_migration_token(&signup_token, CommsChannel::Email, email); 413 409 assert!(matches!(result, Err(VerifyError::PurposeMismatch))); 414 410 } 415 411
+6 -5
crates/tranquil-pds/src/comms/service.rs
··· 499 499 user_repo: &dyn UserRepository, 500 500 infra_repo: &dyn InfraRepository, 501 501 user_id: Uuid, 502 - email: &str, 502 + channel: tranquil_db_traits::CommsChannel, 503 + recipient: &str, 503 504 token: &str, 504 505 hostname: &str, 505 506 ) -> Result<Uuid, DbError> { ··· 508 509 .await? 509 510 .ok_or(DbError::NotFound)?; 510 511 let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); 511 - let encoded_email = urlencoding::encode(email); 512 + let encoded_recipient = urlencoding::encode(recipient); 512 513 let encoded_token = urlencoding::encode(token); 513 514 let verify_page = format!("https://{}/app/verify", hostname); 514 515 let verify_link = format!( 515 516 "https://{}/app/verify?token={}&identifier={}", 516 - hostname, encoded_token, encoded_email 517 + hostname, encoded_token, encoded_recipient 517 518 ); 518 519 let body = format_message( 519 520 strings.migration_verification_body, ··· 531 532 infra_repo 532 533 .enqueue_comms( 533 534 Some(user_id), 534 - tranquil_db_traits::CommsChannel::Email, 535 + channel, 535 536 CommsType::MigrationVerification, 536 - email, 537 + recipient, 537 538 Some(&subject), 538 539 &body, 539 540 None,
+1 -1
crates/tranquil-pds/tests/legacy_2fa.rs
··· 127 127 body["message"] 128 128 .as_str() 129 129 .unwrap_or("") 130 - .contains("sign in code") 130 + .contains("sign-in code") 131 131 ); 132 132 } 133 133
+4 -10
frontend/src/App.svelte
··· 15 15 import OAuthConsent from './routes/OAuthConsent.svelte' 16 16 import OAuthLogin from './routes/OAuthLogin.svelte' 17 17 import OAuthAccounts from './routes/OAuthAccounts.svelte' 18 - import OAuth2FA from './routes/OAuth2FA.svelte' 19 - import OAuthTotp from './routes/OAuthTotp.svelte' 18 + import OAuthVerifyCode from './routes/OAuthVerifyCode.svelte' 20 19 import OAuthPasskey from './routes/OAuthPasskey.svelte' 21 20 import OAuthDelegation from './routes/OAuthDelegation.svelte' 22 21 import OAuthError from './routes/OAuthError.svelte' 23 22 import SsoRegisterComplete from './routes/SsoRegisterComplete.svelte' 24 23 import Register from './routes/Register.svelte' 25 - import RegisterPassword from './routes/RegisterPassword.svelte' 24 + 26 25 import ActAs from './routes/ActAs.svelte' 27 26 import Migration from './routes/Migration.svelte' 28 - import UiTest from './routes/UiTest.svelte' 29 27 import { _ } from './lib/i18n' 30 28 initI18n() 31 29 ··· 105 103 case '/oauth/accounts': 106 104 return OAuthAccounts 107 105 case '/oauth/2fa': 108 - return OAuth2FA 109 106 case '/oauth/totp': 110 - return OAuthTotp 107 + return OAuthVerifyCode 111 108 case '/oauth/passkey': 112 109 return OAuthPasskey 113 110 case '/oauth/delegation': ··· 118 115 return SsoRegisterComplete 119 116 case '/register': 120 117 case '/oauth/register': 118 + case '/oauth/register-password': 121 119 return Register 122 120 case '/oauth/register-sso': 123 121 return RegisterSso 124 - case '/oauth/register-password': 125 - return RegisterPassword 126 122 case '/act-as': 127 123 return ActAs 128 124 case '/migrate': 129 125 return Migration 130 - case '/ui-test': 131 - return UiTest 132 126 default: 133 127 return Login 134 128 }
-20
frontend/src/components/CommsChannelPicker.svelte
··· 10 10 signalUsername: string 11 11 availableChannels: VerificationChannel[] 12 12 disabled?: boolean 13 - discordInUse?: boolean 14 - telegramInUse?: boolean 15 - signalInUse?: boolean 16 13 onChannelChange: (channel: VerificationChannel) => void 17 14 onEmailChange: (value: string) => void 18 15 onDiscordChange: (value: string) => void 19 16 onTelegramChange: (value: string) => void 20 17 onSignalChange: (value: string) => void 21 - onCheckInUse?: (channel: 'discord' | 'telegram' | 'signal', identifier: string) => void 22 18 } 23 19 24 20 let { ··· 29 25 signalUsername, 30 26 availableChannels, 31 27 disabled = false, 32 - discordInUse = false, 33 - telegramInUse = false, 34 - signalInUse = false, 35 28 onChannelChange, 36 29 onEmailChange, 37 30 onDiscordChange, 38 31 onTelegramChange, 39 32 onSignalChange, 40 - onCheckInUse, 41 33 }: Props = $props() 42 34 43 35 function channelLabel(ch: string): string { ··· 92 84 type="text" 93 85 value={discordUsername} 94 86 oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)} 95 - onblur={() => onCheckInUse?.('discord', discordUsername)} 96 87 placeholder={$_('register.discordUsernamePlaceholder')} 97 88 {disabled} 98 89 required 99 90 /> 100 - {#if discordInUse} 101 - <p class="hint warning">{$_('register.discordInUseWarning')}</p> 102 - {/if} 103 91 </div> 104 92 {:else if channel === 'telegram'} 105 93 <div> ··· 109 97 type="text" 110 98 value={telegramUsername} 111 99 oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)} 112 - onblur={() => onCheckInUse?.('telegram', telegramUsername)} 113 100 placeholder={$_('register.telegramUsernamePlaceholder')} 114 101 {disabled} 115 102 required 116 103 /> 117 - {#if telegramInUse} 118 - <p class="hint warning">{$_('register.telegramInUseWarning')}</p> 119 - {/if} 120 104 </div> 121 105 {:else if channel === 'signal'} 122 106 <div> ··· 126 110 type="tel" 127 111 value={signalUsername} 128 112 oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)} 129 - onblur={() => onCheckInUse?.('signal', signalUsername)} 130 113 placeholder={$_('register.signalUsernamePlaceholder')} 131 114 {disabled} 132 115 required 133 116 /> 134 117 <p class="hint">{$_('register.signalUsernameHint')}</p> 135 - {#if signalInUse} 136 - <p class="hint warning">{$_('register.signalInUseWarning')}</p> 137 - {/if} 138 118 </div> 139 119 {/if}
-32
frontend/src/components/dashboard/CommsContent.svelte
··· 33 33 let verifyingChannel = $state<string | null>(null) 34 34 let verificationCode = $state('') 35 35 let historyLoading = $state(true) 36 - let discordInUse = $state(false) 37 - let telegramInUse = $state(false) 38 - let signalInUse = $state(false) 39 36 let messages = $state<Array<{ 40 37 createdAt: string 41 38 channel: string ··· 147 144 return formatDateTime(dateStr) 148 145 } 149 146 150 - async function checkChannelInUse(channel: 'discord' | 'telegram' | 'signal', identifier: string) { 151 - const trimmed = identifier.trim() 152 - if (!trimmed) { 153 - const resetMap = { discord: () => discordInUse = false, telegram: () => telegramInUse = false, signal: () => signalInUse = false } 154 - resetMap[channel]() 155 - return 156 - } 157 - try { 158 - const result = await api.checkCommsChannelInUse(channel, trimmed) 159 - const setMap = { discord: (v: boolean) => discordInUse = v, telegram: (v: boolean) => telegramInUse = v, signal: (v: boolean) => signalInUse = v } 160 - setMap[channel](result.inUse) 161 - } catch { 162 - const resetMap = { discord: () => discordInUse = false, telegram: () => telegramInUse = false, signal: () => signalInUse = false } 163 - resetMap[channel]() 164 - } 165 - } 166 - 167 147 const channels = ['email', 'discord', 'telegram', 'signal'] 168 148 169 149 function getChannelName(id: string): string { ··· 261 241 id="discord" 262 242 type="text" 263 243 bind:value={discordUsername} 264 - onblur={() => checkChannelInUse('discord', discordUsername)} 265 244 placeholder={$_('register.discordUsernamePlaceholder')} 266 245 disabled={saving} 267 246 /> 268 247 </div> 269 - {#if discordInUse} 270 - <p class="hint warning">{$_('comms.discordInUseWarning')}</p> 271 - {/if} 272 248 {#if discordUsername && discordUsername === savedDiscordUsername && !discordVerified && discordBotUsername} 273 249 {@const encodedHandle = session.handle.replaceAll('.', '_')} 274 250 <div class="discord-verify-prompt"> ··· 296 272 id="telegram" 297 273 type="text" 298 274 bind:value={telegramUsername} 299 - onblur={() => checkChannelInUse('telegram', telegramUsername)} 300 275 placeholder={$_('register.telegramUsernamePlaceholder')} 301 276 disabled={saving} 302 277 /> 303 278 </div> 304 - {#if telegramInUse} 305 - <p class="hint warning">{$_('comms.telegramInUseWarning')}</p> 306 - {/if} 307 279 {#if telegramUsername && telegramUsername === savedTelegramUsername && !telegramVerified && telegramBotUsername} 308 280 {@const encodedHandle = session.handle.replaceAll('.', '_')} 309 281 <div class="telegram-verify-prompt"> ··· 329 301 id="signal" 330 302 type="text" 331 303 bind:value={signalUsername} 332 - onblur={() => checkChannelInUse('signal', signalUsername)} 333 304 placeholder={$_('register.signalUsernamePlaceholder')} 334 305 disabled={saving} 335 306 /> 336 307 </div> 337 - {#if signalInUse} 338 - <p class="hint warning">{$_('comms.signalInUseWarning')}</p> 339 - {/if} 340 308 {#if signalUsername && signalUsername === savedSignalUsername && !signalVerified} 341 309 <div class="verify-form"> 342 310 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="512" />
-9
frontend/src/lib/api.ts
··· 525 525 }); 526 526 }, 527 527 528 - checkCommsChannelInUse( 529 - channel: "email" | "discord" | "telegram" | "signal", 530 - identifier: string, 531 - ): Promise<{ inUse: boolean }> { 532 - return xrpc("_account.checkCommsChannelInUse", { 533 - method: "POST", 534 - body: { channel, identifier }, 535 - }); 536 - }, 537 528 538 529 async getSession(token: AccessToken): Promise<Session> { 539 530 const raw = await xrpc<unknown>("com.atproto.server.getSession", { token });
+8 -9
frontend/src/lib/auth.svelte.ts
··· 1 - import { api, ApiError, castSession, typedApi } from "./api.ts"; 1 + import { api, ApiError, castSession } from "./api.ts"; 2 2 import type { 3 3 CreateAccountParams, 4 4 CreateAccountResult, ··· 15 15 unsafeAsRefreshToken, 16 16 } from "./types/branded.ts"; 17 17 import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; 18 - import { assertNever } from "./types/exhaustive.ts"; 19 18 import { 20 19 checkForOAuthCallback, 21 20 clearAllOAuthState, ··· 392 391 : null; 393 392 setLoading(previousSession); 394 393 395 - const result = await typedApi.createSession(identifier, password); 396 - if (isErr(result)) { 397 - const error = toAuthError(result.error); 394 + try { 395 + const session = await api.createSession(identifier, password); 396 + setAuthenticated(session); 397 + return ok(session); 398 + } catch (e) { 399 + const error = toAuthError(e); 398 400 setError(error); 399 401 return err(error); 400 402 } 401 - 402 - setAuthenticated(result.value); 403 - return ok(result.value); 404 403 } 405 404 406 405 export async function loginWithOAuth(): Promise<Result<void, AuthError>> { ··· 654 653 case "error": 655 654 return handlers.error(current.error, current.savedAccounts); 656 655 default: 657 - return assertNever(current); 656 + throw new Error(`Unexpected auth state: ${(current as { kind: string }).kind}`); 658 657 } 659 658 } 660 659
-24
frontend/src/lib/registration/flow.svelte.ts
··· 38 38 selectedDomain: string; 39 39 handleAvailable: boolean | null; 40 40 checkingHandle: boolean; 41 - discordInUse: boolean; 42 - telegramInUse: boolean; 43 - signalInUse: boolean; 44 41 } 45 42 46 43 export function createRegistrationFlow( ··· 73 70 selectedDomain: "", 74 71 handleAvailable: null, 75 72 checkingHandle: false, 76 - discordInUse: false, 77 - telegramInUse: false, 78 - signalInUse: false, 79 73 }); 80 74 81 75 function getPdsEndpoint(): string { ··· 149 143 state.handleAvailable = null; 150 144 } finally { 151 145 state.checkingHandle = false; 152 - } 153 - } 154 - 155 - async function checkCommsChannelInUse( 156 - channel: "discord" | "telegram" | "signal", 157 - identifier: string, 158 - ): Promise<void> { 159 - const trimmed = identifier.trim(); 160 - if (!trimmed) { 161 - state[`${channel}InUse`] = false; 162 - return; 163 - } 164 - try { 165 - const result = await api.checkCommsChannelInUse(channel, trimmed); 166 - state[`${channel}InUse`] = result.inUse; 167 - } catch { 168 - state[`${channel}InUse`] = false; 169 146 } 170 147 } 171 148 ··· 498 475 finalizeSession, 499 476 goBack, 500 477 checkHandleAvailability, 501 - checkCommsChannelInUse, 502 478 503 479 setError(msg: string) { 504 480 state.error = msg;
+9 -10
frontend/src/locales/en.json
··· 86 86 "discord": "Discord", 87 87 "discordUsername": "Discord Username", 88 88 "discordUsernamePlaceholder": "yourusername", 89 - "discordInUseWarning": "Discord username in use by another account", 90 89 "telegram": "Telegram", 91 90 "telegramUsername": "Telegram Username", 92 91 "telegramUsernamePlaceholder": "@yourusername", 93 - "telegramInUseWarning": "Telegram username in use by another account", 94 92 "signal": "Signal", 95 93 "signalUsername": "Signal Username", 96 94 "signalUsernamePlaceholder": "username.01", 97 - "signalInUseWarning": "Signal username in use by another account", 98 95 "notConfigured": "not configured", 99 96 "inviteCode": "Invite Code", 100 97 "inviteCodePlaceholder": "Enter your invite code", ··· 412 409 "verifiedSuccess": "{channel} verified successfully", 413 410 "messageHistory": "Message History", 414 411 "noMessages": "No messages found.", 415 - "discordInUseWarning": "This Discord username is already associated with another account.", 416 - "telegramInUseWarning": "This Telegram username is already associated with another account.", 417 - "signalInUseWarning": "This Signal username is already associated with another account.", 418 412 "telegramStartBot": "Or send /start {handle} to @{botUsername} manually", 419 413 "telegramOpenLink": "Open Telegram to verify", 420 414 "discordStartBot": "DM @{botUsername} on Discord and send /start {handle}", ··· 1027 1021 "continue": "Continue" 1028 1022 }, 1029 1023 "emailVerify": { 1030 - "title": "Verify Your Email", 1024 + "title": "Verify Your Account", 1031 1025 "desc": "A verification code has been sent to {email}.", 1032 - "hint": "Enter the code below, or click the link in the email to continue automatically.", 1026 + "hint": "Enter the code below, or click the link in the message to continue automatically.", 1033 1027 "tokenLabel": "Verification Code", 1034 - "tokenPlaceholder": "Enter code from email", 1035 - "resend": "Resend Code" 1028 + "tokenPlaceholder": "Enter verification code", 1029 + "resend": "Resend Code", 1030 + "telegramInstructions": "Message the Telegram bot to verify your account.", 1031 + "discordInstructions": "Message the Discord bot to verify your account.", 1032 + "openTelegram": "Open Telegram to verify", 1033 + "openDiscord": "Open Discord to verify", 1034 + "waitingForVerification": "Waiting for verification..." 1036 1035 }, 1037 1036 "plcToken": { 1038 1037 "title": "Verify Migration",
+9 -10
frontend/src/locales/fi.json
··· 86 86 "discord": "Discord", 87 87 "discordUsername": "Discord-käyttäjänimi", 88 88 "discordUsernamePlaceholder": "käyttäjänimesi", 89 - "discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.", 90 89 "telegram": "Telegram", 91 90 "telegramUsername": "Telegram-käyttäjänimi", 92 91 "telegramUsernamePlaceholder": "@käyttäjänimesi", 93 - "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", 94 92 "signal": "Signal", 95 93 "signalUsername": "Signal-käyttäjänimi", 96 94 "signalUsernamePlaceholder": "käyttäjänimi.01", 97 - "signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.", 98 95 "notConfigured": "ei määritetty", 99 96 "inviteCode": "Kutsukoodi", 100 97 "inviteCodePlaceholder": "Syötä kutsukoodisi", ··· 408 405 "verifiedSuccess": "{channel} vahvistettu", 409 406 "messageHistory": "Viestihistoria", 410 407 "noMessages": "Viestejä ei löytynyt.", 411 - "discordInUseWarning": "Tämä Discord-käyttäjänimi on jo yhdistetty toiseen tiliin.", 412 - "telegramInUseWarning": "Tämä Telegram-käyttäjänimi on jo yhdistetty toiseen tiliin.", 413 - "signalInUseWarning": "Tämä Signal-käyttäjänimi on jo yhdistetty toiseen tiliin.", 414 408 "telegramStartBot": "Tai lähetä /start {handle} käyttäjälle @{botUsername} manuaalisesti", 415 409 "telegramOpenLink": "Avaa Telegram vahvistaaksesi", 416 410 "discordStartBot": "Lähetä @{botUsername}-botille viesti /start {handle} Discordissa", ··· 1026 1020 "continue": "Jatka" 1027 1021 }, 1028 1022 "emailVerify": { 1029 - "title": "Vahvista sähköpostisi", 1023 + "title": "Vahvista tilisi", 1030 1024 "desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.", 1031 - "hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.", 1025 + "hint": "Syötä koodi alle tai klikkaa viestissä olevaa linkkiä jatkaaksesi automaattisesti.", 1032 1026 "tokenLabel": "Vahvistuskoodi", 1033 - "tokenPlaceholder": "Syötä sähköpostista saatu koodi", 1034 - "resend": "Lähetä koodi uudelleen" 1027 + "tokenPlaceholder": "Syötä vahvistuskoodi", 1028 + "resend": "Lähetä koodi uudelleen", 1029 + "telegramInstructions": "Lähetä viesti Telegram-botille vahvistaaksesi tilisi.", 1030 + "discordInstructions": "Lähetä viesti Discord-botille vahvistaaksesi tilisi.", 1031 + "openTelegram": "Avaa Telegram vahvistaaksesi", 1032 + "openDiscord": "Avaa Discord vahvistaaksesi", 1033 + "waitingForVerification": "Odotetaan vahvistusta..." 1035 1034 }, 1036 1035 "plcToken": { 1037 1036 "title": "Vahvista siirto",
+9 -10
frontend/src/locales/ja.json
··· 86 86 "discord": "Discord", 87 87 "discordUsername": "Discord ユーザー名", 88 88 "discordUsernamePlaceholder": "yourusername", 89 - "discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。", 90 89 "telegram": "Telegram", 91 90 "telegramUsername": "Telegram ユーザー名", 92 91 "telegramUsernamePlaceholder": "@yourusername", 93 - "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", 94 92 "signal": "Signal", 95 93 "signalUsername": "Signal ユーザー名", 96 94 "signalUsernamePlaceholder": "username.01", 97 - "signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。", 98 95 "notConfigured": "未設定", 99 96 "inviteCode": "招待コード", 100 97 "inviteCodePlaceholder": "招待コードを入力", ··· 408 405 "verifiedSuccess": "{channel} を確認しました", 409 406 "messageHistory": "メッセージ履歴", 410 407 "noMessages": "メッセージが見つかりません。", 411 - "discordInUseWarning": "この Discord ユーザー名は既に別のアカウントに関連付けられています。", 412 - "telegramInUseWarning": "この Telegram ユーザー名は既に別のアカウントに関連付けられています。", 413 - "signalInUseWarning": "この Signal ユーザー名は既に別のアカウントに使用されています。", 414 408 "telegramStartBot": "または @{botUsername} に /start {handle} を手動で送信", 415 409 "telegramOpenLink": "Telegram で確認する", 416 410 "discordStartBot": "Discordで @{botUsername} にDMして /start {handle} を送信", ··· 1026 1020 "continue": "続ける" 1027 1021 }, 1028 1022 "emailVerify": { 1029 - "title": "メールアドレスを確認", 1023 + "title": "アカウントを確認", 1030 1024 "desc": "確認コードが {email} に送信されました。", 1031 - "hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。", 1025 + "hint": "下記にコードを入力するか、メッセージ内のリンクをクリックして自動的に続行できます。", 1032 1026 "tokenLabel": "確認コード", 1033 - "tokenPlaceholder": "メールに記載されたコードを入力", 1034 - "resend": "コードを再送信" 1027 + "tokenPlaceholder": "確認コードを入力", 1028 + "resend": "コードを再送信", 1029 + "telegramInstructions": "Telegram ボットにメッセージを送信してアカウントを確認してください。", 1030 + "discordInstructions": "Discord ボットにメッセージを送信してアカウントを確認してください。", 1031 + "openTelegram": "Telegram で確認する", 1032 + "openDiscord": "Discord で確認する", 1033 + "waitingForVerification": "確認を待っています..." 1035 1034 }, 1036 1035 "plcToken": { 1037 1036 "title": "移行を確認",
+9 -10
frontend/src/locales/ko.json
··· 86 86 "discord": "Discord", 87 87 "discordUsername": "Discord 사용자명", 88 88 "discordUsernamePlaceholder": "yourusername", 89 - "discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.", 90 89 "telegram": "Telegram", 91 90 "telegramUsername": "Telegram 사용자 이름", 92 91 "telegramUsernamePlaceholder": "@yourusername", 93 - "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", 94 92 "signal": "Signal", 95 93 "signalUsername": "Signal 사용자명", 96 94 "signalUsernamePlaceholder": "username.01", 97 - "signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.", 98 95 "notConfigured": "구성되지 않음", 99 96 "inviteCode": "초대 코드", 100 97 "inviteCodePlaceholder": "초대 코드 입력", ··· 408 405 "verifiedSuccess": "{channel} 인증 완료", 409 406 "messageHistory": "메시지 기록", 410 407 "noMessages": "메시지가 없습니다.", 411 - "discordInUseWarning": "이 Discord 사용자명은 이미 다른 계정과 연결되어 있습니다.", 412 - "telegramInUseWarning": "이 Telegram 사용자 이름은 이미 다른 계정과 연결되어 있습니다.", 413 - "signalInUseWarning": "이 Signal 사용자명은 이미 다른 계정에서 사용 중입니다.", 414 408 "telegramStartBot": "또는 @{botUsername}에게 /start {handle}을 직접 보내세요", 415 409 "telegramOpenLink": "Telegram에서 인증하기", 416 410 "discordStartBot": "Discord에서 @{botUsername}에게 DM으로 /start {handle} 보내기", ··· 1026 1020 "continue": "계속" 1027 1021 }, 1028 1022 "emailVerify": { 1029 - "title": "이메일 인증", 1023 + "title": "계정 인증", 1030 1024 "desc": "인증 코드가 {email}(으)로 전송되었습니다.", 1031 - "hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.", 1025 + "hint": "아래에 코드를 입력하거나, 메시지의 링크를 클릭하여 자동으로 계속할 수 있습니다.", 1032 1026 "tokenLabel": "인증 코드", 1033 - "tokenPlaceholder": "이메일에서 받은 코드 입력", 1034 - "resend": "코드 재전송" 1027 + "tokenPlaceholder": "인증 코드 입력", 1028 + "resend": "코드 재전송", 1029 + "telegramInstructions": "Telegram 봇에 메시지를 보내 계정을 인증하세요.", 1030 + "discordInstructions": "Discord 봇에 메시지를 보내 계정을 인증하세요.", 1031 + "openTelegram": "Telegram에서 인증하기", 1032 + "openDiscord": "Discord에서 인증하기", 1033 + "waitingForVerification": "인증 대기 중..." 1035 1034 }, 1036 1035 "plcToken": { 1037 1036 "title": "마이그레이션 확인",
+9 -10
frontend/src/locales/sv.json
··· 86 86 "discord": "Discord", 87 87 "discordUsername": "Discord-användarnamn", 88 88 "discordUsernamePlaceholder": "dittanvändarnamn", 89 - "discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.", 90 89 "telegram": "Telegram", 91 90 "telegramUsername": "Telegram-användarnamn", 92 91 "telegramUsernamePlaceholder": "@dittanvändarnamn", 93 - "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", 94 92 "signal": "Signal", 95 93 "signalUsername": "Signal-användarnamn", 96 94 "signalUsernamePlaceholder": "användarnamn.01", 97 - "signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.", 98 95 "notConfigured": "ej konfigurerad", 99 96 "inviteCode": "Inbjudningskod", 100 97 "inviteCodePlaceholder": "Ange din inbjudningskod", ··· 408 405 "verifiedSuccess": "{channel} verifierad", 409 406 "messageHistory": "Meddelandehistorik", 410 407 "noMessages": "Inga meddelanden hittades.", 411 - "discordInUseWarning": "Detta Discord-användarnamn är redan kopplat till ett annat konto.", 412 - "telegramInUseWarning": "Detta Telegram-användarnamn är redan kopplat till ett annat konto.", 413 - "signalInUseWarning": "Detta Signal-användarnamn är redan kopplat till ett annat konto.", 414 408 "telegramStartBot": "Eller skicka /start {handle} till @{botUsername} manuellt", 415 409 "telegramOpenLink": "Öppna Telegram för att verifiera", 416 410 "discordStartBot": "DM:a @{botUsername} på Discord och skicka /start {handle}", ··· 1026 1020 "continue": "Fortsätt" 1027 1021 }, 1028 1022 "emailVerify": { 1029 - "title": "Verifiera din e-post", 1023 + "title": "Verifiera ditt konto", 1030 1024 "desc": "En verifieringskod har skickats till {email}.", 1031 - "hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.", 1025 + "hint": "Ange koden nedan eller klicka på länken i meddelandet för att fortsätta automatiskt.", 1032 1026 "tokenLabel": "Verifieringskod", 1033 - "tokenPlaceholder": "Ange kod från e-post", 1034 - "resend": "Skicka kod igen" 1027 + "tokenPlaceholder": "Ange verifieringskod", 1028 + "resend": "Skicka kod igen", 1029 + "telegramInstructions": "Skicka ett meddelande till Telegram-boten för att verifiera ditt konto.", 1030 + "discordInstructions": "Skicka ett meddelande till Discord-boten för att verifiera ditt konto.", 1031 + "openTelegram": "Öppna Telegram för att verifiera", 1032 + "openDiscord": "Öppna Discord för att verifiera", 1033 + "waitingForVerification": "Väntar på verifiering..." 1035 1034 }, 1036 1035 "plcToken": { 1037 1036 "title": "Verifiera flytt",
+9 -10
frontend/src/locales/zh.json
··· 86 86 "discord": "Discord", 87 87 "discordUsername": "Discord 用户名", 88 88 "discordUsernamePlaceholder": "yourusername", 89 - "discordInUseWarning": "此 Discord 用户名已与另一个账户关联。", 90 89 "telegram": "Telegram", 91 90 "telegramUsername": "Telegram 用户名", 92 91 "telegramUsernamePlaceholder": "@yourusername", 93 - "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", 94 92 "signal": "Signal", 95 93 "signalUsername": "Signal 用户名", 96 94 "signalUsernamePlaceholder": "username.01", 97 - "signalInUseWarning": "此 Signal 用户名已被其他账户使用。", 98 95 "notConfigured": "未配置", 99 96 "inviteCode": "邀请码", 100 97 "inviteCodePlaceholder": "输入您的邀请码", ··· 408 405 "verifiedSuccess": "{channel} 验证成功", 409 406 "messageHistory": "消息历史", 410 407 "noMessages": "暂无消息记录", 411 - "discordInUseWarning": "此 Discord 用户名已与另一个账户关联。", 412 - "telegramInUseWarning": "此 Telegram 用户名已与另一个账户关联。", 413 - "signalInUseWarning": "此 Signal 用户名已与另一个账户关联。", 414 408 "telegramStartBot": "或手动向 @{botUsername} 发送 /start {handle}", 415 409 "telegramOpenLink": "打开 Telegram 验证", 416 410 "discordStartBot": "在 Discord 上私信 @{botUsername} 并发送 /start {handle}", ··· 1026 1020 "continue": "继续" 1027 1021 }, 1028 1022 "emailVerify": { 1029 - "title": "验证您的邮箱", 1023 + "title": "验证您的账户", 1030 1024 "desc": "验证码已发送至 {email}。", 1031 - "hint": "在下方输入验证码,或点击邮件中的链接自动继续。", 1025 + "hint": "在下方输入验证码,或点击消息中的链接自动继续。", 1032 1026 "tokenLabel": "验证码", 1033 - "tokenPlaceholder": "输入邮件中的验证码", 1034 - "resend": "重新发送" 1027 + "tokenPlaceholder": "输入验证码", 1028 + "resend": "重新发送", 1029 + "telegramInstructions": "向 Telegram 机器人发送消息以验证您的账户。", 1030 + "discordInstructions": "向 Discord 机器人发送消息以验证您的账户。", 1031 + "openTelegram": "打开 Telegram 验证", 1032 + "openDiscord": "打开 Discord 验证", 1033 + "waitingForVerification": "等待验证..." 1035 1034 }, 1036 1035 "plcToken": { 1037 1036 "title": "验证迁移",
+18 -6
frontend/src/routes/OAuthAccounts.svelte
··· 13 13 let submitting = $state(false) 14 14 let accounts = $state<AccountInfo[]>([]) 15 15 16 - function getRequestUri(): string | null { 17 - const params = new URLSearchParams(window.location.search) 18 - return params.get('request_uri') 16 + function getParam(name: string): string | null { 17 + return new URLSearchParams(window.location.search).get(name) 19 18 } 20 19 21 20 async function fetchAccounts() { 22 - const requestUri = getRequestUri() 21 + const requestUri = getParam('request_uri') 23 22 if (!requestUri) { 24 23 error = 'Missing request_uri parameter' 25 24 loading = false ··· 36 35 } 37 36 const data = await response.json() 38 37 accounts = data.accounts || [] 38 + 39 + const loginHint = getParam('login_hint') 40 + if (loginHint && accounts.length > 0) { 41 + const hint = loginHint.toLowerCase() 42 + const matched = accounts.find( 43 + (a) => a.did === hint || a.handle.toLowerCase() === hint 44 + ) 45 + if (matched) { 46 + loading = false 47 + handleSelectAccount(matched.did) 48 + return 49 + } 50 + } 39 51 } catch { 40 52 error = 'Failed to connect to server' 41 53 } finally { ··· 44 56 } 45 57 46 58 async function handleSelectAccount(did: string) { 47 - const requestUri = getRequestUri() 59 + const requestUri = getParam('request_uri') 48 60 if (!requestUri) { 49 61 error = 'Missing request_uri parameter' 50 62 return ··· 98 110 } 99 111 100 112 function handleDifferentAccount() { 101 - const requestUri = getRequestUri() 113 + const requestUri = getParam('request_uri') 102 114 if (requestUri) { 103 115 navigate(routes.oauthLogin, { params: { request_uri: requestUri } }) 104 116 } else {
+8 -8
frontend/src/routes/OAuthConsent.svelte
··· 310 310 <div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div> 311 311 <div class="delegation-info"> 312 312 <div class="info-row"> 313 - <span class="label">{$_('oauthConsent.actingAs')}</span> 314 - <span class="did">{consentData.did}</span> 313 + <span class="consent-account-label">{$_('oauthConsent.actingAs')}</span> 314 + <span class="consent-account-did">{consentData.did}</span> 315 315 </div> 316 316 <div class="info-row"> 317 - <span class="label">{$_('oauthConsent.controller')}</span> 318 - <span class="handle">@{consentData.controller_handle || consentData.controller_did}</span> 317 + <span class="consent-account-label">{$_('oauthConsent.controller')}</span> 318 + <span class="consent-account-handle">@{consentData.controller_handle || consentData.controller_did}</span> 319 319 </div> 320 320 <div class="info-row"> 321 - <span class="label">{$_('oauthConsent.accessLevel')}</span> 321 + <span class="consent-account-label">{$_('oauthConsent.accessLevel')}</span> 322 322 <span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span> 323 323 </div> 324 324 </div> ··· 340 340 </div> 341 341 {/if} 342 342 {:else} 343 - <span class="label">{$_('oauth.consent.signingInAs')}</span> 343 + <span class="consent-account-label">{$_('oauth.consent.signingInAs')}</span> 344 344 {#if consentData.handle} 345 - <span class="handle">@{consentData.handle}</span> 345 + <span class="consent-account-handle">@{consentData.handle}</span> 346 346 {/if} 347 - <span class="did">{consentData.did}</span> 347 + <span class="consent-account-did">{consentData.did}</span> 348 348 {/if} 349 349 </div> 350 350 </div>
+11 -15
frontend/src/routes/OAuthLogin.svelte
··· 494 494 <span>{$_('oauth.login.rememberDevice')}</span> 495 495 </label> 496 496 497 - <button type="submit" disabled={submitting || !username || !password}> 498 - {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 499 - </button> 497 + <div class="actions"> 498 + <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 499 + {$_('common.cancel')} 500 + </button> 501 + <button type="submit" disabled={submitting || !username || !password}> 502 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 503 + </button> 504 + </div> 500 505 </div> 501 506 {/if} 502 507 </div> 503 - 504 - <div class="cancel-row"> 505 - <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 506 - {$_('common.cancel')} 507 - </button> 508 - </div> 509 508 {:else} 510 509 {#if hasPassword || !securityStatusChecked} 511 510 <div> ··· 526 525 </label> 527 526 528 527 <div class="actions"> 528 + <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 529 + {$_('common.cancel')} 530 + </button> 529 531 <button type="submit" disabled={submitting || !username || !password}> 530 532 {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 531 533 </button> 532 534 </div> 533 535 {/if} 534 - 535 - <div class="cancel-row"> 536 - <button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}> 537 - {$_('common.cancel')} 538 - </button> 539 - </div> 540 536 {/if} 541 537 </form> 542 538
+161
frontend/src/routes/OAuthVerifyCode.svelte
··· 1 + <script lang="ts"> 2 + import { navigate, routes } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 4 + 5 + import { getCurrentPath } from '../lib/router.svelte' 6 + 7 + let mode = $derived(getCurrentPath().includes('totp') ? 'totp' as const : '2fa' as const) 8 + 9 + let code = $state('') 10 + let trustDevice = $state(false) 11 + let submitting = $state(false) 12 + let error = $state<string | null>(null) 13 + 14 + function getRequestUri(): string | null { 15 + const params = new URLSearchParams(window.location.search) 16 + return params.get('request_uri') 17 + } 18 + 19 + function getChannel(): string { 20 + const params = new URLSearchParams(window.location.search) 21 + return params.get('channel') || 'email' 22 + } 23 + 24 + let isBackupCode = $derived(mode === 'totp' && code.trim().length === 8 && /^[A-Z0-9]+$/i.test(code.trim())) 25 + let isTotpCode = $derived(mode === 'totp' && code.trim().length === 6 && /^[0-9]+$/.test(code.trim())) 26 + let is2faCode = $derived(mode === '2fa' && code.trim().length === 6) 27 + let canSubmit = $derived(isBackupCode || isTotpCode || is2faCode) 28 + 29 + async function handleSubmit(e: Event) { 30 + e.preventDefault() 31 + const requestUri = getRequestUri() 32 + if (!requestUri) { 33 + error = mode === 'totp' ? $_('common.error') : $_('oauth.twoFactorCode.errors.missingRequestUri') 34 + return 35 + } 36 + 37 + submitting = true 38 + error = null 39 + 40 + try { 41 + const body: Record<string, unknown> = { 42 + request_uri: requestUri, 43 + code: mode === 'totp' ? code.trim().toUpperCase() : code.trim(), 44 + } 45 + if (mode === 'totp') { 46 + body.trust_device = trustDevice 47 + } 48 + 49 + const response = await fetch('/oauth/authorize/2fa', { 50 + method: 'POST', 51 + headers: { 52 + 'Content-Type': 'application/json', 53 + 'Accept': 'application/json' 54 + }, 55 + body: JSON.stringify(body) 56 + }) 57 + 58 + const data = await response.json() 59 + 60 + if (!response.ok) { 61 + error = data.error_description || data.error || $_('common.error') 62 + submitting = false 63 + return 64 + } 65 + 66 + if (data.redirect_uri) { 67 + window.location.href = data.redirect_uri 68 + return 69 + } 70 + 71 + error = mode === '2fa' ? $_('oauth.twoFactorCode.errors.unexpectedResponse') : $_('common.error') 72 + submitting = false 73 + } catch { 74 + error = mode === '2fa' ? $_('oauth.twoFactorCode.errors.connectionFailed') : $_('common.error') 75 + submitting = false 76 + } 77 + } 78 + 79 + function handleCancel() { 80 + const requestUri = getRequestUri() 81 + if (requestUri) { 82 + navigate(routes.oauthLogin, { params: { request_uri: requestUri } }) 83 + } else { 84 + window.history.back() 85 + } 86 + } 87 + 88 + let channel = $derived(getChannel()) 89 + </script> 90 + 91 + <div class={mode === 'totp' ? 'oauth-totp-container' : 'oauth-2fa-container'}> 92 + <h1>{mode === 'totp' ? $_('oauth.totp.title') : $_('oauth.twoFactorCode.title')}</h1> 93 + {#if mode === '2fa'} 94 + <p class="subtitle"> 95 + {$_('oauth.twoFactorCode.subtitle', { values: { channel } })} 96 + </p> 97 + {/if} 98 + 99 + {#if error} 100 + <div class="error">{error}</div> 101 + {/if} 102 + 103 + <form onsubmit={handleSubmit}> 104 + <div> 105 + <label for="code"> 106 + {mode === 'totp' ? $_('oauth.totp.codePlaceholder') : $_('oauth.twoFactorCode.codeLabel')} 107 + </label> 108 + {#if mode === 'totp'} 109 + <input 110 + id="code" 111 + type="text" 112 + bind:value={code} 113 + placeholder={isBackupCode ? $_('oauth.totp.backupCodePlaceholder') : $_('oauth.totp.codePlaceholder')} 114 + disabled={submitting} 115 + required 116 + maxlength="8" 117 + autocomplete="one-time-code" 118 + autocapitalize="characters" 119 + /> 120 + {#if isBackupCode || isTotpCode} 121 + <p class="hint"> 122 + {isBackupCode ? $_('oauth.totp.hintBackupCode') : $_('oauth.totp.hintTotpCode')} 123 + </p> 124 + {/if} 125 + {:else} 126 + <input 127 + id="code" 128 + type="text" 129 + bind:value={code} 130 + placeholder={$_('oauth.twoFactorCode.codePlaceholder')} 131 + disabled={submitting} 132 + required 133 + maxlength="6" 134 + pattern="[0-9]{6}" 135 + autocomplete="one-time-code" 136 + inputmode="numeric" 137 + /> 138 + {/if} 139 + </div> 140 + 141 + {#if mode === 'totp'} 142 + <label class="trust-device-label"> 143 + <input 144 + type="checkbox" 145 + bind:checked={trustDevice} 146 + disabled={submitting} 147 + /> 148 + <span>{$_('oauth.totp.trustDevice')}</span> 149 + </label> 150 + {/if} 151 + 152 + <div class="actions"> 153 + <button type="button" class="cancel" onclick={handleCancel} disabled={submitting}> 154 + {$_('common.cancel')} 155 + </button> 156 + <button type="submit" disabled={submitting || !canSubmit}> 157 + {submitting ? $_('common.verifying') : $_('common.verify')} 158 + </button> 159 + </div> 160 + </form> 161 + </div>
+107 -195
frontend/src/routes/Register.svelte
··· 1 1 <script lang="ts"> 2 - import { navigate, routes, getFullUrl } from '../lib/router.svelte' 2 + import { navigate, routes, getFullUrl, getCurrentPath } from '../lib/router.svelte' 3 3 import { api } from '../lib/api' 4 4 import { _ } from '../lib/i18n' 5 5 import { ··· 8 8 VerificationStep, 9 9 KeyChoiceStep, 10 10 DidDocStep, 11 - AppPasswordStep, 12 11 } from '../lib/registration' 13 - import { 14 - prepareCreationOptions, 15 - serializeAttestationResponse, 16 - type WebAuthnCreationOptionsResponse, 17 - } from '../lib/webauthn' 12 + import type { RegistrationMode } from '../lib/registration' 13 + import AppPasswordStep from '../components/migration/AppPasswordStep.svelte' 14 + import PasskeySetupStep from '../components/migration/PasskeySetupStep.svelte' 15 + import { performPasskeyRegistration, PasskeyCancelledError } from '../lib/flows/perform-passkey-registration' 18 16 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 17 import HandleInput from '../components/HandleInput.svelte' 18 + import IdentityTypeSection from '../components/IdentityTypeSection.svelte' 19 + import CommsChannelPicker from '../components/CommsChannelPicker.svelte' 20 20 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 21 21 22 + const mode: RegistrationMode = getCurrentPath().includes('register-password') ? 'password' : 'passkey' 23 + const isPasskey = mode === 'passkey' 24 + 22 25 let serverInfo = $state<{ 23 26 availableUserDomains: string[] 24 27 inviteCodeRequired: boolean 25 - availableCommsChannels?: string[] 28 + availableCommsChannels?: import('../lib/types/api').VerificationChannel[] 26 29 selfHostedDidWebEnabled?: boolean 27 30 } | null>(null) 28 31 let loadingServerInfo = $state(true) ··· 31 34 32 35 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 33 36 let passkeyName = $state('') 37 + let confirmPassword = $state('') 34 38 let clientName = $state<string | null>(null) 35 39 let selectedDomain = $state('') 36 40 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null ··· 99 103 $effect(() => { 100 104 if (flow?.state.step === 'creating' && !creatingStarted) { 101 105 creatingStarted = true 102 - flow.createPasskeyAccount() 106 + if (isPasskey) { 107 + flow.createPasskeyAccount() 108 + } else { 109 + flow.createPasswordAccount() 110 + } 103 111 } 104 112 }) 105 113 106 114 async function loadServerInfo() { 107 115 try { 108 116 const restored = restoreRegistrationFlow() 109 - if (restored && restored.state.mode === 'passkey') { 117 + if (restored && restored.state.mode === mode) { 110 118 flow = restored 111 119 serverInfo = await api.describeServer() 112 120 } else { 113 121 serverInfo = await api.describeServer() 114 122 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 115 - flow = createRegistrationFlow('passkey', hostname) 123 + flow = createRegistrationFlow(mode, hostname) 116 124 } 117 125 selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 118 126 if (flow) flow.setSelectedDomain(selectedDomain) ··· 128 136 const info = flow.info 129 137 if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired') 130 138 if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots') 139 + if (!isPasskey) { 140 + if (!info.password) return $_('register.validation.passwordRequired') 141 + if (info.password.length < 8) return $_('register.validation.passwordLength') 142 + if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 143 + } 131 144 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 132 145 return $_('registerPasskey.errors.inviteRequired') 133 146 } ··· 162 175 return 163 176 } 164 177 165 - if (!window.PublicKeyCredential) { 178 + if (isPasskey && !window.PublicKeyCredential) { 166 179 flow.setError($_('registerPasskey.errors.passkeysNotSupported')) 167 180 return 168 181 } ··· 174 187 async function handlePasskeyRegistration() { 175 188 if (!flow || !flow.account) return 176 189 190 + const { did, setupToken } = flow.account 191 + if (!setupToken) return 192 + 177 193 flow.setSubmitting(true) 178 194 flow.clearError() 179 195 180 196 try { 181 - const { options } = await api.startPasskeyRegistrationForSetup( 182 - flow.account.did, 183 - flow.account.setupToken!, 184 - passkeyName || undefined 185 - ) 186 - 187 - const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 188 - const credential = await navigator.credentials.create({ 189 - publicKey: publicKeyOptions 190 - }) 191 - 192 - if (!credential) { 193 - flow.setError($_('registerPasskey.errors.passkeyCancelled')) 194 - flow.setSubmitting(false) 195 - return 196 - } 197 - 198 - const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 199 - 200 - const result = await api.completePasskeySetup( 201 - flow.account.did, 202 - flow.account.setupToken!, 203 - credentialResponse, 204 - passkeyName || undefined 205 - ) 197 + const result = await performPasskeyRegistration({ 198 + startRegistration: () => api.startPasskeyRegistrationForSetup( 199 + did, setupToken, passkeyName || undefined, 200 + ), 201 + completeSetup: (credential, name) => api.completePasskeySetup( 202 + did, setupToken, credential, name, 203 + ), 204 + }, passkeyName || undefined) 206 205 207 206 flow.setPasskeyComplete(result.appPassword, result.appPasswordName) 208 207 } catch (err) { 209 - if (err instanceof DOMException && err.name === 'NotAllowedError') { 208 + if (err instanceof PasskeyCancelledError || (err instanceof DOMException && err.name === 'NotAllowedError')) { 210 209 flow.setError($_('registerPasskey.errors.passkeyCancelled')) 211 210 } else if (err instanceof Error) { 212 211 flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed')) ··· 221 220 async function completeOAuthRegistration() { 222 221 const requestUri = getRequestUriFromUrl() 223 222 if (!requestUri || !flow?.account) { 223 + if (!isPasskey && flow) { 224 + await flow.finalizeSession() 225 + } 224 226 navigate(routes.dashboard) 225 227 return 226 228 } ··· 235 237 body: JSON.stringify({ 236 238 request_uri: requestUri, 237 239 did: flow.account.did, 238 - app_password: flow.account.appPassword, 240 + app_password: flow.account.appPassword || (isPasskey ? undefined : flow.info.password), 239 241 }), 240 242 }) 241 243 ··· 258 260 } 259 261 } 260 262 261 - function isChannelAvailable(ch: string): boolean { 262 - const available = serverInfo?.availableCommsChannels ?? ['email'] 263 - return available.includes(ch) 264 - } 265 - 266 - function channelLabel(ch: string): string { 267 - switch (ch) { 268 - case 'email': 269 - return $_('register.email') 270 - case 'discord': 271 - return $_('register.discord') 272 - case 'telegram': 273 - return $_('register.telegram') 274 - case 'signal': 275 - return $_('register.signal') 276 - default: 277 - return ch 278 - } 279 - } 280 - 281 263 let fullHandle = $derived(() => { 282 264 if (!flow?.info.handle.trim()) return '' 283 265 if (flow.info.handle.includes('.')) return flow.info.handle.trim() ··· 332 314 <div class="loading"></div> 333 315 {:else if flow} 334 316 <header class="page-header"> 335 - <h1>{$_('oauth.register.title')}</h1> 317 + <h1>{isPasskey ? $_('oauth.register.title') : $_('register.title')}</h1> 336 318 {#if clientName} 337 319 <p class="subtitle">{$_('oauth.register.subtitle')} <strong>{clientName}</strong></p> 338 320 {/if} ··· 354 336 </div> 355 337 </div> 356 338 357 - <AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} /> 339 + <AccountTypeSwitcher active={mode} {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} /> 358 340 359 341 <form class="register-form" onsubmit={handleInfoSubmit}> 360 342 <div> ··· 381 363 {/if} 382 364 </div> 383 365 384 - <div> 385 - <label for="verification-channel">{$_('register.verificationMethod')}</label> 386 - <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 387 - <option value="email">{channelLabel('email')}</option> 388 - {#if isChannelAvailable('discord')} 389 - <option value="discord">{channelLabel('discord')}</option> 390 - {/if} 391 - {#if isChannelAvailable('telegram')} 392 - <option value="telegram">{channelLabel('telegram')}</option> 393 - {/if} 394 - {#if isChannelAvailable('signal')} 395 - <option value="signal">{channelLabel('signal')}</option> 396 - {/if} 397 - </select> 398 - </div> 399 - 400 - {#if flow.info.verificationChannel === 'email'} 366 + {#if !isPasskey} 401 367 <div> 402 - <label for="email">{$_('register.emailAddress')}</label> 368 + <label for="password">{$_('register.password')}</label> 403 369 <input 404 - id="email" 405 - type="email" 406 - bind:value={flow.info.email} 407 - placeholder={$_('register.emailPlaceholder')} 408 - disabled={flow.state.submitting} 409 - required 410 - /> 411 - </div> 412 - {:else if flow.info.verificationChannel === 'discord'} 413 - <div> 414 - <label for="discord-username">{$_('register.discordUsername')}</label> 415 - <input 416 - id="discord-username" 417 - type="text" 418 - bind:value={flow.info.discordUsername} 419 - placeholder={$_('register.discordUsernamePlaceholder')} 420 - disabled={flow.state.submitting} 421 - required 422 - /> 423 - </div> 424 - {:else if flow.info.verificationChannel === 'telegram'} 425 - <div> 426 - <label for="telegram-username">{$_('register.telegramUsername')}</label> 427 - <input 428 - id="telegram-username" 429 - type="text" 430 - bind:value={flow.info.telegramUsername} 431 - placeholder={$_('register.telegramUsernamePlaceholder')} 370 + id="password" 371 + type="password" 372 + bind:value={flow.info.password} 373 + placeholder={$_('register.passwordPlaceholder')} 432 374 disabled={flow.state.submitting} 433 375 required 376 + minlength="8" 434 377 /> 435 378 </div> 436 - {:else if flow.info.verificationChannel === 'signal'} 379 + 437 380 <div> 438 - <label for="signal-number">{$_('register.signalUsername')}</label> 381 + <label for="confirm-password">{$_('register.confirmPassword')}</label> 439 382 <input 440 - id="signal-number" 441 - type="tel" 442 - bind:value={flow.info.signalUsername} 443 - placeholder={$_('register.signalUsernamePlaceholder')} 383 + id="confirm-password" 384 + type="password" 385 + bind:value={confirmPassword} 386 + placeholder={$_('register.confirmPasswordPlaceholder')} 444 387 disabled={flow.state.submitting} 445 388 required 446 389 /> 447 - <p class="hint">{$_('register.signalUsernameHint')}</p> 448 390 </div> 449 391 {/if} 450 392 451 - <fieldset class="identity-section"> 452 - <legend>{$_('registerPasskey.identityType')}</legend> 453 - <div class="radio-group"> 454 - <label class="radio-label"> 455 - <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 456 - <span class="radio-content"> 457 - <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 458 - <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 459 - </span> 460 - </label> 461 - <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 462 - <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 463 - <span class="radio-content"> 464 - <strong>{$_('registerPasskey.didWeb')}</strong> 465 - {#if serverInfo?.selfHostedDidWebEnabled === false} 466 - <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 467 - {:else} 468 - <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 469 - {/if} 470 - </span> 471 - </label> 472 - <label class="radio-label"> 473 - <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 474 - <span class="radio-content"> 475 - <strong>{$_('registerPasskey.didWebBYOD')}</strong> 476 - <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 477 - </span> 478 - </label> 479 - </div> 480 - </fieldset> 393 + <CommsChannelPicker 394 + channel={flow.info.verificationChannel} 395 + email={flow.info.email} 396 + discordUsername={flow.info.discordUsername ?? ''} 397 + telegramUsername={flow.info.telegramUsername ?? ''} 398 + signalUsername={flow.info.signalUsername ?? ''} 399 + availableChannels={serverInfo?.availableCommsChannels ?? ['email']} 400 + disabled={flow.state.submitting} 401 + onChannelChange={(ch) => { if (flow) flow.info.verificationChannel = ch }} 402 + onEmailChange={(v) => { if (flow) flow.info.email = v }} 403 + onDiscordChange={(v) => { if (flow) flow.info.discordUsername = v }} 404 + onTelegramChange={(v) => { if (flow) flow.info.telegramUsername = v }} 405 + onSignalChange={(v) => { if (flow) flow.info.signalUsername = v }} 406 + /> 481 407 482 - {#if flow.info.didType === 'web'} 483 - <div class="warning-box"> 484 - <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 485 - <ul> 486 - <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 487 - <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 488 - {#if $_('registerPasskey.didWebWarning3')} 489 - <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 490 - {/if} 491 - </ul> 492 - </div> 493 - {/if} 494 - 495 - {#if flow.info.didType === 'web-external'} 496 - <div> 497 - <label for="external-did">{$_('registerPasskey.externalDid')}</label> 498 - <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required /> 499 - <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 500 - </div> 501 - {/if} 408 + <IdentityTypeSection 409 + didType={flow.info.didType} 410 + externalDid={flow.info.externalDid ?? ''} 411 + disabled={flow.state.submitting} 412 + selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false} 413 + defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'} 414 + onDidTypeChange={(v) => { if (flow) flow.info.didType = v }} 415 + onExternalDidChange={(v) => { if (flow) flow.info.externalDid = v }} 416 + /> 502 417 503 418 {#if serverInfo?.inviteCodeRequired} 504 419 <div> ··· 528 443 <KeyChoiceStep {flow} /> 529 444 530 445 {:else if flow.state.step === 'initial-did-doc'} 531 - <DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} /> 446 + <DidDocStep 447 + {flow} 448 + type="initial" 449 + onConfirm={() => isPasskey ? flow?.createPasskeyAccount() : flow?.createPasswordAccount()} 450 + onBack={() => flow?.goBack()} 451 + /> 532 452 533 453 {:else if flow.state.step === 'creating'} 534 454 <div class="loading"> 535 - <p>{$_('registerPasskey.creatingAccount')}</p> 455 + <p>{isPasskey ? $_('registerPasskey.creatingAccount') : $_('common.creating')}</p> 536 456 </div> 537 457 538 - {:else if flow.state.step === 'passkey'} 539 - <div class="passkey-step"> 540 - <h2>{$_('registerPasskey.setupPasskey')}</h2> 541 - <p>{$_('registerPasskey.passkeyDescription')}</p> 458 + {:else if isPasskey && flow.state.step === 'passkey'} 459 + <PasskeySetupStep 460 + {passkeyName} 461 + loading={flow.state.submitting} 462 + error={flow.state.error} 463 + onPasskeyNameChange={(n) => passkeyName = n} 464 + onRegister={handlePasskeyRegistration} 465 + /> 542 466 543 - <div class="field"> 544 - <label for="passkey-name">{$_('registerPasskey.passkeyName')}</label> 545 - <input 546 - id="passkey-name" 547 - type="text" 548 - bind:value={passkeyName} 549 - placeholder={$_('registerPasskey.passkeyNamePlaceholder')} 550 - disabled={flow.state.submitting} 551 - /> 552 - <p class="hint">{$_('registerPasskey.passkeyNameHint')}</p> 553 - </div> 554 - 555 - <button 556 - type="button" 557 - class="primary" 558 - onclick={handlePasskeyRegistration} 559 - disabled={flow.state.submitting} 560 - > 561 - {flow.state.submitting ? $_('common.loading') : $_('registerPasskey.createPasskey')} 562 - </button> 563 - </div> 564 - 565 - {:else if flow.state.step === 'app-password'} 566 - <AppPasswordStep {flow} /> 467 + {:else if isPasskey && flow.state.step === 'app-password'} 468 + <AppPasswordStep 469 + appPassword={flow.account?.appPassword ?? ''} 470 + appPasswordName={flow.account?.appPasswordName ?? ''} 471 + loading={flow.state.submitting} 472 + onContinue={() => flow!.proceedFromAppPassword()} 473 + /> 567 474 568 475 {:else if flow.state.step === 'verify'} 569 476 <VerificationStep {flow} /> ··· 571 478 {:else if flow.state.step === 'updated-did-doc'} 572 479 <DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} /> 573 480 574 - {:else if flow.state.step === 'activating'} 481 + {:else if isPasskey && flow.state.step === 'activating'} 575 482 <div class="loading"> 576 483 <p>{$_('registerPasskey.activatingAccount')}</p> 484 + </div> 485 + 486 + {:else if !isPasskey && flow.state.step === 'redirect-to-dashboard'} 487 + <div class="loading"> 488 + <p>{$_('register.redirecting')}</p> 577 489 </div> 578 490 {/if} 579 491 {/if}
-16
frontend/src/routes/ResetPassword.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 3 import { api, ApiError } from '../lib/api' 4 - import { getAuthState } from '../lib/auth.svelte' 5 4 import { _ } from '../lib/i18n' 6 - import type { Session } from '../lib/types/api' 7 5 import { unsafeAsEmail } from '../lib/types/branded' 8 6 9 - const auth = $derived(getAuthState()) 10 - 11 - function getSession(): Session | null { 12 - return auth.kind === 'authenticated' ? auth.session : null 13 - } 14 - 15 - const session = $derived(getSession()) 16 - 17 7 let email = $state('') 18 8 let token = $state('') 19 9 let newPassword = $state('') ··· 22 12 let error = $state<string | null>(null) 23 13 let success = $state<string | null>(null) 24 14 let tokenSent = $state(false) 25 - 26 - $effect(() => { 27 - if (session) { 28 - navigate(routes.dashboard) 29 - } 30 - }) 31 15 32 16 async function handleRequestReset(e: Event) { 33 17 e.preventDefault()
+11 -52
frontend/src/routes/SsoRegisterComplete.svelte
··· 4 4 import { toast } from '../lib/toast.svelte' 5 5 import SsoIcon from '../components/SsoIcon.svelte' 6 6 import HandleInput from '../components/HandleInput.svelte' 7 + import IdentityTypeSection from '../components/IdentityTypeSection.svelte' 7 8 8 9 interface PendingRegistration { 9 10 request_uri: string ··· 91 92 return commsChannels[ch as keyof CommsChannelConfig] ?? false 92 93 } 93 94 94 - function extractDomain(did: string): string { 95 - return did.replace('did:web:', '').replace(/%3A/g, ':') 96 - } 95 + 97 96 98 97 let fullHandle = $derived(() => { 99 98 if (!handle.trim()) return '' ··· 497 496 </div> 498 497 </fieldset> 499 498 500 - <fieldset> 501 - <legend>{$_('registerPasskey.identityType')}</legend> 502 - <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p> 503 - <div class="radio-group"> 504 - <label class="radio-label"> 505 - <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 506 - <span class="radio-content"> 507 - <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 508 - <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 509 - </span> 510 - </label> 511 - <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 512 - <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 513 - <span class="radio-content"> 514 - <strong>{$_('registerPasskey.didWeb')}</strong> 515 - {#if serverInfo?.selfHostedDidWebEnabled === false} 516 - <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 517 - {:else} 518 - <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 519 - {/if} 520 - </span> 521 - </label> 522 - <label class="radio-label"> 523 - <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 524 - <span class="radio-content"> 525 - <strong>{$_('registerPasskey.didWebBYOD')}</strong> 526 - <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 527 - </span> 528 - </label> 529 - </div> 530 - {#if didType === 'web'} 531 - <div class="warning-box"> 532 - <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 533 - <ul> 534 - <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 535 - <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 536 - <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 537 - <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> 538 - </ul> 539 - </div> 540 - {/if} 541 - {#if didType === 'web-external'} 542 - <div class="field"> 543 - <label for="external-did">{$_('registerPasskey.externalDid')}</label> 544 - <input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required /> 545 - <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 546 - </div> 547 - {/if} 548 - </fieldset> 499 + <IdentityTypeSection 500 + {didType} 501 + {externalDid} 502 + disabled={submitting} 503 + selfHostedDidWebEnabled={serverInfo?.selfHostedDidWebEnabled !== false} 504 + defaultDomain={serverInfo?.availableUserDomains?.[0] || 'this-pds.com'} 505 + onDidTypeChange={(v) => didType = v} 506 + onExternalDidChange={(v) => externalDid = v} 507 + /> 549 508 550 509 {#if serverInfo?.inviteCodeRequired} 551 510 <div>
+1 -1
frontend/src/routes/Verify.svelte
··· 261 261 error = null 262 262 263 263 try { 264 - await api.resendMigrationVerification(unsafeAsEmail(identifier.trim())) 264 + await api.resendMigrationVerification('email', identifier.trim()) 265 265 resendMessage = $_('verify.codeResentDetail') 266 266 } catch (e) { 267 267 error = e instanceof Error ? e.message : 'Failed to resend verification'
+3 -94
frontend/src/styles/base.css
··· 435 435 .modal-backdrop { 436 436 position: fixed; 437 437 inset: 0; 438 + width: 100vw; 439 + height: 100vh; 438 440 background: var(--overlay-bg); 439 441 z-index: var(--z-modal); 440 442 display: flex; ··· 496 498 padding: var(--space-7); 497 499 } 498 500 499 - .page-lg { 500 - max-width: var(--width-xl); 501 - margin: 0 auto; 502 - padding: var(--space-7); 503 - } 504 - 505 501 .page-header { 506 502 margin-bottom: var(--space-6); 507 503 } ··· 541 537 text-decoration: none; 542 538 } 543 539 544 - .text-muted { 545 - color: var(--text-muted); 546 - } 547 - 548 - .text-secondary { 549 - color: var(--text-secondary); 550 - } 551 - 552 - .text-sm { 553 - font-size: var(--text-sm); 554 - } 555 - 556 - .text-xs { 557 - font-size: var(--text-xs); 558 - } 559 - 560 540 .text-center { 561 541 text-align: center; 562 542 } ··· 565 545 font-family: var(--font-mono); 566 546 } 567 547 568 - .mt-4 { 569 - margin-top: var(--space-4); 570 - } 571 - .mt-5 { 572 - margin-top: var(--space-5); 573 - } 574 - .mt-6 { 575 - margin-top: var(--space-6); 576 - } 577 - .mb-4 { 578 - margin-bottom: var(--space-4); 579 - } 580 - .mb-5 { 581 - margin-bottom: var(--space-5); 582 - } 583 - .mb-6 { 584 - margin-bottom: var(--space-6); 585 - } 586 - 587 548 .split-layout { 588 549 display: grid; 589 550 grid-template-columns: 1fr; ··· 605 566 606 567 .split-layout > * { 607 568 min-width: 0; 608 - } 609 - 610 - .form-row { 611 - display: grid; 612 - grid-template-columns: 1fr; 613 - gap: var(--space-4); 614 569 } 615 570 616 571 @media (min-width: 600px) { ··· 650 605 margin-bottom: 0; 651 606 } 652 607 653 - 654 608 .skeleton { 655 609 background: var(--bg-secondary); 656 610 } ··· 843 797 min-width: 0; 844 798 } 845 799 846 - .form-links { 847 - margin-top: var(--space-6); 848 - } 849 - 850 800 .form-links .link-text { 851 801 text-align: center; 852 802 color: var(--text-secondary); ··· 989 939 text-decoration: none; 990 940 } 991 941 992 - .card-interactive { 993 - cursor: pointer; 994 - } 995 - 996 942 .card-interactive:hover { 997 943 border-color: var(--secondary); 998 944 box-shadow: 0 2px 8px var(--accent-muted); 999 945 } 1000 946 1001 - .card-danger { 1002 - background: var(--error-bg); 1003 - border-color: var(--error-border); 1004 - } 1005 - 1006 - .padding-none { padding: 0; } 1007 - .padding-sm { padding: var(--space-4); } 1008 - .padding-md { padding: var(--space-6); } 1009 - .padding-lg { padding: var(--space-7); } 1010 - 1011 947 section.danger { 1012 948 background: var(--error-bg); 1013 949 } ··· 1082 1018 pointer-events: auto; 1083 1019 } 1084 1020 1085 - .toast-success { 1086 - background: var(--success-bg); 1087 - color: var(--success-text); 1088 - } 1089 - 1090 - .toast-error { 1091 - background: var(--error-bg); 1092 - color: var(--error-text); 1093 - } 1094 - 1095 - .toast-warning { 1096 - background: var(--warning-bg); 1097 - color: var(--warning-text); 1098 - } 1099 - 1100 - .toast-info { 1101 - background: var(--bg-secondary); 1102 - color: var(--text-primary); 1103 - } 1104 - 1105 1021 .toast-message { 1106 1022 flex: 1; 1107 1023 font-size: var(--text-sm); ··· 1286 1202 margin-bottom: var(--space-4); 1287 1203 } 1288 1204 1289 - .modal-content button:not(.tab) { 1205 + .modal-content button:not(.tab):not(.secondary) { 1290 1206 width: 100%; 1291 1207 } 1292 1208 ··· 1338 1254 display: block; 1339 1255 } 1340 1256 1341 - 1342 1257 .form-actions { 1343 1258 display: flex; 1344 1259 flex-direction: row; 1345 1260 gap: var(--space-4); 1346 1261 margin-top: var(--space-5); 1347 - } 1348 - 1349 - .cancel-row { 1350 - display: flex; 1351 - justify-content: center; 1352 - margin-top: var(--space-4); 1353 1262 } 1354 1263 1355 1264 .form-actions .primary {
+62 -145
frontend/src/styles/pages.css
··· 1 - .register-redirect { 2 - min-height: 100vh; 3 - display: flex; 4 - flex-direction: column; 5 - align-items: center; 6 - justify-content: center; 7 - gap: var(--space-4); 8 - } 9 - 10 - .loading-content { 11 - display: flex; 12 - flex-direction: column; 13 - align-items: center; 14 - gap: var(--space-4); 15 - } 16 - 17 1 .loading-content p { 18 2 margin: 0; 19 3 color: var(--text-secondary); ··· 83 67 flex-direction: column; 84 68 gap: var(--space-3); 85 69 margin-top: var(--space-3); 86 - } 87 - 88 - @media (min-width: 600px) { 89 - .login-page .actions { 90 - flex-direction: row; 91 - } 92 - 93 - .login-page .actions button { 94 - flex: 1; 95 - } 96 70 } 97 71 98 72 .link-text { ··· 543 517 padding: 0 var(--space-2); 544 518 } 545 519 546 - .passkey-step { 547 - display: flex; 548 - flex-direction: column; 549 - gap: var(--space-4); 550 - max-width: 500px; 551 - } 552 - 553 520 .passkey-step h2 { 554 521 margin: 0; 555 522 } ··· 630 597 margin-top: var(--space-4); 631 598 } 632 599 633 - @media (min-width: 600px) { 634 - .oauth-login .auth-methods { 635 - grid-template-columns: 1fr auto 1fr; 636 - align-items: start; 637 - } 638 - } 639 - 640 600 .auth-methods { 641 601 display: grid; 642 602 grid-template-columns: 1fr; ··· 644 604 margin-top: var(--space-4); 645 605 } 646 606 647 - @media (min-width: 600px) { 648 - .auth-methods { 649 - grid-template-columns: 1fr auto 1fr; 650 - align-items: start; 651 - } 652 - } 653 - 654 607 .auth-methods.single-method { 655 608 grid-template-columns: 1fr; 656 609 } 657 610 658 - @media (min-width: 600px) { 659 - .auth-methods.single-method { 660 - grid-template-columns: 1fr; 661 - max-width: 400px; 662 - margin: var(--space-4) auto 0; 663 - } 664 - } 665 - 666 611 .passkey-method, 667 612 .password-method { 668 613 display: flex; ··· 690 635 font-size: var(--text-sm); 691 636 } 692 637 693 - @media (min-width: 600px) { 694 - .method-divider { 695 - flex-direction: column; 696 - padding: 0 var(--space-3); 697 - } 698 - 699 - .method-divider::before, 700 - .method-divider::after { 701 - content: ''; 702 - width: 1px; 703 - height: var(--space-6); 704 - background: var(--border-color); 705 - } 706 - 707 - .method-divider span { 708 - writing-mode: vertical-rl; 709 - text-orientation: mixed; 710 - transform: rotate(180deg); 711 - padding: var(--space-2) 0; 712 - } 713 - } 714 - 715 638 @media (max-width: 599px) { 716 639 .method-divider { 717 640 gap: var(--space-4); ··· 742 665 743 666 .oauth-login .actions { 744 667 display: flex; 745 - gap: var(--space-4); 668 + gap: var(--space-3); 746 669 margin-top: var(--space-2); 747 - } 748 - 749 - .oauth-login .actions button { 750 - flex: 1; 670 + justify-content: flex-end; 751 671 } 752 672 753 673 .passkey-unavailable { ··· 854 774 background: var(--bg-secondary); 855 775 } 856 776 857 - @media (min-width: 800px) { 858 - .client-info { 859 - text-align: left; 860 - } 861 - } 862 - 863 777 .client-logo { 864 778 width: 64px; 865 779 height: 64px; ··· 892 806 margin-bottom: var(--space-6); 893 807 } 894 808 895 - .consent-container .account-info .label { 809 + .consent-account-label { 896 810 font-size: var(--text-xs); 897 811 color: var(--text-muted); 898 812 text-transform: uppercase; 899 813 letter-spacing: 0.05em; 900 814 } 901 815 902 - .consent-container .account-info .did { 816 + .consent-account-did { 903 817 font-family: var(--font-mono); 904 818 font-size: var(--text-sm); 905 819 color: var(--text-secondary); 906 820 word-break: break-all; 907 821 } 908 822 909 - .consent-container .account-info .handle { 823 + .consent-account-handle { 910 824 font-size: var(--text-base); 911 825 font-weight: var(--font-medium); 912 826 color: var(--text-primary); ··· 1103 1017 margin-top: var(--space-6); 1104 1018 } 1105 1019 1106 - @media (min-width: 800px) { 1107 - .consent-container .actions { 1108 - max-width: 400px; 1109 - margin-left: auto; 1110 - } 1111 - } 1112 - 1113 1020 .consent-container .actions button { 1114 1021 flex: 1; 1115 1022 padding: var(--space-3); ··· 1308 1215 justify-content: center; 1309 1216 } 1310 1217 1311 - .oauth-register-container { 1312 - max-width: var(--width-lg); 1313 - } 1314 - 1315 1218 .oauth-register-container .loading, 1316 1219 .oauth-register-container .creating { 1317 1220 display: flex; ··· 1346 1249 margin-top: var(--space-2); 1347 1250 } 1348 1251 1349 - .secondary-actions { 1350 - display: flex; 1351 - justify-content: center; 1352 - gap: var(--space-4); 1353 - margin-top: var(--space-4); 1354 - } 1355 - 1356 1252 .oauth-register-container fieldset { 1357 1253 border: 1px solid var(--border-color); 1358 1254 padding: var(--space-4); ··· 1363 1259 font-weight: var(--font-medium); 1364 1260 } 1365 1261 1366 - .sso-register-container { 1367 - max-width: var(--width-lg); 1368 - } 1369 - 1370 1262 .sso-register-container .loading { 1371 1263 padding: var(--space-8); 1372 1264 } ··· 1397 1289 margin-top: var(--space-3); 1398 1290 } 1399 1291 1400 - .color-pair { 1401 - display: flex; 1402 - gap: var(--space-2); 1403 - align-items: center; 1404 - } 1405 - 1406 1292 .color-pair input[type="color"] { 1407 1293 width: 40px; 1408 1294 height: 36px; ··· 1413 1299 1414 1300 .color-pair input[type="text"] { 1415 1301 flex: 1; 1416 - } 1417 - 1418 - .swatch { 1419 - padding: var(--space-3) var(--space-4); 1420 - margin-bottom: var(--space-2); 1421 - font-size: var(--text-xs); 1422 - } 1423 - 1424 - .spacing-row { 1425 - display: flex; 1426 - flex-wrap: wrap; 1427 - gap: var(--space-5); 1428 - align-items: flex-end; 1429 - } 1430 - 1431 - .spacing-item { 1432 - display: flex; 1433 - flex-direction: column; 1434 - align-items: center; 1435 - gap: var(--space-2); 1436 - } 1437 - 1438 - .spacing-box { 1439 - background: var(--accent); 1440 - min-width: 2px; 1441 - min-height: 2px; 1442 1302 } 1443 1303 1444 1304 .key-choice-step { ··· 1576 1436 font-size: var(--text-sm); 1577 1437 margin-top: var(--space-1); 1578 1438 } 1439 + 1440 + @media (min-width: 600px) { 1441 + .login-page .actions { 1442 + flex-direction: row; 1443 + } 1444 + 1445 + .login-page .actions button { 1446 + flex: 1; 1447 + } 1448 + 1449 + .oauth-login .auth-methods { 1450 + grid-template-columns: 1fr auto 1fr; 1451 + align-items: start; 1452 + } 1453 + 1454 + .auth-methods { 1455 + grid-template-columns: 1fr auto 1fr; 1456 + align-items: start; 1457 + } 1458 + 1459 + .auth-methods.single-method { 1460 + grid-template-columns: 1fr; 1461 + max-width: 400px; 1462 + margin: var(--space-4) auto 0; 1463 + } 1464 + 1465 + .method-divider { 1466 + flex-direction: column; 1467 + padding: 0 var(--space-3); 1468 + } 1469 + 1470 + .method-divider::before, 1471 + .method-divider::after { 1472 + content: ''; 1473 + width: 1px; 1474 + height: var(--space-6); 1475 + background: var(--border-color); 1476 + } 1477 + 1478 + .method-divider span { 1479 + writing-mode: vertical-rl; 1480 + text-orientation: mixed; 1481 + transform: rotate(180deg); 1482 + padding: var(--space-2) 0; 1483 + } 1484 + } 1485 + 1486 + @media (min-width: 800px) { 1487 + .client-info { 1488 + text-align: left; 1489 + } 1490 + 1491 + .consent-container .actions { 1492 + max-width: 400px; 1493 + margin-left: auto; 1494 + } 1495 + }