Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

OAuth prompt=create, frontend improvements #2

merged opened by lewis.moe targeting main from feat/oauth-prompt-create

our frontend now does account creation through the new oauth prompt=create method. also, removed comms channel uniqueness since it's not necessary. also, cleaned up frontend styles to be more consistent.

Labels

None yet.

assignee
Participants 2
Referenced by
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mcs5ol6ay522
+4030 -2351
Diff #1
-77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc" 29 - ] 30 - } 31 - } 32 - } 33 - }, 34 - { 35 - "ordinal": 3, 36 - "name": "provider_user_id", 37 - "type_info": "Text" 38 - }, 39 - { 40 - "ordinal": 4, 41 - "name": "provider_username", 42 - "type_info": "Text" 43 - }, 44 - { 45 - "ordinal": 5, 46 - "name": "provider_email", 47 - "type_info": "Text" 48 - }, 49 - { 50 - "ordinal": 6, 51 - "name": "created_at", 52 - "type_info": "Timestamptz" 53 - }, 54 - { 55 - "ordinal": 7, 56 - "name": "expires_at", 57 - "type_info": "Timestamptz" 58 - } 59 - ], 60 - "parameters": { 61 - "Left": [ 62 - "Text" 63 - ] 64 - }, 65 - "nullable": [ 66 - false, 67 - false, 68 - false, 69 - false, 70 - true, 71 - true, 72 - false, 73 - false 74 - ] 75 - }, 76 - "hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82" 77 - }
+22
.sqlx/query-1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "handle", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66" 22 + }
+2 -2
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json .sqlx/query-44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 3 + "query": "SELECT email_verified FROM users WHERE did = $1 OR email = $1 OR handle = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 18 18 false 19 19 ] 20 20 }, 21 - "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" 21 + "hash": "44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934" 22 22 }
-77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc" 29 - ] 30 - } 31 - } 32 - } 33 - }, 34 - { 35 - "ordinal": 3, 36 - "name": "provider_user_id", 37 - "type_info": "Text" 38 - }, 39 - { 40 - "ordinal": 4, 41 - "name": "provider_username", 42 - "type_info": "Text" 43 - }, 44 - { 45 - "ordinal": 5, 46 - "name": "provider_email", 47 - "type_info": "Text" 48 - }, 49 - { 50 - "ordinal": 6, 51 - "name": "created_at", 52 - "type_info": "Timestamptz" 53 - }, 54 - { 55 - "ordinal": 7, 56 - "name": "expires_at", 57 - "type_info": "Timestamptz" 58 - } 59 - ], 60 - "parameters": { 61 - "Left": [ 62 - "Text" 63 - ] 64 - }, 65 - "nullable": [ 66 - false, 67 - false, 68 - false, 69 - false, 70 - true, 71 - true, 72 - false, 73 - false 74 - ] 75 - }, 76 - "hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a" 77 - }
+22
.sqlx/query-8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3" 22 + }
+17
.sqlx/query-821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3, expires_at = $4\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Timestamptz" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67" 17 + }
-31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - { 10 - "Custom": { 11 - "name": "sso_provider_type", 12 - "kind": { 13 - "Enum": [ 14 - "github", 15 - "discord", 16 - "google", 17 - "gitlab", 18 - "oidc" 19 - ] 20 - } 21 - } 22 - }, 23 - "Text", 24 - "Text", 25 - "Text" 26 - ] 27 - }, 28 - "nullable": [] 29 - }, 30 - "hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6" 31 - }
-32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc" 20 - ] 21 - } 22 - } 23 - }, 24 - "Text", 25 - "Text", 26 - "Text" 27 - ] 28 - }, 29 - "nullable": [] 30 - }, 31 - "hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a" 32 - }
+10
crates/tranquil-db-traits/src/user.rs
··· 359 359 handle: &str, 360 360 ) -> Result<Option<Uuid>, DbError>; 361 361 362 + async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError>; 363 + 364 + async fn count_accounts_by_comms_identifier( 365 + &self, 366 + channel: CommsChannel, 367 + identifier: &str, 368 + ) -> Result<i64, DbError>; 369 + 370 + async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError>; 371 + 362 372 async fn set_password_reset_code( 363 373 &self, 364 374 user_id: Uuid,
+7 -2
crates/tranquil-db/src/postgres/oauth.rs
··· 18 18 19 19 use super::user::map_sqlx_error; 20 20 21 + const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600; 22 + 21 23 fn to_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value, DbError> { 22 24 serde_json::to_value(value).map_err(|e| { 23 25 tracing::error!("JSON serialization error: {}", e); ··· 462 464 did: &Did, 463 465 device_id: Option<&DeviceId>, 464 466 ) -> Result<(), DbError> { 467 + let extended_expiry = 468 + chrono::Utc::now() + chrono::Duration::seconds(REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS); 465 469 sqlx::query!( 466 470 r#" 467 471 UPDATE oauth_authorization_request 468 - SET did = $2, device_id = $3 472 + SET did = $2, device_id = $3, expires_at = $4 469 473 WHERE id = $1 470 474 "#, 471 475 request_id.as_str(), 472 476 did.as_str(), 473 - device_id.map(|d| d.as_str()) 477 + device_id.map(|d| d.as_str()), 478 + extended_expiry 474 479 ) 475 480 .execute(&self.pool) 476 481 .await
+54 -16
crates/tranquil-db/src/postgres/user.rs
··· 549 549 identifier: &str, 550 550 ) -> Result<Option<bool>, DbError> { 551 551 let row = sqlx::query_scalar!( 552 - "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 552 + "SELECT email_verified FROM users WHERE did = $1 OR email = $1 OR handle = $1", 553 553 identifier 554 554 ) 555 555 .fetch_optional(&self.pool) ··· 717 717 } 718 718 719 719 async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result<bool, DbError> { 720 - let result = sqlx::query!( 720 + sqlx::query!( 721 721 "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", 722 722 email, 723 723 user_id 724 724 ) 725 725 .execute(&self.pool) 726 - .await; 727 - match result { 728 - Ok(_) => Ok(true), 729 - Err(e) => { 730 - if e.as_database_error() 731 - .map(|db| db.is_unique_violation()) 732 - .unwrap_or(false) 733 - { 734 - Ok(false) 735 - } else { 736 - Err(map_sqlx_error(e)) 737 - } 738 - } 739 - } 726 + .await 727 + .map_err(map_sqlx_error)?; 728 + Ok(true) 740 729 } 741 730 742 731 async fn verify_discord_channel(&self, user_id: Uuid, discord_id: &str) -> Result<(), DbError> { ··· 1593 1582 .map_err(map_sqlx_error) 1594 1583 } 1595 1584 1585 + async fn count_accounts_by_email(&self, email: &str) -> Result<i64, DbError> { 1586 + sqlx::query_scalar!( 1587 + "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL", 1588 + email 1589 + ) 1590 + .fetch_one(&self.pool) 1591 + .await 1592 + .map(|c| c.unwrap_or(0)) 1593 + .map_err(map_sqlx_error) 1594 + } 1595 + 1596 + async fn count_accounts_by_comms_identifier( 1597 + &self, 1598 + channel: CommsChannel, 1599 + identifier: &str, 1600 + ) -> Result<i64, DbError> { 1601 + let query = match channel { 1602 + CommsChannel::Email => { 1603 + "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL" 1604 + } 1605 + CommsChannel::Discord => { 1606 + "SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL" 1607 + } 1608 + CommsChannel::Telegram => { 1609 + "SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL" 1610 + } 1611 + CommsChannel::Signal => { 1612 + "SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL" 1613 + } 1614 + }; 1615 + sqlx::query_scalar(query) 1616 + .bind(identifier) 1617 + .fetch_one(&self.pool) 1618 + .await 1619 + .map(|c: Option<i64>| c.unwrap_or(0)) 1620 + .map_err(map_sqlx_error) 1621 + } 1622 + 1623 + async fn get_handles_by_email(&self, email: &str) -> Result<Vec<Handle>, DbError> { 1624 + sqlx::query_scalar!( 1625 + "SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC", 1626 + email 1627 + ) 1628 + .fetch_all(&self.pool) 1629 + .await 1630 + .map(|handles| handles.into_iter().map(Handle::from).collect()) 1631 + .map_err(map_sqlx_error) 1632 + } 1633 + 1596 1634 async fn set_password_reset_code( 1597 1635 &self, 1598 1636 user_id: Uuid,
+2
crates/tranquil-oauth/src/types.rs
··· 94 94 pub response_mode: Option<String>, 95 95 pub login_hint: Option<String>, 96 96 pub dpop_jkt: Option<String>, 97 + pub prompt: Option<String>, 97 98 #[serde(flatten)] 98 99 pub extra: Option<JsonValue>, 99 100 } ··· 423 424 response_mode: None, 424 425 login_hint: None, 425 426 dpop_jkt: None, 427 + prompt: None, 426 428 extra: None, 427 429 }, 428 430 expires_at: Utc::now() + expires_in,
-13
crates/tranquil-pds/src/api/notification_prefs.rs
··· 234 234 if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) { 235 235 info!(did = %user.did, "Email unchanged, skipping"); 236 236 } else { 237 - match state 238 - .user_repo 239 - .check_email_exists(&email_clean, user_id) 240 - .await 241 - { 242 - Ok(true) => return ApiError::EmailTaken.into_response(), 243 - Err(e) => { 244 - return ApiError::InternalError(Some(format!("Database error: {}", e))) 245 - .into_response(); 246 - } 247 - Ok(false) => {} 248 - } 249 - 250 237 if let Err(e) = request_channel_verification( 251 238 &state, 252 239 user_id,
+2 -12
crates/tranquil-pds/src/api/server/app_password.rs
··· 1 1 use crate::api::EmptyResponse; 2 2 use crate::api::error::ApiError; 3 - use crate::auth::BearerAuth; 3 + use crate::auth::{BearerAuth, generate_app_password}; 4 4 use crate::delegation::{DelegationActionType, intersect_scopes}; 5 5 use crate::state::{AppState, RateLimitKind}; 6 6 use axum::{ ··· 154 154 (input.scopes.clone(), None) 155 155 }; 156 156 157 - let password: String = (0..4) 158 - .map(|_| { 159 - use rand::Rng; 160 - let mut rng = rand::thread_rng(); 161 - let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); 162 - (0..4) 163 - .map(|_| chars[rng.gen_range(0..chars.len())]) 164 - .collect::<String>() 165 - }) 166 - .collect::<Vec<String>>() 167 - .join("-"); 157 + let password = generate_app_password(); 168 158 169 159 let password_clone = password.clone(); 170 160 let password_hash = match tokio::task::spawn_blocking(move || {
+116 -30
crates/tranquil-pds/src/api/server/email.rs
··· 14 14 use std::time::Duration; 15 15 use subtle::ConstantTimeEq; 16 16 use tracing::{error, info, warn}; 17 + use tranquil_db_traits::CommsChannel; 17 18 18 19 const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60); 19 20 ··· 92 93 let formatted_code = crate::auth::verification_token::format_token_for_display(&code); 93 94 94 95 if let Some(Json(ref inp)) = input 95 - && let Some(ref new_email) = inp.new_email { 96 - let new_email = new_email.trim().to_lowercase(); 97 - if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { 98 - let pending = PendingEmailUpdate { 99 - new_email, 100 - token_hash: hash_token(&code), 101 - authorized: false, 102 - }; 103 - if let Ok(json) = serde_json::to_string(&pending) { 104 - let cache_key = email_update_cache_key(&auth.0.did); 105 - if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { 106 - warn!("Failed to cache pending email update: {:?}", e); 107 - } 96 + && let Some(ref new_email) = inp.new_email 97 + { 98 + let new_email = new_email.trim().to_lowercase(); 99 + if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { 100 + let pending = PendingEmailUpdate { 101 + new_email, 102 + token_hash: hash_token(&code), 103 + authorized: false, 104 + }; 105 + if let Ok(json) = serde_json::to_string(&pending) { 106 + let cache_key = email_update_cache_key(&auth.0.did); 107 + if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { 108 + warn!("Failed to cache pending email update: {:?}", e); 108 109 } 109 110 } 110 111 } 112 + } 111 113 112 114 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 113 115 if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token( ··· 278 280 let cache_key = email_update_cache_key(did); 279 281 if let Some(pending_json) = state.cache.get(&cache_key).await 280 282 && let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json) 281 - && pending.authorized && pending.new_email == new_email { 282 - authorized_via_link = true; 283 - let _ = state.cache.delete(&cache_key).await; 284 - info!(did = %did, "Email update completed via link authorization"); 285 - } 283 + && pending.authorized 284 + && pending.new_email == new_email 285 + { 286 + authorized_via_link = true; 287 + let _ = state.cache.delete(&cache_key).await; 288 + info!(did = %did, "Email update completed via link authorization"); 289 + } 286 290 287 291 if !authorized_via_link { 288 292 let Some(ref t) = input.token else { ··· 318 322 } 319 323 } 320 324 321 - if let Ok(true) = state 322 - .user_repo 323 - .check_email_exists(&new_email, user_id) 324 - .await 325 - { 326 - return ApiError::InvalidRequest("Email is already in use".into()).into_response(); 327 - } 328 - 329 325 if let Err(e) = state.user_repo.update_email(user_id, &new_email).await { 330 326 error!("DB error updating email: {:?}", e); 331 327 return ApiError::InternalError(None).into_response(); ··· 481 477 482 478 pending.authorized = true; 483 479 if let Ok(json) = serde_json::to_string(&pending) 484 - && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { 485 - warn!("Failed to update pending email authorization: {:?}", e); 486 - return ApiError::InternalError(None).into_response(); 487 - } 480 + && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await 481 + { 482 + warn!("Failed to update pending email authorization: {:?}", e); 483 + return ApiError::InternalError(None).into_response(); 484 + } 488 485 489 486 info!(did = %did, "Email update authorized via link click"); 490 487 ··· 541 538 })) 542 539 .into_response() 543 540 } 541 + 542 + #[derive(Deserialize)] 543 + pub struct CheckEmailInUseInput { 544 + pub email: String, 545 + } 546 + 547 + pub async fn check_email_in_use( 548 + State(state): State<AppState>, 549 + headers: axum::http::HeaderMap, 550 + Json(input): Json<CheckEmailInUseInput>, 551 + ) -> Response { 552 + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 553 + if !state 554 + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 555 + .await 556 + { 557 + return ApiError::RateLimitExceeded(None).into_response(); 558 + } 559 + 560 + let email = input.email.trim().to_lowercase(); 561 + if email.is_empty() { 562 + return ApiError::InvalidRequest("email is required".into()).into_response(); 563 + } 564 + 565 + let count = match state.user_repo.count_accounts_by_email(&email).await { 566 + Ok(c) => c, 567 + Err(e) => { 568 + error!("DB error checking email usage: {:?}", e); 569 + return ApiError::InternalError(None).into_response(); 570 + } 571 + }; 572 + 573 + Json(json!({ 574 + "inUse": count > 0, 575 + })) 576 + .into_response() 577 + } 578 + 579 + #[derive(Deserialize)] 580 + pub struct CheckCommsChannelInUseInput { 581 + pub channel: String, 582 + pub identifier: String, 583 + } 584 + 585 + pub async fn check_comms_channel_in_use( 586 + State(state): State<AppState>, 587 + headers: axum::http::HeaderMap, 588 + Json(input): Json<CheckCommsChannelInUseInput>, 589 + ) -> Response { 590 + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 591 + if !state 592 + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 593 + .await 594 + { 595 + return ApiError::RateLimitExceeded(None).into_response(); 596 + } 597 + 598 + let channel = match input.channel.to_lowercase().as_str() { 599 + "email" => CommsChannel::Email, 600 + "discord" => CommsChannel::Discord, 601 + "telegram" => CommsChannel::Telegram, 602 + "signal" => CommsChannel::Signal, 603 + _ => { 604 + return ApiError::InvalidRequest("invalid channel".into()).into_response(); 605 + } 606 + }; 607 + 608 + let identifier = input.identifier.trim(); 609 + if identifier.is_empty() { 610 + return ApiError::InvalidRequest("identifier is required".into()).into_response(); 611 + } 612 + 613 + let count = match state 614 + .user_repo 615 + .count_accounts_by_comms_identifier(channel, identifier) 616 + .await 617 + { 618 + Ok(c) => c, 619 + Err(e) => { 620 + error!("DB error checking comms channel usage: {:?}", e); 621 + return ApiError::InternalError(None).into_response(); 622 + } 623 + }; 624 + 625 + Json(json!({ 626 + "inUse": count > 0, 627 + })) 628 + .into_response() 629 + }
+3 -2
crates/tranquil-pds/src/api/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_email_update_status, check_email_verified, confirm_email, 27 - request_email_update, update_email, 26 + authorize_email_update, check_comms_channel_in_use, check_email_in_use, 27 + check_email_update_status, check_email_verified, confirm_email, request_email_update, 28 + update_email, 28 29 }; 29 30 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 30 31 pub use logo::get_logo;
+1 -14
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 18 18 use uuid::Uuid; 19 19 20 20 use crate::api::repo::record::utils::create_signed_commit; 21 - use crate::auth::{ServiceTokenVerifier, is_service_token}; 21 + use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}; 22 22 use crate::state::{AppState, RateLimitKind}; 23 23 use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; 24 24 use crate::validation::validate_password; ··· 52 52 .collect() 53 53 } 54 54 55 - fn generate_app_password() -> String { 56 - let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 57 - let mut rng = rand::thread_rng(); 58 - let segments: Vec<String> = (0..4) 59 - .map(|_| { 60 - (0..4) 61 - .map(|_| chars[rng.gen_range(0..chars.len())] as char) 62 - .collect() 63 - }) 64 - .collect(); 65 - segments.join("-") 66 - } 67 - 68 55 #[derive(Deserialize)] 69 56 #[serde(rename_all = "camelCase")] 70 57 pub struct CreatePasskeyAccountInput {
+23 -2
crates/tranquil-pds/src/api/server/password.rs
··· 60 60 let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); 61 61 let normalized = identifier.to_lowercase(); 62 62 let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); 63 + let is_email_lookup = normalized.contains('@'); 63 64 let normalized_handle = if normalized.contains('@') || normalized.contains('.') { 64 65 normalized.to_string() 65 66 } else { 66 67 format!("{}.{}", normalized, hostname_for_handles) 67 68 }; 69 + 70 + let multiple_accounts_warning = if is_email_lookup { 71 + match state.user_repo.count_accounts_by_email(normalized).await { 72 + Ok(count) if count > 1 => Some(count), 73 + _ => None, 74 + } 75 + } else { 76 + None 77 + }; 78 + 68 79 let user_id = match state 69 80 .user_repo 70 81 .get_id_by_email_or_handle(normalized, &normalized_handle) ··· 73 84 Ok(Some(id)) => id, 74 85 Ok(None) => { 75 86 info!("Password reset requested for unknown identifier"); 76 - return EmptyResponse::ok().into_response(); 87 + return Json(serde_json::json!({ "success": true })).into_response(); 77 88 } 78 89 Err(e) => { 79 90 error!("DB error in request_password_reset: {:?}", e); ··· 103 114 warn!("Failed to enqueue password reset notification: {:?}", e); 104 115 } 105 116 info!("Password reset requested for user {}", user_id); 106 - EmptyResponse::ok().into_response() 117 + 118 + match multiple_accounts_warning { 119 + Some(count) => Json(serde_json::json!({ 120 + "success": true, 121 + "multipleAccounts": true, 122 + "accountCount": count, 123 + "message": "Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account." 124 + })) 125 + .into_response(), 126 + None => Json(serde_json::json!({ "success": true })).into_response(), 127 + } 107 128 } 108 129 109 130 #[derive(Deserialize)]
+3 -1
crates/tranquil-pds/src/api/server/reauth.rs
··· 84 84 85 85 let app_password_valid = app_password_hashes 86 86 .iter() 87 - .any(|h| bcrypt::verify(&input.password, h).unwrap_or(false)); 87 + .fold(false, |acc, h| { 88 + acc | bcrypt::verify(&input.password, h).unwrap_or(false) 89 + }); 88 90 89 91 if !app_password_valid { 90 92 warn!(did = %&auth.0.did, "Re-auth failed: invalid password");
+10
crates/tranquil-pds/src/auth/mod.rs
··· 44 44 crate::config::decrypt_key(encrypted, Some(version)) 45 45 } 46 46 47 + pub fn generate_app_password() -> String { 48 + use rand::Rng; 49 + let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; 50 + let mut rng = rand::thread_rng(); 51 + let segments: Vec<String> = (0..4) 52 + .map(|_| (0..4).map(|_| chars[rng.gen_range(0..chars.len())] as char).collect()) 53 + .collect(); 54 + segments.join("-") 55 + } 56 + 47 57 const KEY_CACHE_TTL_SECS: u64 = 300; 48 58 const SESSION_CACHE_TTL_SECS: u64 = 60; 49 59 const USER_STATUS_CACHE_TTL_SECS: u64 = 60;
+12
crates/tranquil-pds/src/lib.rs
··· 295 295 "/_account.checkEmailUpdateStatus", 296 296 get(api::server::check_email_update_status), 297 297 ) 298 + .route( 299 + "/_account.checkEmailInUse", 300 + post(api::server::check_email_in_use), 301 + ) 302 + .route( 303 + "/_account.checkCommsChannelInUse", 304 + post(api::server::check_comms_channel_in_use), 305 + ) 298 306 .route( 299 307 "/com.atproto.server.reserveSigningKey", 300 308 post(api::server::reserve_signing_key), ··· 556 564 .route("/passkey/start", post(oauth::endpoints::passkey_start)) 557 565 .route("/passkey/finish", post(oauth::endpoints::passkey_finish)) 558 566 .route("/authorize/deny", post(oauth::endpoints::authorize_deny)) 567 + .route( 568 + "/register/complete", 569 + post(oauth::endpoints::register_complete), 570 + ) 559 571 .route("/authorize/consent", get(oauth::endpoints::consent_get)) 560 572 .route("/authorize/consent", post(oauth::endpoints::consent_post)) 561 573 .route(
+312
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 329 329 tracing::info!("No login_hint in request"); 330 330 } 331 331 332 + if request_data.parameters.prompt.as_deref() == Some("create") { 333 + return redirect_see_other(&format!( 334 + "/app/oauth/register?request_uri={}", 335 + url_encode(&request_uri) 336 + )); 337 + } 338 + 332 339 if !force_new_account 333 340 && let Some(device_id) = extract_device_cookie(&headers) 334 341 && let Ok(accounts) = state ··· 3208 3215 ) 3209 3216 .into_response() 3210 3217 } 3218 + 3219 + #[derive(Debug, Deserialize)] 3220 + pub struct RegisterCompleteInput { 3221 + pub request_uri: String, 3222 + pub did: String, 3223 + pub app_password: String, 3224 + } 3225 + 3226 + pub async fn register_complete( 3227 + State(state): State<AppState>, 3228 + headers: HeaderMap, 3229 + Json(form): Json<RegisterCompleteInput>, 3230 + ) -> Response { 3231 + let client_ip = extract_client_ip(&headers); 3232 + 3233 + if !state 3234 + .check_rate_limit(RateLimitKind::OAuthRegisterComplete, &client_ip) 3235 + .await 3236 + { 3237 + return ( 3238 + StatusCode::TOO_MANY_REQUESTS, 3239 + Json(serde_json::json!({ 3240 + "error": "RateLimitExceeded", 3241 + "error_description": "Too many attempts. Please try again later." 3242 + })), 3243 + ) 3244 + .into_response(); 3245 + } 3246 + 3247 + let did = Did::from(form.did.clone()); 3248 + 3249 + let request_id = RequestId::from(form.request_uri.clone()); 3250 + let request_data = match state 3251 + .oauth_repo 3252 + .get_authorization_request(&request_id) 3253 + .await 3254 + { 3255 + Ok(Some(data)) => data, 3256 + Ok(None) => { 3257 + return ( 3258 + StatusCode::BAD_REQUEST, 3259 + Json(serde_json::json!({ 3260 + "error": "invalid_request", 3261 + "error_description": "Invalid or expired request_uri." 3262 + })), 3263 + ) 3264 + .into_response(); 3265 + } 3266 + Err(e) => { 3267 + tracing::error!( 3268 + request_uri = %form.request_uri, 3269 + error = ?e, 3270 + "register_complete: failed to fetch authorization request" 3271 + ); 3272 + return ( 3273 + StatusCode::INTERNAL_SERVER_ERROR, 3274 + Json(serde_json::json!({ 3275 + "error": "server_error", 3276 + "error_description": "An error occurred." 3277 + })), 3278 + ) 3279 + .into_response(); 3280 + } 3281 + }; 3282 + 3283 + if request_data.expires_at < Utc::now() { 3284 + let _ = state 3285 + .oauth_repo 3286 + .delete_authorization_request(&request_id) 3287 + .await; 3288 + return ( 3289 + StatusCode::BAD_REQUEST, 3290 + Json(serde_json::json!({ 3291 + "error": "invalid_request", 3292 + "error_description": "Authorization request has expired." 3293 + })), 3294 + ) 3295 + .into_response(); 3296 + } 3297 + 3298 + if request_data.parameters.prompt.as_deref() != Some("create") { 3299 + tracing::warn!( 3300 + request_uri = %form.request_uri, 3301 + prompt = ?request_data.parameters.prompt, 3302 + "register_complete called on non-registration OAuth flow" 3303 + ); 3304 + return ( 3305 + StatusCode::BAD_REQUEST, 3306 + Json(serde_json::json!({ 3307 + "error": "invalid_request", 3308 + "error_description": "This endpoint is only for registration flows." 3309 + })), 3310 + ) 3311 + .into_response(); 3312 + } 3313 + 3314 + if request_data.code.is_some() { 3315 + tracing::warn!( 3316 + request_uri = %form.request_uri, 3317 + "register_complete called on already-completed OAuth flow" 3318 + ); 3319 + return ( 3320 + StatusCode::BAD_REQUEST, 3321 + Json(serde_json::json!({ 3322 + "error": "invalid_request", 3323 + "error_description": "Authorization has already been completed." 3324 + })), 3325 + ) 3326 + .into_response(); 3327 + } 3328 + 3329 + if let Some(existing_did) = &request_data.did 3330 + && existing_did != &form.did 3331 + { 3332 + tracing::warn!( 3333 + request_uri = %form.request_uri, 3334 + existing_did = %existing_did, 3335 + attempted_did = %form.did, 3336 + "register_complete attempted with different DID than already bound" 3337 + ); 3338 + return ( 3339 + StatusCode::BAD_REQUEST, 3340 + Json(serde_json::json!({ 3341 + "error": "invalid_request", 3342 + "error_description": "Authorization request is already bound to a different account." 3343 + })), 3344 + ) 3345 + .into_response(); 3346 + } 3347 + 3348 + let password_hashes = match state 3349 + .session_repo 3350 + .get_app_password_hashes_by_did(&did) 3351 + .await 3352 + { 3353 + Ok(hashes) => hashes, 3354 + Err(e) => { 3355 + tracing::error!( 3356 + did = %did, 3357 + error = ?e, 3358 + "register_complete: failed to fetch app password hashes" 3359 + ); 3360 + return ( 3361 + StatusCode::INTERNAL_SERVER_ERROR, 3362 + Json(serde_json::json!({ 3363 + "error": "server_error", 3364 + "error_description": "An error occurred." 3365 + })), 3366 + ) 3367 + .into_response(); 3368 + } 3369 + }; 3370 + 3371 + let password_valid = password_hashes 3372 + .iter() 3373 + .fold(false, |acc, hash| { 3374 + acc | bcrypt::verify(&form.app_password, hash).unwrap_or(false) 3375 + }); 3376 + 3377 + if !password_valid { 3378 + return ( 3379 + StatusCode::FORBIDDEN, 3380 + Json(serde_json::json!({ 3381 + "error": "access_denied", 3382 + "error_description": "Invalid credentials." 3383 + })), 3384 + ) 3385 + .into_response(); 3386 + } 3387 + 3388 + let is_verified = match state.user_repo.get_session_info_by_did(&did).await { 3389 + Ok(Some(info)) => { 3390 + info.email_verified 3391 + || info.discord_verified 3392 + || info.telegram_verified 3393 + || info.signal_verified 3394 + } 3395 + Ok(None) => { 3396 + return ( 3397 + StatusCode::FORBIDDEN, 3398 + Json(serde_json::json!({ 3399 + "error": "access_denied", 3400 + "error_description": "Account not found." 3401 + })), 3402 + ) 3403 + .into_response(); 3404 + } 3405 + Err(e) => { 3406 + tracing::error!( 3407 + did = %did, 3408 + error = ?e, 3409 + "register_complete: failed to fetch session info" 3410 + ); 3411 + return ( 3412 + StatusCode::INTERNAL_SERVER_ERROR, 3413 + Json(serde_json::json!({ 3414 + "error": "server_error", 3415 + "error_description": "An error occurred." 3416 + })), 3417 + ) 3418 + .into_response(); 3419 + } 3420 + }; 3421 + 3422 + if !is_verified { 3423 + return ( 3424 + StatusCode::FORBIDDEN, 3425 + Json(serde_json::json!({ 3426 + "error": "access_denied", 3427 + "error_description": "Please verify your account before continuing." 3428 + })), 3429 + ) 3430 + .into_response(); 3431 + } 3432 + 3433 + if let Err(e) = state 3434 + .oauth_repo 3435 + .set_authorization_did(&request_id, &did, None) 3436 + .await 3437 + { 3438 + tracing::error!( 3439 + request_uri = %form.request_uri, 3440 + did = %did, 3441 + error = ?e, 3442 + "register_complete: failed to set authorization DID" 3443 + ); 3444 + return ( 3445 + StatusCode::INTERNAL_SERVER_ERROR, 3446 + Json(serde_json::json!({ 3447 + "error": "server_error", 3448 + "error_description": "An error occurred." 3449 + })), 3450 + ) 3451 + .into_response(); 3452 + } 3453 + 3454 + let requested_scope_str = request_data 3455 + .parameters 3456 + .scope 3457 + .as_deref() 3458 + .unwrap_or("atproto"); 3459 + let requested_scopes: Vec<String> = requested_scope_str 3460 + .split_whitespace() 3461 + .map(|s| s.to_string()) 3462 + .collect(); 3463 + let client_id_typed = ClientId::from(request_data.parameters.client_id.clone()); 3464 + let needs_consent = should_show_consent( 3465 + state.oauth_repo.as_ref(), 3466 + &did, 3467 + &client_id_typed, 3468 + &requested_scopes, 3469 + ) 3470 + .await 3471 + .unwrap_or(true); 3472 + 3473 + if needs_consent { 3474 + tracing::info!( 3475 + did = %did, 3476 + client_id = %request_data.parameters.client_id, 3477 + "OAuth registration complete, redirecting to consent" 3478 + ); 3479 + let consent_url = format!( 3480 + "/app/oauth/consent?request_uri={}", 3481 + url_encode(&form.request_uri) 3482 + ); 3483 + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 3484 + } 3485 + 3486 + let code = Code::generate(); 3487 + let auth_code = AuthorizationCode::from(code.0.clone()); 3488 + if let Err(e) = state 3489 + .oauth_repo 3490 + .update_authorization_request(&request_id, &did, None, &auth_code) 3491 + .await 3492 + { 3493 + tracing::error!( 3494 + request_uri = %form.request_uri, 3495 + did = %did, 3496 + error = ?e, 3497 + "register_complete: failed to update authorization request with code" 3498 + ); 3499 + return ( 3500 + StatusCode::INTERNAL_SERVER_ERROR, 3501 + Json(serde_json::json!({ 3502 + "error": "server_error", 3503 + "error_description": "An error occurred." 3504 + })), 3505 + ) 3506 + .into_response(); 3507 + } 3508 + 3509 + tracing::info!( 3510 + did = %did, 3511 + client_id = %request_data.parameters.client_id, 3512 + "OAuth registration flow completed successfully" 3513 + ); 3514 + 3515 + let redirect_url = build_intermediate_redirect_url( 3516 + &request_data.parameters.redirect_uri, 3517 + &code.0, 3518 + request_data.parameters.state.as_deref(), 3519 + request_data.parameters.response_mode.as_deref(), 3520 + ); 3521 + Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() 3522 + }
+9
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
··· 50 50 pub introspection_endpoint: Option<String>, 51 51 #[serde(skip_serializing_if = "Option::is_none")] 52 52 pub client_id_metadata_document_supported: Option<bool>, 53 + #[serde(skip_serializing_if = "Option::is_none")] 54 + pub prompt_values_supported: Option<Vec<String>>, 53 55 } 54 56 55 57 pub async fn oauth_protected_resource( ··· 113 115 revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)), 114 116 introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)), 115 117 client_id_metadata_document_supported: Some(true), 118 + prompt_values_supported: Some(vec![ 119 + "none".to_string(), 120 + "login".to_string(), 121 + "consent".to_string(), 122 + "select_account".to_string(), 123 + "create".to_string(), 124 + ]), 116 125 }) 117 126 } 118 127
+23
crates/tranquil-pds/src/oauth/endpoints/par.rs
··· 37 37 pub client_assertion: Option<String>, 38 38 #[serde(default)] 39 39 pub client_assertion_type: Option<String>, 40 + #[serde(default)] 41 + pub prompt: Option<String>, 40 42 } 41 43 42 44 #[derive(Debug, Serialize)] ··· 109 111 ))); 110 112 } 111 113 }; 114 + let prompt = validate_prompt(&request.prompt)?; 112 115 let parameters = AuthorizationRequestParameters { 113 116 response_type: request.response_type, 114 117 client_id: request.client_id.clone(), ··· 120 123 response_mode, 121 124 login_hint: request.login_hint, 122 125 dpop_jkt: request.dpop_jkt, 126 + prompt, 123 127 extra: None, 124 128 }; 125 129 let request_data = RequestData { ··· 261 265 262 266 false 263 267 } 268 + 269 + fn validate_prompt(prompt: &Option<String>) -> Result<Option<String>, OAuthError> { 270 + const VALID_PROMPTS: &[&str] = &["none", "login", "consent", "select_account", "create"]; 271 + 272 + match prompt { 273 + None => Ok(None), 274 + Some(p) if p.is_empty() => Ok(None), 275 + Some(p) => { 276 + if VALID_PROMPTS.contains(&p.as_str()) { 277 + Ok(Some(p.clone())) 278 + } else { 279 + Err(OAuthError::InvalidRequest(format!( 280 + "Unsupported prompt value: {}", 281 + p 282 + ))) 283 + } 284 + } 285 + } 286 + }
+6
crates/tranquil-pds/src/rate_limit.rs
··· 36 36 pub sso_initiate: Arc<KeyedRateLimiter>, 37 37 pub sso_callback: Arc<KeyedRateLimiter>, 38 38 pub sso_unlink: Arc<KeyedRateLimiter>, 39 + pub oauth_register_complete: Arc<KeyedRateLimiter>, 39 40 } 40 41 41 42 impl Default for RateLimiters { ··· 107 108 sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute( 108 109 NonZeroU32::new(10).unwrap(), 109 110 ))), 111 + oauth_register_complete: Arc::new(RateLimiter::keyed( 112 + Quota::with_period(std::time::Duration::from_secs(60)) 113 + .unwrap() 114 + .allow_burst(NonZeroU32::new(5).unwrap()), 115 + )), 110 116 } 111 117 } 112 118
+132 -45
crates/tranquil-pds/src/sso/endpoints.rs
··· 12 12 use super::config::SsoConfig; 13 13 use crate::api::error::ApiError; 14 14 use crate::auth::extractor::extract_bearer_token_from_header; 15 - use crate::auth::validate_bearer_token_cached; 15 + use crate::auth::{generate_app_password, validate_bearer_token_cached}; 16 16 use crate::rate_limit::extract_client_ip; 17 17 use crate::state::{AppState, RateLimitKind}; 18 18 ··· 87 87 return Err(ApiError::SsoProviderNotFound); 88 88 } 89 89 if let Some(ref uri) = input.request_uri 90 - && uri.len() > 500 { 91 - return Err(ApiError::InvalidRequest("Request URI too long".into())); 92 - } 90 + && uri.len() > 500 91 + { 92 + return Err(ApiError::InvalidRequest("Request URI too long".into())); 93 + } 93 94 if let Some(ref action) = input.action 94 - && action.len() > 20 { 95 - return Err(ApiError::SsoInvalidAction); 96 - } 95 + && action.len() > 20 96 + { 97 + return Err(ApiError::SsoInvalidAction); 98 + } 97 99 98 100 let provider_type = 99 101 SsoProviderType::parse(&input.provider).ok_or(ApiError::SsoProviderNotFound)?; ··· 426 428 } 427 429 }; 428 430 431 + let is_verified = match state.user_repo.get_session_info_by_did(&identity.did).await { 432 + Ok(Some(info)) => { 433 + info.email_verified 434 + || info.discord_verified 435 + || info.telegram_verified 436 + || info.signal_verified 437 + } 438 + Ok(None) => { 439 + tracing::error!("User not found for SSO login: {}", identity.did); 440 + return redirect_to_error("Account not found"); 441 + } 442 + Err(e) => { 443 + tracing::error!("Database error checking verification status: {:?}", e); 444 + return redirect_to_error("Database error"); 445 + } 446 + }; 447 + 448 + if !is_verified { 449 + tracing::warn!( 450 + did = %identity.did, 451 + provider = %provider.as_str(), 452 + "SSO login attempt for unverified account" 453 + ); 454 + return redirect_to_login_with_error( 455 + request_uri, 456 + "Please verify your account before logging in", 457 + ); 458 + } 459 + 429 460 if let Err(e) = state 430 461 .sso_repo 431 462 .update_external_identity_login( ··· 813 844 pub discord_id: Option<String>, 814 845 pub telegram_username: Option<String>, 815 846 pub signal_number: Option<String>, 847 + pub did_type: Option<String>, 848 + pub did: Option<String>, 816 849 } 817 850 818 851 #[derive(Debug, Serialize)] ··· 825 858 pub access_jwt: Option<String>, 826 859 #[serde(skip_serializing_if = "Option::is_none")] 827 860 pub refresh_jwt: Option<String>, 861 + #[serde(skip_serializing_if = "Option::is_none")] 862 + pub app_password: Option<String>, 863 + #[serde(skip_serializing_if = "Option::is_none")] 864 + pub app_password_name: Option<String>, 828 865 } 829 866 830 867 pub async fn complete_registration( ··· 914 951 if !crate::api::validation::is_valid_email(e) { 915 952 return Err(ApiError::InvalidEmail); 916 953 } 917 - let email_exists = state 918 - .user_repo 919 - .check_email_exists(e, uuid::Uuid::nil()) 920 - .await 921 - .unwrap_or(true); 922 - if email_exists { 923 - return Err(ApiError::EmailTaken); 924 - } 925 954 Some(e.clone()) 926 955 } 927 956 None => None, ··· 967 996 }; 968 997 969 998 let pds_endpoint = format!("https://{}", hostname); 970 - let rotation_key = std::env::var("PLC_ROTATION_KEY") 971 - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); 999 + let did_type = input.did_type.as_deref().unwrap_or("plc"); 1000 + 1001 + let did = match did_type { 1002 + "web" => { 1003 + let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 1004 + let encoded_subdomain = subdomain_host.replace(':', "%3A"); 1005 + let self_hosted_did = format!("did:web:{}", encoded_subdomain); 1006 + tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account"); 1007 + self_hosted_did 1008 + } 1009 + "web-external" => { 1010 + let d = match &input.did { 1011 + Some(d) if !d.trim().is_empty() => d.trim(), 1012 + _ => { 1013 + return Err(ApiError::InvalidRequest( 1014 + "External did:web requires the 'did' field to be provided".into(), 1015 + )); 1016 + } 1017 + }; 1018 + if !d.starts_with("did:web:") { 1019 + return Err(ApiError::InvalidDid( 1020 + "External DID must be a did:web".into(), 1021 + )); 1022 + } 1023 + tracing::info!(did = %d, "Creating external did:web SSO account"); 1024 + d.to_string() 1025 + } 1026 + _ => { 1027 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 1028 + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); 1029 + 1030 + let genesis_result = match crate::plc::create_genesis_operation( 1031 + &signing_key, 1032 + &rotation_key, 1033 + &handle, 1034 + &pds_endpoint, 1035 + ) { 1036 + Ok(r) => r, 1037 + Err(e) => { 1038 + tracing::error!("Error creating PLC genesis operation: {:?}", e); 1039 + return Err(ApiError::InternalError(Some( 1040 + "Failed to create PLC operation".into(), 1041 + ))); 1042 + } 1043 + }; 972 1044 973 - let genesis_result = match crate::plc::create_genesis_operation( 974 - &signing_key, 975 - &rotation_key, 976 - &handle, 977 - &pds_endpoint, 978 - ) { 979 - Ok(r) => r, 980 - Err(e) => { 981 - tracing::error!("Error creating PLC genesis operation: {:?}", e); 982 - return Err(ApiError::InternalError(Some( 983 - "Failed to create PLC operation".into(), 984 - ))); 1045 + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); 1046 + if let Err(e) = plc_client 1047 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 1048 + .await 1049 + { 1050 + tracing::error!("Failed to submit PLC genesis operation: {:?}", e); 1051 + return Err(ApiError::UpstreamErrorMsg(format!( 1052 + "Failed to register DID with PLC directory: {}", 1053 + e 1054 + ))); 1055 + } 1056 + genesis_result.did 985 1057 } 986 1058 }; 987 - 988 - let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); 989 - if let Err(e) = plc_client 990 - .send_operation(&genesis_result.did, &genesis_result.signed_operation) 991 - .await 992 - { 993 - tracing::error!("Failed to submit PLC genesis operation: {:?}", e); 994 - return Err(ApiError::UpstreamErrorMsg(format!( 995 - "Failed to register DID with PLC directory: {}", 996 - e 997 - ))); 998 - } 999 - 1000 - let did = genesis_result.did; 1001 1059 tracing::info!(did = %did, handle = %handle, provider = %pending_preview.provider.as_str(), "Created DID for SSO account"); 1002 1060 1003 1061 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { ··· 1093 1151 pending_registration_token: input.token.clone(), 1094 1152 }; 1095 1153 1096 - let _create_result = match state.user_repo.create_sso_account(&create_input).await { 1154 + let create_result = match state.user_repo.create_sso_account(&create_input).await { 1097 1155 Ok(r) => r, 1098 1156 Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { 1099 1157 return Err(ApiError::HandleNotAvailable(None)); ··· 1145 1203 tracing::warn!("Failed to create default profile for {}: {}", did, e); 1146 1204 } 1147 1205 1206 + let app_password = generate_app_password(); 1207 + let app_password_name = "bsky.app".to_string(); 1208 + let app_password_hash = match bcrypt::hash(&app_password, bcrypt::DEFAULT_COST) { 1209 + Ok(h) => h, 1210 + Err(e) => { 1211 + tracing::error!("Failed to hash app password: {:?}", e); 1212 + return Err(ApiError::InternalError(None)); 1213 + } 1214 + }; 1215 + 1216 + let app_password_data = tranquil_db_traits::AppPasswordCreate { 1217 + user_id: create_result.user_id, 1218 + name: app_password_name.clone(), 1219 + password_hash: app_password_hash, 1220 + privileged: false, 1221 + scopes: None, 1222 + created_by_controller_did: None, 1223 + }; 1224 + if let Err(e) = state.session_repo.create_app_password(&app_password_data).await { 1225 + tracing::warn!("Failed to create initial app password: {:?}", e); 1226 + } 1227 + 1148 1228 let is_standalone = pending_preview.request_uri == "standalone"; 1149 1229 1150 1230 if !is_standalone { ··· 1250 1330 redirect_url: "/app/dashboard".to_string(), 1251 1331 access_jwt: Some(access_meta.token), 1252 1332 refresh_jwt: Some(refresh_meta.token), 1333 + app_password: Some(app_password), 1334 + app_password_name: Some(app_password_name), 1253 1335 })); 1254 1336 } 1255 1337 ··· 1262 1344 ), 1263 1345 access_jwt: None, 1264 1346 refresh_jwt: None, 1347 + app_password: Some(app_password), 1348 + app_password_name: Some(app_password_name), 1265 1349 })); 1266 1350 } 1267 1351 ··· 1291 1375 format!("/app/verify?did={}", urlencoding::encode(&did)) 1292 1376 } else { 1293 1377 format!( 1294 - "/app/oauth/verify?request_uri={}", 1378 + "/app/verify?did={}&request_uri={}", 1379 + urlencoding::encode(&did), 1295 1380 urlencoding::encode(&pending_preview.request_uri) 1296 1381 ) 1297 1382 }; ··· 1302 1387 redirect_url, 1303 1388 access_jwt: None, 1304 1389 refresh_jwt: None, 1390 + app_password: Some(app_password), 1391 + app_password_name: Some(app_password_name), 1305 1392 })) 1306 1393 }
+15 -12
crates/tranquil-pds/src/sso/providers.rs
··· 833 833 { 834 834 let cache = self.client_secret_cache.read().await; 835 835 if let Some(ref cached) = *cache 836 - && cached.expires_at > now + 3600 { 837 - return Ok(cached.secret.clone()); 838 - } 836 + && cached.expires_at > now + 3600 837 + { 838 + return Ok(cached.secret.clone()); 839 + } 839 840 } 840 841 841 842 let (secret, expires_at) = self.generate_client_secret()?; ··· 1069 1070 cfg, 1070 1071 Some("https://accounts.google.com"), 1071 1072 "Google", 1072 - ) { 1073 - providers.insert(SsoProviderType::Google, Arc::new(provider)); 1074 - } 1073 + ) 1074 + { 1075 + providers.insert(SsoProviderType::Google, Arc::new(provider)); 1076 + } 1075 1077 1076 1078 if let Some(ref cfg) = config.gitlab 1077 1079 && let Some(provider) = OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab") 1078 - { 1079 - providers.insert(SsoProviderType::Gitlab, Arc::new(provider)); 1080 - } 1080 + { 1081 + providers.insert(SsoProviderType::Gitlab, Arc::new(provider)); 1082 + } 1081 1083 1082 1084 if let Some(ref cfg) = config.oidc 1083 1085 && let Some(provider) = OidcProvider::new( ··· 1085 1087 cfg, 1086 1088 None, 1087 1089 cfg.display_name.as_deref().unwrap_or("SSO"), 1088 - ) { 1089 - providers.insert(SsoProviderType::Oidc, Arc::new(provider)); 1090 - } 1090 + ) 1091 + { 1092 + providers.insert(SsoProviderType::Oidc, Arc::new(provider)); 1093 + } 1091 1094 1092 1095 if let Some(ref cfg) = config.apple { 1093 1096 match AppleProvider::new(cfg) {
+4
crates/tranquil-pds/src/state.rs
··· 62 62 SsoInitiate, 63 63 SsoCallback, 64 64 SsoUnlink, 65 + OAuthRegisterComplete, 65 66 } 66 67 67 68 impl RateLimitKind { ··· 85 86 Self::SsoInitiate => "sso_initiate", 86 87 Self::SsoCallback => "sso_callback", 87 88 Self::SsoUnlink => "sso_unlink", 89 + Self::OAuthRegisterComplete => "oauth_register_complete", 88 90 } 89 91 } 90 92 ··· 108 110 Self::SsoInitiate => (10, 60_000), 109 111 Self::SsoCallback => (30, 60_000), 110 112 Self::SsoUnlink => (10, 60_000), 113 + Self::OAuthRegisterComplete => (5, 300_000), 111 114 } 112 115 } 113 116 } ··· 251 254 RateLimitKind::SsoInitiate => &self.rate_limiters.sso_initiate, 252 255 RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback, 253 256 RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink, 257 + RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete, 254 258 }; 255 259 256 260 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+12 -9
crates/tranquil-pds/tests/email_update.rs
··· 463 463 } 464 464 465 465 #[tokio::test] 466 - async fn test_update_email_taken_by_another_user() { 466 + async fn test_update_email_to_same_as_another_user_allowed() { 467 467 let client = common::client(); 468 468 let base_url = common::base_url().await; 469 469 let pool = common::get_test_db_pool().await; ··· 499 499 .send() 500 500 .await 501 501 .expect("Failed to update email"); 502 - assert_eq!(res.status(), StatusCode::BAD_REQUEST); 503 - let body: Value = res.json().await.expect("Invalid JSON"); 504 - assert_eq!(body["error"], "InvalidRequest"); 505 - assert!( 506 - body["message"] 507 - .as_str() 508 - .unwrap_or("") 509 - .contains("already in use") 502 + assert_eq!( 503 + res.status(), 504 + StatusCode::OK, 505 + "Multiple accounts can share the same email address" 510 506 ); 507 + 508 + let user_email: Option<String> = 509 + sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did2) 510 + .fetch_one(pool) 511 + .await 512 + .expect("User not found"); 513 + assert_eq!(user_email, Some(email1.clone())); 511 514 }
+409
crates/tranquil-pds/tests/oauth.rs
··· 1237 1237 "Should require lxm parameter for granular scopes" 1238 1238 ); 1239 1239 } 1240 + 1241 + #[tokio::test] 1242 + async fn test_oauth_metadata_includes_prompt_values_supported() { 1243 + let url = base_url().await; 1244 + let client = client(); 1245 + let as_res = client 1246 + .get(format!("{}/.well-known/oauth-authorization-server", url)) 1247 + .send() 1248 + .await 1249 + .unwrap(); 1250 + assert_eq!(as_res.status(), StatusCode::OK); 1251 + let as_body: Value = as_res.json().await.unwrap(); 1252 + let prompt_values = as_body["prompt_values_supported"] 1253 + .as_array() 1254 + .expect("prompt_values_supported should be an array"); 1255 + assert!( 1256 + prompt_values.contains(&json!("none")), 1257 + "Should support prompt=none" 1258 + ); 1259 + assert!( 1260 + prompt_values.contains(&json!("login")), 1261 + "Should support prompt=login" 1262 + ); 1263 + assert!( 1264 + prompt_values.contains(&json!("consent")), 1265 + "Should support prompt=consent" 1266 + ); 1267 + assert!( 1268 + prompt_values.contains(&json!("select_account")), 1269 + "Should support prompt=select_account" 1270 + ); 1271 + assert!( 1272 + prompt_values.contains(&json!("create")), 1273 + "Should support prompt=create" 1274 + ); 1275 + } 1276 + 1277 + #[tokio::test] 1278 + async fn test_par_accepts_valid_prompt_values() { 1279 + let url = base_url().await; 1280 + let client = client(); 1281 + let redirect_uri = "https://example.com/callback"; 1282 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1283 + let client_id = mock_client.uri(); 1284 + let (_, code_challenge) = generate_pkce(); 1285 + let valid_prompts = ["none", "login", "consent", "select_account", "create"]; 1286 + for prompt in valid_prompts { 1287 + let par_res = client 1288 + .post(format!("{}/oauth/par", url)) 1289 + .form(&[ 1290 + ("response_type", "code"), 1291 + ("client_id", &client_id), 1292 + ("redirect_uri", redirect_uri), 1293 + ("code_challenge", &code_challenge), 1294 + ("code_challenge_method", "S256"), 1295 + ("scope", "atproto"), 1296 + ("state", "test-state"), 1297 + ("prompt", prompt), 1298 + ]) 1299 + .send() 1300 + .await 1301 + .unwrap(); 1302 + assert_eq!( 1303 + par_res.status(), 1304 + StatusCode::CREATED, 1305 + "PAR should accept prompt={}", 1306 + prompt 1307 + ); 1308 + } 1309 + } 1310 + 1311 + #[tokio::test] 1312 + async fn test_par_rejects_invalid_prompt_value() { 1313 + let url = base_url().await; 1314 + let client = client(); 1315 + let redirect_uri = "https://example.com/callback"; 1316 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1317 + let client_id = mock_client.uri(); 1318 + let (_, code_challenge) = generate_pkce(); 1319 + let par_res = client 1320 + .post(format!("{}/oauth/par", url)) 1321 + .form(&[ 1322 + ("response_type", "code"), 1323 + ("client_id", &client_id), 1324 + ("redirect_uri", redirect_uri), 1325 + ("code_challenge", &code_challenge), 1326 + ("code_challenge_method", "S256"), 1327 + ("scope", "atproto"), 1328 + ("state", "test-state"), 1329 + ("prompt", "invalid_prompt"), 1330 + ]) 1331 + .send() 1332 + .await 1333 + .unwrap(); 1334 + assert_eq!( 1335 + par_res.status(), 1336 + StatusCode::BAD_REQUEST, 1337 + "PAR should reject invalid prompt value" 1338 + ); 1339 + let body: Value = par_res.json().await.unwrap(); 1340 + assert_eq!(body["error"], "invalid_request"); 1341 + assert!( 1342 + body["error_description"] 1343 + .as_str() 1344 + .unwrap_or("") 1345 + .contains("prompt"), 1346 + "Error should mention prompt" 1347 + ); 1348 + } 1349 + 1350 + #[tokio::test] 1351 + async fn test_prompt_create_redirects_to_register() { 1352 + let url = base_url().await; 1353 + let client = no_redirect_client(); 1354 + let redirect_uri = "https://example.com/callback"; 1355 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1356 + let client_id = mock_client.uri(); 1357 + let (_, code_challenge) = generate_pkce(); 1358 + let par_res = reqwest::Client::new() 1359 + .post(format!("{}/oauth/par", url)) 1360 + .form(&[ 1361 + ("response_type", "code"), 1362 + ("client_id", &client_id), 1363 + ("redirect_uri", redirect_uri), 1364 + ("code_challenge", &code_challenge), 1365 + ("code_challenge_method", "S256"), 1366 + ("scope", "atproto"), 1367 + ("state", "test-state"), 1368 + ("prompt", "create"), 1369 + ]) 1370 + .send() 1371 + .await 1372 + .unwrap(); 1373 + assert_eq!(par_res.status(), StatusCode::CREATED); 1374 + let par_body: Value = par_res.json().await.unwrap(); 1375 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1376 + let auth_res = client 1377 + .get(format!("{}/oauth/authorize", url)) 1378 + .query(&[("request_uri", request_uri)]) 1379 + .send() 1380 + .await 1381 + .unwrap(); 1382 + assert!( 1383 + auth_res.status().is_redirection(), 1384 + "Should redirect when prompt=create" 1385 + ); 1386 + let location = auth_res 1387 + .headers() 1388 + .get("location") 1389 + .expect("Should have Location header") 1390 + .to_str() 1391 + .unwrap(); 1392 + assert!( 1393 + location.contains("/app/oauth/register"), 1394 + "Should redirect to /app/oauth/register, got: {}", 1395 + location 1396 + ); 1397 + assert!( 1398 + location.contains("request_uri="), 1399 + "Should include request_uri in redirect" 1400 + ); 1401 + } 1402 + 1403 + #[tokio::test] 1404 + async fn test_register_complete_rejects_invalid_request_uri() { 1405 + let url = base_url().await; 1406 + let client = client(); 1407 + let res = client 1408 + .post(format!("{}/oauth/register/complete", url)) 1409 + .json(&json!({ 1410 + "request_uri": "urn:ietf:params:oauth:request_uri:nonexistent", 1411 + "did": "did:plc:test123", 1412 + "app_password": "test-password" 1413 + })) 1414 + .send() 1415 + .await 1416 + .unwrap(); 1417 + assert_eq!( 1418 + res.status(), 1419 + StatusCode::BAD_REQUEST, 1420 + "Should reject invalid request_uri" 1421 + ); 1422 + let body: Value = res.json().await.unwrap(); 1423 + assert_eq!(body["error"], "invalid_request"); 1424 + } 1425 + 1426 + #[tokio::test] 1427 + async fn test_register_complete_rejects_wrong_credentials() { 1428 + let url = base_url().await; 1429 + let http_client = client(); 1430 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 1431 + let handle = format!("rc{}", suffix); 1432 + let email = format!("rc{}@example.com", suffix); 1433 + let password = "Regcomplete123!"; 1434 + let create_res = http_client 1435 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1436 + .json(&json!({ "handle": handle, "email": email, "password": password })) 1437 + .send() 1438 + .await 1439 + .unwrap(); 1440 + assert_eq!(create_res.status(), StatusCode::OK); 1441 + let account: Value = create_res.json().await.unwrap(); 1442 + let user_did = account["did"].as_str().unwrap(); 1443 + verify_new_account(&http_client, user_did).await; 1444 + let redirect_uri = "https://example.com/callback"; 1445 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1446 + let client_id = mock_client.uri(); 1447 + let (_, code_challenge) = generate_pkce(); 1448 + let par_res = http_client 1449 + .post(format!("{}/oauth/par", url)) 1450 + .form(&[ 1451 + ("response_type", "code"), 1452 + ("client_id", &client_id), 1453 + ("redirect_uri", redirect_uri), 1454 + ("code_challenge", &code_challenge), 1455 + ("code_challenge_method", "S256"), 1456 + ("scope", "atproto"), 1457 + ("state", "test-state"), 1458 + ("prompt", "create"), 1459 + ]) 1460 + .send() 1461 + .await 1462 + .unwrap(); 1463 + let par_body: Value = par_res.json().await.unwrap(); 1464 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1465 + let res = http_client 1466 + .post(format!("{}/oauth/register/complete", url)) 1467 + .json(&json!({ 1468 + "request_uri": request_uri, 1469 + "did": user_did, 1470 + "app_password": "wrong-password" 1471 + })) 1472 + .send() 1473 + .await 1474 + .unwrap(); 1475 + assert_eq!( 1476 + res.status(), 1477 + StatusCode::FORBIDDEN, 1478 + "Should reject wrong credentials" 1479 + ); 1480 + let body: Value = res.json().await.unwrap(); 1481 + assert_eq!(body["error"], "access_denied"); 1482 + } 1483 + 1484 + #[tokio::test] 1485 + async fn test_full_oauth_registration_flow() { 1486 + let url = base_url().await; 1487 + let http_client = client(); 1488 + 1489 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 1490 + let handle = format!("oauthreg{}", suffix); 1491 + let email = format!("oauthreg{}@example.com", suffix); 1492 + let password = "OauthRegTest123!"; 1493 + 1494 + let redirect_uri = "https://example.com/callback"; 1495 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1496 + let client_id = mock_client.uri(); 1497 + let (code_verifier, code_challenge) = generate_pkce(); 1498 + let state = format!("state-{}", suffix); 1499 + 1500 + let par_res = http_client 1501 + .post(format!("{}/oauth/par", url)) 1502 + .form(&[ 1503 + ("response_type", "code"), 1504 + ("client_id", &client_id), 1505 + ("redirect_uri", redirect_uri), 1506 + ("code_challenge", &code_challenge), 1507 + ("code_challenge_method", "S256"), 1508 + ("scope", "atproto"), 1509 + ("state", &state), 1510 + ("prompt", "create"), 1511 + ]) 1512 + .send() 1513 + .await 1514 + .unwrap(); 1515 + assert_eq!( 1516 + par_res.status(), 1517 + StatusCode::CREATED, 1518 + "PAR with prompt=create should succeed" 1519 + ); 1520 + let par_body: Value = par_res.json().await.unwrap(); 1521 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1522 + 1523 + let create_res = http_client 1524 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 1525 + .json(&json!({ "handle": handle, "email": email, "password": password })) 1526 + .send() 1527 + .await 1528 + .unwrap(); 1529 + assert_eq!( 1530 + create_res.status(), 1531 + StatusCode::OK, 1532 + "Account creation should succeed" 1533 + ); 1534 + let account: Value = create_res.json().await.unwrap(); 1535 + let user_did = account["did"].as_str().unwrap(); 1536 + let access_jwt = account["accessJwt"].as_str().unwrap(); 1537 + 1538 + let app_password_res = http_client 1539 + .post(format!( 1540 + "{}/xrpc/com.atproto.server.createAppPassword", 1541 + url 1542 + )) 1543 + .header("Authorization", format!("Bearer {}", access_jwt)) 1544 + .json(&json!({ "name": "oauth-test-app" })) 1545 + .send() 1546 + .await 1547 + .unwrap(); 1548 + assert_eq!( 1549 + app_password_res.status(), 1550 + StatusCode::OK, 1551 + "App password creation should succeed" 1552 + ); 1553 + let app_password_body: Value = app_password_res.json().await.unwrap(); 1554 + let app_password = app_password_body["password"].as_str().unwrap(); 1555 + 1556 + verify_new_account(&http_client, user_did).await; 1557 + 1558 + let complete_res = http_client 1559 + .post(format!("{}/oauth/register/complete", url)) 1560 + .json(&json!({ 1561 + "request_uri": request_uri, 1562 + "did": user_did, 1563 + "app_password": app_password 1564 + })) 1565 + .send() 1566 + .await 1567 + .unwrap(); 1568 + assert_eq!( 1569 + complete_res.status(), 1570 + StatusCode::OK, 1571 + "register_complete should succeed" 1572 + ); 1573 + let complete_body: Value = complete_res.json().await.unwrap(); 1574 + let mut redirect_location = complete_body["redirect_uri"] 1575 + .as_str() 1576 + .expect("Expected redirect_uri from register_complete") 1577 + .to_string(); 1578 + 1579 + if redirect_location.contains("/oauth/consent") { 1580 + let consent_res = http_client 1581 + .post(format!("{}/oauth/authorize/consent", url)) 1582 + .header("Content-Type", "application/json") 1583 + .json(&json!({ 1584 + "request_uri": request_uri, 1585 + "approved_scopes": ["atproto"], 1586 + "remember": false 1587 + })) 1588 + .send() 1589 + .await 1590 + .unwrap(); 1591 + assert_eq!( 1592 + consent_res.status(), 1593 + StatusCode::OK, 1594 + "Consent should succeed" 1595 + ); 1596 + let consent_body: Value = consent_res.json().await.unwrap(); 1597 + redirect_location = consent_body["redirect_uri"] 1598 + .as_str() 1599 + .expect("Expected redirect_uri from consent") 1600 + .to_string(); 1601 + } 1602 + 1603 + assert!( 1604 + redirect_location.contains("code="), 1605 + "Should have authorization code in redirect: {}", 1606 + redirect_location 1607 + ); 1608 + 1609 + let code = redirect_location 1610 + .split("code=") 1611 + .nth(1) 1612 + .unwrap() 1613 + .split('&') 1614 + .next() 1615 + .unwrap(); 1616 + 1617 + let token_res = http_client 1618 + .post(format!("{}/oauth/token", url)) 1619 + .form(&[ 1620 + ("grant_type", "authorization_code"), 1621 + ("code", code), 1622 + ("redirect_uri", redirect_uri), 1623 + ("code_verifier", &code_verifier), 1624 + ("client_id", &client_id), 1625 + ]) 1626 + .send() 1627 + .await 1628 + .unwrap(); 1629 + assert_eq!( 1630 + token_res.status(), 1631 + StatusCode::OK, 1632 + "Token exchange should succeed" 1633 + ); 1634 + let token_body: Value = token_res.json().await.unwrap(); 1635 + assert!( 1636 + token_body["access_token"].is_string(), 1637 + "Should have access_token" 1638 + ); 1639 + assert!( 1640 + token_body["refresh_token"].is_string(), 1641 + "Should have refresh_token" 1642 + ); 1643 + assert_eq!(token_body["token_type"], "Bearer"); 1644 + assert_eq!( 1645 + token_body["sub"], user_did, 1646 + "Token sub should match user DID" 1647 + ); 1648 + }
+1 -1
crates/tranquil-pds/tests/sso.rs
··· 1039 1039 1040 1040 let redirect_url = body["redirectUrl"].as_str().unwrap(); 1041 1041 assert!( 1042 - redirect_url.contains("/app/oauth/verify"), 1042 + redirect_url.contains("/app/verify"), 1043 1043 "Non-auto-verified channel should redirect to verify, got: {}", 1044 1044 redirect_url 1045 1045 );
+9 -3
frontend/public/homepage.html
··· 439 439 <div class="actions" id="heroActions"> 440 440 <a href="/app/register" class="btn primary" id="heroPrimary" 441 441 >Join This Server</a> 442 + <a href="/app/login" class="btn secondary" id="heroLogin">Login</a> 442 443 <a 443 444 href="https://tangled.org/tranquil.farm/tranquil-pds" 444 445 class="btn secondary" ··· 461 462 <div class="feature"> 462 463 <h3>Real security</h3> 463 464 <p> 464 - Sign in with passkeys or SSO, add two-factor authentication, 465 - set up backup codes, and mark devices you trust. Your account 466 - stays yours. 465 + Sign in with passkeys or SSO, add two-factor authentication, set 466 + up backup codes, and mark devices you trust. Your account stays 467 + yours. 467 468 </p> 468 469 </div> 469 470 ··· 545 546 <div class="actions" id="footerActions"> 546 547 <a href="/app/register" class="btn primary" id="footerPrimary" 547 548 >Join This Server</a> 549 + <a href="/app/login" class="btn secondary" id="footerLogin">Login</a> 548 550 <a 549 551 href="https://tangled.org/tranquil.farm/tranquil-pds" 550 552 class="btn secondary" ··· 585 587 footerPrimary.href = "/app/dashboard"; 586 588 footerPrimary.textContent = handle; 587 589 } 590 + var heroLogin = document.getElementById("heroLogin"); 591 + var footerLogin = document.getElementById("footerLogin"); 592 + if (heroLogin) heroLogin.classList.add("hidden"); 593 + if (footerLogin) footerLogin.classList.add("hidden"); 588 594 if (heroSecondary) { 589 595 heroSecondary.classList.add("hidden"); 590 596 }
+12 -10
frontend/src/App.svelte
··· 6 6 import { isLoading as i18nLoading } from 'svelte-i18n' 7 7 import Toast from './components/Toast.svelte' 8 8 import Login from './routes/Login.svelte' 9 - import Register from './routes/Register.svelte' 10 - import RegisterPasskey from './routes/RegisterPasskey.svelte' 11 9 import RegisterSso from './routes/RegisterSso.svelte' 12 10 import Verify from './routes/Verify.svelte' 13 11 import ResetPassword from './routes/ResetPassword.svelte' ··· 29 27 import OAuthPasskey from './routes/OAuthPasskey.svelte' 30 28 import OAuthDelegation from './routes/OAuthDelegation.svelte' 31 29 import OAuthError from './routes/OAuthError.svelte' 32 - import OAuthSsoRegister from './routes/OAuthSsoRegister.svelte' 30 + import SsoRegisterComplete from './routes/SsoRegisterComplete.svelte' 31 + import Register from './routes/Register.svelte' 32 + import RegisterPassword from './routes/RegisterPassword.svelte' 33 33 import Security from './routes/Security.svelte' 34 34 import TrustedDevices from './routes/TrustedDevices.svelte' 35 35 import Controllers from './routes/Controllers.svelte' ··· 98 98 switch (path) { 99 99 case '/login': 100 100 return Login 101 - case '/register': 102 - return RegisterPasskey 103 - case '/register-password': 104 - return Register 105 - case '/register-sso': 106 - return RegisterSso 107 101 case '/verify': 108 102 return Verify 109 103 case '/reset-password': ··· 145 139 case '/oauth/error': 146 140 return OAuthError 147 141 case '/oauth/sso-register': 148 - return OAuthSsoRegister 142 + return SsoRegisterComplete 143 + case '/register': 144 + case '/oauth/register': 145 + return Register 146 + case '/oauth/register-sso': 147 + return RegisterSso 148 + case '/oauth/register-password': 149 + return RegisterPassword 149 150 case '/security': 150 151 return Security 151 152 case '/trusted-devices': ··· 167 168 168 169 let currentPath = $derived(getCurrentPath()) 169 170 let CurrentComponent = $derived(getComponent(currentPath)) 171 + 170 172 </script> 171 173 172 174 <main>
+14 -4
frontend/src/components/AccountTypeSwitcher.svelte
··· 6 6 interface Props { 7 7 active: 'passkey' | 'password' | 'sso' 8 8 ssoAvailable?: boolean 9 + oauthRequestUri?: string | null 9 10 } 10 11 11 - let { active, ssoAvailable = true }: Props = $props() 12 + let { active, ssoAvailable = true, oauthRequestUri = null }: Props = $props() 13 + 14 + function buildOauthUrl(route: string): string { 15 + const url = getFullUrl(route) 16 + return oauthRequestUri ? `${url}?request_uri=${encodeURIComponent(oauthRequestUri)}` : url 17 + } 18 + 19 + const passkeyUrl = $derived(buildOauthUrl(routes.oauthRegister)) 20 + const passwordUrl = $derived(buildOauthUrl(routes.oauthRegisterPassword)) 21 + const ssoUrl = $derived(buildOauthUrl(routes.oauthRegisterSso)) 12 22 </script> 13 23 14 24 <div class="account-type-switcher"> 15 - <a href={getFullUrl(routes.register)} class="switcher-option" class:active={active === 'passkey'}> 25 + <a href={passkeyUrl} class="switcher-option" class:active={active === 'passkey'}> 16 26 {$_('register.passkeyAccount')} 17 27 </a> 18 - <a href={getFullUrl(routes.registerPassword)} class="switcher-option" class:active={active === 'password'}> 28 + <a href={passwordUrl} class="switcher-option" class:active={active === 'password'}> 19 29 {$_('register.passwordAccount')} 20 30 </a> 21 31 {#if ssoAvailable || active === 'sso'} 22 - <a href={getFullUrl(routes.registerSso)} class="switcher-option" class:active={active === 'sso'}> 32 + <a href={ssoUrl} class="switcher-option" class:active={active === 'sso'}> 23 33 {$_('register.ssoAccount')} 24 34 </a> 25 35 {:else}
+30 -90
frontend/src/components/ReauthModal.svelte
··· 186 186 <div class="modal-content"> 187 187 {#if activeMethod === 'password'} 188 188 <form onsubmit={handlePasswordSubmit}> 189 - <div class="form-group"> 189 + <div class="field"> 190 190 <label for="reauth-password">{$_('reauth.password')}</label> 191 191 <input 192 192 id="reauth-password" ··· 196 196 autocomplete="current-password" 197 197 /> 198 198 </div> 199 - <button type="submit" class="btn-primary" disabled={loading || !password}> 199 + <button type="submit" disabled={loading || !password}> 200 200 {loading ? $_('common.verifying') : $_('common.verify')} 201 201 </button> 202 202 </form> 203 203 {:else if activeMethod === 'totp'} 204 204 <form onsubmit={handleTotpSubmit}> 205 - <div class="form-group"> 205 + <div class="field"> 206 206 <label for="reauth-totp">{$_('reauth.authenticatorCode')}</label> 207 207 <input 208 208 id="reauth-totp" ··· 215 215 maxlength="6" 216 216 /> 217 217 </div> 218 - <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 218 + <button type="submit" disabled={loading || !totpCode}> 219 219 {loading ? $_('common.verifying') : $_('common.verify')} 220 220 </button> 221 221 </form> 222 222 {:else if activeMethod === 'passkey'} 223 223 <div class="passkey-auth"> 224 224 <p>{$_('reauth.passkeyPrompt')}</p> 225 - <button 226 - class="btn-primary" 227 - onclick={handlePasskeyAuth} 228 - disabled={loading} 229 - > 225 + <button onclick={handlePasskeyAuth} disabled={loading}> 230 226 {loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')} 231 227 </button> 232 228 </div> ··· 234 230 </div> 235 231 236 232 <div class="modal-footer"> 237 - <button class="btn-secondary" onclick={handleClose} disabled={loading}> 233 + <button class="secondary" onclick={handleClose} disabled={loading}> 238 234 {$_('reauth.cancel')} 239 235 </button> 240 236 </div> ··· 246 242 .modal-backdrop { 247 243 position: fixed; 248 244 inset: 0; 249 - background: rgba(0, 0, 0, 0.5); 245 + background: var(--overlay-bg); 250 246 display: flex; 251 247 align-items: center; 252 248 justify-content: center; 253 - z-index: 1000; 249 + z-index: var(--z-modal); 254 250 } 255 251 256 252 .modal { 257 253 background: var(--bg-card); 258 - border-radius: 8px; 259 - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 260 - max-width: 400px; 254 + border-radius: var(--radius-xl); 255 + box-shadow: var(--shadow-lg); 256 + max-width: var(--width-sm); 261 257 width: 90%; 262 258 max-height: 90vh; 263 259 overflow-y: auto; ··· 267 263 display: flex; 268 264 justify-content: space-between; 269 265 align-items: center; 270 - padding: 1rem 1.5rem; 266 + padding: var(--space-4) var(--space-6); 271 267 border-bottom: 1px solid var(--border-color); 272 268 } 273 269 274 270 .modal-header h2 { 275 271 margin: 0; 276 - font-size: 1.25rem; 272 + font-size: var(--text-lg); 277 273 } 278 274 279 275 .close-btn { 280 276 background: none; 281 277 border: none; 282 - font-size: 1.5rem; 278 + font-size: var(--text-xl); 283 279 cursor: pointer; 284 280 color: var(--text-secondary); 285 281 padding: 0; ··· 291 287 } 292 288 293 289 .modal-description { 294 - padding: 1rem 1.5rem 0; 290 + padding: var(--space-4) var(--space-6) 0; 295 291 margin: 0; 296 292 color: var(--text-secondary); 297 293 } 298 294 299 295 .error-message { 300 - margin: 1rem 1.5rem 0; 301 - padding: 0.75rem; 296 + margin: var(--space-4) var(--space-6) 0; 297 + padding: var(--space-3); 302 298 background: var(--error-bg); 303 299 border: 1px solid var(--error-border); 304 - border-radius: 4px; 300 + border-radius: var(--radius-md); 305 301 color: var(--error-text); 306 - font-size: 0.875rem; 302 + font-size: var(--text-sm); 307 303 } 308 304 309 305 .method-tabs { 310 306 display: flex; 311 - gap: 0.5rem; 312 - padding: 1rem 1.5rem 0; 307 + gap: var(--space-2); 308 + padding: var(--space-4) var(--space-6) 0; 313 309 } 314 310 315 311 .tab { 316 312 flex: 1; 317 - padding: 0.5rem 1rem; 313 + padding: var(--space-2) var(--space-4); 318 314 background: var(--bg-input); 319 315 border: 1px solid var(--border-color); 320 - border-radius: 4px; 316 + border-radius: var(--radius-md); 321 317 cursor: pointer; 322 318 color: var(--text-secondary); 323 - font-size: 0.875rem; 319 + font-size: var(--text-sm); 324 320 } 325 321 326 322 .tab:hover { ··· 334 330 } 335 331 336 332 .modal-content { 337 - padding: 1.5rem; 338 - } 339 - 340 - .form-group { 341 - margin-bottom: 1rem; 342 - } 343 - 344 - .form-group label { 345 - display: block; 346 - margin-bottom: 0.5rem; 347 - font-weight: 500; 348 - } 349 - 350 - .form-group input { 351 - width: 100%; 352 - padding: 0.75rem; 353 - border: 1px solid var(--border-color); 354 - border-radius: 4px; 355 - background: var(--bg-input); 356 - color: var(--text-primary); 357 - font-size: 1rem; 333 + padding: var(--space-6); 358 334 } 359 335 360 - .form-group input:focus { 361 - outline: none; 362 - border-color: var(--accent); 336 + .modal-content .field { 337 + margin-bottom: var(--space-4); 363 338 } 364 339 365 340 .passkey-auth { ··· 367 342 } 368 343 369 344 .passkey-auth p { 370 - margin-bottom: 1rem; 345 + margin-bottom: var(--space-4); 371 346 color: var(--text-secondary); 372 347 } 373 348 374 - .btn-primary { 349 + .modal-content button:not(.tab) { 375 350 width: 100%; 376 - padding: 0.75rem 1.5rem; 377 - background: var(--accent); 378 - color: var(--text-inverse); 379 - border: none; 380 - border-radius: 4px; 381 - font-size: 1rem; 382 - cursor: pointer; 383 - } 384 - 385 - .btn-primary:hover:not(:disabled) { 386 - background: var(--accent-hover); 387 - } 388 - 389 - .btn-primary:disabled { 390 - opacity: 0.6; 391 - cursor: not-allowed; 392 351 } 393 352 394 353 .modal-footer { 395 - padding: 0 1.5rem 1.5rem; 354 + padding: 0 var(--space-6) var(--space-6); 396 355 display: flex; 397 356 justify-content: flex-end; 398 357 } 399 - 400 - .btn-secondary { 401 - padding: 0.5rem 1rem; 402 - background: var(--bg-input); 403 - border: 1px solid var(--border-color); 404 - border-radius: 4px; 405 - color: var(--text-secondary); 406 - cursor: pointer; 407 - font-size: 0.875rem; 408 - } 409 - 410 - .btn-secondary:hover:not(:disabled) { 411 - background: var(--bg-secondary); 412 - } 413 - 414 - .btn-secondary:disabled { 415 - opacity: 0.6; 416 - cursor: not-allowed; 417 - } 418 358 </style>
-4
frontend/src/components/Skeleton.svelte
··· 70 70 animation: skeleton-pulse 1.5s ease-in-out infinite; 71 71 } 72 72 73 - @keyframes skeleton-pulse { 74 - 0%, 100% { opacity: 1; } 75 - 50% { opacity: 0.4; } 76 - } 77 73 </style>
-3
frontend/src/components/ui/Button.svelte
··· 69 69 animation: spin 0.6s linear infinite; 70 70 } 71 71 72 - @keyframes spin { 73 - to { transform: rotate(360deg); } 74 - } 75 72 </style>
+17
frontend/src/lib/api.ts
··· 310 310 }); 311 311 }, 312 312 313 + checkEmailInUse(email: string): Promise<{ inUse: boolean }> { 314 + return xrpc("_account.checkEmailInUse", { 315 + method: "POST", 316 + body: { email }, 317 + }); 318 + }, 319 + 320 + checkCommsChannelInUse( 321 + channel: "email" | "discord" | "telegram" | "signal", 322 + identifier: string, 323 + ): Promise<{ inUse: boolean }> { 324 + return xrpc("_account.checkCommsChannelInUse", { 325 + method: "POST", 326 + body: { channel, identifier }, 327 + }); 328 + }, 329 + 313 330 async getSession(token: AccessToken): Promise<Session> { 314 331 const raw = await xrpc<unknown>("com.atproto.server.getSession", { token }); 315 332 return castSession(raw);
+1
frontend/src/lib/migration/flow.svelte.ts
··· 915 915 authMethod: "password", 916 916 passkeySetupToken: null, 917 917 oauthCodeVerifier: null, 918 + localAccessToken: null, 918 919 generatedAppPassword: null, 919 920 generatedAppPasswordName: null, 920 921 };
+7 -7
frontend/src/lib/migration/index.ts
··· 1 - export * from "./types"; 2 - export * from "./atproto-client"; 3 - export * from "./storage"; 4 - export * from "./blob-migration"; 1 + export * from "./types.ts"; 2 + export * from "./atproto-client.ts"; 3 + export * from "./storage.ts"; 4 + export * from "./blob-migration.ts"; 5 5 export { 6 6 createInboundMigrationFlow, 7 7 type InboundMigrationFlow, 8 - } from "./flow.svelte"; 8 + } from "./flow.svelte.ts"; 9 9 export { 10 10 clearOfflineState, 11 11 createOfflineInboundMigrationFlow, 12 12 getOfflineResumeInfo, 13 13 hasPendingOfflineMigration, 14 - } from "./offline-flow.svelte"; 15 - export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte"; 14 + } from "./offline-flow.svelte.ts"; 15 + export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte.ts";
+80 -3
frontend/src/lib/oauth.ts
··· 261 261 } 262 262 } 263 263 264 - export async function startOAuthLogin(loginHint?: string): Promise<void> { 264 + async function startOAuthFlow(options?: { 265 + loginHint?: string; 266 + prompt?: string; 267 + }): Promise<void> { 265 268 clearAllOAuthState(); 266 269 267 270 const state = generateState(); ··· 283 286 code_challenge_method: "S256", 284 287 dpop_jkt: dpopJkt, 285 288 }; 286 - if (loginHint) { 287 - parParams.login_hint = loginHint; 289 + if (options?.loginHint) { 290 + parParams.login_hint = options.loginHint; 291 + } 292 + if (options?.prompt) { 293 + parParams.prompt = options.prompt; 288 294 } 289 295 290 296 const parResponse = await fetch("/oauth/par", { ··· 311 317 globalThis.location.href = authorizeUrl.toString(); 312 318 } 313 319 320 + export async function startOAuthLogin(loginHint?: string): Promise<void> { 321 + return startOAuthFlow({ loginHint }); 322 + } 323 + 324 + export async function startOAuthRegister(): Promise<void> { 325 + return startOAuthFlow({ prompt: "create" }); 326 + } 327 + 328 + export async function getOAuthRequestUri(prompt?: string): Promise<string> { 329 + clearAllOAuthState(); 330 + 331 + const state = generateState(); 332 + const codeVerifier = generateCodeVerifier(); 333 + const codeChallenge = await generateCodeChallenge(codeVerifier); 334 + 335 + const keyPair = await getOrCreateDPoPKeyPair(); 336 + const dpopJkt = await computeJwkThumbprint(keyPair.jwk); 337 + 338 + saveOAuthState({ state, codeVerifier }); 339 + 340 + const parParams: Record<string, string> = { 341 + client_id: CLIENT_ID, 342 + redirect_uri: REDIRECT_URI, 343 + response_type: "code", 344 + scope: SCOPES, 345 + state: state, 346 + code_challenge: codeChallenge, 347 + code_challenge_method: "S256", 348 + dpop_jkt: dpopJkt, 349 + }; 350 + if (prompt) { 351 + parParams.prompt = prompt; 352 + } 353 + 354 + const parResponse = await fetch("/oauth/par", { 355 + method: "POST", 356 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 357 + body: new URLSearchParams(parParams), 358 + }); 359 + 360 + if (!parResponse.ok) { 361 + const error = await parResponse.json().catch(() => ({ 362 + error: "Unknown error", 363 + })); 364 + throw new Error( 365 + error.error_description || error.error || "Failed to get request URI", 366 + ); 367 + } 368 + 369 + const { request_uri } = await parResponse.json(); 370 + return request_uri; 371 + } 372 + 373 + export function getRequestUriFromUrl(): string | null { 374 + const params = new URLSearchParams(globalThis.location.search); 375 + return params.get("request_uri"); 376 + } 377 + 378 + export async function ensureRequestUri( 379 + prompt = "create", 380 + ): Promise<string | null> { 381 + const existing = getRequestUriFromUrl(); 382 + if (existing) return existing; 383 + 384 + const newRequestUri = await getOAuthRequestUri(prompt); 385 + const url = new URL(globalThis.location.href); 386 + url.searchParams.set("request_uri", newRequestUri); 387 + globalThis.location.href = url.toString(); 388 + return null; 389 + } 390 + 314 391 export interface OAuthTokens { 315 392 access_token: string; 316 393 refresh_token?: string;
-70
frontend/src/lib/registration/AppPasswordStep.svelte
··· 49 49 </button> 50 50 </div> 51 51 52 - <style> 53 - .app-password-step { 54 - display: flex; 55 - flex-direction: column; 56 - gap: var(--space-4); 57 - } 58 - 59 - .warning-box { 60 - padding: var(--space-5); 61 - background: var(--warning-bg); 62 - border: 1px solid var(--warning-border); 63 - border-radius: var(--radius-lg); 64 - font-size: var(--text-sm); 65 - } 66 - 67 - .warning-box strong { 68 - display: block; 69 - margin-bottom: var(--space-3); 70 - color: var(--warning-text); 71 - } 72 - 73 - .warning-box p { 74 - margin: 0; 75 - color: var(--warning-text); 76 - } 77 - 78 - .app-password-display { 79 - background: var(--bg-card); 80 - border: 2px solid var(--accent); 81 - border-radius: var(--radius-xl); 82 - padding: var(--space-6); 83 - text-align: center; 84 - } 85 - 86 - .app-password-label { 87 - font-size: var(--text-sm); 88 - color: var(--text-secondary); 89 - margin-bottom: var(--space-4); 90 - } 91 - 92 - .app-password-code { 93 - display: block; 94 - font-size: var(--text-xl); 95 - font-family: ui-monospace, monospace; 96 - letter-spacing: 0.1em; 97 - padding: var(--space-5); 98 - background: var(--bg-input); 99 - border-radius: var(--radius-md); 100 - margin-bottom: var(--space-4); 101 - user-select: all; 102 - } 103 - 104 - .copy-btn { 105 - padding: var(--space-3) var(--space-5); 106 - font-size: var(--text-sm); 107 - } 108 - 109 - .checkbox-label { 110 - display: flex; 111 - align-items: center; 112 - gap: var(--space-3); 113 - cursor: pointer; 114 - font-weight: var(--font-normal); 115 - } 116 - 117 - .checkbox-label input[type="checkbox"] { 118 - width: auto; 119 - padding: 0; 120 - } 121 - </style>
+30
frontend/src/lib/registration/VerificationStep.svelte
··· 1 1 <script lang="ts"> 2 + import { onDestroy } from 'svelte' 2 3 import { api, ApiError } from '../api' 3 4 import { resendVerification } from '../auth.svelte' 4 5 import type { RegistrationFlow } from './flow.svelte' ··· 13 14 let resending = $state(false) 14 15 let resendMessage = $state<string | null>(null) 15 16 17 + let pollingInterval: ReturnType<typeof setInterval> | null = null 18 + 19 + $effect(() => { 20 + if (flow.state.step === 'verify' && flow.account && !verificationCode.trim()) { 21 + pollingInterval = setInterval(async () => { 22 + if (verificationCode.trim()) return 23 + const advanced = await flow.checkAndAdvanceIfVerified() 24 + if (advanced && pollingInterval) { 25 + clearInterval(pollingInterval) 26 + pollingInterval = null 27 + } 28 + }, 3000) 29 + } 30 + 31 + return () => { 32 + if (pollingInterval) { 33 + clearInterval(pollingInterval) 34 + pollingInterval = null 35 + } 36 + } 37 + }) 38 + 39 + onDestroy(() => { 40 + if (pollingInterval) { 41 + clearInterval(pollingInterval) 42 + pollingInterval = null 43 + } 44 + }) 45 + 16 46 function channelLabel(ch: string): string { 17 47 switch (ch) { 18 48 case 'email': return 'email'
+108
frontend/src/lib/registration/flow.svelte.ts
··· 34 34 error: string | null; 35 35 submitting: boolean; 36 36 pdsHostname: string; 37 + emailInUse: boolean; 38 + discordInUse: boolean; 39 + telegramInUse: boolean; 40 + signalInUse: boolean; 37 41 } 38 42 39 43 export function createRegistrationFlow( ··· 63 67 error: null, 64 68 submitting: false, 65 69 pdsHostname, 70 + emailInUse: false, 71 + discordInUse: false, 72 + telegramInUse: false, 73 + signalInUse: false, 66 74 }); 67 75 68 76 function getPdsEndpoint(): string { ··· 105 113 } 106 114 } 107 115 116 + async function checkEmailInUse(email: string): Promise<void> { 117 + if (!email.trim() || !email.includes("@")) { 118 + state.emailInUse = false; 119 + return; 120 + } 121 + try { 122 + const result = await api.checkEmailInUse(email.trim()); 123 + state.emailInUse = result.inUse; 124 + } catch { 125 + state.emailInUse = false; 126 + } 127 + } 128 + 129 + async function checkCommsChannelInUse( 130 + channel: "discord" | "telegram" | "signal", 131 + identifier: string, 132 + ): Promise<void> { 133 + const trimmed = identifier.trim(); 134 + if (!trimmed) { 135 + state[`${channel}InUse`] = false; 136 + return; 137 + } 138 + try { 139 + const result = await api.checkCommsChannelInUse(channel, trimmed); 140 + state[`${channel}InUse`] = result.inUse; 141 + } catch { 142 + state[`${channel}InUse`] = false; 143 + } 144 + } 145 + 108 146 function proceedFromInfo() { 109 147 state.error = null; 110 148 if (state.info.didType === "web-external") { ··· 356 394 } 357 395 } 358 396 397 + let checkingVerification = false; 398 + 399 + async function checkAndAdvanceIfVerified(): Promise<boolean> { 400 + if (checkingVerification || !state.account) return false; 401 + 402 + checkingVerification = true; 403 + try { 404 + const result = await api.checkEmailVerified(state.account.did); 405 + if (!result.verified) return false; 406 + 407 + if (state.info.didType === "web-external") { 408 + const password = state.mode === "passkey" 409 + ? state.account.appPassword! 410 + : state.info.password!; 411 + const session = await api.createSession(state.account.did, password); 412 + state.session = { 413 + accessJwt: session.accessJwt, 414 + refreshJwt: session.refreshJwt, 415 + }; 416 + 417 + if (state.externalDidWeb.keyMode === "byod") { 418 + const credentials = await api.getRecommendedDidCredentials( 419 + session.accessJwt, 420 + ); 421 + const newPublicKeyMultibase = 422 + credentials.verificationMethods?.atproto?.replace("did:key:", "") || 423 + ""; 424 + 425 + const didDoc = generateDidDocument( 426 + state.info.externalDid!.trim(), 427 + newPublicKeyMultibase, 428 + state.account.handle, 429 + getPdsEndpoint(), 430 + ); 431 + state.externalDidWeb.updatedDidDocument = JSON.stringify( 432 + didDoc, 433 + null, 434 + "\t", 435 + ); 436 + state.step = "updated-did-doc"; 437 + persistState(); 438 + } else { 439 + await api.activateAccount(session.accessJwt); 440 + await finalizeSession(); 441 + state.step = "redirect-to-dashboard"; 442 + } 443 + } else { 444 + const password = state.mode === "passkey" 445 + ? state.account.appPassword! 446 + : state.info.password!; 447 + const session = await api.createSession(state.account.did, password); 448 + state.session = { 449 + accessJwt: session.accessJwt, 450 + refreshJwt: session.refreshJwt, 451 + }; 452 + await finalizeSession(); 453 + state.step = "redirect-to-dashboard"; 454 + } 455 + 456 + return true; 457 + } catch { 458 + return false; 459 + } finally { 460 + checkingVerification = false; 461 + } 462 + } 463 + 359 464 function goBack() { 360 465 switch (state.step) { 361 466 case "key-choice": ··· 413 518 setPasskeyComplete, 414 519 proceedFromAppPassword, 415 520 verifyAccount, 521 + checkAndAdvanceIfVerified, 416 522 activateAccount, 417 523 finalizeSession, 418 524 goBack, 525 + checkEmailInUse, 526 + checkCommsChannelInUse, 419 527 420 528 setError(msg: string) { 421 529 state.error = msg;
+2 -2
frontend/src/lib/registration/index.ts
··· 1 - export * from "./types"; 2 - export * from "./flow.svelte"; 1 + export * from "./types.ts"; 2 + export * from "./flow.svelte.ts"; 3 3 export { default as VerificationStep } from "./VerificationStep.svelte"; 4 4 export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte"; 5 5 export { default as DidDocStep } from "./DidDocStep.svelte";
+1 -1
frontend/src/lib/serverConfig.svelte.ts
··· 1 - import { api } from "./api"; 1 + import { api } from "./api.ts"; 2 2 3 3 interface ServerConfigState { 4 4 serverName: string | null;
+6 -3
frontend/src/lib/types/routes.ts
··· 1 1 export const routes = { 2 2 login: "/login", 3 - register: "/register", 4 - registerPassword: "/register-password", 5 - registerSso: "/register-sso", 6 3 dashboard: "/dashboard", 7 4 settings: "/settings", 8 5 security: "/security", ··· 31 28 oauthDelegation: "/oauth/delegation", 32 29 oauthError: "/oauth/error", 33 30 oauthSsoRegister: "/oauth/sso-register", 31 + oauthRegister: "/oauth/register", 32 + oauthRegisterSso: "/oauth/register-sso", 33 + oauthRegisterPassword: "/oauth/register-password", 34 34 } as const; 35 35 36 36 export type Route = (typeof routes)[keyof typeof routes]; ··· 55 55 [routes.oauthError]: { error?: string; error_description?: string }; 56 56 [routes.migrate]: { code?: string; state?: string }; 57 57 [routes.oauthSsoRegister]: { token?: string }; 58 + [routes.oauthRegister]: { request_uri?: string }; 59 + [routes.oauthRegisterSso]: { request_uri?: string }; 60 + [routes.oauthRegisterPassword]: { request_uri?: string }; 58 61 } 59 62 60 63 export type RoutesWithParams = keyof RouteParams;
+17 -1
frontend/src/locales/en.json
··· 150 150 "email": "Email", 151 151 "emailAddress": "Email Address", 152 152 "emailPlaceholder": "you@example.com", 153 + "emailInUseWarning": "This email is already associated with another account. You can still use it, but for account recovery you may need to use your handle instead.", 153 154 "discord": "Discord", 154 155 "discordId": "Discord User ID", 155 156 "discordIdPlaceholder": "Your Discord user ID", 156 157 "discordIdHint": "Your numeric Discord user ID (enable Developer Mode to find it)", 158 + "discordInUseWarning": "This Discord ID is already associated with another account.", 157 159 "telegram": "Telegram", 158 160 "telegramUsername": "Telegram Username", 159 161 "telegramUsernamePlaceholder": "@yourusername", 162 + "telegramInUseWarning": "This Telegram username is already associated with another account.", 160 163 "signal": "Signal", 161 164 "signalNumber": "Signal Phone Number", 162 165 "signalNumberPlaceholder": "+1234567890", 163 166 "signalNumberHint": "Include country code (eg., +1 for US)", 167 + "signalInUseWarning": "This Signal number is already associated with another account.", 164 168 "notConfigured": "not configured", 165 169 "inviteCode": "Invite Code", 166 170 "inviteCodePlaceholder": "Enter your invite code", ··· 275 279 "currentEmail": "Current: {email}", 276 280 "newEmail": "New Email", 277 281 "newEmailPlaceholder": "new@example.com", 282 + "emailInUseWarning": "This email is already used by another account. You can still use it, but account recovery may require your handle.", 278 283 "changeEmailButton": "Change Email", 279 284 "requesting": "Requesting...", 280 285 "verificationCode": "Verification Code", ··· 437 442 "noCodes": "No invite codes yet", 438 443 "available": "Available", 439 444 "used": "Used by @{handle}", 445 + "spent": "Spent", 440 446 "disabled": "Disabled", 441 447 "usedBy": "Used by", 442 448 "disableConfirm": "Disable this invite code? It can no longer be used.", ··· 577 583 "hideHistory": "Hide History", 578 584 "noMessages": "No messages found.", 579 585 "sent": "sent", 580 - "failed": "failed" 586 + "failed": "failed", 587 + "discordInUseWarning": "This Discord ID is already associated with another account.", 588 + "telegramInUseWarning": "This Telegram username is already associated with another account.", 589 + "signalInUseWarning": "This Signal number is already associated with another account." 581 590 }, 582 591 "repoExplorer": { 583 592 "title": "Repository Explorer", ··· 777 786 "subtitle": "Select an account to continue", 778 787 "useAnother": "Use a different account" 779 788 }, 789 + "register": { 790 + "title": "Create Account", 791 + "subtitle": "Create an account to continue to", 792 + "subtitleGeneric": "Create an account to continue", 793 + "haveAccount": "Already have an account? Sign in" 794 + }, 780 795 "twoFactor": { 781 796 "title": "Two-Factor Authentication", 782 797 "subtitle": "Additional verification is required", ··· 887 902 "sendCode": "Send Reset Code", 888 903 "sending": "Sending...", 889 904 "codeSent": "Password reset code sent! Check your preferred notification channel.", 905 + "multipleAccountsWarning": "Multiple accounts share this email. The reset code was sent to the most recently created account. Use your handle instead for a specific account.", 890 906 "enterCode": "Enter the code you received and your new password.", 891 907 "code": "Reset Code", 892 908 "codePlaceholder": "Enter reset code",
+24 -2
frontend/src/locales/fi.json
··· 150 150 "email": "Sรคhkรถposti", 151 151 "emailAddress": "Sรคhkรถpostiosoite", 152 152 "emailPlaceholder": "sinรค@esimerkki.fi", 153 + "emailInUseWarning": "Tรคmรค sรคhkรถposti on jo yhdistetty toiseen tiliin. Voit silti kรคyttรครค sitรค, mutta tilin palauttamiseen saatat joutua kรคyttรคmรครคn kรคsittelynimeรคsi.", 153 154 "discord": "Discord", 154 155 "discordId": "Discord-kรคyttรคjรคtunnus", 155 156 "discordIdPlaceholder": "Discord-kรคyttรคjรคtunnuksesi", 156 157 "discordIdHint": "Numeerinen Discord-kรคyttรคjรคtunnuksesi (ota Kehittรคjรคtila kรคyttรถรถn lรถytรครคksesi sen)", 158 + "discordInUseWarning": "Tรคmรค Discord-tunnus on jo yhdistetty toiseen tiliin.", 157 159 "telegram": "Telegram", 158 160 "telegramUsername": "Telegram-kรคyttรคjรคnimi", 159 161 "telegramUsernamePlaceholder": "@kรคyttรคjรคnimesi", 162 + "telegramInUseWarning": "Tรคmรค Telegram-kรคyttรคjรคnimi on jo yhdistetty toiseen tiliin.", 160 163 "signal": "Signal", 161 164 "signalNumber": "Signal-puhelinnumero", 162 165 "signalNumberPlaceholder": "+358401234567", 163 166 "signalNumberHint": "Sisรคllytรค maakoodi (esim. +358 Suomelle)", 167 + "signalInUseWarning": "Tรคmรค Signal-numero on jo yhdistetty toiseen tiliin.", 164 168 "notConfigured": "ei mรครคritetty", 165 169 "inviteCode": "Kutsukoodi", 166 170 "inviteCodePlaceholder": "Syรถtรค kutsukoodisi", ··· 275 279 "currentEmail": "Nykyinen: {email}", 276 280 "newEmail": "Uusi sรคhkรถposti", 277 281 "newEmailPlaceholder": "uusi@esimerkki.fi", 282 + "emailInUseWarning": "Tรคmรค sรคhkรถposti on jo toisen tilin kรคytรถssรค. Voit silti kรคyttรครค sitรค, mutta tilin palauttaminen voi vaatia kรคsittelynimeรคsi.", 278 283 "changeEmailButton": "Vaihda sรคhkรถposti", 279 284 "requesting": "Pyydetรครคn...", 280 285 "verificationCode": "Vahvistuskoodi", ··· 437 442 "noCodes": "Ei vielรค kutsukoodeja", 438 443 "available": "Saatavilla", 439 444 "used": "Kรคyttรคnyt @{handle}", 445 + "spent": "Kรคytetty", 440 446 "disabled": "Poistettu kรคytรถstรค", 441 447 "usedBy": "Kรคyttรคnyt", 442 448 "disableConfirm": "Poista tรคmรค kutsukoodi kรคytรถstรค? Sitรค ei voi enรครค kรคyttรครค.", ··· 577 583 "hideHistory": "Piilota historia", 578 584 "noMessages": "Viestejรค ei lรถytynyt.", 579 585 "sent": "lรคhetetty", 580 - "failed": "epรคonnistui" 586 + "failed": "epรคonnistui", 587 + "discordInUseWarning": "Tรคmรค Discord-tunnus on jo yhdistetty toiseen tiliin.", 588 + "telegramInUseWarning": "Tรคmรค Telegram-kรคyttรคjรคnimi on jo yhdistetty toiseen tiliin.", 589 + "signalInUseWarning": "Tรคmรค Signal-numero on jo yhdistetty toiseen tiliin." 581 590 }, 582 591 "repoExplorer": { 583 592 "title": "Tietovarastoselaaja", ··· 696 705 "orContinueWith": "Tai jatka kรคyttรคen", 697 706 "orUseCredentials": "Tai kirjaudu tunnuksilla" 698 707 }, 708 + "register": { 709 + "title": "Luo tili", 710 + "subtitle": "Luo tili jatkaaksesi sovellukseen", 711 + "subtitleGeneric": "Luo tili jatkaaksesi", 712 + "haveAccount": "Onko sinulla jo tili? Kirjaudu sisรครคn" 713 + }, 699 714 "sso": { 700 715 "linkedAccounts": "Linkitetyt tilit", 701 716 "linkedAccountsDesc": "Ulkoiset tilit, jotka on linkitetty identiteettiisi kertakirjautumista varten.", ··· 829 844 "error_expired": "Rekisterรถintisessio on vanhentunut. Yritรค uudelleen.", 830 845 "error_handle_required": "Valitse kรคsittelynimi", 831 846 "emailVerifiedByProvider": "Tรคmรค sรคhkรถposti on vahvistettu {provider} kautta. Lisรคvahvistusta ei tarvita.", 832 - "emailChangedNeedsVerification": "Jos kรคytรคt eri sรคhkรถpostia, sinun tรคytyy vahvistaa se." 847 + "emailChangedNeedsVerification": "Jos kรคytรคt eri sรคhkรถpostia, sinun tรคytyy vahvistaa se.", 848 + "infoAfterTitle": "Tilin luomisen jรคlkeen", 849 + "infoAddPassword": "Lisรครค salasana perinteistรค kirjautumista varten", 850 + "infoAddPasskey": "Mรครคritรค pรครคsyavain salasanattomaan kirjautumiseen", 851 + "infoLinkProviders": "Linkitรค lisรครค SSO-palveluntarjoajia", 852 + "infoChangeHandle": "Vaihda kรคsittelynimesi tai kรคytรค omaa verkkotunnusta", 853 + "tryAgain": "Yritรค uudelleen" 833 854 }, 834 855 "verify": { 835 856 "title": "Vahvista tilisi", ··· 881 902 "sendCode": "Lรคhetรค palautuskoodi", 882 903 "sending": "Lรคhetetรครคn...", 883 904 "codeSent": "Palautuskoodi lรคhetetty! Tarkista ensisijainen ilmoituskanavasi.", 905 + "multipleAccountsWarning": "Useampi tili kรคyttรครค tรคtรค sรคhkรถpostia. Palautuskoodi lรคhetettiin viimeksi luodulle tilille. Kรคytรค kรคsittelynimeรคsi tietylle tilille.", 884 906 "enterCode": "Syรถtรค saamasi koodi ja uusi salasanasi.", 885 907 "code": "Palautuskoodi", 886 908 "codePlaceholder": "Syรถtรค palautuskoodi",
+24 -2
frontend/src/locales/ja.json
··· 143 143 "email": "ใƒกใƒผใƒซ", 144 144 "emailAddress": "ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚น", 145 145 "emailPlaceholder": "you@example.com", 146 + "emailInUseWarning": "ใ“ใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚ๅผ•ใ็ถšใไฝฟ็”จใงใใพใ™ใŒใ€ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ›žๅพฉใซใฏใƒใƒณใƒ‰ใƒซใŒๅฟ…่ฆใซใชใ‚‹ๅ ดๅˆใŒใ‚ใ‚Šใพใ™ใ€‚", 146 147 "discord": "Discord", 147 148 "discordId": "Discord ใƒฆใƒผใ‚ถใƒผ ID", 148 149 "discordIdPlaceholder": "Discord ใƒฆใƒผใ‚ถใƒผ ID", 149 150 "discordIdHint": "ๆ•ฐๅ€คใฎ Discord ใƒฆใƒผใ‚ถใƒผ ID๏ผˆ้–‹็™บ่€…ใƒขใƒผใƒ‰ใ‚’ๆœ‰ๅŠนใซใ—ใฆ็ขบ่ช๏ผ‰", 151 + "discordInUseWarning": "ใ“ใฎ Discord ID ใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚", 150 152 "telegram": "Telegram", 151 153 "telegramUsername": "Telegram ใƒฆใƒผใ‚ถใƒผๅ", 152 154 "telegramUsernamePlaceholder": "@yourusername", 155 + "telegramInUseWarning": "ใ“ใฎ Telegram ใƒฆใƒผใ‚ถใƒผๅใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚", 153 156 "signal": "Signal", 154 157 "signalNumber": "Signal ้›ป่ฉฑ็•ชๅท", 155 158 "signalNumberPlaceholder": "+81XXXXXXXXXX", 156 159 "signalNumberHint": "ๅ›ฝ็•ชๅทใ‚’ๅซใ‚ใฆใใ ใ•ใ„๏ผˆไพ‹: ๆ—ฅๆœฌใฏ +81๏ผ‰", 160 + "signalInUseWarning": "ใ“ใฎ Signal ็•ชๅทใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚", 157 161 "notConfigured": "ๆœช่จญๅฎš", 158 162 "inviteCode": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰", 159 163 "inviteCodePlaceholder": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", ··· 268 272 "currentEmail": "็พๅœจ: {email}", 269 273 "newEmail": "ๆ–ฐใ—ใ„ใƒกใƒผใƒซ", 270 274 "newEmailPlaceholder": "new@example.com", 275 + "emailInUseWarning": "ใ“ใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใงไฝฟ็”จใ•ใ‚Œใฆใ„ใพใ™ใ€‚ๅผ•ใ็ถšใไฝฟ็”จใงใใพใ™ใŒใ€ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ›žๅพฉใซใฏใƒใƒณใƒ‰ใƒซใŒๅฟ…่ฆใซใชใ‚‹ๅ ดๅˆใŒใ‚ใ‚Šใพใ™ใ€‚", 271 276 "changeEmailButton": "ใƒกใƒผใƒซใ‚’ๅค‰ๆ›ด", 272 277 "requesting": "ใƒชใ‚ฏใ‚จใ‚นใƒˆไธญ...", 273 278 "verificationCode": "็ขบ่ชใ‚ณใƒผใƒ‰", ··· 430 435 "noCodes": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใฏใพใ ใ‚ใ‚Šใพใ›ใ‚“", 431 436 "available": "ๅˆฉ็”จๅฏ่ƒฝ", 432 437 "used": "@{handle} ใŒไฝฟ็”จๆธˆใฟ", 438 + "spent": "ไฝฟ็”จๆธˆใฟ", 433 439 "disabled": "็„กๅŠน", 434 440 "usedBy": "ไฝฟ็”จ่€…", 435 441 "disableConfirm": "ใ“ใฎๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’็„กๅŠนใซใ—ใพใ™ใ‹๏ผŸไฝฟ็”จใงใใชใใชใ‚Šใพใ™ใ€‚", ··· 570 576 "hideHistory": "ๅฑฅๆญดใ‚’้š ใ™", 571 577 "noMessages": "ใƒกใƒƒใ‚ปใƒผใ‚ธใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ใ€‚", 572 578 "sent": "้€ไฟกๆธˆใฟ", 573 - "failed": "ๅคฑๆ•—" 579 + "failed": "ๅคฑๆ•—", 580 + "discordInUseWarning": "ใ“ใฎ Discord ID ใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚", 581 + "telegramInUseWarning": "ใ“ใฎ Telegram ใƒฆใƒผใ‚ถใƒผๅใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚", 582 + "signalInUseWarning": "ใ“ใฎ Signal ็•ชๅทใฏๆ—ขใซๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้–ข้€ฃไป˜ใ‘ใ‚‰ใ‚Œใฆใ„ใพใ™ใ€‚" 574 583 }, 575 584 "repoExplorer": { 576 585 "title": "ใƒชใƒใ‚ธใƒˆใƒชใ‚จใ‚ฏใ‚นใƒ—ใƒญใƒผใƒฉใƒผ", ··· 689 698 "orContinueWith": "ใพใŸใฏๆฌกใฎๆ–นๆณ•ใง็ถš่กŒ", 690 699 "orUseCredentials": "ใพใŸใฏ่ช่จผๆƒ…ๅ ฑใงใ‚ตใ‚คใƒณใ‚คใƒณ" 691 700 }, 701 + "register": { 702 + "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆไฝœๆˆ", 703 + "subtitle": "็ถš่กŒใ™ใ‚‹ใซใฏใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ—ใฆใใ ใ•ใ„", 704 + "subtitleGeneric": "็ถš่กŒใ™ใ‚‹ใซใฏใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ—ใฆใใ ใ•ใ„", 705 + "haveAccount": "ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใŠๆŒใกใงใ™ใ‹๏ผŸใ‚ตใ‚คใƒณใ‚คใƒณ" 706 + }, 692 707 "sso": { 693 708 "linkedAccounts": "้€ฃๆบใ‚ขใ‚ซใ‚ฆใƒณใƒˆ", 694 709 "linkedAccountsDesc": "ใ‚ทใƒณใ‚ฐใƒซใ‚ตใ‚คใƒณใ‚ชใƒณ็”จใซ้€ฃๆบใ•ใ‚ŒใŸๅค–้ƒจใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ€‚", ··· 822 837 "error_expired": "็™ป้Œฒใ‚ปใƒƒใ‚ทใƒงใƒณใŒๆœŸ้™ๅˆ‡ใ‚Œใงใ™ใ€‚ใ‚‚ใ†ไธ€ๅบฆใŠ่ฉฆใ—ใใ ใ•ใ„ใ€‚", 823 838 "error_handle_required": "ใƒใƒณใƒ‰ใƒซใ‚’้ธๆŠžใ—ใฆใใ ใ•ใ„", 824 839 "emailVerifiedByProvider": "ใ“ใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใฏ{provider}ใง็ขบ่ชๆธˆใฟใงใ™ใ€‚่ฟฝๅŠ ใฎ็ขบ่ชใฏไธ่ฆใงใ™ใ€‚", 825 - "emailChangedNeedsVerification": "ๅˆฅใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใ‚’ไฝฟ็”จใ™ใ‚‹ๅ ดๅˆใฏใ€็ขบ่ชใŒๅฟ…่ฆใงใ™ใ€‚" 840 + "emailChangedNeedsVerification": "ๅˆฅใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใ‚’ไฝฟ็”จใ™ใ‚‹ๅ ดๅˆใฏใ€็ขบ่ชใŒๅฟ…่ฆใงใ™ใ€‚", 841 + "infoAfterTitle": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆไฝœๆˆๅพŒ", 842 + "infoAddPassword": "ๅพ“ๆฅใฎใƒญใ‚ฐใ‚คใƒณ็”จใซใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’่ฟฝๅŠ ", 843 + "infoAddPasskey": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใƒฌใ‚นใ‚ตใ‚คใƒณใ‚คใƒณ็”จใซใƒ‘ใ‚นใ‚ญใƒผใ‚’่จญๅฎš", 844 + "infoLinkProviders": "่ฟฝๅŠ ใฎSSOใƒ—ใƒญใƒใ‚คใƒ€ใƒผใ‚’้€ฃๆบ", 845 + "infoChangeHandle": "ใƒใƒณใƒ‰ใƒซใฎๅค‰ๆ›ดใพใŸใฏใ‚ซใ‚นใ‚ฟใƒ ใƒ‰ใƒกใ‚คใƒณใฎไฝฟ็”จ", 846 + "tryAgain": "ใ‚‚ใ†ไธ€ๅบฆ่ฉฆใ™" 826 847 }, 827 848 "verify": { 828 849 "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็ขบ่ช", ··· 874 895 "sendCode": "ใƒชใ‚ปใƒƒใƒˆใ‚ณใƒผใƒ‰ใ‚’้€ไฟก", 875 896 "sending": "้€ไฟกไธญ...", 876 897 "codeSent": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใƒชใ‚ปใƒƒใƒˆใ‚ณใƒผใƒ‰ใ‚’้€ไฟกใ—ใพใ—ใŸ๏ผๅ„ชๅ…ˆ้€š็Ÿฅใƒใƒฃใƒณใƒใƒซใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 898 + "multipleAccountsWarning": "่ค‡ๆ•ฐใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใŒใ“ใฎใƒกใƒผใƒซใ‚’ๅ…ฑๆœ‰ใ—ใฆใ„ใพใ™ใ€‚ใƒชใ‚ปใƒƒใƒˆใ‚ณใƒผใƒ‰ใฏๆœ€ๅพŒใซไฝœๆˆใ•ใ‚ŒใŸใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซ้€ไฟกใ•ใ‚Œใพใ—ใŸใ€‚็‰นๅฎšใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซใฏใƒใƒณใƒ‰ใƒซใ‚’ไฝฟ็”จใ—ใฆใใ ใ•ใ„ใ€‚", 877 899 "enterCode": "ๅ—ใ‘ๅ–ใฃใŸใ‚ณใƒผใƒ‰ใจๆ–ฐใ—ใ„ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆใใ ใ•ใ„ใ€‚", 878 900 "code": "ใƒชใ‚ปใƒƒใƒˆใ‚ณใƒผใƒ‰", 879 901 "codePlaceholder": "ใƒชใ‚ปใƒƒใƒˆใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›",
+25 -3
frontend/src/locales/ko.json
··· 143 143 "email": "์ด๋ฉ”์ผ", 144 144 "emailAddress": "์ด๋ฉ”์ผ ์ฃผ์†Œ", 145 145 "emailPlaceholder": "you@example.com", 146 + "emailInUseWarning": "์ด ์ด๋ฉ”์ผ์€ ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ณ„์† ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ๊ณ„์ • ๋ณต๊ตฌ ์‹œ ํ•ธ๋“ค์ด ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 146 147 "discord": "Discord", 147 148 "discordId": "Discord ์‚ฌ์šฉ์ž ID", 148 149 "discordIdPlaceholder": "Discord ์‚ฌ์šฉ์ž ID", 149 150 "discordIdHint": "์ˆซ์ž Discord ์‚ฌ์šฉ์ž ID (๊ฐœ๋ฐœ์ž ๋ชจ๋“œ๋ฅผ ํ™œ์„ฑํ™”ํ•˜์—ฌ ์ฐพ๊ธฐ)", 151 + "discordInUseWarning": "์ด Discord ID๋Š” ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", 150 152 "telegram": "Telegram", 151 153 "telegramUsername": "Telegram ์‚ฌ์šฉ์ž ์ด๋ฆ„", 152 154 "telegramUsernamePlaceholder": "@yourusername", 155 + "telegramInUseWarning": "์ด Telegram ์‚ฌ์šฉ์ž ์ด๋ฆ„์€ ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", 153 156 "signal": "Signal", 154 157 "signalNumber": "Signal ์ „ํ™”๋ฒˆํ˜ธ", 155 158 "signalNumberPlaceholder": "+821012345678", 156 159 "signalNumberHint": "๊ตญ๊ฐ€ ์ฝ”๋“œ ํฌํ•จ (์˜ˆ: ํ•œ๊ตญ +82)", 160 + "signalInUseWarning": "์ด Signal ๋ฒˆํ˜ธ๋Š” ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", 157 161 "notConfigured": "๊ตฌ์„ฑ๋˜์ง€ ์•Š์Œ", 158 162 "inviteCode": "์ดˆ๋Œ€ ์ฝ”๋“œ", 159 163 "inviteCodePlaceholder": "์ดˆ๋Œ€ ์ฝ”๋“œ ์ž…๋ ฅ", ··· 269 273 "newEmail": "์ƒˆ ์ด๋ฉ”์ผ", 270 274 "newEmailPlaceholder": "new@example.com", 271 275 "changeEmailButton": "์ด๋ฉ”์ผ ๋ณ€๊ฒฝ", 276 + "emailInUseWarning": "์ด ์ด๋ฉ”์ผ์€ ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๊ณ„์† ์‚ฌ์šฉํ•˜์‹ค ์ˆ˜ ์žˆ์ง€๋งŒ, ๊ณ„์ • ๋ณต๊ตฌ ์‹œ ์ด๋ฉ”์ผ ๋Œ€์‹  ํ•ธ๋“ค์„ ์‚ฌ์šฉํ•ด์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 272 277 "requesting": "์š”์ฒญ ์ค‘...", 273 278 "verificationCode": "์ธ์ฆ ์ฝ”๋“œ", 274 279 "verificationCodePlaceholder": "์ธ์ฆ ์ฝ”๋“œ ์ž…๋ ฅ", ··· 430 435 "noCodes": "์ดˆ๋Œ€ ์ฝ”๋“œ๊ฐ€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค", 431 436 "available": "์‚ฌ์šฉ ๊ฐ€๋Šฅ", 432 437 "used": "@{handle}์ด(๊ฐ€) ์‚ฌ์šฉํ•จ", 438 + "spent": "์†Œ์ง„๋จ", 433 439 "disabled": "๋น„ํ™œ์„ฑํ™”๋จ", 434 440 "usedBy": "์‚ฌ์šฉ์ž", 435 441 "disableConfirm": "์ด ์ดˆ๋Œ€ ์ฝ”๋“œ๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ๋” ์ด์ƒ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", ··· 570 576 "hideHistory": "๊ธฐ๋ก ์ˆจ๊ธฐ๊ธฐ", 571 577 "noMessages": "๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", 572 578 "sent": "์ „์†ก๋จ", 573 - "failed": "์‹คํŒจ" 579 + "failed": "์‹คํŒจ", 580 + "discordInUseWarning": "์ด Discord ID๋Š” ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", 581 + "telegramInUseWarning": "์ด Telegram ์‚ฌ์šฉ์ž ์ด๋ฆ„์€ ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", 582 + "signalInUseWarning": "์ด Signal ๋ฒˆํ˜ธ๋Š” ์ด๋ฏธ ๋‹ค๋ฅธ ๊ณ„์ •๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค." 574 583 }, 575 584 "repoExplorer": { 576 585 "title": "์ €์žฅ์†Œ ํƒ์ƒ‰๊ธฐ", ··· 689 698 "orContinueWith": "๋˜๋Š” ๋‹ค์Œ์œผ๋กœ ๊ณ„์†", 690 699 "orUseCredentials": "๋˜๋Š” ์ž๊ฒฉ ์ฆ๋ช…์œผ๋กœ ๋กœ๊ทธ์ธ" 691 700 }, 701 + "register": { 702 + "title": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 703 + "subtitle": "๊ณ„์†ํ•˜๋ ค๋ฉด ๊ณ„์ •์„ ๋งŒ๋“œ์„ธ์š”", 704 + "subtitleGeneric": "๊ณ„์†ํ•˜๋ ค๋ฉด ๊ณ„์ •์„ ๋งŒ๋“œ์„ธ์š”", 705 + "haveAccount": "์ด๋ฏธ ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”? ๋กœ๊ทธ์ธ" 706 + }, 692 707 "sso": { 693 708 "linkedAccounts": "์—ฐ๊ฒฐ๋œ ๊ณ„์ •", 694 709 "linkedAccountsDesc": "์‹ฑ๊ธ€ ์‚ฌ์ธ์˜จ์„ ์œ„ํ•ด ์—ฐ๊ฒฐ๋œ ์™ธ๋ถ€ ๊ณ„์ •์ž…๋‹ˆ๋‹ค.", ··· 822 837 "error_expired": "๋“ฑ๋ก ์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.", 823 838 "error_handle_required": "ํ•ธ๋“ค์„ ์„ ํƒํ•ด ์ฃผ์„ธ์š”", 824 839 "emailVerifiedByProvider": "์ด ์ด๋ฉ”์ผ์€ {provider}์—์„œ ์ธ์ฆ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ถ”๊ฐ€ ์ธ์ฆ์ด ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", 825 - "emailChangedNeedsVerification": "๋‹ค๋ฅธ ์ด๋ฉ”์ผ์„ ์‚ฌ์šฉํ•˜์‹œ๋ฉด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค." 840 + "emailChangedNeedsVerification": "๋‹ค๋ฅธ ์ด๋ฉ”์ผ์„ ์‚ฌ์šฉํ•˜์‹œ๋ฉด ์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", 841 + "infoAfterTitle": "๊ณ„์ • ์ƒ์„ฑ ํ›„", 842 + "infoAddPassword": "๊ธฐ์กด ๋กœ๊ทธ์ธ์„ ์œ„ํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ถ”๊ฐ€", 843 + "infoAddPasskey": "๋น„๋ฐ€๋ฒˆํ˜ธ ์—†๋Š” ๋กœ๊ทธ์ธ์„ ์œ„ํ•œ ํŒจ์Šคํ‚ค ์„ค์ •", 844 + "infoLinkProviders": "์ถ”๊ฐ€ SSO ์ œ๊ณต์ž ์—ฐ๊ฒฐ", 845 + "infoChangeHandle": "ํ•ธ๋“ค ๋ณ€๊ฒฝ ๋˜๋Š” ์‚ฌ์šฉ์ž ์ •์˜ ๋„๋ฉ”์ธ ์‚ฌ์šฉ", 846 + "tryAgain": "๋‹ค์‹œ ์‹œ๋„" 826 847 }, 827 848 "verify": { 828 849 "title": "๊ณ„์ • ์ธ์ฆ", ··· 886 907 "success": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์žฌ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", 887 908 "requestNewCode": "์ƒˆ ์ฝ”๋“œ ์š”์ฒญ", 888 909 "passwordsMismatch": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", 889 - "passwordLength": "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค" 910 + "passwordLength": "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค", 911 + "multipleAccountsWarning": "์—ฌ๋Ÿฌ ๊ณ„์ •์—์„œ ์ด ์ด๋ฉ”์ผ์„ ๊ณต์œ ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์žฌ์„ค์ • ์ฝ”๋“œ๋Š” ๊ฐ€์žฅ ์ตœ๊ทผ์— ์ƒ์„ฑ๋œ ๊ณ„์ •์œผ๋กœ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํŠน์ • ๊ณ„์ •์„ ๋ณต๊ตฌํ•˜๋ ค๋ฉด ํ•ธ๋“ค์„ ์‚ฌ์šฉํ•˜์„ธ์š”." 890 912 }, 891 913 "recoverPasskey": { 892 914 "title": "๊ณ„์ • ๋ณต๊ตฌ",
+26 -4
frontend/src/locales/sv.json
··· 147 147 "discordId": "Discord anvรคndar-ID", 148 148 "discordIdPlaceholder": "Ditt Discord anvรคndar-ID", 149 149 "discordIdHint": "Ditt numeriska Discord anvรคndar-ID (aktivera Utvecklarlรคge fรถr att hitta det)", 150 + "discordInUseWarning": "Detta Discord-ID รคr redan kopplat till ett annat konto.", 150 151 "telegram": "Telegram", 151 152 "telegramUsername": "Telegram-anvรคndarnamn", 152 153 "telegramUsernamePlaceholder": "@dittanvรคndarnamn", 154 + "telegramInUseWarning": "Detta Telegram-anvรคndarnamn รคr redan kopplat till ett annat konto.", 153 155 "signal": "Signal", 154 156 "signalNumber": "Signal-telefonnummer", 155 157 "signalNumberPlaceholder": "+46701234567", 156 158 "signalNumberHint": "Inkludera landskod (t.ex. +46 fรถr Sverige)", 159 + "signalInUseWarning": "Detta Signal-nummer รคr redan kopplat till ett annat konto.", 157 160 "notConfigured": "ej konfigurerad", 158 161 "inviteCode": "Inbjudningskod", 159 162 "inviteCodePlaceholder": "Ange din inbjudningskod", ··· 161 164 "createButton": "Skapa konto", 162 165 "alreadyHaveAccount": "Har du redan ett konto?", 163 166 "signIn": "Logga in", 167 + "emailInUseWarning": "Denna e-post รคr redan kopplad till ett annat konto. Du kan fortfarande anvรคnda den, men fรถr kontoรฅterstรคllning kan du behรถva anvรคnda ditt anvรคndarnamn istรคllet.", 164 168 "passkeyAccount": "Nyckel", 165 169 "passwordAccount": "Lรถsenord", 166 170 "ssoAccount": "SSO", ··· 269 273 "newEmail": "Ny e-post", 270 274 "newEmailPlaceholder": "ny@exempel.se", 271 275 "changeEmailButton": "ร„ndra e-post", 276 + "emailInUseWarning": "Denna e-post anvรคnds redan av ett annat konto. Du kan fortfarande anvรคnda den, men kontoรฅterstรคllning kan krรคva ditt anvรคndarnamn.", 272 277 "requesting": "Begรคr...", 273 278 "verificationCode": "Verifieringskod", 274 279 "verificationCodePlaceholder": "Ange verifieringskod", ··· 435 440 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte lรคngre anvรคndas.", 436 441 "created": "Inbjudningskod skapad", 437 442 "copy": "Kopiera", 438 - "createdOn": "Skapad {date}" 443 + "createdOn": "Skapad {date}", 444 + "spent": "Fรถrbrukad" 439 445 }, 440 446 "security": { 441 447 "title": "Sรคkerhet", ··· 570 576 "hideHistory": "Dรถlj historik", 571 577 "noMessages": "Inga meddelanden hittades.", 572 578 "sent": "skickad", 573 - "failed": "misslyckades" 579 + "failed": "misslyckades", 580 + "discordInUseWarning": "Detta Discord-ID รคr redan kopplat till ett annat konto.", 581 + "telegramInUseWarning": "Detta Telegram-anvรคndarnamn รคr redan kopplat till ett annat konto.", 582 + "signalInUseWarning": "Detta Signal-nummer รคr redan kopplat till ett annat konto." 574 583 }, 575 584 "repoExplorer": { 576 585 "title": "Datafรถrvarsutforskare", ··· 689 698 "orContinueWith": "Eller fortsรคtt med", 690 699 "orUseCredentials": "Eller logga in med uppgifter" 691 700 }, 701 + "register": { 702 + "title": "Skapa konto", 703 + "subtitle": "Skapa ett konto med {app}", 704 + "subtitleGeneric": "Skapa ett konto fรถr att fortsรคtta", 705 + "haveAccount": "Har du redan ett konto?" 706 + }, 692 707 "sso": { 693 708 "linkedAccounts": "Lรคnkade konton", 694 709 "linkedAccountsDesc": "Externa konton lรคnkade till din identitet fรถr enkel inloggning.", ··· 822 837 "error_expired": "Registreringssessionen har lรถpt ut. Fรถrsรถk igen.", 823 838 "error_handle_required": "Vรคlj ett anvรคndarnamn", 824 839 "emailVerifiedByProvider": "Denna e-post รคr verifierad av {provider}. Ingen ytterligare verifiering behรถvs.", 825 - "emailChangedNeedsVerification": "Om du anvรคnder en annan e-post mรฅste du verifiera den." 840 + "emailChangedNeedsVerification": "Om du anvรคnder en annan e-post mรฅste du verifiera den.", 841 + "infoAfterTitle": "Efter att du skapat ditt konto", 842 + "infoAddPassword": "Lรคgg till ett lรถsenord fรถr traditionell inloggning", 843 + "infoAddPasskey": "Konfigurera en nyckel fรถr lรถsenordsfri inloggning", 844 + "infoLinkProviders": "Lรคnka ytterligare SSO-leverantรถrer", 845 + "infoChangeHandle": "Byt anvรคndarnamn eller anvรคnd en egen domรคn", 846 + "tryAgain": "Fรถrsรถk igen" 826 847 }, 827 848 "verify": { 828 849 "title": "Verifiera ditt konto", ··· 886 907 "success": "Lรถsenord รฅterstรคllt!", 887 908 "requestNewCode": "Begรคr ny kod", 888 909 "passwordsMismatch": "Lรถsenorden matchar inte", 889 - "passwordLength": "Lรถsenordet mรฅste vara minst 8 tecken" 910 + "passwordLength": "Lรถsenordet mรฅste vara minst 8 tecken", 911 + "multipleAccountsWarning": "Flera konton delar denna e-post. ร…terstรคllningskoden skickades till det senast skapade kontot. Anvรคnd ditt anvรคndarnamn istรคllet fรถr ett specifikt konto." 890 912 }, 891 913 "recoverPasskey": { 892 914 "title": "ร…terstรคll ditt konto",
+26 -4
frontend/src/locales/zh.json
··· 147 147 "discordId": "Discord ็”จๆˆท ID", 148 148 "discordIdPlaceholder": "ๆ‚จ็š„ Discord ็”จๆˆท ID", 149 149 "discordIdHint": "ๆ‚จ็š„ Discord ๆ•ฐๅญ—็”จๆˆท ID๏ผˆๅผ€ๅฏๅผ€ๅ‘่€…ๆจกๅผๅŽๅฏไปฅๅคๅˆถ๏ผ‰", 150 + "discordInUseWarning": "ๆญค Discord ID ๅทฒไธŽๅฆไธ€ไธช่ดฆๆˆทๅ…ณ่”ใ€‚", 150 151 "telegram": "Telegram", 151 152 "telegramUsername": "Telegram ็”จๆˆทๅ", 152 153 "telegramUsernamePlaceholder": "@yourusername", 154 + "telegramInUseWarning": "ๆญค Telegram ็”จๆˆทๅๅทฒไธŽๅฆไธ€ไธช่ดฆๆˆทๅ…ณ่”ใ€‚", 153 155 "signal": "Signal", 154 156 "signalNumber": "Signal ็”ต่ฏๅท็ ", 155 157 "signalNumberPlaceholder": "+1234567890", 156 158 "signalNumberHint": "ๅŒ…ๅซๅ›ฝๅฎถไปฃ็ ๏ผˆไพ‹ๅฆ‚ไธญๅ›ฝไธบ +86๏ผ‰", 159 + "signalInUseWarning": "ๆญค Signal ๅท็ ๅทฒไธŽๅฆไธ€ไธช่ดฆๆˆทๅ…ณ่”ใ€‚", 157 160 "notConfigured": "ๆœช้…็ฝฎ", 158 161 "inviteCode": "้‚€่ฏท็ ", 159 162 "inviteCodePlaceholder": "่พ“ๅ…ฅๆ‚จ็š„้‚€่ฏท็ ", ··· 161 164 "createButton": "ๅˆ›ๅปบ่ดฆๆˆท", 162 165 "alreadyHaveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ", 163 166 "signIn": "็ซ‹ๅณ็™ปๅฝ•", 167 + "emailInUseWarning": "ๆญค้‚ฎ็ฎฑๅทฒไธŽๅ…ถไป–่ดฆๆˆทๅ…ณ่”ใ€‚ๆ‚จไปๅฏไฝฟ็”จ๏ผŒไฝ†่ดฆๆˆทๆขๅคๆ—ถๅฏ่ƒฝ้œ€่ฆไฝฟ็”จ็”จๆˆทๅใ€‚", 164 168 "passkeyAccount": "้€š่กŒๅฏ†้’ฅ", 165 169 "passwordAccount": "ๅฏ†็ ", 166 170 "ssoAccount": "SSO", ··· 269 273 "newEmail": "ๆ–ฐ้‚ฎ็ฎฑ", 270 274 "newEmailPlaceholder": "new@example.com", 271 275 "changeEmailButton": "ๆ›ดๆ”น้‚ฎ็ฎฑ", 276 + "emailInUseWarning": "ๆญค้‚ฎ็ฎฑๅทฒ่ขซๅ…ถไป–่ดฆๆˆทไฝฟ็”จใ€‚ๆ‚จไปๅฏไฝฟ็”จ๏ผŒไฝ†่ดฆๆˆทๆขๅคๅฏ่ƒฝ้œ€่ฆไฝฟ็”จ็”จๆˆทๅใ€‚", 272 277 "requesting": "่ฏทๆฑ‚ไธญ...", 273 278 "verificationCode": "้ชŒ่ฏ็ ", 274 279 "verificationCodePlaceholder": "่พ“ๅ…ฅ้ชŒ่ฏ็ ", ··· 435 440 "disableConfirm": "็ฆ็”จๆญค้‚€่ฏท็ ๏ผŸๅฎƒๅฐ†ๆ— ๆณ•ๅ†่ขซไฝฟ็”จใ€‚", 436 441 "created": "้‚€่ฏท็ ๅทฒๅˆ›ๅปบ", 437 442 "copy": "ๅคๅˆถ", 438 - "createdOn": "ๅˆ›ๅปบไบŽ {date}" 443 + "createdOn": "ๅˆ›ๅปบไบŽ {date}", 444 + "spent": "ๅทฒไฝฟ็”จ" 439 445 }, 440 446 "security": { 441 447 "title": "ๅฎ‰ๅ…จ่ฎพ็ฝฎ", ··· 570 576 "hideHistory": "้š่—ๅކๅฒ", 571 577 "noMessages": "ๆš‚ๆ— ๆถˆๆฏ่ฎฐๅฝ•", 572 578 "sent": "ๅทฒๅ‘้€", 573 - "failed": "ๅ‘้€ๅคฑ่ดฅ" 579 + "failed": "ๅ‘้€ๅคฑ่ดฅ", 580 + "discordInUseWarning": "ๆญค Discord ID ๅทฒไธŽๅฆไธ€ไธช่ดฆๆˆทๅ…ณ่”ใ€‚", 581 + "telegramInUseWarning": "ๆญค Telegram ็”จๆˆทๅๅทฒไธŽๅฆไธ€ไธช่ดฆๆˆทๅ…ณ่”ใ€‚", 582 + "signalInUseWarning": "ๆญค Signal ๅท็ ๅทฒไธŽๅฆไธ€ไธช่ดฆๆˆทๅ…ณ่”ใ€‚" 574 583 }, 575 584 "repoExplorer": { 576 585 "title": "ๆ•ฐๆฎๆต่งˆๅ™จ", ··· 689 698 "orContinueWith": "ๆˆ–ไฝฟ็”จไปฅไธ‹ๆ–นๅผ็ปง็ปญ", 690 699 "orUseCredentials": "ๆˆ–ไฝฟ็”จๅ‡ญ่ฏ็™ปๅฝ•" 691 700 }, 701 + "register": { 702 + "title": "ๅˆ›ๅปบ่ดฆๆˆท", 703 + "subtitle": "ไฝฟ็”จ {app} ๅˆ›ๅปบ่ดฆๆˆท", 704 + "subtitleGeneric": "ๅˆ›ๅปบ่ดฆๆˆทไปฅ็ปง็ปญ", 705 + "haveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ" 706 + }, 692 707 "sso": { 693 708 "linkedAccounts": "ๅทฒๅ…ณ่”่ดฆๆˆท", 694 709 "linkedAccountsDesc": "ๅทฒๅ…ณ่”ๅˆฐๆ‚จ่บซไปฝ็š„ๅค–้ƒจ่ดฆๆˆท๏ผŒ็”จไบŽๅ•็‚น็™ปๅฝ•ใ€‚", ··· 822 837 "error_expired": "ๆณจๅ†Œไผš่ฏๅทฒ่ฟ‡ๆœŸใ€‚่ฏท้‡่ฏ•ใ€‚", 823 838 "error_handle_required": "่ฏท้€‰ๆ‹ฉไธ€ไธชๆ˜ต็งฐ", 824 839 "emailVerifiedByProvider": "ๆญค้‚ฎ็ฎฑๅทฒ็”ฑ{provider}้ชŒ่ฏใ€‚ๆ— ้œ€้ขๅค–้ชŒ่ฏใ€‚", 825 - "emailChangedNeedsVerification": "ๅฆ‚ๆžœๆ‚จไฝฟ็”จๅ…ถไป–้‚ฎ็ฎฑ๏ผŒๅˆ™้œ€่ฆ่ฟ›่กŒ้ชŒ่ฏใ€‚" 840 + "emailChangedNeedsVerification": "ๅฆ‚ๆžœๆ‚จไฝฟ็”จๅ…ถไป–้‚ฎ็ฎฑ๏ผŒๅˆ™้œ€่ฆ่ฟ›่กŒ้ชŒ่ฏใ€‚", 841 + "infoAfterTitle": "ๅˆ›ๅปบ่ดฆๆˆทๅŽ", 842 + "infoAddPassword": "ๆทปๅŠ ๅฏ†็ ไปฅไฝฟ็”จไผ ็ปŸๆ–นๅผ็™ปๅฝ•", 843 + "infoAddPasskey": "่ฎพ็ฝฎ้€š่กŒๅฏ†้’ฅไปฅๅฎž็Žฐๆ— ๅฏ†็ ็™ปๅฝ•", 844 + "infoLinkProviders": "ๅ…ณ่”ๅ…ถไป–SSOๆไพ›ๅ•†", 845 + "infoChangeHandle": "ๆ›ดๆ”น็”จๆˆทๅๆˆ–ไฝฟ็”จ่‡ชๅฎšไน‰ๅŸŸๅ", 846 + "tryAgain": "้‡่ฏ•" 826 847 }, 827 848 "verify": { 828 849 "title": "้ชŒ่ฏ่ดฆๆˆท", ··· 886 907 "success": "ๅฏ†็ ้‡็ฝฎๆˆๅŠŸ๏ผ", 887 908 "requestNewCode": "้‡ๆ–ฐ่Žทๅ–้ชŒ่ฏ็ ", 888 909 "passwordsMismatch": "ไธคๆฌก่พ“ๅ…ฅ็š„ๅฏ†็ ไธไธ€่‡ด", 889 - "passwordLength": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆ8ไฝๅญ—็ฌฆ" 910 + "passwordLength": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆ8ไฝๅญ—็ฌฆ", 911 + "multipleAccountsWarning": "ๅคšไธช่ดฆๆˆทๅ…ฑไบซๆญค้‚ฎ็ฎฑใ€‚้‡็ฝฎ้ชŒ่ฏ็ ๅทฒๅ‘้€่‡ณๆœ€ๆ–ฐๅˆ›ๅปบ็š„่ดฆๆˆทใ€‚ๅฆ‚้œ€ๆขๅค็‰นๅฎš่ดฆๆˆท๏ผŒ่ฏทไฝฟ็”จ็”จๆˆทๅใ€‚" 890 912 }, 891 913 "recoverPasskey": { 892 914 "title": "ๆขๅค่ดฆๆˆท",
+6 -9
frontend/src/routes/Admin.svelte
··· 1010 1010 } 1011 1011 1012 1012 .code { 1013 - font-family: monospace; 1013 + font-family: var(--font-mono); 1014 1014 font-size: var(--text-xs); 1015 1015 } 1016 1016 ··· 1032 1032 } 1033 1033 1034 1034 .action-btn.danger:hover { 1035 - background: #900; 1035 + filter: brightness(0.8); 1036 1036 } 1037 1037 1038 1038 .muted { ··· 1049 1049 1050 1050 .modal-overlay { 1051 1051 position: fixed; 1052 - top: 0; 1053 - left: 0; 1054 - right: 0; 1055 - bottom: 0; 1056 - background: rgba(0, 0, 0, 0.5); 1052 + inset: 0; 1053 + background: var(--overlay-bg); 1057 1054 display: flex; 1058 1055 align-items: center; 1059 1056 justify-content: center; 1060 - z-index: 1000; 1057 + z-index: var(--z-modal); 1061 1058 } 1062 1059 1063 1060 .modal { ··· 1117 1114 } 1118 1115 1119 1116 .mono { 1120 - font-family: monospace; 1117 + font-family: var(--font-mono); 1121 1118 font-size: var(--text-xs); 1122 1119 word-break: break-all; 1123 1120 }
+1 -5
frontend/src/routes/AppPasswords.svelte
··· 281 281 .password-code { 282 282 display: block; 283 283 font-size: var(--text-xl); 284 - font-family: ui-monospace, monospace; 284 + font-family: var(--font-mono); 285 285 letter-spacing: 0.1em; 286 286 padding: var(--space-5); 287 287 background: var(--bg-input); ··· 469 469 animation: skeleton-pulse 1.5s ease-in-out infinite; 470 470 } 471 471 472 - @keyframes skeleton-pulse { 473 - 0%, 100% { opacity: 1; } 474 - 50% { opacity: 0.5; } 475 - } 476 472 </style>
+44 -9
frontend/src/routes/Comms.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) 36 39 let messages = $state<Array<{ 37 40 createdAt: string 38 41 channel: string ··· 132 135 function formatDate(dateStr: string): string { 133 136 return formatDateTime(dateStr) 134 137 } 138 + async function checkChannelInUse(channel: 'discord' | 'telegram' | 'signal', identifier: string) { 139 + const trimmed = identifier.trim() 140 + if (!trimmed) { 141 + switch (channel) { 142 + case 'discord': discordInUse = false; break 143 + case 'telegram': telegramInUse = false; break 144 + case 'signal': signalInUse = false; break 145 + } 146 + return 147 + } 148 + try { 149 + const result = await api.checkCommsChannelInUse(channel, trimmed) 150 + switch (channel) { 151 + case 'discord': discordInUse = result.inUse; break 152 + case 'telegram': telegramInUse = result.inUse; break 153 + case 'signal': signalInUse = result.inUse; break 154 + } 155 + } catch { 156 + switch (channel) { 157 + case 'discord': discordInUse = false; break 158 + case 'telegram': telegramInUse = false; break 159 + case 'signal': signalInUse = false; break 160 + } 161 + } 162 + } 135 163 const channels = ['email', 'discord', 'telegram', 'signal'] 136 164 function getChannelName(id: string): string { 137 165 switch (id) { ··· 242 270 id="discord" 243 271 type="text" 244 272 bind:value={discordId} 273 + onblur={() => checkChannelInUse('discord', discordId)} 245 274 placeholder={$_('register.discordIdPlaceholder')} 246 275 disabled={saving || !isChannelAvailableOnServer('discord')} 247 276 /> ··· 250 279 {/if} 251 280 </div> 252 281 <p class="config-hint">{$_('comms.discordIdHint')}</p> 282 + {#if discordInUse} 283 + <p class="config-hint warning">{$_('comms.discordInUseWarning')}</p> 284 + {/if} 253 285 {#if verifyingChannel === 'discord'} 254 286 <div class="verify-form"> 255 287 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> ··· 277 309 id="telegram" 278 310 type="text" 279 311 bind:value={telegramUsername} 312 + onblur={() => checkChannelInUse('telegram', telegramUsername)} 280 313 placeholder={$_('register.telegramUsernamePlaceholder')} 281 314 disabled={saving || !isChannelAvailableOnServer('telegram')} 282 315 /> ··· 285 318 {/if} 286 319 </div> 287 320 <p class="config-hint">{$_('comms.telegramHint')}</p> 321 + {#if telegramInUse} 322 + <p class="config-hint warning">{$_('comms.telegramInUseWarning')}</p> 323 + {/if} 288 324 {#if verifyingChannel === 'telegram'} 289 325 <div class="verify-form"> 290 326 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> ··· 312 348 id="signal" 313 349 type="tel" 314 350 bind:value={signalNumber} 351 + onblur={() => checkChannelInUse('signal', signalNumber)} 315 352 placeholder={$_('register.signalNumberPlaceholder')} 316 353 disabled={saving || !isChannelAvailableOnServer('signal')} 317 354 /> ··· 320 357 {/if} 321 358 </div> 322 359 <p class="config-hint">{$_('comms.signalHint')}</p> 360 + {#if signalInUse} 361 + <p class="config-hint warning">{$_('comms.signalInUseWarning')}</p> 362 + {/if} 323 363 {#if verifyingChannel === 'signal'} 324 364 <div class="verify-form"> 325 365 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> ··· 573 613 margin: 0; 574 614 } 575 615 616 + .config-hint.warning { 617 + color: var(--warning-text); 618 + } 619 + 576 620 .actions { 577 621 display: flex; 578 622 justify-content: flex-end; ··· 677 721 margin-bottom: var(--space-1); 678 722 } 679 723 680 - @keyframes skeleton-pulse { 681 - 0%, 100% { opacity: 1; } 682 - 50% { opacity: 0.4; } 683 - } 684 - 685 724 .no-messages { 686 725 color: var(--text-secondary); 687 726 font-style: italic; ··· 772 811 animation: skeleton-pulse 1.5s ease-in-out infinite; 773 812 } 774 813 775 - @keyframes skeleton-pulse { 776 - 0%, 100% { opacity: 1; } 777 - 50% { opacity: 0.5; } 778 - } 779 814 </style>
-4
frontend/src/routes/Controllers.svelte
··· 770 770 animation: skeleton-pulse 1.5s ease-in-out infinite; 771 771 } 772 772 773 - @keyframes skeleton-pulse { 774 - 0%, 100% { opacity: 1; } 775 - 50% { opacity: 0.5; } 776 - } 777 773 </style>
+1 -6
frontend/src/routes/Dashboard.svelte
··· 425 425 } 426 426 427 427 .mono { 428 - font-family: ui-monospace, monospace; 428 + font-family: var(--font-mono); 429 429 font-size: var(--text-sm); 430 430 word-break: break-all; 431 431 } ··· 523 523 animation: skeleton-pulse 1.5s ease-in-out infinite; 524 524 } 525 525 526 - @keyframes skeleton-pulse { 527 - 0%, 100% { opacity: 1; } 528 - 50% { opacity: 0.5; } 529 - } 530 - 531 526 .deactivated-banner { 532 527 background: var(--warning-bg); 533 528 border: 1px solid var(--warning-border);
-4
frontend/src/routes/DelegationAudit.svelte
··· 333 333 animation: skeleton-pulse 1.5s ease-in-out infinite; 334 334 } 335 335 336 - @keyframes skeleton-pulse { 337 - 0%, 100% { opacity: 1; } 338 - 50% { opacity: 0.5; } 339 - } 340 336 </style>
+1 -5
frontend/src/routes/DidDocumentEditor.svelte
··· 361 361 } 362 362 363 363 .handle-item span { 364 - font-family: ui-monospace, monospace; 364 + font-family: var(--font-mono); 365 365 font-size: var(--text-sm); 366 366 } 367 367 ··· 475 475 height: 250px; 476 476 } 477 477 478 - @keyframes skeleton-pulse { 479 - 0%, 100% { opacity: 1; } 480 - 50% { opacity: 0.5; } 481 - } 482 478 </style>
+9 -6
frontend/src/routes/InviteCodes.svelte
··· 152 152 <span class="status disabled">{$_('inviteCodes.disabled')}</span> 153 153 {:else if code.uses.length > 0} 154 154 <span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedByHandle || code.uses[0].usedBy.split(':').pop() } })}</span> 155 + {:else if code.available === 0} 156 + <span class="status spent">{$_('inviteCodes.spent')}</span> 155 157 {:else} 156 158 <span class="status available">{$_('inviteCodes.available')}</span> 157 159 {/if} ··· 217 219 218 220 .code-display code { 219 221 font-size: var(--text-lg); 220 - font-family: ui-monospace, monospace; 222 + font-family: var(--font-mono); 221 223 flex: 1; 222 224 } 223 225 ··· 273 275 } 274 276 275 277 .code-main code { 276 - font-family: ui-monospace, monospace; 278 + font-family: var(--font-mono); 277 279 font-size: var(--text-sm); 278 280 } 279 281 ··· 317 319 color: var(--text-secondary); 318 320 } 319 321 322 + .status.spent { 323 + background: var(--bg-tertiary); 324 + color: var(--text-tertiary); 325 + } 326 + 320 327 .status.disabled { 321 328 background: var(--error-bg); 322 329 color: var(--error-text); ··· 334 341 animation: skeleton-pulse 1.5s ease-in-out infinite; 335 342 } 336 343 337 - @keyframes skeleton-pulse { 338 - 0%, 100% { opacity: 1; } 339 - 50% { opacity: 0.5; } 340 - } 341 344 </style>
+1 -1
frontend/src/routes/Login.svelte
··· 349 349 .account-did { 350 350 font-size: var(--text-xs); 351 351 color: var(--text-muted); 352 - font-family: ui-monospace, monospace; 352 + font-family: var(--font-mono); 353 353 overflow: hidden; 354 354 text-overflow: ellipsis; 355 355 max-width: 250px;
+7 -35
frontend/src/routes/Migration.svelte
··· 208 208 {/if} 209 209 210 210 {#if oauthLoading} 211 - <div class="oauth-loading"> 212 - <div class="loading-spinner"></div> 211 + <div class="loading"> 212 + <div class="spinner md"></div> 213 213 <p>{$_('migration.oauthCompleting')}</p> 214 214 </div> 215 215 {:else if oauthError} ··· 317 317 flex-direction: column; 318 318 align-items: stretch; 319 319 background: var(--bg-secondary); 320 - border: 1px solid var(--border); 320 + border: 1px solid var(--border-color); 321 321 border-radius: var(--radius-xl); 322 322 padding: var(--space-6); 323 323 text-align: left; ··· 411 411 .modal-overlay { 412 412 position: fixed; 413 413 inset: 0; 414 - background: rgba(0, 0, 0, 0.5); 414 + background: var(--overlay-bg); 415 415 display: flex; 416 416 align-items: center; 417 417 justify-content: center; 418 - z-index: 1000; 418 + z-index: var(--z-modal); 419 419 } 420 420 421 421 .modal { 422 422 background: var(--bg-primary); 423 423 border-radius: var(--radius-xl); 424 424 padding: var(--space-6); 425 - max-width: 400px; 425 + max-width: var(--width-sm); 426 426 width: 90%; 427 427 } 428 428 ··· 450 450 } 451 451 452 452 .detail-row:not(:last-child) { 453 - border-bottom: 1px solid var(--border); 453 + border-bottom: 1px solid var(--border-color); 454 454 } 455 455 456 456 .detail-row .label { ··· 472 472 justify-content: flex-end; 473 473 } 474 474 475 - .oauth-loading { 476 - display: flex; 477 - flex-direction: column; 478 - align-items: center; 479 - justify-content: center; 480 - padding: var(--space-12); 481 - text-align: center; 482 - } 483 - 484 - .loading-spinner { 485 - width: 48px; 486 - height: 48px; 487 - border: 3px solid var(--border); 488 - border-top-color: var(--accent); 489 - border-radius: 50%; 490 - animation: spin 1s linear infinite; 491 - margin-bottom: var(--space-4); 492 - } 493 - 494 - @keyframes spin { 495 - to { transform: rotate(360deg); } 496 - } 497 - 498 - .oauth-loading p { 499 - color: var(--text-secondary); 500 - margin: 0; 501 - } 502 - 503 475 .oauth-error { 504 476 max-width: 500px; 505 477 margin: 0 auto;
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 487 487 } 488 488 489 489 .account-info .did { 490 - font-family: monospace; 490 + font-family: var(--font-mono); 491 491 font-size: var(--text-sm); 492 492 color: var(--text-secondary); 493 493 word-break: break-all;
+24 -24
frontend/src/routes/OAuthError.svelte
··· 1 1 <script lang="ts"> 2 2 import { _ } from '../lib/i18n' 3 + import { routes, buildUrl } from '../lib/types/routes' 4 + import { getRequestUriFromUrl } from '../lib/oauth' 3 5 4 6 function getError(): string { 5 7 const params = new URLSearchParams(window.location.search) ··· 15 17 window.history.back() 16 18 } 17 19 20 + function handleSignIn() { 21 + const requestUri = getRequestUriFromUrl() 22 + const url = requestUri 23 + ? buildUrl(routes.oauthLogin, { request_uri: requestUri }) 24 + : routes.login 25 + window.location.href = `/app${url}` 26 + } 27 + 18 28 let error = $derived(getError()) 19 29 let errorDescription = $derived(getErrorDescription()) 20 30 </script> 21 31 22 - <div class="oauth-error-container"> 32 + <div class="page-sm text-center"> 23 33 <h1>{$_('oauth.error.title')}</h1> 24 34 25 35 <div class="error-box"> ··· 29 39 {/if} 30 40 </div> 31 41 32 - <button type="button" onclick={handleBack}> 33 - {$_('oauth.error.tryAgain')} 34 - </button> 42 + <div class="actions"> 43 + <button type="button" onclick={handleBack}> 44 + {$_('oauth.error.tryAgain')} 45 + </button> 46 + <button type="button" class="secondary" onclick={handleSignIn}> 47 + {$_('common.signIn')} 48 + </button> 49 + </div> 35 50 </div> 36 51 37 52 <style> 38 - .oauth-error-container { 39 - max-width: var(--width-sm); 40 - margin: var(--space-9) auto; 41 - padding: var(--space-7); 42 - text-align: center; 43 - } 44 - 45 53 h1 { 46 54 margin: 0 0 var(--space-6) 0; 47 55 color: var(--error-text); ··· 56 64 } 57 65 58 66 .error-code { 59 - font-family: monospace; 67 + font-family: var(--font-mono); 60 68 font-size: var(--text-base); 61 69 color: var(--error-text); 62 70 margin-bottom: var(--space-2); ··· 67 75 font-size: var(--text-sm); 68 76 } 69 77 70 - button { 71 - padding: var(--space-3) var(--space-6); 72 - background: var(--accent); 73 - color: var(--text-inverse); 74 - border: none; 75 - border-radius: var(--radius-md); 76 - font-size: var(--text-base); 77 - cursor: pointer; 78 - } 79 - 80 - button:hover { 81 - background: var(--accent-hover); 78 + .actions { 79 + display: flex; 80 + gap: var(--space-3); 81 + justify-content: center; 82 82 } 83 83 </style>
+18 -122
frontend/src/routes/OAuthLogin.svelte
··· 63 63 const response = await fetch('/oauth/sso/providers') 64 64 if (response.ok) { 65 65 const data = await response.json() 66 - ssoProviders = data.providers || [] 66 + ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name)) 67 67 } 68 68 } catch { 69 69 ssoProviders = [] ··· 337 337 } 338 338 } 339 339 340 - async function handleCancel() { 341 - const requestUri = getRequestUri() 342 - if (!requestUri) { 343 - window.history.back() 344 - return 345 - } 346 - 347 - submitting = true 348 - try { 349 - const response = await fetch('/oauth/authorize/deny', { 350 - method: 'POST', 351 - headers: { 352 - 'Content-Type': 'application/json', 353 - 'Accept': 'application/json' 354 - }, 355 - body: JSON.stringify({ request_uri: requestUri }) 356 - }) 357 - 358 - const data = await response.json() 359 - if (data.redirect_uri) { 360 - window.location.href = data.redirect_uri 361 - } 362 - } catch { 363 - window.history.back() 364 - } 340 + function handleCancel() { 341 + window.location.href = '/' 365 342 } 366 343 </script> 367 344 368 - <div class="oauth-login-container"> 345 + <div class="page-sm"> 369 346 <header class="page-header"> 370 347 <h1>{$_('oauth.login.title')}</h1> 371 348 <p class="subtitle"> ··· 378 355 </header> 379 356 380 357 {#if error} 381 - <div class="error">{error}</div> 358 + <div class="message error">{error}</div> 382 359 {/if} 383 360 384 361 <form onsubmit={handleSubmit}> ··· 406 383 disabled={submitting || ssoLoading !== null} 407 384 > 408 385 {#if ssoLoading === provider.provider} 409 - <span class="loading-spinner"></span> 386 + <span class="spinner sm"></span> 410 387 {:else} 411 388 <SsoIcon provider={provider.icon} size={20} /> 412 389 {/if} ··· 543 520 text-decoration: underline; 544 521 } 545 522 546 - .oauth-login-container { 547 - max-width: var(--width-md); 548 - margin: var(--space-9) auto; 549 - padding: var(--space-7); 550 - } 551 - 552 - .page-header { 553 - margin-bottom: var(--space-6); 554 - } 555 - 556 - h1 { 557 - margin: 0 0 var(--space-2) 0; 558 - } 559 - 560 - .subtitle { 561 - color: var(--text-secondary); 562 - margin: 0; 563 - } 564 - 565 523 form { 566 524 display: flex; 567 525 flex-direction: column; ··· 582 540 } 583 541 } 584 542 543 + .auth-methods.single-method { 544 + grid-template-columns: 1fr; 545 + } 546 + 547 + @media (min-width: 600px) { 548 + .auth-methods.single-method { 549 + grid-template-columns: 1fr; 550 + max-width: 400px; 551 + margin: var(--space-4) auto 0; 552 + } 553 + } 554 + 585 555 .passkey-method, 586 556 .password-method { 587 557 display: flex; ··· 652 622 } 653 623 } 654 624 655 - .field { 656 - display: flex; 657 - flex-direction: column; 658 - gap: var(--space-1); 659 - } 660 - 661 - label { 662 - font-size: var(--text-sm); 663 - font-weight: var(--font-medium); 664 - } 665 - 666 - input[type="text"], 667 - input[type="password"] { 668 - padding: var(--space-3); 669 - border: 1px solid var(--border-color); 670 - border-radius: var(--radius-md); 671 - font-size: var(--text-base); 672 - background: var(--bg-input); 673 - color: var(--text-primary); 674 - } 675 - 676 - input:focus { 677 - outline: none; 678 - border-color: var(--accent); 679 - } 680 - 681 625 .remember-device { 682 626 display: flex; 683 627 align-items: center; ··· 692 636 height: 16px; 693 637 } 694 638 695 - .error { 696 - padding: var(--space-3); 697 - background: var(--error-bg); 698 - border: 1px solid var(--error-border); 699 - border-radius: var(--radius-md); 700 - color: var(--error-text); 701 - margin-bottom: var(--space-4); 702 - } 703 - 704 639 .actions { 705 640 display: flex; 706 641 gap: var(--space-4); ··· 709 644 710 645 .actions button { 711 646 flex: 1; 712 - padding: var(--space-3); 713 - border: none; 714 - border-radius: var(--radius-md); 715 - font-size: var(--text-base); 716 - cursor: pointer; 717 - transition: background-color var(--transition-fast); 718 - } 719 - 720 - .actions button:disabled { 721 - opacity: 0.6; 722 - cursor: not-allowed; 723 647 } 724 648 725 649 .cancel-row { ··· 757 681 background: var(--accent-hover); 758 682 } 759 683 760 - 761 684 .passkey-btn { 762 685 display: flex; 763 686 align-items: center; ··· 867 790 opacity: 0.6; 868 791 cursor: not-allowed; 869 792 } 870 - 871 - .auth-methods.single-method { 872 - grid-template-columns: 1fr; 873 - } 874 - 875 - @media (min-width: 600px) { 876 - .auth-methods.single-method { 877 - grid-template-columns: 1fr; 878 - max-width: 400px; 879 - margin: var(--space-4) auto 0; 880 - } 881 - } 882 - 883 - .loading-spinner { 884 - width: 20px; 885 - height: 20px; 886 - border: 2px solid var(--border-color); 887 - border-top-color: var(--accent); 888 - border-radius: 50%; 889 - animation: spin 0.8s linear infinite; 890 - } 891 - 892 - @keyframes spin { 893 - to { 894 - transform: rotate(360deg); 895 - } 896 - } 897 793 </style>
-6
frontend/src/routes/OAuthPasskey.svelte
··· 197 197 animation: spin 1s linear infinite; 198 198 } 199 199 200 - @keyframes spin { 201 - to { 202 - transform: rotate(360deg); 203 - } 204 - } 205 - 206 200 .loading-indicator p { 207 201 margin: 0; 208 202 color: var(--text-secondary);
+439 -431
frontend/src/routes/Register.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 - import { api, ApiError } from '../lib/api' 3 + import { api } from '../lib/api' 4 4 import { _ } from '../lib/i18n' 5 5 import { 6 6 createRegistrationFlow, ··· 8 8 VerificationStep, 9 9 KeyChoiceStep, 10 10 DidDocStep, 11 + AppPasswordStep, 11 12 } from '../lib/registration' 13 + import { 14 + prepareCreationOptions, 15 + serializeAttestationResponse, 16 + type WebAuthnCreationOptionsResponse, 17 + } from '../lib/webauthn' 12 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 + import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 13 20 14 21 let serverInfo = $state<{ 15 22 availableUserDomains: string[] ··· 22 29 let ssoAvailable = $state(false) 23 30 24 31 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 25 - let confirmPassword = $state('') 32 + let passkeyName = $state('') 33 + let clientName = $state<string | null>(null) 26 34 27 35 $effect(() => { 28 36 if (!serverInfoLoaded) { 29 37 serverInfoLoaded = true 30 - loadServerInfo() 31 - checkSsoAvailable() 38 + ensureRequestUri().then((requestUri) => { 39 + if (!requestUri) return 40 + loadServerInfo() 41 + fetchClientName() 42 + checkSsoAvailable() 43 + }).catch((err) => { 44 + console.error('Failed to ensure OAuth request URI:', err) 45 + }) 32 46 } 33 47 }) 34 48 ··· 44 58 } 45 59 } 46 60 61 + async function fetchClientName() { 62 + const requestUri = getRequestUriFromUrl() 63 + if (!requestUri) return 64 + 65 + try { 66 + const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { 67 + headers: { 'Accept': 'application/json' } 68 + }) 69 + if (response.ok) { 70 + const data = await response.json() 71 + clientName = data.client_name || null 72 + } 73 + } catch { 74 + clientName = null 75 + } 76 + } 77 + 47 78 $effect(() => { 48 79 if (flow?.state.step === 'redirect-to-dashboard') { 49 - navigate(routes.dashboard) 80 + completeOAuthRegistration() 50 81 } 51 82 }) 52 83 ··· 54 85 $effect(() => { 55 86 if (flow?.state.step === 'creating' && !creatingStarted) { 56 87 creatingStarted = true 57 - flow.createPasswordAccount() 88 + flow.createPasskeyAccount() 58 89 } 59 90 }) 60 91 61 92 async function loadServerInfo() { 62 93 try { 63 94 const restored = restoreRegistrationFlow() 64 - if (restored && restored.state.mode === 'password') { 95 + if (restored && restored.state.mode === 'passkey') { 65 96 flow = restored 66 97 serverInfo = await api.describeServer() 67 98 } else { 68 99 serverInfo = await api.describeServer() 69 100 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 70 - flow = createRegistrationFlow('password', hostname) 101 + flow = createRegistrationFlow('passkey', hostname) 71 102 } 72 103 } catch (e) { 73 104 console.error('Failed to load server info:', e) ··· 79 110 function validateInfoStep(): string | null { 80 111 if (!flow) return 'Flow not initialized' 81 112 const info = flow.info 82 - if (!info.handle.trim()) return $_('register.validation.handleRequired') 83 - if (info.handle.includes('.')) return $_('register.validation.handleNoDots') 84 - if (!info.password) return $_('register.validation.passwordRequired') 85 - if (info.password.length < 8) return $_('register.validation.passwordLength') 86 - if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 113 + if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired') 114 + if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots') 87 115 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 88 - return $_('register.validation.inviteCodeRequired') 116 + return $_('registerPasskey.errors.inviteRequired') 89 117 } 90 118 if (info.didType === 'web-external') { 91 - if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired') 92 - if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 119 + if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired') 120 + if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat') 93 121 } 94 122 switch (info.verificationChannel) { 95 123 case 'email': 96 - if (!info.email.trim()) return $_('register.validation.emailRequired') 124 + if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') 97 125 break 98 126 case 'discord': 99 - if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 127 + if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired') 100 128 break 101 129 case 'telegram': 102 - if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 130 + if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') 103 131 break 104 132 case 'signal': 105 - if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 133 + if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired') 106 134 break 107 135 } 108 136 return null ··· 118 146 return 119 147 } 120 148 149 + if (!window.PublicKeyCredential) { 150 + flow.setError($_('registerPasskey.errors.passkeysNotSupported')) 151 + return 152 + } 153 + 121 154 flow.clearError() 122 155 flow.proceedFromInfo() 123 156 } 124 157 125 - async function handleCreateAccount() { 126 - if (!flow) return 127 - await flow.createPasswordAccount() 158 + async function handlePasskeyRegistration() { 159 + if (!flow || !flow.account) return 160 + 161 + flow.setSubmitting(true) 162 + flow.clearError() 163 + 164 + try { 165 + const { options } = await api.startPasskeyRegistrationForSetup( 166 + flow.account.did, 167 + flow.account.setupToken!, 168 + passkeyName || undefined 169 + ) 170 + 171 + const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 172 + const credential = await navigator.credentials.create({ 173 + publicKey: publicKeyOptions 174 + }) 175 + 176 + if (!credential) { 177 + flow.setError($_('registerPasskey.errors.passkeyCancelled')) 178 + flow.setSubmitting(false) 179 + return 180 + } 181 + 182 + const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 183 + 184 + const result = await api.completePasskeySetup( 185 + flow.account.did, 186 + flow.account.setupToken!, 187 + credentialResponse, 188 + passkeyName || undefined 189 + ) 190 + 191 + flow.setPasskeyComplete(result.appPassword, result.appPasswordName) 192 + } catch (err) { 193 + if (err instanceof DOMException && err.name === 'NotAllowedError') { 194 + flow.setError($_('registerPasskey.errors.passkeyCancelled')) 195 + } else if (err instanceof Error) { 196 + flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed')) 197 + } else { 198 + flow.setError($_('registerPasskey.errors.passkeyFailed')) 199 + } 200 + } finally { 201 + flow.setSubmitting(false) 202 + } 128 203 } 129 204 130 - async function handleComplete() { 131 - if (flow) { 132 - await flow.finalizeSession() 205 + async function completeOAuthRegistration() { 206 + const requestUri = getRequestUriFromUrl() 207 + if (!requestUri || !flow?.account) { 208 + navigate(routes.dashboard) 209 + return 210 + } 211 + 212 + try { 213 + const response = await fetch('/oauth/register/complete', { 214 + method: 'POST', 215 + headers: { 216 + 'Content-Type': 'application/json', 217 + 'Accept': 'application/json', 218 + }, 219 + body: JSON.stringify({ 220 + request_uri: requestUri, 221 + did: flow.account.did, 222 + app_password: flow.account.appPassword, 223 + }), 224 + }) 225 + 226 + const data = await response.json() 227 + 228 + if (!response.ok) { 229 + flow.setError(data.error_description || data.error || $_('common.error')) 230 + return 231 + } 232 + 233 + if (data.redirect_uri) { 234 + window.location.href = data.redirect_uri 235 + return 236 + } 237 + 238 + navigate(routes.dashboard) 239 + } catch (err) { 240 + console.error('OAuth registration completion failed:', err) 241 + flow.setError(err instanceof Error ? err.message : $_('common.error')) 133 242 } 134 - navigate(routes.dashboard) 135 243 } 136 244 137 245 function isChannelAvailable(ch: string): boolean { ··· 141 249 142 250 function channelLabel(ch: string): string { 143 251 switch (ch) { 144 - case 'email': return $_('register.email') 145 - case 'discord': return $_('register.discord') 146 - case 'telegram': return $_('register.telegram') 147 - case 'signal': return $_('register.signal') 148 - default: return ch 252 + case 'email': 253 + return $_('register.email') 254 + case 'discord': 255 + return $_('register.discord') 256 + case 'telegram': 257 + return $_('register.telegram') 258 + case 'signal': 259 + return $_('register.signal') 260 + default: 261 + return ch 149 262 } 150 263 } 151 264 152 265 let fullHandle = $derived(() => { 153 266 if (!flow?.info.handle.trim()) return '' 154 - if (flow.info.handle.includes('.')) return flow.info.handle.trim() 155 - const domain = serverInfo?.availableUserDomains?.[0] 156 - if (domain) return `${flow.info.handle.trim()}.${domain}` 157 - return flow.info.handle.trim() 267 + return `${flow.info.handle.trim()}.${flow.state.pdsHostname}` 158 268 }) 159 269 160 - function extractDomain(did: string): string { 161 - return did.replace('did:web:', '').replace(/%3A/g, ':') 270 + async function handleCancel() { 271 + const requestUri = getRequestUriFromUrl() 272 + if (!requestUri) { 273 + window.history.back() 274 + return 275 + } 276 + 277 + try { 278 + const response = await fetch('/oauth/authorize/deny', { 279 + method: 'POST', 280 + headers: { 281 + 'Content-Type': 'application/json', 282 + 'Accept': 'application/json' 283 + }, 284 + body: JSON.stringify({ request_uri: requestUri }) 285 + }) 286 + 287 + const data = await response.json() 288 + if (data.redirect_uri) { 289 + window.location.href = data.redirect_uri 290 + } 291 + } catch (err) { 292 + console.error('OAuth deny failed:', err) 293 + window.history.back() 294 + } 162 295 } 163 296 164 - function getSubtitle(): string { 165 - if (!flow) return '' 166 - switch (flow.state.step) { 167 - case 'info': return $_('register.subtitle') 168 - case 'key-choice': return $_('register.subtitleKeyChoice') 169 - case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 170 - case 'creating': return $_('common.creating') 171 - case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 172 - case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 173 - case 'activating': return $_('register.subtitleActivating') 174 - case 'redirect-to-dashboard': return $_('register.subtitleComplete') 175 - default: return '' 297 + function goToLogin() { 298 + const requestUri = getRequestUriFromUrl() 299 + if (requestUri) { 300 + navigate(routes.oauthLogin, { params: { request_uri: requestUri } }) 301 + } else { 302 + navigate(routes.login) 176 303 } 177 304 } 178 305 </script> 179 306 180 - <div class="register-page"> 181 - <header class="page-header"> 182 - <h1>{$_('register.title')}</h1> 183 - <p class="subtitle">{getSubtitle()}</p> 184 - </header> 185 - 186 - {#if flow?.state.error} 187 - <div class="message error">{flow.state.error}</div> 188 - {/if} 189 - 190 - {#if loadingServerInfo || !flow} 191 - <div class="loading"></div> 192 - {:else if flow.state.step === 'info'} 193 - <div class="migrate-callout"> 194 - <div class="migrate-icon">โ†—</div> 195 - <div class="migrate-content"> 196 - <strong>{$_('register.migrateTitle')}</strong> 197 - <p>{$_('register.migrateDescription')}</p> 198 - <a href={getFullUrl(routes.migrate)} class="migrate-link"> 199 - {$_('register.migrateLink')} โ†’ 200 - </a> 201 - </div> 307 + <div class="page"> 308 + {#if loadingServerInfo} 309 + <div class="loading"> 310 + <div class="spinner"></div> 311 + <p>{$_('common.loading')}</p> 202 312 </div> 313 + {:else if flow} 314 + <header class="page-header"> 315 + <h1>{$_('oauth.register.title')}</h1> 316 + <p class="subtitle"> 317 + {#if clientName} 318 + {$_('oauth.register.subtitle')} <strong>{clientName}</strong> 319 + {:else} 320 + {$_('oauth.register.subtitleGeneric')} 321 + {/if} 322 + </p> 323 + </header> 324 + 325 + {#if flow.state.error} 326 + <div class="message error">{flow.state.error}</div> 327 + {/if} 328 + 329 + {#if flow.state.step === 'info'} 330 + <div class="migrate-callout"> 331 + <div class="migrate-icon">โ†—</div> 332 + <div class="migrate-content"> 333 + <strong>{$_('register.migrateTitle')}</strong> 334 + <p>{$_('register.migrateDescription')}</p> 335 + <a href={getFullUrl(routes.migrate)} class="migrate-link"> 336 + {$_('register.migrateLink')} โ†’ 337 + </a> 338 + </div> 339 + </div> 203 340 204 - <AccountTypeSwitcher active="password" {ssoAvailable} /> 205 - 206 - <div class="split-layout sidebar-right"> 207 - <div class="form-section"> 208 - <form onsubmit={handleInfoSubmit}> 209 - <div class="field"> 210 - <label for="handle">{$_('register.handle')}</label> 211 - <input 212 - id="handle" 213 - type="text" 214 - bind:value={flow.info.handle} 215 - placeholder={$_('register.handlePlaceholder')} 216 - disabled={flow.state.submitting} 217 - required 218 - /> 219 - {#if flow.info.handle.includes('.')} 220 - <p class="hint warning">{$_('register.handleDotWarning')}</p> 221 - {:else if fullHandle()} 222 - <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 223 - {/if} 224 - </div> 225 - 226 - <div class="form-row"> 227 - <div class="field"> 228 - <label for="password">{$_('register.password')}</label> 229 - <input 230 - id="password" 231 - type="password" 232 - bind:value={flow.info.password} 233 - placeholder={$_('register.passwordPlaceholder')} 234 - disabled={flow.state.submitting} 235 - required 236 - minlength="8" 237 - /> 238 - </div> 341 + <AccountTypeSwitcher active="passkey" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} /> 342 + 343 + <div class="split-layout"> 344 + <div class="form-section"> 345 + <form onsubmit={handleInfoSubmit}> 346 + <div class="field"> 347 + <label for="handle">{$_('register.handle')}</label> 348 + <input 349 + id="handle" 350 + type="text" 351 + bind:value={flow.info.handle} 352 + placeholder={$_('register.handlePlaceholder')} 353 + disabled={flow.state.submitting} 354 + required 355 + autocomplete="off" 356 + /> 357 + {#if fullHandle()} 358 + <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 359 + {/if} 360 + </div> 239 361 362 + <fieldset> 363 + <legend>{$_('register.contactMethod')}</legend> 364 + <div class="contact-fields"> 240 365 <div class="field"> 241 - <label for="confirm-password">{$_('register.confirmPassword')}</label> 242 - <input 243 - id="confirm-password" 244 - type="password" 245 - bind:value={confirmPassword} 246 - placeholder={$_('register.confirmPasswordPlaceholder')} 247 - disabled={flow.state.submitting} 248 - required 249 - /> 250 - </div> 251 - </div> 252 - 253 - <fieldset class="section-fieldset"> 254 - <legend>{$_('register.identityType')}</legend> 255 - <div class="radio-group"> 256 - <label class="radio-label"> 257 - <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 258 - <span class="radio-content"> 259 - <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 260 - <span class="radio-hint">{$_('register.didPlcHint')}</span> 261 - </span> 262 - </label> 263 - 264 - <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 265 - <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 266 - <span class="radio-content"> 267 - <strong>{$_('register.didWeb')}</strong> 268 - {#if serverInfo?.selfHostedDidWebEnabled === false} 269 - <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span> 270 - {:else} 271 - <span class="radio-hint">{$_('register.didWebHint')}</span> 272 - {/if} 273 - </span> 274 - </label> 275 - 276 - <label class="radio-label"> 277 - <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 278 - <span class="radio-content"> 279 - <strong>{$_('register.didWebBYOD')}</strong> 280 - <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 281 - </span> 282 - </label> 366 + <label for="verification-channel">{$_('register.verificationMethod')}</label> 367 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 368 + <option value="email">{channelLabel('email')}</option> 369 + {#if isChannelAvailable('discord')} 370 + <option value="discord">{channelLabel('discord')}</option> 371 + {/if} 372 + {#if isChannelAvailable('telegram')} 373 + <option value="telegram">{channelLabel('telegram')}</option> 374 + {/if} 375 + {#if isChannelAvailable('signal')} 376 + <option value="signal">{channelLabel('signal')}</option> 377 + {/if} 378 + </select> 283 379 </div> 284 380 285 - {#if flow.info.didType === 'web'} 286 - <div class="warning-box"> 287 - <strong>{$_('register.didWebWarningTitle')}</strong> 288 - <ul> 289 - <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 290 - <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 291 - <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 292 - <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 293 - </ul> 381 + {#if flow.info.verificationChannel === 'email'} 382 + <div class="field"> 383 + <label for="email">{$_('register.emailAddress')}</label> 384 + <input 385 + id="email" 386 + type="email" 387 + bind:value={flow.info.email} 388 + placeholder={$_('register.emailPlaceholder')} 389 + disabled={flow.state.submitting} 390 + required 391 + /> 294 392 </div> 295 - {/if} 296 - 297 - {#if flow.info.didType === 'web-external'} 393 + {:else if flow.info.verificationChannel === 'discord'} 298 394 <div class="field"> 299 - <label for="external-did">{$_('register.externalDid')}</label> 395 + <label for="discord-id">{$_('register.discordId')}</label> 300 396 <input 301 - id="external-did" 397 + id="discord-id" 302 398 type="text" 303 - bind:value={flow.info.externalDid} 304 - placeholder={$_('register.externalDidPlaceholder')} 399 + bind:value={flow.info.discordId} 400 + placeholder={$_('register.discordIdPlaceholder')} 305 401 disabled={flow.state.submitting} 306 402 required 307 403 /> 308 - <p class="hint">{$_('register.externalDidHint')}</p> 404 + <p class="hint">{$_('register.discordIdHint')}</p> 309 405 </div> 310 - {/if} 311 - </fieldset> 312 - 313 - <fieldset class="section-fieldset"> 314 - <legend>{$_('register.contactMethod')}</legend> 315 - <div class="contact-fields"> 406 + {:else if flow.info.verificationChannel === 'telegram'} 407 + <div class="field"> 408 + <label for="telegram-username">{$_('register.telegramUsername')}</label> 409 + <input 410 + id="telegram-username" 411 + type="text" 412 + bind:value={flow.info.telegramUsername} 413 + placeholder={$_('register.telegramUsernamePlaceholder')} 414 + disabled={flow.state.submitting} 415 + required 416 + /> 417 + </div> 418 + {:else if flow.info.verificationChannel === 'signal'} 316 419 <div class="field"> 317 - <label for="verification-channel">{$_('register.verificationMethod')}</label> 318 - <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 319 - <option value="email">{$_('register.email')}</option> 320 - <option value="discord" disabled={!isChannelAvailable('discord')}> 321 - {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 322 - </option> 323 - <option value="telegram" disabled={!isChannelAvailable('telegram')}> 324 - {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 325 - </option> 326 - <option value="signal" disabled={!isChannelAvailable('signal')}> 327 - {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 328 - </option> 329 - </select> 420 + <label for="signal-number">{$_('register.signalNumber')}</label> 421 + <input 422 + id="signal-number" 423 + type="tel" 424 + bind:value={flow.info.signalNumber} 425 + placeholder={$_('register.signalNumberPlaceholder')} 426 + disabled={flow.state.submitting} 427 + required 428 + /> 429 + <p class="hint">{$_('register.signalNumberHint')}</p> 330 430 </div> 331 - 332 - {#if flow.info.verificationChannel === 'email'} 333 - <div class="field"> 334 - <label for="email">{$_('register.emailAddress')}</label> 335 - <input 336 - id="email" 337 - type="email" 338 - bind:value={flow.info.email} 339 - placeholder={$_('register.emailPlaceholder')} 340 - disabled={flow.state.submitting} 341 - required 342 - /> 343 - </div> 344 - {:else if flow.info.verificationChannel === 'discord'} 345 - <div class="field"> 346 - <label for="discord-id">{$_('register.discordId')}</label> 347 - <input 348 - id="discord-id" 349 - type="text" 350 - bind:value={flow.info.discordId} 351 - placeholder={$_('register.discordIdPlaceholder')} 352 - disabled={flow.state.submitting} 353 - required 354 - /> 355 - <p class="hint">{$_('register.discordIdHint')}</p> 356 - </div> 357 - {:else if flow.info.verificationChannel === 'telegram'} 358 - <div class="field"> 359 - <label for="telegram-username">{$_('register.telegramUsername')}</label> 360 - <input 361 - id="telegram-username" 362 - type="text" 363 - bind:value={flow.info.telegramUsername} 364 - placeholder={$_('register.telegramUsernamePlaceholder')} 365 - disabled={flow.state.submitting} 366 - required 367 - /> 368 - </div> 369 - {:else if flow.info.verificationChannel === 'signal'} 370 - <div class="field"> 371 - <label for="signal-number">{$_('register.signalNumber')}</label> 372 - <input 373 - id="signal-number" 374 - type="tel" 375 - bind:value={flow.info.signalNumber} 376 - placeholder={$_('register.signalNumberPlaceholder')} 377 - disabled={flow.state.submitting} 378 - required 379 - /> 380 - <p class="hint">{$_('register.signalNumberHint')}</p> 381 - </div> 382 - {/if} 431 + {/if} 432 + </div> 433 + </fieldset> 434 + 435 + <fieldset> 436 + <legend>{$_('registerPasskey.identityType')}</legend> 437 + <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p> 438 + <div class="radio-group"> 439 + <label class="radio-label"> 440 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 441 + <span class="radio-content"> 442 + <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 443 + <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 444 + </span> 445 + </label> 446 + <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 447 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 448 + <span class="radio-content"> 449 + <strong>{$_('registerPasskey.didWeb')}</strong> 450 + {#if serverInfo?.selfHostedDidWebEnabled === false} 451 + <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 452 + {:else} 453 + <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 454 + {/if} 455 + </span> 456 + </label> 457 + <label class="radio-label"> 458 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 459 + <span class="radio-content"> 460 + <strong>{$_('registerPasskey.didWebBYOD')}</strong> 461 + <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 462 + </span> 463 + </label> 464 + </div> 465 + {#if flow.info.didType === 'web'} 466 + <div class="warning-box"> 467 + <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 468 + <ul> 469 + <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 470 + <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 471 + <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 472 + <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> 473 + </ul> 383 474 </div> 384 - </fieldset> 385 - 386 - {#if serverInfo?.inviteCodeRequired} 475 + {/if} 476 + {#if flow.info.didType === 'web-external'} 387 477 <div class="field"> 388 - <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 389 - <input 390 - id="invite-code" 391 - type="text" 392 - bind:value={flow.info.inviteCode} 393 - placeholder={$_('register.inviteCodePlaceholder')} 394 - disabled={flow.state.submitting} 395 - required 396 - /> 478 + <label for="external-did">{$_('registerPasskey.externalDid')}</label> 479 + <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required /> 480 + <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? flow.extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 397 481 </div> 398 482 {/if} 483 + </fieldset> 399 484 400 - <button type="submit" disabled={flow.state.submitting}> 401 - {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 402 - </button> 403 - </form> 485 + {#if serverInfo?.inviteCodeRequired} 486 + <div class="field"> 487 + <label for="invite-code">{$_('register.inviteCode')} <span class="required">*</span></label> 488 + <input 489 + id="invite-code" 490 + type="text" 491 + bind:value={flow.info.inviteCode} 492 + placeholder={$_('register.inviteCodePlaceholder')} 493 + disabled={flow.state.submitting} 494 + required 495 + /> 496 + </div> 497 + {/if} 404 498 405 - <div class="form-links"> 406 - <p class="link-text"> 407 - {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a> 408 - </p> 499 + <div class="actions"> 500 + <button type="submit" class="primary" disabled={flow.state.submitting}> 501 + {flow.state.submitting ? $_('common.loading') : $_('common.continue')} 502 + </button> 409 503 </div> 410 - </div> 411 - 412 - <aside class="info-panel"> 413 - <h3>{$_('register.identityHint')}</h3> 414 - <p>{$_('register.infoIdentityDesc')}</p> 415 - 416 - <h3>{$_('register.contactMethodHint')}</h3> 417 - <p>{$_('register.infoContactDesc')}</p> 418 - 419 - <h3>{$_('register.infoNextTitle')}</h3> 420 - <p>{$_('register.infoNextDesc')}</p> 421 - </aside> 422 - </div> 423 - 424 - {:else if flow.state.step === 'key-choice'} 425 - <KeyChoiceStep {flow} /> 426 - 427 - {:else if flow.state.step === 'initial-did-doc'} 428 - <DidDocStep 429 - {flow} 430 - type="initial" 431 - onConfirm={handleCreateAccount} 432 - onBack={() => flow?.goBack()} 433 - /> 434 - 435 - {:else if flow.state.step === 'creating'} 436 - <p class="loading">{$_('common.creating')}</p> 437 504 438 - {:else if flow.state.step === 'verify'} 439 - <VerificationStep {flow} /> 440 - 441 - {:else if flow.state.step === 'updated-did-doc'} 442 - <DidDocStep 443 - {flow} 444 - type="updated" 445 - onConfirm={() => flow?.activateAccount()} 446 - /> 447 - 448 - {:else if flow.state.step === 'redirect-to-dashboard'} 449 - <p class="loading">{$_('register.redirecting')}</p> 450 - {/if} 451 - </div> 452 - 453 - <style> 454 - .register-page { 455 - max-width: var(--width-lg); 456 - margin: var(--space-9) auto; 457 - padding: var(--space-7); 458 - } 505 + <div class="secondary-actions"> 506 + <button type="button" class="link" onclick={goToLogin}> 507 + {$_('oauth.register.haveAccount')} 508 + </button> 509 + <button type="button" class="link" onclick={handleCancel}> 510 + {$_('common.cancel')} 511 + </button> 512 + </div> 513 + </form> 459 514 460 - .page-header { 461 - margin-bottom: var(--space-6); 462 - } 515 + <div class="form-links"> 516 + <p class="link-text"> 517 + {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a> 518 + </p> 519 + </div> 520 + </div> 463 521 464 - .form-section { 465 - min-width: 0; 466 - } 522 + <aside class="info-panel"> 523 + <h3>{$_('registerPasskey.infoWhyPasskey')}</h3> 524 + <p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p> 467 525 468 - .form-links { 469 - margin-top: var(--space-6); 470 - } 526 + <h3>{$_('registerPasskey.infoHowItWorks')}</h3> 527 + <p>{$_('registerPasskey.infoHowItWorksDesc')}</p> 471 528 472 - .migrate-callout { 473 - display: flex; 474 - gap: var(--space-4); 475 - padding: var(--space-5); 476 - background: var(--accent-muted); 477 - border: 1px solid var(--accent); 478 - border-radius: var(--radius-xl); 479 - margin-bottom: var(--space-6); 480 - } 529 + <h3>{$_('registerPasskey.infoAppAccess')}</h3> 530 + <p>{$_('registerPasskey.infoAppAccessDesc')}</p> 531 + </aside> 532 + </div> 481 533 482 - .migrate-icon { 483 - font-size: var(--text-2xl); 484 - line-height: 1; 485 - color: var(--accent); 486 - } 534 + {:else if flow.state.step === 'key-choice'} 535 + <KeyChoiceStep {flow} /> 487 536 488 - .migrate-content { 489 - flex: 1; 490 - } 537 + {:else if flow.state.step === 'initial-did-doc'} 538 + <DidDocStep {flow} type="initial" onConfirm={() => flow?.createPasskeyAccount()} onBack={() => flow?.goBack()} /> 491 539 492 - .migrate-content strong { 493 - display: block; 494 - color: var(--text-primary); 495 - margin-bottom: var(--space-2); 496 - } 540 + {:else if flow.state.step === 'creating'} 541 + <div class="loading"> 542 + <div class="spinner md"></div> 543 + <p>{$_('registerPasskey.creatingAccount')}</p> 544 + </div> 497 545 498 - .migrate-content p { 499 - margin: 0 0 var(--space-3) 0; 500 - font-size: var(--text-sm); 501 - color: var(--text-secondary); 502 - line-height: var(--leading-relaxed); 503 - } 546 + {:else if flow.state.step === 'passkey'} 547 + <div class="passkey-step"> 548 + <h2>{$_('registerPasskey.setupPasskey')}</h2> 549 + <p>{$_('registerPasskey.passkeyDescription')}</p> 550 + 551 + <div class="field"> 552 + <label for="passkey-name">{$_('registerPasskey.passkeyName')}</label> 553 + <input 554 + id="passkey-name" 555 + type="text" 556 + bind:value={passkeyName} 557 + placeholder={$_('registerPasskey.passkeyNamePlaceholder')} 558 + disabled={flow.state.submitting} 559 + /> 560 + <p class="hint">{$_('registerPasskey.passkeyNameHint')}</p> 561 + </div> 504 562 505 - .migrate-link { 506 - font-size: var(--text-sm); 507 - font-weight: var(--font-medium); 508 - color: var(--accent); 509 - text-decoration: none; 510 - } 563 + <button 564 + type="button" 565 + class="primary" 566 + onclick={handlePasskeyRegistration} 567 + disabled={flow.state.submitting} 568 + > 569 + {flow.state.submitting ? $_('common.loading') : $_('registerPasskey.createPasskey')} 570 + </button> 571 + </div> 511 572 512 - .migrate-link:hover { 513 - text-decoration: underline; 514 - } 573 + {:else if flow.state.step === 'app-password'} 574 + <AppPasswordStep {flow} /> 515 575 516 - h1 { 517 - margin: 0 0 var(--space-3) 0; 518 - } 576 + {:else if flow.state.step === 'verify'} 577 + <VerificationStep {flow} /> 519 578 520 - .subtitle { 521 - color: var(--text-secondary); 522 - margin: 0 0 var(--space-7) 0; 523 - } 579 + {:else if flow.state.step === 'updated-did-doc'} 580 + <DidDocStep {flow} type="updated" onConfirm={() => flow?.activateAccount()} /> 524 581 525 - .loading { 526 - text-align: center; 527 - color: var(--text-secondary); 528 - } 582 + {:else if flow.state.step === 'activating'} 583 + <div class="loading"> 584 + <div class="spinner md"></div> 585 + <p>{$_('registerPasskey.activatingAccount')}</p> 586 + </div> 587 + {/if} 588 + {/if} 589 + </div> 529 590 591 + <style> 530 592 form { 531 593 display: flex; 532 594 flex-direction: column; 533 595 gap: var(--space-5); 534 596 } 535 597 536 - .required { 537 - color: var(--error-text); 538 - } 539 - 540 - .radio-group { 598 + .actions { 541 599 display: flex; 542 - flex-direction: column; 543 600 gap: var(--space-4); 601 + margin-top: var(--space-2); 544 602 } 545 603 546 - .radio-label { 547 - display: flex; 548 - align-items: flex-start; 549 - gap: var(--space-3); 550 - cursor: pointer; 551 - font-size: var(--text-base); 552 - font-weight: var(--font-normal); 553 - margin-bottom: 0; 604 + .actions button { 605 + flex: 1; 554 606 } 555 607 556 - .radio-label input[type="radio"] { 557 - margin-top: var(--space-1); 558 - width: auto; 608 + .secondary-actions { 609 + display: flex; 610 + justify-content: center; 611 + gap: var(--space-4); 612 + margin-top: var(--space-4); 559 613 } 560 614 561 - .radio-content { 615 + .passkey-step { 562 616 display: flex; 563 617 flex-direction: column; 564 - gap: var(--space-1); 565 - } 566 - 567 - .radio-hint { 568 - font-size: var(--text-xs); 569 - color: var(--text-secondary); 570 - } 571 - 572 - .radio-label.disabled { 573 - opacity: 0.5; 574 - cursor: not-allowed; 575 - } 576 - 577 - .radio-hint.disabled-hint { 578 - color: var(--warning-text); 579 - } 580 - 581 - .warning-box { 582 - margin-top: var(--space-5); 583 - padding: var(--space-5); 584 - background: var(--warning-bg); 585 - border: 1px solid var(--warning-border); 586 - border-radius: var(--radius-lg); 587 - font-size: var(--text-sm); 588 - } 589 - 590 - .warning-box strong { 591 - color: var(--warning-text); 592 - } 593 - 594 - .warning-box ul { 595 - margin: var(--space-4) 0 0 0; 596 - padding-left: var(--space-5); 597 - } 598 - 599 - .warning-box li { 600 - margin-bottom: var(--space-3); 601 - line-height: var(--leading-normal); 602 - } 603 - 604 - .warning-box li:last-child { 605 - margin-bottom: 0; 618 + gap: var(--space-4); 606 619 } 607 620 608 - button[type="submit"] { 609 - margin-top: var(--space-3); 621 + .passkey-step h2 { 622 + margin: 0; 610 623 } 611 624 612 - .link-text { 613 - text-align: center; 614 - margin-top: var(--space-6); 625 + .passkey-step p { 615 626 color: var(--text-secondary); 616 - } 617 - 618 - .link-text a { 619 - color: var(--accent); 627 + margin: 0; 620 628 } 621 629 </style>
-668
frontend/src/routes/RegisterPasskey.svelte
··· 1 - <script lang="ts"> 2 - import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 - import { api, ApiError } from '../lib/api' 4 - import { _ } from '../lib/i18n' 5 - import { 6 - createRegistrationFlow, 7 - restoreRegistrationFlow, 8 - VerificationStep, 9 - KeyChoiceStep, 10 - DidDocStep, 11 - AppPasswordStep, 12 - } from '../lib/registration' 13 - import { 14 - prepareCreationOptions, 15 - serializeAttestationResponse, 16 - type WebAuthnCreationOptionsResponse, 17 - } from '../lib/webauthn' 18 - import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 - 20 - let serverInfo = $state<{ 21 - availableUserDomains: string[] 22 - inviteCodeRequired: boolean 23 - availableCommsChannels?: string[] 24 - selfHostedDidWebEnabled?: boolean 25 - } | null>(null) 26 - let loadingServerInfo = $state(true) 27 - let serverInfoLoaded = false 28 - let ssoAvailable = $state(false) 29 - 30 - let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 31 - let passkeyName = $state('') 32 - 33 - $effect(() => { 34 - if (!serverInfoLoaded) { 35 - serverInfoLoaded = true 36 - loadServerInfo() 37 - checkSsoAvailable() 38 - } 39 - }) 40 - 41 - async function checkSsoAvailable() { 42 - try { 43 - const response = await fetch('/oauth/sso/providers') 44 - if (response.ok) { 45 - const data = await response.json() 46 - ssoAvailable = (data.providers?.length ?? 0) > 0 47 - } 48 - } catch { 49 - ssoAvailable = false 50 - } 51 - } 52 - 53 - $effect(() => { 54 - if (flow?.state.step === 'redirect-to-dashboard') { 55 - navigate('/dashboard') 56 - } 57 - }) 58 - 59 - let creatingStarted = false 60 - $effect(() => { 61 - if (flow?.state.step === 'creating' && !creatingStarted) { 62 - creatingStarted = true 63 - flow.createPasskeyAccount() 64 - } 65 - }) 66 - 67 - async function loadServerInfo() { 68 - try { 69 - const restored = restoreRegistrationFlow() 70 - if (restored && restored.state.mode === 'passkey') { 71 - flow = restored 72 - serverInfo = await api.describeServer() 73 - } else { 74 - serverInfo = await api.describeServer() 75 - const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 76 - flow = createRegistrationFlow('passkey', hostname) 77 - } 78 - } catch (e) { 79 - console.error('Failed to load server info:', e) 80 - } finally { 81 - loadingServerInfo = false 82 - } 83 - } 84 - 85 - function validateInfoStep(): string | null { 86 - if (!flow) return 'Flow not initialized' 87 - const info = flow.info 88 - if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired') 89 - if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots') 90 - if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 91 - return $_('registerPasskey.errors.inviteRequired') 92 - } 93 - if (info.didType === 'web-external') { 94 - if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired') 95 - if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat') 96 - } 97 - switch (info.verificationChannel) { 98 - case 'email': 99 - if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired') 100 - break 101 - case 'discord': 102 - if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired') 103 - break 104 - case 'telegram': 105 - if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired') 106 - break 107 - case 'signal': 108 - if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired') 109 - break 110 - } 111 - return null 112 - } 113 - 114 - async function handleInfoSubmit(e: Event) { 115 - e.preventDefault() 116 - if (!flow) return 117 - 118 - const validationError = validateInfoStep() 119 - if (validationError) { 120 - flow.setError(validationError) 121 - return 122 - } 123 - 124 - if (!window.PublicKeyCredential) { 125 - flow.setError($_('registerPasskey.errors.passkeysNotSupported')) 126 - return 127 - } 128 - 129 - flow.clearError() 130 - flow.proceedFromInfo() 131 - } 132 - 133 - async function handleCreateAccount() { 134 - if (!flow) return 135 - await flow.createPasskeyAccount() 136 - } 137 - 138 - async function handlePasskeyRegistration() { 139 - if (!flow || !flow.account) return 140 - 141 - flow.setSubmitting(true) 142 - flow.clearError() 143 - 144 - try { 145 - const { options } = await api.startPasskeyRegistrationForSetup( 146 - flow.account.did, 147 - flow.account.setupToken!, 148 - passkeyName || undefined 149 - ) 150 - 151 - const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 152 - const credential = await navigator.credentials.create({ 153 - publicKey: publicKeyOptions 154 - }) 155 - 156 - if (!credential) { 157 - flow.setError($_('registerPasskey.errors.passkeyCancelled')) 158 - flow.setSubmitting(false) 159 - return 160 - } 161 - 162 - const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 163 - 164 - const result = await api.completePasskeySetup( 165 - flow.account.did, 166 - flow.account.setupToken!, 167 - credentialResponse, 168 - passkeyName || undefined 169 - ) 170 - 171 - flow.setPasskeyComplete(result.appPassword, result.appPasswordName) 172 - } catch (err) { 173 - if (err instanceof DOMException && err.name === 'NotAllowedError') { 174 - flow.setError($_('registerPasskey.errors.passkeyCancelled')) 175 - } else if (err instanceof ApiError) { 176 - flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed')) 177 - } else if (err instanceof Error) { 178 - flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed')) 179 - } else { 180 - flow.setError($_('registerPasskey.errors.passkeyFailed')) 181 - } 182 - } finally { 183 - flow.setSubmitting(false) 184 - } 185 - } 186 - 187 - async function handleComplete() { 188 - if (flow) { 189 - await flow.finalizeSession() 190 - } 191 - navigate('/dashboard') 192 - } 193 - 194 - function isChannelAvailable(ch: string): boolean { 195 - const available = serverInfo?.availableCommsChannels ?? ['email'] 196 - return available.includes(ch) 197 - } 198 - 199 - function channelLabel(ch: string): string { 200 - switch (ch) { 201 - case 'email': return $_('register.email') 202 - case 'discord': return $_('register.discord') 203 - case 'telegram': return $_('register.telegram') 204 - case 'signal': return $_('register.signal') 205 - default: return ch 206 - } 207 - } 208 - 209 - let fullHandle = $derived(() => { 210 - if (!flow?.info.handle.trim()) return '' 211 - if (flow.info.handle.includes('.')) return flow.info.handle.trim() 212 - const domain = serverInfo?.availableUserDomains?.[0] 213 - if (domain) return `${flow.info.handle.trim()}.${domain}` 214 - return flow.info.handle.trim() 215 - }) 216 - 217 - function extractDomain(did: string): string { 218 - return did.replace('did:web:', '').replace(/%3A/g, ':') 219 - } 220 - 221 - function getSubtitle(): string { 222 - if (!flow) return '' 223 - switch (flow.state.step) { 224 - case 'info': return $_('registerPasskey.subtitle') 225 - case 'key-choice': return $_('registerPasskey.subtitleKeyChoice') 226 - case 'initial-did-doc': return $_('registerPasskey.subtitleInitialDidDoc') 227 - case 'creating': return $_('registerPasskey.subtitleCreating') 228 - case 'passkey': return $_('registerPasskey.subtitlePasskey') 229 - case 'app-password': return $_('registerPasskey.subtitleAppPassword') 230 - case 'verify': return $_('registerPasskey.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 231 - case 'updated-did-doc': return $_('registerPasskey.subtitleUpdatedDidDoc') 232 - case 'activating': return $_('registerPasskey.subtitleActivating') 233 - case 'redirect-to-dashboard': return $_('registerPasskey.subtitleComplete') 234 - default: return '' 235 - } 236 - } 237 - </script> 238 - 239 - <div class="register-page"> 240 - <header class="page-header"> 241 - <h1>{$_('registerPasskey.title')}</h1> 242 - <p class="subtitle">{getSubtitle()}</p> 243 - </header> 244 - 245 - {#if flow?.state.error} 246 - <div class="message error">{flow.state.error}</div> 247 - {/if} 248 - 249 - {#if loadingServerInfo || !flow} 250 - <div class="loading"></div> 251 - 252 - {:else if flow.state.step === 'info'} 253 - <div class="migrate-callout"> 254 - <div class="migrate-icon">โ†—</div> 255 - <div class="migrate-content"> 256 - <strong>{$_('register.migrateTitle')}</strong> 257 - <p>{$_('register.migrateDescription')}</p> 258 - <a href={getFullUrl(routes.migrate)} class="migrate-link"> 259 - {$_('register.migrateLink')} โ†’ 260 - </a> 261 - </div> 262 - </div> 263 - 264 - <AccountTypeSwitcher active="passkey" {ssoAvailable} /> 265 - 266 - <div class="split-layout sidebar-right"> 267 - <div class="form-section"> 268 - <form onsubmit={handleInfoSubmit}> 269 - <div class="field"> 270 - <label for="handle">{$_('registerPasskey.handle')}</label> 271 - <input 272 - id="handle" 273 - type="text" 274 - bind:value={flow.info.handle} 275 - placeholder={$_('registerPasskey.handlePlaceholder')} 276 - disabled={flow.state.submitting} 277 - required 278 - /> 279 - {#if flow.info.handle.includes('.')} 280 - <p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p> 281 - {:else if fullHandle()} 282 - <p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p> 283 - {/if} 284 - </div> 285 - 286 - <fieldset class="section-fieldset"> 287 - <legend>{$_('registerPasskey.contactMethod')}</legend> 288 - <p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p> 289 - <div class="field"> 290 - <label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label> 291 - <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 292 - <option value="email">{$_('register.email')}</option> 293 - <option value="discord" disabled={!isChannelAvailable('discord')}> 294 - {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 295 - </option> 296 - <option value="telegram" disabled={!isChannelAvailable('telegram')}> 297 - {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 298 - </option> 299 - <option value="signal" disabled={!isChannelAvailable('signal')}> 300 - {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 301 - </option> 302 - </select> 303 - </div> 304 - {#if flow.info.verificationChannel === 'email'} 305 - <div class="field"> 306 - <label for="email">{$_('registerPasskey.email')}</label> 307 - <input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required /> 308 - </div> 309 - {:else if flow.info.verificationChannel === 'discord'} 310 - <div class="field"> 311 - <label for="discord-id">{$_('register.discordId')}</label> 312 - <input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required /> 313 - <p class="hint">{$_('register.discordIdHint')}</p> 314 - </div> 315 - {:else if flow.info.verificationChannel === 'telegram'} 316 - <div class="field"> 317 - <label for="telegram-username">{$_('register.telegramUsername')}</label> 318 - <input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required /> 319 - </div> 320 - {:else if flow.info.verificationChannel === 'signal'} 321 - <div class="field"> 322 - <label for="signal-number">{$_('register.signalNumber')}</label> 323 - <input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required /> 324 - <p class="hint">{$_('register.signalNumberHint')}</p> 325 - </div> 326 - {/if} 327 - </fieldset> 328 - 329 - <fieldset class="section-fieldset"> 330 - <legend>{$_('registerPasskey.identityType')}</legend> 331 - <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p> 332 - <div class="radio-group"> 333 - <label class="radio-label"> 334 - <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 335 - <span class="radio-content"> 336 - <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 337 - <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 338 - </span> 339 - </label> 340 - <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 341 - <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 342 - <span class="radio-content"> 343 - <strong>{$_('registerPasskey.didWeb')}</strong> 344 - {#if serverInfo?.selfHostedDidWebEnabled === false} 345 - <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 346 - {:else} 347 - <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 348 - {/if} 349 - </span> 350 - </label> 351 - <label class="radio-label"> 352 - <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 353 - <span class="radio-content"> 354 - <strong>{$_('registerPasskey.didWebBYOD')}</strong> 355 - <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 356 - </span> 357 - </label> 358 - </div> 359 - {#if flow.info.didType === 'web'} 360 - <div class="warning-box"> 361 - <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 362 - <ul> 363 - <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 364 - <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 365 - <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 366 - <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> 367 - </ul> 368 - </div> 369 - {/if} 370 - {#if flow.info.didType === 'web-external'} 371 - <div class="field"> 372 - <label for="external-did">{$_('registerPasskey.externalDid')}</label> 373 - <input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required /> 374 - <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 375 - </div> 376 - {/if} 377 - </fieldset> 378 - 379 - {#if serverInfo?.inviteCodeRequired} 380 - <div class="field"> 381 - <label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label> 382 - <input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required /> 383 - </div> 384 - {/if} 385 - 386 - <button type="submit" disabled={flow.state.submitting}> 387 - {flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')} 388 - </button> 389 - </form> 390 - 391 - <div class="form-links"> 392 - <p class="link-text"> 393 - {$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a> 394 - </p> 395 - </div> 396 - </div> 397 - 398 - <aside class="info-panel"> 399 - <h3>{$_('registerPasskey.infoWhyPasskey')}</h3> 400 - <p>{$_('registerPasskey.infoWhyPasskeyDesc')}</p> 401 - 402 - <h3>{$_('registerPasskey.infoHowItWorks')}</h3> 403 - <p>{$_('registerPasskey.infoHowItWorksDesc')}</p> 404 - 405 - <h3>{$_('registerPasskey.infoAppAccess')}</h3> 406 - <p>{$_('registerPasskey.infoAppAccessDesc')}</p> 407 - </aside> 408 - </div> 409 - 410 - 411 - {:else if flow.state.step === 'key-choice'} 412 - <KeyChoiceStep {flow} /> 413 - 414 - {:else if flow.state.step === 'initial-did-doc'} 415 - <DidDocStep 416 - {flow} 417 - type="initial" 418 - onConfirm={handleCreateAccount} 419 - onBack={() => flow?.goBack()} 420 - /> 421 - 422 - {:else if flow.state.step === 'creating'} 423 - <p class="loading">{$_('registerPasskey.subtitleCreating')}</p> 424 - 425 - {:else if flow.state.step === 'passkey'} 426 - <div class="step-content"> 427 - <div class="field"> 428 - <label for="passkey-name">{$_('registerPasskey.passkeyNameLabel')}</label> 429 - <input id="passkey-name" type="text" bind:value={passkeyName} placeholder={$_('registerPasskey.passkeyNamePlaceholder')} disabled={flow.state.submitting} /> 430 - <p class="hint">{$_('registerPasskey.passkeyNameHint')}</p> 431 - </div> 432 - 433 - <div class="info-box"> 434 - <p>{$_('registerPasskey.passkeyPrompt')}</p> 435 - <ul> 436 - <li>{$_('registerPasskey.passkeyPromptBullet1')}</li> 437 - <li>{$_('registerPasskey.passkeyPromptBullet2')}</li> 438 - <li>{$_('registerPasskey.passkeyPromptBullet3')}</li> 439 - </ul> 440 - </div> 441 - 442 - <button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn"> 443 - {flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')} 444 - </button> 445 - 446 - <button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}> 447 - {$_('registerPasskey.back')} 448 - </button> 449 - </div> 450 - 451 - {:else if flow.state.step === 'app-password'} 452 - <AppPasswordStep {flow} /> 453 - 454 - {:else if flow.state.step === 'verify'} 455 - <VerificationStep {flow} /> 456 - 457 - {:else if flow.state.step === 'updated-did-doc'} 458 - <DidDocStep 459 - {flow} 460 - type="updated" 461 - onConfirm={() => flow?.activateAccount()} 462 - /> 463 - 464 - {:else if flow.state.step === 'redirect-to-dashboard'} 465 - <p class="loading">{$_('registerPasskey.redirecting')}</p> 466 - {/if} 467 - </div> 468 - 469 - <style> 470 - .register-page { 471 - max-width: var(--width-lg); 472 - margin: var(--space-9) auto; 473 - padding: var(--space-7); 474 - } 475 - 476 - .page-header { 477 - margin-bottom: var(--space-6); 478 - } 479 - 480 - .form-section { 481 - min-width: 0; 482 - } 483 - 484 - .form-links { 485 - margin-top: var(--space-6); 486 - } 487 - 488 - .link-text { 489 - text-align: center; 490 - color: var(--text-secondary); 491 - } 492 - 493 - .link-text a { 494 - color: var(--accent); 495 - } 496 - 497 - .migrate-callout { 498 - display: flex; 499 - gap: var(--space-4); 500 - padding: var(--space-5); 501 - background: var(--accent-muted); 502 - border: 1px solid var(--accent); 503 - border-radius: var(--radius-xl); 504 - margin-bottom: var(--space-6); 505 - } 506 - 507 - .migrate-icon { 508 - font-size: var(--text-2xl); 509 - line-height: 1; 510 - color: var(--accent); 511 - } 512 - 513 - .migrate-content { 514 - flex: 1; 515 - } 516 - 517 - .migrate-content strong { 518 - display: block; 519 - color: var(--text-primary); 520 - margin-bottom: var(--space-2); 521 - } 522 - 523 - .migrate-content p { 524 - margin: 0 0 var(--space-3) 0; 525 - font-size: var(--text-sm); 526 - color: var(--text-secondary); 527 - line-height: var(--leading-relaxed); 528 - } 529 - 530 - .migrate-link { 531 - font-size: var(--text-sm); 532 - font-weight: var(--font-medium); 533 - color: var(--accent); 534 - text-decoration: none; 535 - } 536 - 537 - .migrate-link:hover { 538 - text-decoration: underline; 539 - } 540 - 541 - h1 { 542 - margin: 0 0 var(--space-3) 0; 543 - } 544 - 545 - .subtitle { 546 - color: var(--text-secondary); 547 - margin: 0 0 var(--space-7) 0; 548 - } 549 - 550 - .loading { 551 - text-align: center; 552 - color: var(--text-secondary); 553 - } 554 - 555 - form, .step-content { 556 - display: flex; 557 - flex-direction: column; 558 - gap: var(--space-4); 559 - } 560 - 561 - .required { 562 - color: var(--error-text); 563 - } 564 - 565 - .section-hint { 566 - font-size: var(--text-sm); 567 - color: var(--text-secondary); 568 - margin: 0 0 var(--space-5) 0; 569 - } 570 - 571 - .radio-group { 572 - display: flex; 573 - flex-direction: column; 574 - gap: var(--space-4); 575 - } 576 - 577 - .radio-label { 578 - display: flex; 579 - align-items: flex-start; 580 - gap: var(--space-3); 581 - cursor: pointer; 582 - font-size: var(--text-base); 583 - font-weight: var(--font-normal); 584 - margin-bottom: 0; 585 - } 586 - 587 - .radio-label input[type="radio"] { 588 - margin-top: var(--space-1); 589 - width: auto; 590 - } 591 - 592 - .radio-content { 593 - display: flex; 594 - flex-direction: column; 595 - gap: var(--space-1); 596 - } 597 - 598 - .radio-hint { 599 - font-size: var(--text-xs); 600 - color: var(--text-secondary); 601 - } 602 - 603 - .radio-label.disabled { 604 - opacity: 0.5; 605 - cursor: not-allowed; 606 - } 607 - 608 - .radio-hint.disabled-hint { 609 - color: var(--warning-text); 610 - } 611 - 612 - .warning-box { 613 - margin-top: var(--space-5); 614 - padding: var(--space-5); 615 - background: var(--warning-bg); 616 - border: 1px solid var(--warning-border); 617 - border-radius: var(--radius-lg); 618 - font-size: var(--text-sm); 619 - } 620 - 621 - .warning-box strong { 622 - display: block; 623 - margin-bottom: var(--space-3); 624 - color: var(--warning-text); 625 - } 626 - 627 - .warning-box ul { 628 - margin: var(--space-4) 0 0 0; 629 - padding-left: var(--space-5); 630 - } 631 - 632 - .warning-box li { 633 - margin-bottom: var(--space-3); 634 - line-height: var(--leading-normal); 635 - } 636 - 637 - .warning-box li:last-child { 638 - margin-bottom: 0; 639 - } 640 - 641 - .info-box { 642 - background: var(--bg-secondary); 643 - border: 1px solid var(--border-color); 644 - border-radius: var(--radius-lg); 645 - padding: var(--space-5); 646 - font-size: var(--text-sm); 647 - } 648 - 649 - .info-box p { 650 - margin: 0 0 var(--space-3) 0; 651 - color: var(--text-secondary); 652 - } 653 - 654 - .info-box ul { 655 - margin: 0; 656 - padding-left: var(--space-5); 657 - color: var(--text-secondary); 658 - } 659 - 660 - .info-box li { 661 - margin-bottom: var(--space-2); 662 - } 663 - 664 - .passkey-btn { 665 - padding: var(--space-5); 666 - font-size: var(--text-lg); 667 - } 668 - </style>
+560
frontend/src/routes/RegisterPassword.svelte
··· 1 + <script lang="ts"> 2 + import { navigate, routes, getFullUrl } from '../lib/router.svelte' 3 + import { api, ApiError } from '../lib/api' 4 + import { _ } from '../lib/i18n' 5 + import { 6 + createRegistrationFlow, 7 + restoreRegistrationFlow, 8 + VerificationStep, 9 + KeyChoiceStep, 10 + DidDocStep, 11 + } from '../lib/registration' 12 + import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 13 + import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 14 + 15 + let serverInfo = $state<{ 16 + availableUserDomains: string[] 17 + inviteCodeRequired: boolean 18 + availableCommsChannels?: string[] 19 + selfHostedDidWebEnabled?: boolean 20 + } | null>(null) 21 + let loadingServerInfo = $state(true) 22 + let serverInfoLoaded = false 23 + let ssoAvailable = $state(false) 24 + 25 + let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 26 + let confirmPassword = $state('') 27 + let clientName = $state<string | null>(null) 28 + 29 + $effect(() => { 30 + if (!serverInfoLoaded) { 31 + serverInfoLoaded = true 32 + ensureRequestUri().then((requestUri) => { 33 + if (!requestUri) return 34 + loadServerInfo() 35 + checkSsoAvailable() 36 + fetchClientName() 37 + }).catch((err) => { 38 + console.error('Failed to ensure OAuth request URI:', err) 39 + }) 40 + } 41 + }) 42 + 43 + async function fetchClientName() { 44 + const requestUri = getRequestUriFromUrl() 45 + if (!requestUri) return 46 + 47 + try { 48 + const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { 49 + headers: { 'Accept': 'application/json' } 50 + }) 51 + if (response.ok) { 52 + const data = await response.json() 53 + clientName = data.client_name || null 54 + } 55 + } catch { 56 + clientName = null 57 + } 58 + } 59 + 60 + async function checkSsoAvailable() { 61 + try { 62 + const response = await fetch('/oauth/sso/providers') 63 + if (response.ok) { 64 + const data = await response.json() 65 + ssoAvailable = (data.providers?.length ?? 0) > 0 66 + } 67 + } catch { 68 + ssoAvailable = false 69 + } 70 + } 71 + 72 + $effect(() => { 73 + if (flow?.state.step === 'redirect-to-dashboard') { 74 + completeOAuthRegistration() 75 + } 76 + }) 77 + 78 + let creatingStarted = false 79 + $effect(() => { 80 + if (flow?.state.step === 'creating' && !creatingStarted) { 81 + creatingStarted = true 82 + flow.createPasswordAccount() 83 + } 84 + }) 85 + 86 + async function loadServerInfo() { 87 + try { 88 + const restored = restoreRegistrationFlow() 89 + if (restored && restored.state.mode === 'password') { 90 + flow = restored 91 + serverInfo = await api.describeServer() 92 + } else { 93 + serverInfo = await api.describeServer() 94 + const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 95 + flow = createRegistrationFlow('password', hostname) 96 + } 97 + } catch (e) { 98 + console.error('Failed to load server info:', e) 99 + } finally { 100 + loadingServerInfo = false 101 + } 102 + } 103 + 104 + function validateInfoStep(): string | null { 105 + if (!flow) return 'Flow not initialized' 106 + const info = flow.info 107 + if (!info.handle.trim()) return $_('register.validation.handleRequired') 108 + if (info.handle.includes('.')) return $_('register.validation.handleNoDots') 109 + if (!info.password) return $_('register.validation.passwordRequired') 110 + if (info.password.length < 8) return $_('register.validation.passwordLength') 111 + if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 112 + if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 113 + return $_('register.validation.inviteCodeRequired') 114 + } 115 + if (info.didType === 'web-external') { 116 + if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired') 117 + if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 118 + } 119 + switch (info.verificationChannel) { 120 + case 'email': 121 + if (!info.email.trim()) return $_('register.validation.emailRequired') 122 + break 123 + case 'discord': 124 + if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 125 + break 126 + case 'telegram': 127 + if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 128 + break 129 + case 'signal': 130 + if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 131 + break 132 + } 133 + return null 134 + } 135 + 136 + async function handleInfoSubmit(e: Event) { 137 + e.preventDefault() 138 + if (!flow) return 139 + 140 + const validationError = validateInfoStep() 141 + if (validationError) { 142 + flow.setError(validationError) 143 + return 144 + } 145 + 146 + flow.clearError() 147 + flow.proceedFromInfo() 148 + } 149 + 150 + async function handleCreateAccount() { 151 + if (!flow) return 152 + await flow.createPasswordAccount() 153 + } 154 + 155 + async function handleComplete() { 156 + if (flow) { 157 + await flow.finalizeSession() 158 + } 159 + navigate(routes.dashboard) 160 + } 161 + 162 + async function completeOAuthRegistration() { 163 + const requestUri = getRequestUriFromUrl() 164 + if (!requestUri || !flow?.account) { 165 + navigate(routes.dashboard) 166 + return 167 + } 168 + 169 + try { 170 + const response = await fetch('/oauth/register/complete', { 171 + method: 'POST', 172 + headers: { 173 + 'Content-Type': 'application/json', 174 + 'Accept': 'application/json', 175 + }, 176 + body: JSON.stringify({ 177 + request_uri: requestUri, 178 + did: flow.account.did, 179 + app_password: flow.account.appPassword, 180 + }), 181 + }) 182 + 183 + const data = await response.json() 184 + 185 + if (!response.ok) { 186 + flow.setError(data.error_description || data.error || $_('common.error')) 187 + return 188 + } 189 + 190 + if (data.redirect_uri) { 191 + window.location.href = data.redirect_uri 192 + return 193 + } 194 + 195 + navigate(routes.dashboard) 196 + } catch (err) { 197 + console.error('OAuth registration completion failed:', err) 198 + flow.setError(err instanceof Error ? err.message : $_('common.error')) 199 + } 200 + } 201 + 202 + function isChannelAvailable(ch: string): boolean { 203 + const available = serverInfo?.availableCommsChannels ?? ['email'] 204 + return available.includes(ch) 205 + } 206 + 207 + function channelLabel(ch: string): string { 208 + switch (ch) { 209 + case 'email': return $_('register.email') 210 + case 'discord': return $_('register.discord') 211 + case 'telegram': return $_('register.telegram') 212 + case 'signal': return $_('register.signal') 213 + default: return ch 214 + } 215 + } 216 + 217 + let fullHandle = $derived(() => { 218 + if (!flow?.info.handle.trim()) return '' 219 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 220 + const domain = serverInfo?.availableUserDomains?.[0] 221 + if (domain) return `${flow.info.handle.trim()}.${domain}` 222 + return flow.info.handle.trim() 223 + }) 224 + 225 + function extractDomain(did: string): string { 226 + return did.replace('did:web:', '').replace(/%3A/g, ':') 227 + } 228 + 229 + function getSubtitle(): string { 230 + if (!flow) return '' 231 + switch (flow.state.step) { 232 + case 'info': return $_('register.subtitle') 233 + case 'key-choice': return $_('register.subtitleKeyChoice') 234 + case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 235 + case 'creating': return $_('common.creating') 236 + case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 237 + case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 238 + case 'activating': return $_('register.subtitleActivating') 239 + case 'redirect-to-dashboard': return $_('register.subtitleComplete') 240 + default: return '' 241 + } 242 + } 243 + </script> 244 + 245 + <div class="page"> 246 + <header class="page-header"> 247 + <h1>{$_('register.title')}</h1> 248 + <p class="subtitle">{getSubtitle()}</p> 249 + {#if clientName} 250 + <p class="client-name">{$_('oauth.login.subtitle')} <strong>{clientName}</strong></p> 251 + {/if} 252 + </header> 253 + 254 + {#if flow?.state.error} 255 + <div class="message error">{flow.state.error}</div> 256 + {/if} 257 + 258 + {#if loadingServerInfo || !flow} 259 + <div class="loading"> 260 + <div class="spinner md"></div> 261 + </div> 262 + {:else if flow.state.step === 'info'} 263 + <div class="migrate-callout"> 264 + <div class="migrate-icon">โ†—</div> 265 + <div class="migrate-content"> 266 + <strong>{$_('register.migrateTitle')}</strong> 267 + <p>{$_('register.migrateDescription')}</p> 268 + <a href={getFullUrl(routes.migrate)} class="migrate-link"> 269 + {$_('register.migrateLink')} โ†’ 270 + </a> 271 + </div> 272 + </div> 273 + 274 + <AccountTypeSwitcher active="password" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} /> 275 + 276 + <div class="split-layout sidebar-right"> 277 + <div class="form-section"> 278 + <form onsubmit={handleInfoSubmit}> 279 + <div class="field"> 280 + <label for="handle">{$_('register.handle')}</label> 281 + <input 282 + id="handle" 283 + type="text" 284 + bind:value={flow.info.handle} 285 + placeholder={$_('register.handlePlaceholder')} 286 + disabled={flow.state.submitting} 287 + required 288 + /> 289 + {#if flow.info.handle.includes('.')} 290 + <p class="hint warning">{$_('register.handleDotWarning')}</p> 291 + {:else if fullHandle()} 292 + <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 293 + {/if} 294 + </div> 295 + 296 + <div class="form-row"> 297 + <div class="field"> 298 + <label for="password">{$_('register.password')}</label> 299 + <input 300 + id="password" 301 + type="password" 302 + bind:value={flow.info.password} 303 + placeholder={$_('register.passwordPlaceholder')} 304 + disabled={flow.state.submitting} 305 + required 306 + minlength="8" 307 + /> 308 + </div> 309 + 310 + <div class="field"> 311 + <label for="confirm-password">{$_('register.confirmPassword')}</label> 312 + <input 313 + id="confirm-password" 314 + type="password" 315 + bind:value={confirmPassword} 316 + placeholder={$_('register.confirmPasswordPlaceholder')} 317 + disabled={flow.state.submitting} 318 + required 319 + /> 320 + </div> 321 + </div> 322 + 323 + <fieldset class="section-fieldset"> 324 + <legend>{$_('register.identityType')}</legend> 325 + <div class="radio-group"> 326 + <label class="radio-label"> 327 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 328 + <span class="radio-content"> 329 + <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 330 + <span class="radio-hint">{$_('register.didPlcHint')}</span> 331 + </span> 332 + </label> 333 + 334 + <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 335 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 336 + <span class="radio-content"> 337 + <strong>{$_('register.didWeb')}</strong> 338 + {#if serverInfo?.selfHostedDidWebEnabled === false} 339 + <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span> 340 + {:else} 341 + <span class="radio-hint">{$_('register.didWebHint')}</span> 342 + {/if} 343 + </span> 344 + </label> 345 + 346 + <label class="radio-label"> 347 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 348 + <span class="radio-content"> 349 + <strong>{$_('register.didWebBYOD')}</strong> 350 + <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 351 + </span> 352 + </label> 353 + </div> 354 + 355 + {#if flow.info.didType === 'web'} 356 + <div class="warning-box"> 357 + <strong>{$_('register.didWebWarningTitle')}</strong> 358 + <ul> 359 + <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 360 + <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 361 + <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 362 + <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 363 + </ul> 364 + </div> 365 + {/if} 366 + 367 + {#if flow.info.didType === 'web-external'} 368 + <div class="field"> 369 + <label for="external-did">{$_('register.externalDid')}</label> 370 + <input 371 + id="external-did" 372 + type="text" 373 + bind:value={flow.info.externalDid} 374 + placeholder={$_('register.externalDidPlaceholder')} 375 + disabled={flow.state.submitting} 376 + required 377 + /> 378 + <p class="hint">{$_('register.externalDidHint')}</p> 379 + </div> 380 + {/if} 381 + </fieldset> 382 + 383 + <fieldset class="section-fieldset"> 384 + <legend>{$_('register.contactMethod')}</legend> 385 + <div class="contact-fields"> 386 + <div class="field"> 387 + <label for="verification-channel">{$_('register.verificationMethod')}</label> 388 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 389 + <option value="email">{$_('register.email')}</option> 390 + <option value="discord" disabled={!isChannelAvailable('discord')}> 391 + {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 392 + </option> 393 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 394 + {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 395 + </option> 396 + <option value="signal" disabled={!isChannelAvailable('signal')}> 397 + {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 398 + </option> 399 + </select> 400 + </div> 401 + 402 + {#if flow.info.verificationChannel === 'email'} 403 + <div class="field"> 404 + <label for="email">{$_('register.emailAddress')}</label> 405 + <input 406 + id="email" 407 + type="email" 408 + bind:value={flow.info.email} 409 + onblur={() => flow?.checkEmailInUse(flow.info.email)} 410 + placeholder={$_('register.emailPlaceholder')} 411 + disabled={flow.state.submitting} 412 + required 413 + /> 414 + {#if flow.state.emailInUse} 415 + <p class="hint warning">{$_('register.emailInUseWarning')}</p> 416 + {/if} 417 + </div> 418 + {:else if flow.info.verificationChannel === 'discord'} 419 + <div class="field"> 420 + <label for="discord-id">{$_('register.discordId')}</label> 421 + <input 422 + id="discord-id" 423 + type="text" 424 + bind:value={flow.info.discordId} 425 + onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')} 426 + placeholder={$_('register.discordIdPlaceholder')} 427 + disabled={flow.state.submitting} 428 + required 429 + /> 430 + <p class="hint">{$_('register.discordIdHint')}</p> 431 + {#if flow.state.discordInUse} 432 + <p class="hint warning">{$_('register.discordInUseWarning')}</p> 433 + {/if} 434 + </div> 435 + {:else if flow.info.verificationChannel === 'telegram'} 436 + <div class="field"> 437 + <label for="telegram-username">{$_('register.telegramUsername')}</label> 438 + <input 439 + id="telegram-username" 440 + type="text" 441 + bind:value={flow.info.telegramUsername} 442 + onblur={() => flow?.checkCommsChannelInUse('telegram', flow.info.telegramUsername ?? '')} 443 + placeholder={$_('register.telegramUsernamePlaceholder')} 444 + disabled={flow.state.submitting} 445 + required 446 + /> 447 + {#if flow.state.telegramInUse} 448 + <p class="hint warning">{$_('register.telegramInUseWarning')}</p> 449 + {/if} 450 + </div> 451 + {:else if flow.info.verificationChannel === 'signal'} 452 + <div class="field"> 453 + <label for="signal-number">{$_('register.signalNumber')}</label> 454 + <input 455 + id="signal-number" 456 + type="tel" 457 + bind:value={flow.info.signalNumber} 458 + onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')} 459 + placeholder={$_('register.signalNumberPlaceholder')} 460 + disabled={flow.state.submitting} 461 + required 462 + /> 463 + <p class="hint">{$_('register.signalNumberHint')}</p> 464 + {#if flow.state.signalInUse} 465 + <p class="hint warning">{$_('register.signalInUseWarning')}</p> 466 + {/if} 467 + </div> 468 + {/if} 469 + </div> 470 + </fieldset> 471 + 472 + {#if serverInfo?.inviteCodeRequired} 473 + <div class="field"> 474 + <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 475 + <input 476 + id="invite-code" 477 + type="text" 478 + bind:value={flow.info.inviteCode} 479 + placeholder={$_('register.inviteCodePlaceholder')} 480 + disabled={flow.state.submitting} 481 + required 482 + /> 483 + </div> 484 + {/if} 485 + 486 + <button type="submit" disabled={flow.state.submitting}> 487 + {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 488 + </button> 489 + </form> 490 + 491 + <div class="form-links"> 492 + <p class="link-text"> 493 + {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a> 494 + </p> 495 + </div> 496 + </div> 497 + 498 + <aside class="info-panel"> 499 + <h3>{$_('register.identityHint')}</h3> 500 + <p>{$_('register.infoIdentityDesc')}</p> 501 + 502 + <h3>{$_('register.contactMethodHint')}</h3> 503 + <p>{$_('register.infoContactDesc')}</p> 504 + 505 + <h3>{$_('register.infoNextTitle')}</h3> 506 + <p>{$_('register.infoNextDesc')}</p> 507 + </aside> 508 + </div> 509 + 510 + {:else if flow.state.step === 'key-choice'} 511 + <KeyChoiceStep {flow} /> 512 + 513 + {:else if flow.state.step === 'initial-did-doc'} 514 + <DidDocStep 515 + {flow} 516 + type="initial" 517 + onConfirm={handleCreateAccount} 518 + onBack={() => flow?.goBack()} 519 + /> 520 + 521 + {:else if flow.state.step === 'creating'} 522 + <div class="loading"> 523 + <div class="spinner md"></div> 524 + <p>{$_('common.creating')}</p> 525 + </div> 526 + 527 + {:else if flow.state.step === 'verify'} 528 + <VerificationStep {flow} /> 529 + 530 + {:else if flow.state.step === 'updated-did-doc'} 531 + <DidDocStep 532 + {flow} 533 + type="updated" 534 + onConfirm={() => flow?.activateAccount()} 535 + /> 536 + 537 + {:else if flow.state.step === 'redirect-to-dashboard'} 538 + <div class="loading"> 539 + <div class="spinner md"></div> 540 + <p>{$_('register.redirecting')}</p> 541 + </div> 542 + {/if} 543 + </div> 544 + 545 + <style> 546 + .client-name { 547 + color: var(--text-secondary); 548 + margin-top: var(--space-2); 549 + } 550 + 551 + form { 552 + display: flex; 553 + flex-direction: column; 554 + gap: var(--space-5); 555 + } 556 + 557 + button[type="submit"] { 558 + margin-top: var(--space-3); 559 + } 560 + </style>
+40 -114
frontend/src/routes/RegisterSso.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte' 3 2 import { _ } from '../lib/i18n' 4 3 import { getFullUrl } from '../lib/router.svelte' 5 4 import { routes } from '../lib/types/routes' 6 5 import { toast } from '../lib/toast.svelte' 7 6 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 8 7 import SsoIcon from '../components/SsoIcon.svelte' 8 + import { ensureRequestUri, getRequestUriFromUrl, getOAuthRequestUri } from '../lib/oauth' 9 9 10 10 interface SsoProvider { 11 11 provider: string ··· 16 16 let providers = $state<SsoProvider[]>([]) 17 17 let loading = $state(true) 18 18 let initiating = $state<string | null>(null) 19 - 20 - onMount(() => { 21 - fetchProviders() 19 + let initialized = false 20 + 21 + $effect(() => { 22 + if (!initialized) { 23 + initialized = true 24 + ensureRequestUri().then((requestUri) => { 25 + if (!requestUri) return 26 + fetchProviders() 27 + }).catch((err) => { 28 + console.error('Failed to ensure OAuth request URI:', err) 29 + toast.error($_('common.error')) 30 + }) 31 + } 22 32 }) 23 33 24 34 async function fetchProviders() { ··· 26 36 const response = await fetch('/oauth/sso/providers') 27 37 if (response.ok) { 28 38 const data = await response.json() 29 - providers = data.providers || [] 39 + providers = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name)) 30 40 } 31 - } catch { 32 - toast.error($_('common.error')) 41 + } catch (err) { 42 + console.error('Failed to fetch SSO providers:', err) 43 + toast.error(err instanceof Error ? err.message : $_('common.error')) 33 44 } finally { 34 45 loading = false 35 46 } ··· 37 48 38 49 async function initiateRegistration(provider: string) { 39 50 initiating = provider 51 + let requestUri = getRequestUriFromUrl() 40 52 41 53 try { 42 - const response = await fetch('/oauth/sso/initiate', { 54 + let response = await fetch('/oauth/sso/initiate', { 43 55 method: 'POST', 44 56 headers: { 45 57 'Content-Type': 'application/json', ··· 48 60 body: JSON.stringify({ 49 61 provider, 50 62 action: 'register', 63 + request_uri: requestUri, 51 64 }), 52 65 }) 53 66 54 - const data = await response.json() 67 + let data = await response.json() 55 68 56 69 if (!response.ok) { 57 - toast.error(data.error_description || data.error || $_('common.error')) 58 - initiating = null 70 + console.log('SSO initiate failed, restarting OAuth flow', data) 71 + try { 72 + const newRequestUri = await getOAuthRequestUri('create') 73 + const url = new URL(window.location.href) 74 + url.searchParams.set('request_uri', newRequestUri) 75 + window.location.href = url.toString() 76 + } catch (e) { 77 + console.error('Failed to restart OAuth flow:', e) 78 + toast.error(data.message || data.error || $_('common.error')) 79 + initiating = null 80 + } 59 81 return 60 82 } 61 83 ··· 66 88 67 89 toast.error($_('common.error')) 68 90 initiating = null 69 - } catch { 70 - toast.error($_('common.error')) 91 + } catch (err) { 92 + console.error('SSO registration initiation failed:', err) 93 + toast.error(err instanceof Error ? err.message : $_('common.error')) 71 94 initiating = null 72 95 } 73 96 } 74 97 </script> 75 98 76 - <div class="register-sso-page"> 99 + <div class="page"> 77 100 <header class="page-header"> 78 101 <h1>{$_('register.title')}</h1> 79 102 <p class="subtitle">{$_('register.ssoSubtitle')}</p> ··· 90 113 </div> 91 114 </div> 92 115 93 - <AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} /> 116 + <AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} oauthRequestUri={getRequestUriFromUrl()} /> 94 117 95 118 {#if loading} 96 119 <div class="loading"> 97 - <div class="spinner"></div> 120 + <div class="spinner md"></div> 98 121 </div> 99 122 {:else if providers.length === 0} 100 123 <div class="no-providers"> ··· 132 155 </div> 133 156 134 157 <style> 135 - .register-sso-page { 136 - max-width: var(--width-lg); 137 - margin: var(--space-9) auto; 138 - padding: var(--space-7); 139 - } 140 - 141 - .page-header { 142 - margin-bottom: var(--space-6); 143 - } 144 - 145 - .page-header h1 { 146 - margin: 0 0 var(--space-3) 0; 147 - } 148 - 149 - .subtitle { 150 - color: var(--text-secondary); 151 - margin: 0; 152 - } 153 - 154 - .migrate-callout { 155 - display: flex; 156 - gap: var(--space-4); 157 - padding: var(--space-5); 158 - background: var(--accent-muted); 159 - border: 1px solid var(--accent); 160 - border-radius: var(--radius-xl); 161 - margin-bottom: var(--space-6); 162 - } 163 - 164 - .migrate-icon { 165 - font-size: var(--text-2xl); 166 - line-height: 1; 167 - color: var(--accent); 168 - } 169 - 170 - .migrate-content { 171 - flex: 1; 172 - } 173 - 174 - .migrate-content strong { 175 - display: block; 176 - color: var(--text-primary); 177 - margin-bottom: var(--space-2); 178 - } 179 - 180 - .migrate-content p { 181 - margin: 0 0 var(--space-3) 0; 182 - font-size: var(--text-sm); 183 - color: var(--text-secondary); 184 - line-height: var(--leading-relaxed); 185 - } 186 - 187 - .migrate-link { 188 - font-size: var(--text-sm); 189 - font-weight: var(--font-medium); 190 - color: var(--accent); 191 - text-decoration: none; 192 - } 193 - 194 - .migrate-link:hover { 195 - text-decoration: underline; 196 - } 197 - 198 - .loading { 199 - display: flex; 200 - justify-content: center; 201 - padding: var(--space-8); 202 - } 203 - 204 - .spinner { 205 - width: 32px; 206 - height: 32px; 207 - border: 3px solid var(--border-color); 208 - border-top-color: var(--accent); 209 - border-radius: 50%; 210 - animation: spin 1s linear infinite; 211 - } 212 - 213 - @keyframes spin { 214 - to { 215 - transform: rotate(360deg); 216 - } 217 - } 218 - 219 158 .no-providers { 220 159 text-align: center; 221 160 padding: var(--space-8); ··· 274 213 cursor: not-allowed; 275 214 } 276 215 277 - .provider-name { 216 + .provider-button .provider-name { 278 217 flex: 1; 279 218 } 280 - 281 - .form-links { 282 - margin-top: var(--space-8); 283 - } 284 - 285 - .link-text { 286 - text-align: center; 287 - color: var(--text-secondary); 288 - } 289 - 290 - .link-text a { 291 - color: var(--accent); 292 - } 293 219 </style>
+7 -16
frontend/src/routes/RepoExplorer.svelte
··· 561 561 562 562 .did { 563 563 margin: var(--space-1) 0 0 0; 564 - font-family: monospace; 564 + font-family: var(--font-mono); 565 565 font-size: var(--text-xs); 566 566 color: var(--text-muted); 567 567 word-break: break-all; ··· 583 583 } 584 584 585 585 .error-code { 586 - font-family: monospace; 586 + font-family: var(--font-mono); 587 587 font-size: var(--text-sm); 588 588 opacity: 0.9; 589 589 } ··· 788 788 } 789 789 790 790 .rkey { 791 - font-family: monospace; 791 + font-family: var(--font-mono); 792 792 font-weight: var(--font-medium); 793 793 color: var(--accent); 794 794 } 795 795 796 796 .cid { 797 - font-family: monospace; 797 + font-family: var(--font-mono); 798 798 font-size: var(--text-xs); 799 799 color: var(--text-muted); 800 800 } ··· 804 804 padding: var(--space-2); 805 805 background: var(--bg-secondary); 806 806 border-radius: var(--radius-md); 807 - font-family: monospace; 807 + font-family: var(--font-mono); 808 808 font-size: var(--text-xs); 809 809 color: var(--text-secondary); 810 810 white-space: pre-wrap; ··· 855 855 animation: skeleton-pulse 1.5s ease-in-out infinite; 856 856 } 857 857 858 - @keyframes skeleton-pulse { 859 - 0%, 100% { opacity: 1; } 860 - 50% { opacity: 0.4; } 861 - } 862 - 863 858 .record-detail { 864 859 display: flex; 865 860 flex-direction: column; ··· 889 884 } 890 885 891 886 .mono { 892 - font-family: monospace; 887 + font-family: var(--font-mono); 893 888 font-size: var(--text-xs); 894 889 word-break: break-all; 895 890 } ··· 944 939 padding: var(--space-4); 945 940 border: 1px solid var(--border-color); 946 941 border-radius: var(--radius-md); 947 - font-family: monospace; 942 + font-family: var(--font-mono); 948 943 font-size: var(--text-sm); 949 944 background: var(--bg-input); 950 945 color: var(--text-primary); ··· 1001 996 animation: skeleton-pulse 1.5s ease-in-out infinite; 1002 997 } 1003 998 1004 - @keyframes skeleton-pulse { 1005 - 0%, 100% { opacity: 1; } 1006 - 50% { opacity: 0.5; } 1007 - } 1008 999 </style>
+3 -14
frontend/src/routes/Security.svelte
··· 118 118 const response = await fetch('/oauth/sso/providers') 119 119 if (response.ok) { 120 120 const data = await response.json() 121 - ssoProviders = data.providers || [] 121 + ssoProviders = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name)) 122 122 } 123 123 } catch { 124 124 ssoProviders = [] ··· 212 212 pendingAction = () => handleUnlinkAccount(id) 213 213 showReauthModal = true 214 214 } else { 215 - toast.error(data.error_description || data.error || 'Failed to unlink account') 215 + toast.error(data.error_description || data.message || data.error || 'Failed to unlink account') 216 216 } 217 217 unlinkingId = null 218 218 return ··· 1167 1167 border-radius: var(--radius-md); 1168 1168 text-align: center; 1169 1169 font-size: var(--text-sm); 1170 - font-family: ui-monospace, monospace; 1170 + font-family: var(--font-mono); 1171 1171 } 1172 1172 1173 1173 .passkey-list { ··· 1411 1411 animation: skeleton-pulse 1.5s ease-in-out infinite; 1412 1412 } 1413 1413 1414 - @keyframes skeleton-pulse { 1415 - 0%, 100% { opacity: 1; } 1416 - 50% { opacity: 0.5; } 1417 - } 1418 - 1419 1414 @media (max-width: 900px) { 1420 1415 .skeleton-grid { 1421 1416 grid-template-columns: 1fr; ··· 1523 1518 animation: spin 0.8s linear infinite; 1524 1519 } 1525 1520 1526 - @keyframes spin { 1527 - to { 1528 - transform: rotate(360deg); 1529 - } 1530 - } 1531 - 1532 1521 .loading-text { 1533 1522 color: var(--text-secondary); 1534 1523 font-size: var(--text-sm);
-5
frontend/src/routes/Sessions.svelte
··· 201 201 animation: skeleton-pulse 1.5s ease-in-out infinite; 202 202 } 203 203 204 - @keyframes skeleton-pulse { 205 - 0%, 100% { opacity: 1; } 206 - 50% { opacity: 0.5; } 207 - } 208 - 209 204 .sessions-list { 210 205 display: flex; 211 206 flex-direction: column;
+22
frontend/src/routes/Settings.svelte
··· 57 57 let emailTokenRequired = $state(false) 58 58 let emailUpdateAuthorized = $state(false) 59 59 let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null) 60 + let newEmailInUse = $state(false) 61 + 62 + async function checkNewEmailInUse() { 63 + if (!newEmail.trim() || !newEmail.includes('@')) { 64 + newEmailInUse = false 65 + return 66 + } 67 + try { 68 + const result = await api.checkEmailInUse(newEmail.trim()) 69 + newEmailInUse = result.inUse 70 + } catch { 71 + newEmailInUse = false 72 + } 73 + } 60 74 let handleLoading = $state(false) 61 75 let newHandle = $state('') 62 76 let deleteLoading = $state(false) ··· 543 557 id="new-email" 544 558 type="email" 545 559 bind:value={newEmail} 560 + onblur={checkNewEmailInUse} 546 561 placeholder={$_('settings.newEmailPlaceholder')} 547 562 disabled={emailLoading || emailUpdateAuthorized} 548 563 required 549 564 /> 565 + {#if newEmailInUse} 566 + <p class="hint warning">{$_('settings.emailInUseWarning')}</p> 567 + {/if} 550 568 </div> 551 569 <div class="actions"> 552 570 <button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}> ··· 565 583 id="new-email" 566 584 type="email" 567 585 bind:value={newEmail} 586 + onblur={checkNewEmailInUse} 568 587 placeholder={$_('settings.newEmailPlaceholder')} 569 588 disabled={emailLoading} 570 589 required 571 590 /> 591 + {#if newEmailInUse} 592 + <p class="hint warning">{$_('settings.emailInUseWarning')}</p> 593 + {/if} 572 594 </div> 573 595 <button type="submit" disabled={emailLoading || !newEmail.trim()}> 574 596 {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
+160 -181
frontend/src/routes/OAuthSsoRegister.svelte frontend/src/routes/SsoRegisterComplete.svelte
··· 20 20 signal: boolean 21 21 } 22 22 23 + interface RegistrationResult { 24 + did: string 25 + handle: string 26 + redirectUrl: string 27 + accessJwt?: string 28 + refreshJwt?: string 29 + appPassword?: string 30 + appPasswordName?: string 31 + } 32 + 23 33 let pending = $state<PendingRegistration | null>(null) 24 34 let loading = $state(true) 25 35 let submitting = $state(false) ··· 38 48 let checkingHandle = $state(false) 39 49 let handleError = $state<string | null>(null) 40 50 51 + let didType = $state<'plc' | 'web' | 'web-external'>('plc') 52 + let externalDid = $state('') 53 + 41 54 let serverInfo = $state<{ 42 55 availableUserDomains: string[] 43 56 inviteCodeRequired: boolean 57 + selfHostedDidWebEnabled: boolean 44 58 } | null>(null) 45 59 46 60 let commsChannels = $state<CommsChannelConfig>({ ··· 50 64 signal: false, 51 65 }) 52 66 67 + let showAppPassword = $state(false) 68 + let registrationResult = $state<RegistrationResult | null>(null) 69 + let appPasswordCopied = $state(false) 70 + let appPasswordAcknowledged = $state(false) 71 + 53 72 function getToken(): string | null { 54 73 const params = new URLSearchParams(window.location.search) 55 74 return params.get('token') ··· 70 89 return commsChannels[ch as keyof CommsChannelConfig] ?? false 71 90 } 72 91 92 + function extractDomain(did: string): string { 93 + return did.replace('did:web:', '').replace(/%3A/g, ':') 94 + } 95 + 73 96 let fullHandle = $derived(() => { 74 97 if (!handle.trim()) return '' 75 98 const domain = serverInfo?.availableUserDomains?.[0] ··· 89 112 serverInfo = { 90 113 availableUserDomains: data.availableUserDomains || [], 91 114 inviteCodeRequired: data.inviteCodeRequired ?? false, 115 + selfHostedDidWebEnabled: data.selfHostedDidWebEnabled ?? false, 92 116 } 93 117 if (data.commsChannels) { 94 118 commsChannels = { ··· 191 215 } 192 216 } 193 217 218 + function copyAppPassword() { 219 + if (registrationResult?.appPassword) { 220 + navigator.clipboard.writeText(registrationResult.appPassword) 221 + appPasswordCopied = true 222 + } 223 + } 224 + 225 + function proceedFromAppPassword() { 226 + if (!registrationResult) return 227 + 228 + if (registrationResult.accessJwt && registrationResult.refreshJwt) { 229 + localStorage.setItem('accessJwt', registrationResult.accessJwt) 230 + localStorage.setItem('refreshJwt', registrationResult.refreshJwt) 231 + } 232 + 233 + if (registrationResult.redirectUrl) { 234 + if (registrationResult.redirectUrl.startsWith('/app/verify')) { 235 + localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({ 236 + did: registrationResult.did, 237 + handle: registrationResult.handle, 238 + channel: verificationChannel, 239 + })) 240 + const url = new URL(registrationResult.redirectUrl, window.location.origin) 241 + url.searchParams.set('handle', registrationResult.handle) 242 + url.searchParams.set('channel', verificationChannel) 243 + window.location.href = url.pathname + url.search 244 + return 245 + } 246 + window.location.href = registrationResult.redirectUrl 247 + } 248 + } 249 + 194 250 async function handleSubmit(e: Event) { 195 251 e.preventDefault() 196 252 const token = getToken() ··· 229 285 discord_id: discordId || null, 230 286 telegram_username: telegramUsername || null, 231 287 signal_number: signalNumber || null, 288 + did_type: didType, 289 + did: didType === 'web-external' ? externalDid.trim() : null, 232 290 }), 233 291 }) 234 292 ··· 240 298 return 241 299 } 242 300 243 - if (data.accessJwt && data.refreshJwt) { 244 - localStorage.setItem('accessJwt', data.accessJwt) 245 - localStorage.setItem('refreshJwt', data.refreshJwt) 301 + registrationResult = { 302 + did: data.did, 303 + handle: data.handle, 304 + redirectUrl: data.redirectUrl, 305 + accessJwt: data.accessJwt, 306 + refreshJwt: data.refreshJwt, 307 + appPassword: data.appPassword, 308 + appPasswordName: data.appPasswordName, 246 309 } 247 310 248 - if (data.redirectUrl) { 249 - if (data.redirectUrl.startsWith('/app/verify')) { 250 - localStorage.setItem('tranquil_pds_pending_verification', JSON.stringify({ 251 - did: data.did, 252 - handle: data.handle, 253 - channel: verificationChannel, 254 - })) 255 - } 256 - window.location.href = data.redirectUrl 257 - return 311 + if (registrationResult.appPassword) { 312 + showAppPassword = true 313 + submitting = false 314 + } else { 315 + proceedFromAppPassword() 258 316 } 259 - 260 - toast.error($_('common.error')) 261 - submitting = false 262 - } catch { 263 - toast.error($_('common.error')) 317 + } catch (err) { 318 + console.error('SSO registration failed:', err) 319 + toast.error(err instanceof Error ? err.message : $_('common.error')) 264 320 submitting = false 265 321 } 266 322 } 267 323 </script> 268 324 269 - <div class="sso-register-container"> 325 + <div class="page"> 270 326 {#if loading} 271 327 <div class="loading"> 272 - <div class="spinner"></div> 328 + <div class="spinner md"></div> 273 329 <p>{$_('common.loading')}</p> 274 330 </div> 275 331 {:else if error && !pending} ··· 277 333 <div class="error-icon">!</div> 278 334 <h2>{$_('common.error')}</h2> 279 335 <p>{error}</p> 280 - <a href="/app/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a> 336 + <a href="/app/oauth/register-sso" class="back-link">{$_('sso_register.tryAgain')}</a> 337 + </div> 338 + {:else if showAppPassword && registrationResult} 339 + <header class="page-header"> 340 + <h1>{$_('appPasswords.created')}</h1> 341 + <p class="subtitle">{$_('appPasswords.createdMessage')}</p> 342 + </header> 343 + 344 + <div class="app-password-step"> 345 + <div class="warning-box"> 346 + <strong>{$_('appPasswords.saveWarningTitle')}</strong> 347 + <p>{$_('appPasswords.saveWarningMessage')}</p> 348 + </div> 349 + 350 + <div class="app-password-display"> 351 + <div class="app-password-label"> 352 + App Password for: <strong>{registrationResult.appPasswordName}</strong> 353 + </div> 354 + <code class="app-password-code">{registrationResult.appPassword}</code> 355 + <button type="button" class="copy-btn" onclick={copyAppPassword}> 356 + {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 357 + </button> 358 + </div> 359 + 360 + <div class="field"> 361 + <label class="checkbox-label"> 362 + <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 363 + <span>{$_('appPasswords.acknowledgeLabel')}</span> 364 + </label> 365 + </div> 366 + 367 + <button onclick={proceedFromAppPassword} disabled={!appPasswordAcknowledged}> 368 + {$_('common.continue')} 369 + </button> 281 370 </div> 282 371 {:else if pending} 283 372 <header class="page-header"> ··· 404 493 </div> 405 494 </fieldset> 406 495 496 + <fieldset> 497 + <legend>{$_('registerPasskey.identityType')}</legend> 498 + <p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p> 499 + <div class="radio-group"> 500 + <label class="radio-label"> 501 + <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 502 + <span class="radio-content"> 503 + <strong>{$_('registerPasskey.didPlcRecommended')}</strong> 504 + <span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span> 505 + </span> 506 + </label> 507 + <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 508 + <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 509 + <span class="radio-content"> 510 + <strong>{$_('registerPasskey.didWeb')}</strong> 511 + {#if serverInfo?.selfHostedDidWebEnabled === false} 512 + <span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span> 513 + {:else} 514 + <span class="radio-hint">{$_('registerPasskey.didWebHint')}</span> 515 + {/if} 516 + </span> 517 + </label> 518 + <label class="radio-label"> 519 + <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 520 + <span class="radio-content"> 521 + <strong>{$_('registerPasskey.didWebBYOD')}</strong> 522 + <span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span> 523 + </span> 524 + </label> 525 + </div> 526 + {#if didType === 'web'} 527 + <div class="warning-box"> 528 + <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 529 + <ul> 530 + <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 531 + <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 532 + <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 533 + <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> 534 + </ul> 535 + </div> 536 + {/if} 537 + {#if didType === 'web-external'} 538 + <div class="field"> 539 + <label for="external-did">{$_('registerPasskey.externalDid')}</label> 540 + <input id="external-did" type="text" bind:value={externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={submitting} required /> 541 + <p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p> 542 + </div> 543 + {/if} 544 + </fieldset> 545 + 407 546 {#if serverInfo?.inviteCodeRequired} 408 547 <div class="field"> 409 548 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> ··· 438 577 </div> 439 578 440 579 <style> 441 - .sso-register-container { 442 - max-width: var(--width-lg); 443 - margin: var(--space-9) auto; 444 - padding: var(--space-7); 445 - } 446 - 447 - .loading { 448 - display: flex; 449 - flex-direction: column; 450 - align-items: center; 451 - gap: var(--space-4); 452 - padding: var(--space-8); 453 - } 454 - 455 - .loading p { 456 - color: var(--text-secondary); 457 - } 458 - 459 - .error-container { 460 - text-align: center; 461 - padding: var(--space-8); 462 - } 463 - 464 - .error-icon { 465 - width: 48px; 466 - height: 48px; 467 - border-radius: 50%; 468 - background: var(--error-text); 469 - color: var(--text-inverse); 470 - display: flex; 471 - align-items: center; 472 - justify-content: center; 473 - font-size: 24px; 474 - font-weight: bold; 475 - margin: 0 auto var(--space-4); 476 - } 477 - 478 - .error-container h2 { 479 - margin-bottom: var(--space-2); 480 - } 481 - 482 - .error-container p { 483 - color: var(--text-secondary); 484 - margin-bottom: var(--space-6); 485 - } 486 - 487 - .back-link { 488 - color: var(--accent); 489 - text-decoration: none; 490 - } 491 - 492 - .back-link:hover { 493 - text-decoration: underline; 494 - } 495 - 496 - .page-header { 497 - margin-bottom: var(--space-6); 498 - } 499 - 500 - .page-header h1 { 501 - margin: 0 0 var(--space-3) 0; 502 - } 503 - 504 - .subtitle { 505 - color: var(--text-secondary); 506 - margin: 0; 507 - } 508 - 509 - .form-section { 510 - min-width: 0; 511 - } 512 - 513 580 form { 514 581 display: flex; 515 582 flex-direction: column; 516 583 gap: var(--space-5); 517 584 } 518 585 519 - .contact-fields { 520 - display: flex; 521 - flex-direction: column; 522 - gap: var(--space-4); 523 - } 524 - 525 - .contact-fields .field { 526 - margin-bottom: 0; 527 - } 528 - 529 - .hint.success { 530 - color: var(--success-text); 531 - } 532 - 533 - .hint.error { 534 - color: var(--error-text); 535 - } 536 - 537 - .info-panel { 538 - background: var(--bg-secondary); 539 - border-radius: var(--radius-xl); 540 - padding: var(--space-6); 541 - } 542 - 543 - .info-panel h3 { 544 - margin: 0 0 var(--space-4) 0; 545 - font-size: var(--text-base); 546 - font-weight: var(--font-semibold); 547 - } 548 - 549 - .info-list { 550 - margin: 0; 551 - padding-left: var(--space-5); 552 - } 553 - 554 - .info-list li { 555 - margin-bottom: var(--space-2); 556 - font-size: var(--text-sm); 557 - color: var(--text-secondary); 558 - line-height: var(--leading-relaxed); 559 - } 560 - 561 - .info-list li:last-child { 562 - margin-bottom: 0; 563 - } 564 - 565 586 .provider-info { 566 587 margin-bottom: var(--space-6); 567 588 } 568 589 569 - .provider-badge { 570 - display: flex; 571 - align-items: center; 572 - gap: var(--space-3); 573 - padding: var(--space-4); 574 - background: var(--bg-secondary); 575 - border-radius: var(--radius-md); 576 - } 577 - 578 - .provider-details { 579 - display: flex; 580 - flex-direction: column; 581 - } 582 - 583 - .provider-name { 584 - font-weight: var(--font-semibold); 585 - } 586 - 587 - .provider-username { 588 - font-size: var(--text-sm); 589 - color: var(--text-secondary); 590 - } 591 - 592 - .required { 593 - color: var(--error-text); 594 - } 595 - 596 590 button[type="submit"] { 597 591 margin-top: var(--space-3); 598 592 } 599 - 600 - .spinner { 601 - width: 32px; 602 - height: 32px; 603 - border: 3px solid var(--border-color); 604 - border-top-color: var(--accent); 605 - border-radius: 50%; 606 - animation: spin 1s linear infinite; 607 - } 608 - 609 - @keyframes spin { 610 - to { 611 - transform: rotate(360deg); 612 - } 613 - } 614 593 </style>
+4 -77
frontend/src/routes/TrustedDevices.svelte
··· 151 151 placeholder={$_('trustedDevices.deviceNamePlaceholder')} 152 152 /> 153 153 <div class="edit-actions"> 154 - <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>{$_('common.save')}</button> 155 - <button class="btn-small btn-secondary" onclick={cancelEditDevice}>{$_('common.cancel')}</button> 154 + <button class="sm" onclick={handleSaveDeviceName}>{$_('common.save')}</button> 155 + <button class="sm ghost" onclick={cancelEditDevice}>{$_('common.cancel')}</button> 156 156 </div> 157 157 {:else} 158 158 <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3> 159 - <button class="btn-icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}> 159 + <button class="icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}> 160 160 &#9998; 161 161 </button> 162 162 {/if} ··· 192 192 </div> 193 193 194 194 <div class="device-actions"> 195 - <button class="btn-danger" onclick={() => handleRevoke(device.id)}> 195 + <button class="sm danger-outline" onclick={() => handleRevoke(device.id)}> 196 196 {$_('trustedDevices.revoke')} 197 197 </button> 198 198 </div> ··· 203 203 </div> 204 204 205 205 <style> 206 - .page { 207 - max-width: var(--width-lg); 208 - margin: 0 auto; 209 - padding: var(--space-7); 210 - } 211 - 212 206 header { 213 207 margin-bottom: var(--space-7); 214 208 } ··· 300 294 gap: var(--space-2); 301 295 } 302 296 303 - .btn-icon { 304 - background: none; 305 - border: none; 306 - color: var(--text-secondary); 307 - cursor: pointer; 308 - padding: var(--space-1); 309 - font-size: var(--text-base); 310 - } 311 - 312 - .btn-icon:hover { 313 - color: var(--text-primary); 314 - } 315 - 316 297 .device-details { 317 298 margin-bottom: var(--space-3); 318 299 } ··· 338 319 border-top: 1px solid var(--border-color); 339 320 } 340 321 341 - .btn-small { 342 - padding: var(--space-2) var(--space-3); 343 - border-radius: var(--radius-md); 344 - font-size: var(--text-xs); 345 - cursor: pointer; 346 - } 347 - 348 - .btn-primary { 349 - background: var(--accent); 350 - color: var(--text-inverse); 351 - border: none; 352 - } 353 - 354 - .btn-primary:hover { 355 - background: var(--accent-hover); 356 - } 357 - 358 - .btn-secondary { 359 - background: var(--bg-input); 360 - border: 1px solid var(--border-color); 361 - color: var(--text-secondary); 362 - } 363 - 364 - .btn-secondary:hover { 365 - background: var(--bg-secondary); 366 - } 367 - 368 - .btn-danger { 369 - background: transparent; 370 - border: 1px solid var(--error-border); 371 - color: var(--error-text); 372 - padding: var(--space-2) var(--space-4); 373 - border-radius: var(--radius-md); 374 - cursor: pointer; 375 - font-size: var(--text-sm); 376 - } 377 - 378 - .btn-danger:hover { 379 - background: var(--error-bg); 380 - } 381 - 382 322 .skeleton-list { 383 323 display: flex; 384 324 flex-direction: column; 385 325 gap: var(--space-4); 386 326 } 387 - 388 - .skeleton-card { 389 - height: 100px; 390 - background: var(--bg-secondary); 391 - border: 1px solid var(--border-color); 392 - border-radius: var(--radius-xl); 393 - animation: skeleton-pulse 1.5s ease-in-out infinite; 394 - } 395 - 396 - @keyframes skeleton-pulse { 397 - 0%, 100% { opacity: 1; } 398 - 50% { opacity: 0.5; } 399 - } 400 327 </style>
+49 -1
frontend/src/routes/Verify.svelte
··· 31 31 let successPurpose = $state<string | null>(null) 32 32 let successChannel = $state<string | null>(null) 33 33 let tokenFromUrl = $state(false) 34 + let oauthRequestUri = $state<string | null>(null) 34 35 35 36 const auth = $derived(getAuthState()) 36 37 ··· 70 71 } 71 72 } else { 72 73 mode = 'signup' 74 + if (params.request_uri) { 75 + oauthRequestUri = params.request_uri 76 + } 73 77 const stored = localStorage.getItem(STORAGE_KEY) 74 78 if (stored) { 75 79 try { ··· 83 87 pendingVerification = null 84 88 } 85 89 } 90 + if (!pendingVerification && params.did && params.handle && params.channel) { 91 + pendingVerification = { 92 + did: unsafeAsDid(params.did), 93 + handle: params.handle, 94 + channel: params.channel, 95 + } 96 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 97 + did: params.did, 98 + handle: params.handle, 99 + channel: params.channel, 100 + })) 101 + } 86 102 } 87 103 }) 88 104 ··· 93 109 } 94 110 }) 95 111 112 + let pollingVerification = false 113 + $effect(() => { 114 + if (mode === 'signup' && pendingVerification && !verificationCode.trim()) { 115 + const currentPending = pendingVerification 116 + const interval = setInterval(async () => { 117 + if (pollingVerification || verificationCode.trim()) return 118 + pollingVerification = true 119 + try { 120 + const result = await api.checkEmailVerified(currentPending.did) 121 + if (result.verified) { 122 + clearInterval(interval) 123 + clearPendingVerification() 124 + if (oauthRequestUri) { 125 + navigate(routes.oauthConsent, { params: { request_uri: oauthRequestUri } }) 126 + } else { 127 + navigate(routes.login) 128 + } 129 + } 130 + } catch { 131 + } finally { 132 + pollingVerification = false 133 + } 134 + }, 3000) 135 + return () => clearInterval(interval) 136 + } 137 + return undefined 138 + }) 139 + 96 140 function clearPendingVerification() { 97 141 localStorage.removeItem(STORAGE_KEY) 98 142 pendingVerification = null ··· 108 152 try { 109 153 await confirmSignup(pendingVerification.did, verificationCode.trim()) 110 154 clearPendingVerification() 111 - navigate('/dashboard') 155 + if (oauthRequestUri) { 156 + navigate(routes.oauthConsent, { params: { request_uri: oauthRequestUri } }) 157 + } else { 158 + navigate(routes.dashboard) 159 + } 112 160 } catch (e) { 113 161 error = e instanceof Error ? e.message : 'Verification failed' 114 162 } finally {
+372
frontend/src/styles/base.css
··· 194 194 color: var(--text-primary); 195 195 } 196 196 197 + button.link { 198 + background: none; 199 + border: none; 200 + color: var(--accent); 201 + padding: var(--space-2); 202 + font-size: var(--text-sm); 203 + font-weight: var(--font-normal); 204 + } 205 + 206 + button.link:hover:not(:disabled) { 207 + background: none; 208 + text-decoration: underline; 209 + } 210 + 211 + button.sm { 212 + padding: var(--space-2) var(--space-3); 213 + font-size: var(--text-xs); 214 + } 215 + 216 + button.icon { 217 + background: none; 218 + border: none; 219 + color: var(--text-secondary); 220 + padding: var(--space-1); 221 + font-size: var(--text-base); 222 + } 223 + 224 + button.icon:hover:not(:disabled) { 225 + background: none; 226 + color: var(--text-primary); 227 + } 228 + 197 229 label { 198 230 display: block; 199 231 font-size: var(--text-sm); ··· 281 313 color: var(--error-text); 282 314 } 283 315 316 + .hint.success { 317 + color: var(--success-text); 318 + } 319 + 284 320 .message { 285 321 padding: var(--space-4); 286 322 border-radius: var(--radius-md); ··· 372 408 padding: var(--space-7); 373 409 } 374 410 411 + .page-header { 412 + margin-bottom: var(--space-6); 413 + } 414 + 415 + .page-header h1 { 416 + margin: 0 0 var(--space-3) 0; 417 + } 418 + 419 + .page-header .subtitle { 420 + color: var(--text-secondary); 421 + margin: 0; 422 + } 423 + 424 + .loading { 425 + display: flex; 426 + flex-direction: column; 427 + align-items: center; 428 + gap: var(--space-4); 429 + padding: var(--space-8); 430 + } 431 + 432 + .loading p { 433 + color: var(--text-secondary); 434 + margin: 0; 435 + } 436 + 375 437 .back-link { 376 438 display: inline-block; 377 439 color: var(--text-secondary); ··· 510 572 border-width: 2px; 511 573 } 512 574 575 + .spinner.md { 576 + width: 32px; 577 + height: 32px; 578 + } 579 + 513 580 .spinner.lg { 514 581 width: 60px; 515 582 height: 60px; ··· 521 588 transform: rotate(360deg); 522 589 } 523 590 } 591 + 592 + .skeleton { 593 + background: var(--bg-secondary); 594 + border-radius: var(--radius-md); 595 + animation: skeleton-pulse 1.5s ease-in-out infinite; 596 + } 597 + 598 + .skeleton-card { 599 + height: 100px; 600 + background: var(--bg-secondary); 601 + border: 1px solid var(--border-color); 602 + border-radius: var(--radius-xl); 603 + animation: skeleton-pulse 1.5s ease-in-out infinite; 604 + } 605 + 606 + .skeleton-line { 607 + height: var(--space-4); 608 + background: var(--bg-secondary); 609 + border-radius: var(--radius-sm); 610 + animation: skeleton-pulse 1.5s ease-in-out infinite; 611 + } 612 + 613 + @keyframes skeleton-pulse { 614 + 0%, 100% { opacity: 1; } 615 + 50% { opacity: 0.5; } 616 + } 617 + 618 + .section-hint { 619 + font-size: var(--text-sm); 620 + color: var(--text-secondary); 621 + margin: 0 0 var(--space-5) 0; 622 + } 623 + 624 + .radio-group { 625 + display: flex; 626 + flex-direction: column; 627 + gap: var(--space-4); 628 + } 629 + 630 + .radio-label { 631 + display: flex; 632 + align-items: flex-start; 633 + gap: var(--space-3); 634 + cursor: pointer; 635 + font-size: var(--text-base); 636 + font-weight: var(--font-normal); 637 + margin-bottom: 0; 638 + } 639 + 640 + .radio-label input[type="radio"] { 641 + margin-top: var(--space-1); 642 + width: auto; 643 + } 644 + 645 + .radio-content { 646 + display: flex; 647 + flex-direction: column; 648 + gap: var(--space-1); 649 + } 650 + 651 + .radio-hint { 652 + font-size: var(--text-xs); 653 + color: var(--text-secondary); 654 + } 655 + 656 + .radio-label.disabled { 657 + opacity: 0.5; 658 + cursor: not-allowed; 659 + } 660 + 661 + .radio-hint.disabled-hint { 662 + color: var(--warning-text); 663 + } 664 + 665 + .warning-box { 666 + margin-top: var(--space-5); 667 + padding: var(--space-5); 668 + background: var(--warning-bg); 669 + border: 1px solid var(--warning-border); 670 + border-radius: var(--radius-lg); 671 + font-size: var(--text-sm); 672 + } 673 + 674 + .warning-box strong { 675 + display: block; 676 + margin-bottom: var(--space-3); 677 + color: var(--warning-text); 678 + } 679 + 680 + .warning-box ul { 681 + margin: var(--space-4) 0 0 0; 682 + padding-left: var(--space-5); 683 + } 684 + 685 + .warning-box li { 686 + margin-bottom: var(--space-3); 687 + line-height: var(--leading-normal); 688 + } 689 + 690 + .warning-box li:last-child { 691 + margin-bottom: 0; 692 + } 693 + 694 + .migrate-callout { 695 + display: flex; 696 + gap: var(--space-4); 697 + padding: var(--space-5); 698 + background: var(--accent-muted); 699 + border: 1px solid var(--accent); 700 + border-radius: var(--radius-xl); 701 + margin-bottom: var(--space-6); 702 + } 703 + 704 + .migrate-icon { 705 + font-size: var(--text-2xl); 706 + line-height: 1; 707 + color: var(--accent); 708 + } 709 + 710 + .migrate-content { 711 + flex: 1; 712 + } 713 + 714 + .migrate-content strong { 715 + display: block; 716 + color: var(--text-primary); 717 + margin-bottom: var(--space-2); 718 + } 719 + 720 + .migrate-content p { 721 + margin: 0 0 var(--space-3) 0; 722 + font-size: var(--text-sm); 723 + color: var(--text-secondary); 724 + line-height: var(--leading-relaxed); 725 + } 726 + 727 + .migrate-link { 728 + font-size: var(--text-sm); 729 + font-weight: var(--font-medium); 730 + color: var(--accent); 731 + text-decoration: none; 732 + } 733 + 734 + .migrate-link:hover { 735 + text-decoration: underline; 736 + } 737 + 738 + .app-password-step { 739 + display: flex; 740 + flex-direction: column; 741 + gap: var(--space-5); 742 + max-width: var(--width-md); 743 + margin: 0 auto; 744 + } 745 + 746 + .app-password-step .warning-box { 747 + margin-top: 0; 748 + } 749 + 750 + .app-password-step .warning-box p { 751 + margin: 0; 752 + color: var(--warning-text); 753 + } 754 + 755 + .app-password-display { 756 + background: var(--bg-card); 757 + border: 2px solid var(--accent); 758 + border-radius: var(--radius-xl); 759 + padding: var(--space-6); 760 + text-align: center; 761 + } 762 + 763 + .app-password-label { 764 + font-size: var(--text-sm); 765 + color: var(--text-secondary); 766 + margin-bottom: var(--space-4); 767 + } 768 + 769 + .app-password-code { 770 + display: block; 771 + font-size: var(--text-xl); 772 + font-family: var(--font-mono); 773 + letter-spacing: 0.1em; 774 + padding: var(--space-5); 775 + background: var(--bg-input); 776 + border-radius: var(--radius-md); 777 + margin-bottom: var(--space-4); 778 + user-select: all; 779 + } 780 + 781 + .copy-btn { 782 + padding: var(--space-3) var(--space-5); 783 + font-size: var(--text-sm); 784 + } 785 + 786 + .checkbox-label { 787 + display: flex; 788 + align-items: center; 789 + gap: var(--space-3); 790 + cursor: pointer; 791 + font-weight: var(--font-normal); 792 + } 793 + 794 + .checkbox-label input[type="checkbox"] { 795 + width: auto; 796 + padding: 0; 797 + } 798 + 799 + .form-section { 800 + min-width: 0; 801 + } 802 + 803 + .form-links { 804 + margin-top: var(--space-6); 805 + } 806 + 807 + .form-links .link-text { 808 + text-align: center; 809 + color: var(--text-secondary); 810 + } 811 + 812 + .form-links .link-text a { 813 + color: var(--accent); 814 + } 815 + 816 + .contact-fields { 817 + display: flex; 818 + flex-direction: column; 819 + gap: var(--space-4); 820 + } 821 + 822 + .contact-fields .field { 823 + margin-bottom: 0; 824 + } 825 + 826 + .provider-badge { 827 + display: flex; 828 + align-items: center; 829 + gap: var(--space-3); 830 + padding: var(--space-4); 831 + background: var(--bg-secondary); 832 + border-radius: var(--radius-md); 833 + } 834 + 835 + .provider-details { 836 + display: flex; 837 + flex-direction: column; 838 + } 839 + 840 + .provider-name { 841 + font-weight: var(--font-semibold); 842 + } 843 + 844 + .provider-username { 845 + font-size: var(--text-sm); 846 + color: var(--text-secondary); 847 + } 848 + 849 + .error-container { 850 + text-align: center; 851 + padding: var(--space-8); 852 + } 853 + 854 + .error-icon { 855 + width: 48px; 856 + height: 48px; 857 + border-radius: 50%; 858 + background: var(--error-text); 859 + color: var(--text-inverse); 860 + display: flex; 861 + align-items: center; 862 + justify-content: center; 863 + font-size: var(--text-xl); 864 + font-weight: var(--font-bold); 865 + margin: 0 auto var(--space-4); 866 + } 867 + 868 + .error-container h2 { 869 + margin-bottom: var(--space-2); 870 + } 871 + 872 + .error-container p { 873 + color: var(--text-secondary); 874 + margin-bottom: var(--space-6); 875 + } 876 + 877 + .info-list { 878 + margin: 0; 879 + padding-left: var(--space-5); 880 + } 881 + 882 + .info-list li { 883 + margin-bottom: var(--space-2); 884 + font-size: var(--text-sm); 885 + color: var(--text-secondary); 886 + line-height: var(--leading-relaxed); 887 + } 888 + 889 + .info-list li:last-child { 890 + margin-bottom: 0; 891 + } 892 + 893 + .required { 894 + color: var(--error-text); 895 + }
-6
frontend/src/styles/migration.css
··· 445 445 animation: spin 1s linear infinite; 446 446 } 447 447 448 - @keyframes spin { 449 - to { 450 - transform: rotate(360deg); 451 - } 452 - } 453 - 454 448 .passkey-section { 455 449 margin-top: var(--space-5); 456 450 text-align: center;
+3
frontend/src/styles/tokens.css
··· 48 48 --transition-normal: 0.15s ease; 49 49 --transition-slow: 0.25s ease; 50 50 51 + --z-modal: 1000; 52 + --overlay-bg: rgba(0, 0, 0, 0.5); 53 + 51 54 --font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 52 55 53 56 --bg-primary: #f9fafa;
+2 -2
frontend/src/tests/migration/atproto-client.test.ts
··· 12 12 loadDPoPKey, 13 13 prepareWebAuthnCreationOptions, 14 14 saveDPoPKey, 15 - } from "../../lib/migration/atproto-client"; 16 - import type { OAuthServerMetadata } from "../../lib/migration/types"; 15 + } from "../../lib/migration/atproto-client.ts"; 16 + import type { OAuthServerMetadata } from "../../lib/migration/types.ts"; 17 17 18 18 const DPOP_KEY_STORAGE = "migration_dpop_key"; 19 19
+1 -1
frontend/src/tests/migration/offline-flow.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte"; 2 + import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte.ts"; 3 3 4 4 const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 5 5
+1 -1
frontend/src/tests/migration/plc-ops.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { PlcOps, plcOps } from "../../lib/migration/plc-ops"; 2 + import { PlcOps, plcOps } from "../../lib/migration/plc-ops.ts"; 3 3 4 4 describe("migration/plc-ops", () => { 5 5 beforeEach(() => {
+1
frontend/src/tests/migration/storage.test.ts
··· 83 83 authMethod: "password", 84 84 passkeySetupToken: null, 85 85 oauthCodeVerifier: null, 86 + localAccessToken: null, 86 87 generatedAppPassword: null, 87 88 generatedAppPasswordName: null, 88 89 ...overrides,
+1 -1
frontend/src/tests/migration/types.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { MigrationError } from "../../lib/migration/types"; 2 + import { MigrationError } from "../../lib/migration/types.ts"; 3 3 4 4 describe("migration/types", () => { 5 5 describe("MigrationError", () => {
+3 -1
frontend/src/tests/mocks.ts
··· 84 84 export { getToasts, toast }; 85 85 function extractEndpoint(url: string): string { 86 86 const match = url.match(/\/xrpc\/([^?]+)/); 87 - return match ? match[1] : url; 87 + if (match) return match[1]; 88 + const pathOnly = url.split("?")[0]; 89 + return pathOnly; 88 90 } 89 91 export function setupFetchMock(): void { 90 92 globalThis.fetch = vi.fn(
+559
frontend/src/tests/oauth-registration.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { render, screen, waitFor } from "@testing-library/svelte"; 3 + import { 4 + clearMocks, 5 + jsonResponse, 6 + mockData, 7 + mockEndpoint, 8 + setupFetchMock, 9 + } from "./mocks.ts"; 10 + import { _testSetState } from "../lib/auth.svelte.ts"; 11 + 12 + function createMockIndexedDB() { 13 + const stores: Map<string, Map<string, unknown>> = new Map(); 14 + 15 + return { 16 + open: vi.fn((_name: string, _version?: number) => { 17 + const createTransaction = (_storeName: string, _mode?: string) => { 18 + const tx = { 19 + objectStore: (name: string) => { 20 + if (!stores.has(name)) { 21 + stores.set(name, new Map()); 22 + } 23 + const store = stores.get(name)!; 24 + return { 25 + put: (value: unknown, key: string) => { 26 + store.set(key, value); 27 + return { result: undefined }; 28 + }, 29 + get: (key: string) => ({ 30 + result: store.get(key), 31 + }), 32 + }; 33 + }, 34 + oncomplete: null as (() => void) | null, 35 + onerror: null as (() => void) | null, 36 + }; 37 + setTimeout(() => tx.oncomplete?.(), 0); 38 + return tx; 39 + }; 40 + 41 + const request = { 42 + result: { 43 + objectStoreNames: { contains: () => true }, 44 + createObjectStore: vi.fn(), 45 + transaction: createTransaction, 46 + close: vi.fn(), 47 + }, 48 + error: null, 49 + onsuccess: null as (() => void) | null, 50 + onerror: null as (() => void) | null, 51 + onupgradeneeded: null as (() => void) | null, 52 + }; 53 + 54 + setTimeout(() => { 55 + request.onupgradeneeded?.(); 56 + request.onsuccess?.(); 57 + }, 0); 58 + 59 + return request; 60 + }), 61 + }; 62 + } 63 + 64 + describe("OAuth Registration Flow", () => { 65 + beforeEach(() => { 66 + clearMocks(); 67 + setupFetchMock(); 68 + sessionStorage.clear(); 69 + vi.restoreAllMocks(); 70 + 71 + (globalThis as unknown as { indexedDB: unknown }).indexedDB = 72 + createMockIndexedDB(); 73 + 74 + Object.defineProperty(globalThis.location, "search", { 75 + value: "", 76 + writable: true, 77 + configurable: true, 78 + }); 79 + Object.defineProperty(globalThis.location, "pathname", { 80 + value: "/app/register", 81 + writable: true, 82 + configurable: true, 83 + }); 84 + Object.defineProperty(globalThis.location, "origin", { 85 + value: "http://localhost:3000", 86 + writable: true, 87 + configurable: true, 88 + }); 89 + Object.defineProperty(globalThis.location, "href", { 90 + value: "http://localhost:3000/app/register", 91 + writable: true, 92 + configurable: true, 93 + }); 94 + 95 + _testSetState({ 96 + session: null, 97 + loading: false, 98 + error: null, 99 + savedAccounts: [], 100 + }); 101 + }); 102 + 103 + describe("startOAuthRegister", () => { 104 + it("calls PAR endpoint with prompt=create", async () => { 105 + let capturedBody: string | null = null; 106 + 107 + mockEndpoint("/oauth/par", (_url, options) => { 108 + capturedBody = options?.body as string; 109 + return jsonResponse( 110 + { request_uri: "urn:mock:request", expires_in: 60 }, 111 + 201, 112 + ); 113 + }); 114 + 115 + const { startOAuthRegister } = await import("../lib/oauth.ts"); 116 + 117 + const hrefSetter = vi.fn(); 118 + Object.defineProperty(globalThis.location, "href", { 119 + set: hrefSetter, 120 + get: () => "http://localhost:3000/app/register", 121 + configurable: true, 122 + }); 123 + 124 + await startOAuthRegister(); 125 + 126 + expect(capturedBody).not.toBeNull(); 127 + const params = new URLSearchParams(capturedBody!); 128 + expect(params.get("prompt")).toBe("create"); 129 + expect(params.get("response_type")).toBe("code"); 130 + expect(params.get("scope")).toContain("atproto"); 131 + }); 132 + 133 + it("redirects to authorize endpoint after PAR", async () => { 134 + mockEndpoint("/oauth/par", () => 135 + jsonResponse( 136 + { request_uri: "urn:mock:test-request-uri", expires_in: 60 }, 137 + 201, 138 + )); 139 + 140 + const { startOAuthRegister } = await import("../lib/oauth.ts"); 141 + 142 + let redirectUrl: string | null = null; 143 + Object.defineProperty(globalThis.location, "href", { 144 + set: (url: string) => { 145 + redirectUrl = url; 146 + }, 147 + get: () => "http://localhost:3000/app/register", 148 + configurable: true, 149 + }); 150 + 151 + await startOAuthRegister(); 152 + 153 + expect(redirectUrl).not.toBeNull(); 154 + expect(redirectUrl).toContain("/oauth/authorize"); 155 + expect(redirectUrl).toContain("request_uri="); 156 + }); 157 + }); 158 + 159 + describe("Register (passkey) component", () => { 160 + it("adds request_uri to URL when none present", async () => { 161 + mockEndpoint("/oauth/par", () => 162 + jsonResponse( 163 + { request_uri: "urn:mock:request", expires_in: 60 }, 164 + 201, 165 + )); 166 + 167 + let redirectUrl: string | null = null; 168 + Object.defineProperty(globalThis.location, "href", { 169 + set: (url: string) => { 170 + redirectUrl = url; 171 + }, 172 + get: () => "http://localhost:3000/app/register", 173 + configurable: true, 174 + }); 175 + 176 + const Register = (await import("../routes/Register.svelte")) 177 + .default; 178 + render(Register); 179 + 180 + await waitFor( 181 + () => { 182 + expect(redirectUrl).not.toBeNull(); 183 + }, 184 + { timeout: 2000 }, 185 + ); 186 + 187 + expect(redirectUrl).toContain("request_uri="); 188 + }); 189 + 190 + it("shows loading state while fetching request_uri", async () => { 191 + mockEndpoint("/oauth/par", () => 192 + jsonResponse( 193 + { request_uri: "urn:mock:request", expires_in: 60 }, 194 + 201, 195 + )); 196 + 197 + Object.defineProperty(globalThis.location, "href", { 198 + set: () => {}, 199 + get: () => "http://localhost:3000/app/register", 200 + configurable: true, 201 + }); 202 + 203 + const Register = (await import("../routes/Register.svelte")) 204 + .default; 205 + render(Register); 206 + 207 + await waitFor(() => { 208 + expect(screen.getByText(/loading/i)).toBeInTheDocument(); 209 + }); 210 + }); 211 + 212 + it("logs error if OAuth initiation fails", async () => { 213 + mockEndpoint( 214 + "/oauth/par", 215 + () => 216 + jsonResponse({ 217 + error: "invalid_request", 218 + error_description: "Test error", 219 + }, 400), 220 + ); 221 + 222 + const consoleSpy = vi.spyOn(console, "error").mockImplementation( 223 + () => {}, 224 + ); 225 + 226 + Object.defineProperty(globalThis.location, "href", { 227 + set: () => {}, 228 + get: () => "http://localhost:3000/app/register", 229 + configurable: true, 230 + }); 231 + 232 + const Register = (await import("../routes/Register.svelte")) 233 + .default; 234 + render(Register); 235 + 236 + await waitFor( 237 + () => { 238 + expect(consoleSpy).toHaveBeenCalledWith( 239 + expect.stringContaining("Failed to ensure OAuth request URI"), 240 + expect.anything(), 241 + ); 242 + }, 243 + { timeout: 2000 }, 244 + ); 245 + 246 + consoleSpy.mockRestore(); 247 + }); 248 + }); 249 + 250 + describe("RegisterPassword component", () => { 251 + it("adds request_uri to URL when none present", async () => { 252 + mockEndpoint("/oauth/par", () => 253 + jsonResponse( 254 + { request_uri: "urn:mock:request", expires_in: 60 }, 255 + 201, 256 + )); 257 + 258 + Object.defineProperty(globalThis.location, "pathname", { 259 + value: "/app/oauth/register-password", 260 + writable: true, 261 + configurable: true, 262 + }); 263 + 264 + let redirectUrl: string | null = null; 265 + Object.defineProperty(globalThis.location, "href", { 266 + set: (url: string) => { 267 + redirectUrl = url; 268 + }, 269 + get: () => "http://localhost:3000/app/oauth/register-password", 270 + configurable: true, 271 + }); 272 + 273 + const RegisterPassword = 274 + (await import("../routes/RegisterPassword.svelte")).default; 275 + render(RegisterPassword); 276 + 277 + await waitFor( 278 + () => { 279 + expect(redirectUrl).not.toBeNull(); 280 + }, 281 + { timeout: 2000 }, 282 + ); 283 + 284 + expect(redirectUrl).toContain("request_uri="); 285 + }); 286 + 287 + it("renders form when request_uri is present", async () => { 288 + Object.defineProperty(globalThis.location, "search", { 289 + value: "?request_uri=urn:mock:test-request", 290 + writable: true, 291 + configurable: true, 292 + }); 293 + Object.defineProperty(globalThis.location, "pathname", { 294 + value: "/app/oauth/register-password", 295 + writable: true, 296 + configurable: true, 297 + }); 298 + 299 + mockEndpoint( 300 + "com.atproto.server.describeServer", 301 + () => jsonResponse(mockData.describeServer()), 302 + ); 303 + mockEndpoint( 304 + "/oauth/sso/providers", 305 + () => jsonResponse({ providers: [] }), 306 + ); 307 + 308 + const RegisterPassword = 309 + (await import("../routes/RegisterPassword.svelte")).default; 310 + render(RegisterPassword); 311 + 312 + await waitFor(() => { 313 + expect(screen.getByLabelText(/handle/i)).toBeInTheDocument(); 314 + }); 315 + }); 316 + }); 317 + 318 + describe("RegisterSso component", () => { 319 + it("adds request_uri to URL when none present", async () => { 320 + mockEndpoint("/oauth/par", () => 321 + jsonResponse( 322 + { request_uri: "urn:mock:request", expires_in: 60 }, 323 + 201, 324 + )); 325 + 326 + Object.defineProperty(globalThis.location, "pathname", { 327 + value: "/app/register-sso", 328 + writable: true, 329 + configurable: true, 330 + }); 331 + 332 + let redirectUrl: string | null = null; 333 + Object.defineProperty(globalThis.location, "href", { 334 + set: (url: string) => { 335 + redirectUrl = url; 336 + }, 337 + get: () => "http://localhost:3000/app/register-sso", 338 + configurable: true, 339 + }); 340 + 341 + const RegisterSso = 342 + (await import("../routes/RegisterSso.svelte")).default; 343 + render(RegisterSso); 344 + 345 + await waitFor( 346 + () => { 347 + expect(redirectUrl).not.toBeNull(); 348 + }, 349 + { timeout: 2000 }, 350 + ); 351 + 352 + expect(redirectUrl).toContain("request_uri="); 353 + }); 354 + 355 + it("renders SSO providers when request_uri is present", async () => { 356 + Object.defineProperty(globalThis.location, "search", { 357 + value: "?request_uri=urn:mock:test-request", 358 + writable: true, 359 + configurable: true, 360 + }); 361 + Object.defineProperty(globalThis.location, "pathname", { 362 + value: "/app/register-sso", 363 + writable: true, 364 + configurable: true, 365 + }); 366 + 367 + mockEndpoint("/oauth/sso/providers", () => 368 + jsonResponse({ 369 + providers: [{ provider: "google", name: "Google", icon: "google" }], 370 + })); 371 + 372 + const RegisterSso = 373 + (await import("../routes/RegisterSso.svelte")).default; 374 + render(RegisterSso); 375 + 376 + await waitFor(() => { 377 + expect(screen.getByText(/google/i)).toBeInTheDocument(); 378 + }); 379 + }); 380 + 381 + it("passes request_uri when initiating SSO registration", async () => { 382 + Object.defineProperty(globalThis.location, "search", { 383 + value: "?request_uri=urn:mock:test-request-uri", 384 + writable: true, 385 + configurable: true, 386 + }); 387 + Object.defineProperty(globalThis.location, "pathname", { 388 + value: "/app/register-sso", 389 + writable: true, 390 + configurable: true, 391 + }); 392 + 393 + mockEndpoint("/oauth/sso/providers", () => 394 + jsonResponse({ 395 + providers: [{ provider: "google", name: "Google", icon: "google" }], 396 + })); 397 + 398 + let capturedBody: string | null = null; 399 + mockEndpoint("/oauth/sso/initiate", (_url, options) => { 400 + capturedBody = options?.body as string; 401 + return jsonResponse({ redirect_url: "https://google.com/oauth" }); 402 + }); 403 + 404 + Object.defineProperty(globalThis.location, "href", { 405 + set: () => {}, 406 + get: () => 407 + "http://localhost:3000/app/register-sso?request_uri=urn:mock:test-request-uri", 408 + configurable: true, 409 + }); 410 + 411 + const RegisterSso = 412 + (await import("../routes/RegisterSso.svelte")).default; 413 + render(RegisterSso); 414 + 415 + await waitFor(() => { 416 + expect(screen.getByText(/google/i)).toBeInTheDocument(); 417 + }); 418 + 419 + const googleButton = screen.getByRole("button", { name: /google/i }); 420 + googleButton.click(); 421 + 422 + await waitFor(() => { 423 + expect(capturedBody).not.toBeNull(); 424 + }); 425 + 426 + const body = JSON.parse(capturedBody!); 427 + expect(body.request_uri).toBe("urn:mock:test-request-uri"); 428 + expect(body.action).toBe("register"); 429 + }); 430 + }); 431 + 432 + describe("AccountTypeSwitcher with OAuth context", () => { 433 + it("preserves request_uri in links when oauthRequestUri is provided", async () => { 434 + const AccountTypeSwitcher = ( 435 + await import("../components/AccountTypeSwitcher.svelte") 436 + ).default; 437 + 438 + render(AccountTypeSwitcher, { 439 + props: { 440 + active: "passkey", 441 + ssoAvailable: true, 442 + oauthRequestUri: "urn:mock:test-request-uri", 443 + }, 444 + }); 445 + 446 + const passwordLink = screen.getByText(/password/i).closest("a"); 447 + const ssoLink = screen.getByText(/sso/i).closest("a"); 448 + 449 + expect(passwordLink?.getAttribute("href")).toContain("request_uri="); 450 + expect(passwordLink?.getAttribute("href")).toContain( 451 + encodeURIComponent("urn:mock:test-request-uri"), 452 + ); 453 + expect(ssoLink?.getAttribute("href")).toContain("request_uri="); 454 + }); 455 + 456 + it("uses oauth routes without request_uri when no oauthRequestUri provided", async () => { 457 + const AccountTypeSwitcher = ( 458 + await import("../components/AccountTypeSwitcher.svelte") 459 + ).default; 460 + 461 + render(AccountTypeSwitcher, { 462 + props: { 463 + active: "passkey", 464 + ssoAvailable: true, 465 + }, 466 + }); 467 + 468 + const passwordLink = screen.getByText(/password/i).closest("a"); 469 + expect(passwordLink?.getAttribute("href")).toBe( 470 + "/app/oauth/register-password", 471 + ); 472 + expect(passwordLink?.getAttribute("href")).not.toContain("request_uri="); 473 + }); 474 + 475 + it("passkey link goes to oauth/register when in OAuth context", async () => { 476 + const AccountTypeSwitcher = ( 477 + await import("../components/AccountTypeSwitcher.svelte") 478 + ).default; 479 + 480 + render(AccountTypeSwitcher, { 481 + props: { 482 + active: "password", 483 + ssoAvailable: true, 484 + oauthRequestUri: "urn:mock:test-request-uri", 485 + }, 486 + }); 487 + 488 + const passkeyLink = screen.getByText(/passkey/i).closest("a"); 489 + expect(passkeyLink?.getAttribute("href")).toContain("/oauth/register"); 490 + expect(passkeyLink?.getAttribute("href")).toContain("request_uri="); 491 + }); 492 + }); 493 + 494 + describe("Register component (OAuth context)", () => { 495 + beforeEach(() => { 496 + Object.defineProperty(globalThis.location, "search", { 497 + value: "?request_uri=urn:mock:test-request", 498 + writable: true, 499 + configurable: true, 500 + }); 501 + Object.defineProperty(globalThis.location, "pathname", { 502 + value: "/app/oauth/register", 503 + writable: true, 504 + configurable: true, 505 + }); 506 + 507 + mockEndpoint( 508 + "com.atproto.server.describeServer", 509 + () => jsonResponse(mockData.describeServer()), 510 + ); 511 + mockEndpoint( 512 + "/oauth/sso/providers", 513 + () => jsonResponse({ providers: [] }), 514 + ); 515 + mockEndpoint( 516 + "/oauth/authorize", 517 + () => jsonResponse({ client_name: "Test App" }), 518 + ); 519 + }); 520 + 521 + it("renders registration form with AccountTypeSwitcher", async () => { 522 + const Register = (await import("../routes/Register.svelte")) 523 + .default; 524 + render(Register); 525 + 526 + await waitFor(() => { 527 + const switcher = document.querySelector(".account-type-switcher"); 528 + expect(switcher).toBeInTheDocument(); 529 + expect(switcher?.textContent).toContain("Passkey"); 530 + expect(switcher?.textContent).toContain("Password"); 531 + }); 532 + }); 533 + 534 + it("displays client name in subtitle when available", async () => { 535 + mockEndpoint( 536 + "/oauth/authorize", 537 + () => jsonResponse({ client_name: "Awesome App" }), 538 + ); 539 + 540 + const Register = (await import("../routes/Register.svelte")) 541 + .default; 542 + render(Register); 543 + 544 + await waitFor(() => { 545 + expect(screen.getByText(/awesome app/i)).toBeInTheDocument(); 546 + }); 547 + }); 548 + 549 + it("shows handle input field", async () => { 550 + const Register = (await import("../routes/Register.svelte")) 551 + .default; 552 + render(Register); 553 + 554 + await waitFor(() => { 555 + expect(screen.getByLabelText(/handle/i)).toBeInTheDocument(); 556 + }); 557 + }); 558 + }); 559 + });
+1 -1
frontend/src/tests/setup.ts
··· 1 1 import "@testing-library/jest-dom/vitest"; 2 2 import { afterEach, beforeEach, vi } from "vitest"; 3 3 import { init, register, waitLocale } from "svelte-i18n"; 4 - import { _testResetState } from "../lib/auth.svelte"; 4 + import { _testResetState } from "../lib/auth.svelte.ts"; 5 5 6 6 register("en", () => import("../locales/en.json")); 7 7
+1
migrations/20260120_remove_email_uniqueness.sql
··· 1 + ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;

History

2 rounds 1 comment
sign up or login to add to the discussion
1 commit
expand
feat: oauth prompt=create, other frontend fixes
expand 1 comment

me when rustfmt, clippy and sqlx cache

pull request successfully merged
lewis.moe submitted #0
1 commit
expand
feat: oauth prompt=create, other frontend fixes
expand 0 comments