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

fix: user handle domains upgrade #40

merged opened by lewis.moe targeting main from fix/user-handle-domains

Actually use the user handle domains

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mgjzdv26ay22
+534 -323
Diff #0
+8
.config/nextest.toml
··· 21 21 filter = "test(/import_with_verification/) | test(/plc_migration/)" 22 22 test-group = "serial-env-tests" 23 23 24 + [[profile.default.overrides]] 25 + filter = "binary(handle_domains)" 26 + test-group = "serial-env-tests" 27 + 24 28 [[profile.default.overrides]] 25 29 filter = "binary(ripple_cluster)" 26 30 test-group = "serial-env-tests" ··· 41 45 filter = "test(/import_with_verification/) | test(/plc_migration/)" 42 46 test-group = "serial-env-tests" 43 47 48 + [[profile.ci.overrides]] 49 + filter = "binary(handle_domains)" 50 + test-group = "serial-env-tests" 51 + 44 52 [[profile.ci.overrides]] 45 53 filter = "binary(ripple_cluster)" 46 54 test-group = "serial-env-tests"
+15 -15
Cargo.lock
··· 6094 6094 6095 6095 [[package]] 6096 6096 name = "tranquil-auth" 6097 - version = "0.2.1" 6097 + version = "0.3.0" 6098 6098 dependencies = [ 6099 6099 "anyhow", 6100 6100 "base32", ··· 6117 6117 6118 6118 [[package]] 6119 6119 name = "tranquil-cache" 6120 - version = "0.2.1" 6120 + version = "0.3.0" 6121 6121 dependencies = [ 6122 6122 "async-trait", 6123 6123 "base64 0.22.1", ··· 6131 6131 6132 6132 [[package]] 6133 6133 name = "tranquil-comms" 6134 - version = "0.2.1" 6134 + version = "0.3.0" 6135 6135 dependencies = [ 6136 6136 "async-trait", 6137 6137 "base64 0.22.1", ··· 6146 6146 6147 6147 [[package]] 6148 6148 name = "tranquil-config" 6149 - version = "0.2.1" 6149 + version = "0.3.0" 6150 6150 dependencies = [ 6151 6151 "confique", 6152 6152 "serde", ··· 6154 6154 6155 6155 [[package]] 6156 6156 name = "tranquil-crypto" 6157 - version = "0.2.1" 6157 + version = "0.3.0" 6158 6158 dependencies = [ 6159 6159 "aes-gcm", 6160 6160 "base64 0.22.1", ··· 6170 6170 6171 6171 [[package]] 6172 6172 name = "tranquil-db" 6173 - version = "0.2.1" 6173 + version = "0.3.0" 6174 6174 dependencies = [ 6175 6175 "async-trait", 6176 6176 "chrono", ··· 6187 6187 6188 6188 [[package]] 6189 6189 name = "tranquil-db-traits" 6190 - version = "0.2.1" 6190 + version = "0.3.0" 6191 6191 dependencies = [ 6192 6192 "async-trait", 6193 6193 "base64 0.22.1", ··· 6203 6203 6204 6204 [[package]] 6205 6205 name = "tranquil-infra" 6206 - version = "0.2.1" 6206 + version = "0.3.0" 6207 6207 dependencies = [ 6208 6208 "async-trait", 6209 6209 "bytes", ··· 6214 6214 6215 6215 [[package]] 6216 6216 name = "tranquil-oauth" 6217 - version = "0.2.1" 6217 + version = "0.3.0" 6218 6218 dependencies = [ 6219 6219 "anyhow", 6220 6220 "axum", ··· 6237 6237 6238 6238 [[package]] 6239 6239 name = "tranquil-pds" 6240 - version = "0.2.1" 6240 + version = "0.3.0" 6241 6241 dependencies = [ 6242 6242 "aes-gcm", 6243 6243 "anyhow", ··· 6324 6324 6325 6325 [[package]] 6326 6326 name = "tranquil-repo" 6327 - version = "0.2.1" 6327 + version = "0.3.0" 6328 6328 dependencies = [ 6329 6329 "bytes", 6330 6330 "cid", ··· 6336 6336 6337 6337 [[package]] 6338 6338 name = "tranquil-ripple" 6339 - version = "0.2.1" 6339 + version = "0.3.0" 6340 6340 dependencies = [ 6341 6341 "async-trait", 6342 6342 "backon", ··· 6361 6361 6362 6362 [[package]] 6363 6363 name = "tranquil-scopes" 6364 - version = "0.2.1" 6364 + version = "0.3.0" 6365 6365 dependencies = [ 6366 6366 "axum", 6367 6367 "futures", ··· 6377 6377 6378 6378 [[package]] 6379 6379 name = "tranquil-storage" 6380 - version = "0.2.1" 6380 + version = "0.3.0" 6381 6381 dependencies = [ 6382 6382 "async-trait", 6383 6383 "aws-config", ··· 6394 6394 6395 6395 [[package]] 6396 6396 name = "tranquil-types" 6397 - version = "0.2.1" 6397 + version = "0.3.0" 6398 6398 dependencies = [ 6399 6399 "chrono", 6400 6400 "cid",
+1 -1
Cargo.toml
··· 19 19 ] 20 20 21 21 [workspace.package] 22 - version = "0.2.1" 22 + version = "0.3.0" 23 23 edition = "2024" 24 24 license = "AGPL-3.0-or-later" 25 25
-128
TODO.md
··· 1 - # Lewis' Big Boy TODO list 2 - 3 - ## Active development 4 - 5 - ### Storage backend abstraction 6 - Make storage layers swappable via traits. 7 - 8 - sqlite database backend 9 - - [ ] abstract db layer behind trait (queries, transactions, migrations) 10 - - [ ] sqlite implementation matching postgres behavior 11 - - [ ] handle sqlite's single-writer limitation (connection pooling strategy) 12 - - [ ] migrations system that works for both 13 - - [ ] testing: run full test suite against both backends 14 - - [ ] config option to choose backend (postgres vs sqlite) 15 - - [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale) 16 - 17 - - [ ] skip sqlite and just straight-up do our own db?! 18 - 19 - ### Plugin system 20 - WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions. 21 - 22 - WIT interface 23 - - [ ] record hooks before/after create, update, delete 24 - - [ ] blob hooks before/after upload, validate 25 - - [ ] xrpc hooks before/after (middleware), custom endpoint handler 26 - - [ ] firehose hook on_commit 27 - - [ ] host imports http client, kv store, logging, read records 28 - 29 - wasmtime host 30 - - [ ] engine with epoch interruption (kill runaway plugins) 31 - - [ ] plugin manifest (plugin.toml): id, version, capabilities, hooks 32 - - [ ] capability enforcement at runtime 33 - - [ ] plugin loader, lifecycle (enable/disable/reload) 34 - - [ ] resource limits (memory, time) 35 - - [ ] per-plugin fs sandbox 36 - 37 - capabilities 38 - - [ ] http:fetch with domain allowlist 39 - - [ ] kv:read, kv:write 40 - - [ ] record:read, blob:read 41 - - [ ] xrpc:register 42 - - [ ] firehose:subscribe 43 - 44 - pds-plugin-api (rust), MVP for plugin system 45 - - [ ] plugin trait with default impls 46 - - [ ] register_plugin! macro 47 - - [ ] typed host import wrappers 48 - - [ ] publish to crates.io 49 - - [ ] docs + example 50 - 51 - pds-plugin-api in golang, nice to have after the fact 52 - - [ ] wit-bindgen-go bindings 53 - - [ ] go wrappers 54 - - [ ] tinygo build instructions 55 - - [ ] example 56 - 57 - @pds/plugin-api in typescript, nice to have after the fact 58 - - [ ] jco/componentize-js bindings 59 - - [ ] typeScript types 60 - - [ ] build tooling 61 - - [ ] example 62 - 63 - example plugins 64 - - [ ] content filter 65 - - [ ] webhook notifier 66 - - [ ] objsto backup mirror 67 - - [ ] custom lexicon handler 68 - - [ ] better audit logger 69 - 70 - ### Misc 71 - 72 - cross-pds delegation 73 - when a client (eg. tangled.org) tries to log into a delegated account: 74 - - [ ] client starts oauth flow to delegated account's pds 75 - - [ ] delegated pds sees account is externally controlled, launches oauth to controller's pds (delegated pds acts as oauth client) 76 - - [ ] controller authenticates at their own pds 77 - - [ ] delegated pds verifies controller perms and scope from its local delegation grants 78 - - [ ] delegated pds issues session to client within the intersection of controller's granted scope and client's requested scope 79 - 80 - per-request "act as" 81 - - [ ] authed as user X, perform action as delegated user Y in single request 82 - - [ ] approach decision 83 - - [ ] option 1: `X-Act-As` header with target did, server verifies delegation grant 84 - - [ ] option 2: token exchange (RFC 8693) for short-lived delegated token 85 - - [ ] option 3 (lewis fav): extend existing `act` claim to support on-demand minting 86 - - [ ] something else? 87 - 88 - ### Private/encrypted data 89 - Records only authorized parties can see and decrypt. 90 - 91 - research 92 - - [ ] survey atproto discourse on private data 93 - - [ ] document bluesky team's likely approach. wait.. are they even gonna do this? whatever 94 - - [ ] look at matrix/signal for federated e2ee patterns 95 - 96 - key management 97 - - [ ] db schema for encryption keys (user_keys, key_grants, key_rotations) 98 - - [ ] per-user encryption keypair generation (separate from signing keys) 99 - - [ ] key derivation scheme (per-collection? per-record? both?) 100 - - [ ] key storage (encrypted at rest, hsm option?) 101 - - [ ] rotation and revocation flow 102 - 103 - storage layer 104 - - [ ] encrypted record format (encrypted cbor blob + metadata) 105 - - [ ] collection-level vs per-record encryption flag 106 - - [ ] how encrypted records appear in mst (hash of ciphertext? separate tree?) 107 - - [ ] blob encryption (same keys? separate?) 108 - 109 - api surface 110 - - [ ] xrpc getPublicKey, grantAccess, revokeAccess, listGrants 111 - - [ ] xrpc getEncryptedRecord (ciphertext for client-side decrypt) 112 - - [ ] or transparent server-side decrypt if requester has grant? 113 - - [ ] lexicon for key grant records 114 - 115 - sync/federation 116 - - [ ] how encrypted records appear on firehose (ciphertext? omitted? placeholder?) 117 - - [ ] pds-to-pds key exchange protocol 118 - - [ ] appview behavior (can't index without grants) 119 - - [ ] relay behavior with encrypted commits 120 - 121 - client integration 122 - - [ ] client-side encryption (pds never sees plaintext) vs server-side with trust 123 - - [ ] key backup/recovery (lose key = lose data) 124 - 125 - plugin hooks (once core exists) 126 - - [ ] on_access_grant_request for custom authorization 127 - - [ ] on_key_rotation to notify interested parties 128 -
+6 -2
crates/tranquil-config/src/lib.rs
··· 473 473 /// Returns the available user domains, falling back to `[hostname_without_port]`. 474 474 pub fn available_user_domain_list(&self) -> Vec<String> { 475 475 self.available_user_domains 476 - .clone() 476 + .as_deref() 477 + .filter(|v| !v.is_empty()) 478 + .map(|v| v.to_vec()) 477 479 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 478 480 } 479 481 480 482 /// Returns the user handle domains, falling back to `[hostname_without_port]`. 481 483 pub fn user_handle_domain_list(&self) -> Vec<String> { 482 484 self.user_handle_domains 483 - .clone() 485 + .as_deref() 486 + .filter(|v| !v.is_empty()) 487 + .map(|v| v.to_vec()) 484 488 .unwrap_or_else(|| vec![self.hostname_without_port().to_string()]) 485 489 } 486 490 }
+2 -2
crates/tranquil-pds/src/api/admin/account/update.rs
··· 69 69 { 70 70 return Err(ApiError::InvalidHandle(None)); 71 71 } 72 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 72 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 73 73 let handle = if !input_handle.contains('.') { 74 - format!("{}.{}", input_handle, hostname_for_handles) 74 + format!("{}.{}", input_handle, &available_domains[0]) 75 75 } else { 76 76 input_handle.to_string() 77 77 };
+13 -11
crates/tranquil-pds/src/api/delegation.rs
··· 435 435 }; 436 436 437 437 let hostname = &tranquil_config::get().server.hostname; 438 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 439 - let pds_suffix = format!(".{}", hostname_for_handles); 440 - 441 - let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 442 - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 443 - input 438 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 439 + let matched_domain = available_domains 440 + .iter() 441 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 442 + .max_by_key(|d| d.len()); 443 + 444 + let handle = if !input.handle.contains('.') || matched_domain.is_some() { 445 + let handle_to_validate = match matched_domain { 446 + Some(domain) => input 444 447 .handle 445 - .strip_suffix(&pds_suffix) 446 - .unwrap_or(&input.handle) 447 - } else { 448 - &input.handle 448 + .strip_suffix(&format!(".{}", domain)) 449 + .unwrap_or(&input.handle), 450 + None => &input.handle, 449 451 }; 450 452 match crate::api::validation::validate_short_handle(handle_to_validate) { 451 - Ok(h) => format!("{}.{}", h, hostname_for_handles), 453 + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 452 454 Err(e) => { 453 455 return Ok(ApiError::InvalidRequest(e.to_string()).into_response()); 454 456 }
+17 -18
crates/tranquil-pds/src/api/identity/account.rs
··· 140 140 } 141 141 } 142 142 143 - let hostname_for_validation = tranquil_config::get().server.hostname_without_port(); 144 - let pds_suffix = format!(".{}", hostname_for_validation); 143 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 144 + let matched_domain = available_domains 145 + .iter() 146 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 147 + .max_by_key(|d| d.len()); 145 148 146 149 let validated_short_handle = if !input.handle.contains('.') 147 - || input.handle.ends_with(&pds_suffix) 150 + || matched_domain.is_some() 148 151 { 149 - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 150 - input 152 + let handle_to_validate = match matched_domain { 153 + Some(domain) => input 151 154 .handle 152 - .strip_suffix(&pds_suffix) 153 - .unwrap_or(&input.handle) 154 - } else { 155 - &input.handle 155 + .strip_suffix(&format!(".{}", domain)) 156 + .unwrap_or(&input.handle), 157 + None => &input.handle, 156 158 }; 157 159 match crate::api::validation::validate_short_handle(handle_to_validate) { 158 160 Ok(h) => h, ··· 233 235 }) 234 236 }; 235 237 let hostname = &tranquil_config::get().server.hostname; 236 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 237 238 let pds_endpoint = format!("https://{}", hostname); 238 - let suffix = format!(".{}", hostname_for_handles); 239 - let handle = if input.handle.ends_with(&suffix) { 240 - format!("{}.{}", validated_short_handle, hostname_for_handles) 241 - } else if input.handle.contains('.') { 242 - validated_short_handle.clone() 243 - } else { 244 - format!("{}.{}", validated_short_handle, hostname_for_handles) 239 + let handle = match matched_domain { 240 + Some(domain) => format!("{}.{}", validated_short_handle, domain), 241 + None if input.handle.contains('.') => validated_short_handle.clone(), 242 + None => format!("{}.{}", validated_short_handle, &available_domains[0]), 245 243 }; 246 244 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 247 245 if let Some(signing_key_did) = &input.signing_key { ··· 276 274 if !crate::api::server::meta::is_self_hosted_did_web_enabled() { 277 275 return ApiError::SelfHostedDidWebDisabled.into_response(); 278 276 } 279 - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 277 + let pds_hostname = tranquil_config::get().server.hostname_without_port(); 278 + let subdomain_host = format!("{}.{}", input.handle, pds_hostname); 280 279 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 281 280 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 282 281 info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
+18 -14
crates/tranquil-pds/src/api/identity/did.rs
··· 675 675 "Inappropriate language in handle".into(), 676 676 ))); 677 677 } 678 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 679 - let suffix = format!(".{}", hostname_for_handles); 680 - let is_service_domain = 681 - crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles); 682 - let handle = if is_service_domain && new_handle != hostname_for_handles { 683 - let short_part = if new_handle.ends_with(&suffix) { 684 - new_handle.strip_suffix(&suffix).unwrap_or(&new_handle) 685 - } else { 686 - &new_handle 687 - }; 688 - let full_handle = if new_handle.ends_with(&suffix) { 689 - new_handle.clone() 690 - } else { 691 - format!("{}.{}", new_handle, hostname_for_handles) 678 + let handle_domains = tranquil_config::get().server.user_handle_domain_list(); 679 + let matched_handle_domain = handle_domains 680 + .iter() 681 + .filter(|d| new_handle.ends_with(&format!(".{}", d))) 682 + .max_by_key(|d| d.len()) 683 + .cloned(); 684 + let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle); 685 + let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself { 686 + let (short_part, full_handle) = match &matched_handle_domain { 687 + Some(domain) => { 688 + let suffix = format!(".{}", domain); 689 + let short = new_handle.strip_suffix(&suffix).unwrap_or(&new_handle); 690 + (short.to_string(), new_handle.clone()) 691 + } 692 + None => { 693 + let primary = &handle_domains[0]; 694 + (new_handle.clone(), format!("{}.{}", new_handle, primary)) 695 + } 692 696 }; 693 697 if full_handle == current_handle { 694 698 let handle_typed: Handle = match full_handle.parse() {
+14 -11
crates/tranquil-pds/src/api/server/passkey_account.rs
··· 113 113 .unwrap_or(false); 114 114 115 115 let hostname = &tranquil_config::get().server.hostname; 116 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 117 - let pds_suffix = format!(".{}", hostname_for_handles); 116 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 117 + let matched_domain = available_domains 118 + .iter() 119 + .filter(|d| input.handle.ends_with(&format!(".{}", d))) 120 + .max_by_key(|d| d.len()); 118 121 119 - let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 120 - let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 121 - input 122 + let handle = if !input.handle.contains('.') || matched_domain.is_some() { 123 + let handle_to_validate = match matched_domain { 124 + Some(domain) => input 122 125 .handle 123 - .strip_suffix(&pds_suffix) 124 - .unwrap_or(&input.handle) 125 - } else { 126 - &input.handle 126 + .strip_suffix(&format!(".{}", domain)) 127 + .unwrap_or(&input.handle), 128 + None => &input.handle, 127 129 }; 128 130 match crate::api::validation::validate_short_handle(handle_to_validate) { 129 - Ok(h) => format!("{}.{}", h, hostname_for_handles), 131 + Ok(h) => format!("{}.{}", h, matched_domain.unwrap_or(&available_domains[0])), 130 132 Err(_) => { 131 133 return ApiError::InvalidHandle(None).into_response(); 132 134 } ··· 244 246 245 247 let did = match did_type { 246 248 "web" => { 247 - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 249 + let pds_hostname = tranquil_config::get().server.hostname_without_port(); 250 + let subdomain_host = format!("{}.{}", input.handle, pds_hostname); 248 251 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 249 252 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 250 253 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
+6 -5
crates/tranquil-pds/src/sso/endpoints.rs
··· 772 772 } 773 773 }; 774 774 775 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 776 - let full_handle = format!("{}.{}", validated, hostname_for_handles); 775 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 776 + let full_handle = format!("{}.{}", validated, &available_domains[0]); 777 777 let handle_typed: crate::types::Handle = match full_handle.parse() { 778 778 Ok(h) => h, 779 779 Err(_) => return Err(ApiError::InvalidHandle(None)), ··· 856 856 .ok_or(ApiError::SsoSessionExpired)?; 857 857 858 858 let hostname = &tranquil_config::get().server.hostname; 859 - let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); 859 + let available_domains = tranquil_config::get().server.available_user_domain_list(); 860 860 861 861 let handle = match crate::api::validation::validate_short_handle(&input.handle) { 862 - Ok(h) => format!("{}.{}", h, hostname_for_handles), 862 + Ok(h) => format!("{}.{}", h, &available_domains[0]), 863 863 Err(_) => return Err(ApiError::InvalidHandle(None)), 864 864 }; 865 865 ··· 981 981 982 982 let did = match did_type { 983 983 "web" => { 984 - let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); 984 + let pds_hostname = tranquil_config::get().server.hostname_without_port(); 985 + let subdomain_host = format!("{}.{}", input.handle, pds_hostname); 985 986 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 986 987 let self_hosted_did = format!("did:web:{}", encoded_subdomain); 987 988 tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account");
+278
crates/tranquil-pds/tests/handle_domains.rs
··· 1 + mod common; 2 + use common::*; 3 + use reqwest::StatusCode; 4 + use reqwest::header; 5 + use serde_json::{Value, json}; 6 + 7 + const HANDLE_DOMAIN: &str = "handles.test"; 8 + 9 + fn set_handle_domain() { 10 + unsafe { 11 + std::env::set_var("AVAILABLE_USER_DOMAINS", HANDLE_DOMAIN); 12 + std::env::set_var("PDS_USER_HANDLE_DOMAINS", HANDLE_DOMAIN); 13 + } 14 + } 15 + 16 + async fn base_url_with_domain() -> &'static str { 17 + set_handle_domain(); 18 + base_url().await 19 + } 20 + 21 + #[tokio::test] 22 + async fn describe_server_returns_configured_domain() { 23 + let client = client(); 24 + let base = base_url_with_domain().await; 25 + let res = client 26 + .get(format!( 27 + "{}/xrpc/com.atproto.server.describeServer", 28 + base 29 + )) 30 + .send() 31 + .await 32 + .expect("describeServer request failed"); 33 + assert_eq!(res.status(), StatusCode::OK); 34 + let body: Value = res.json().await.expect("Invalid JSON"); 35 + let domains = body["availableUserDomains"] 36 + .as_array() 37 + .expect("No availableUserDomains"); 38 + assert!( 39 + domains.iter().any(|d| d.as_str() == Some(HANDLE_DOMAIN)), 40 + "availableUserDomains should contain {}, got {:?}", 41 + HANDLE_DOMAIN, 42 + domains 43 + ); 44 + } 45 + 46 + #[tokio::test] 47 + async fn short_handle_uses_configured_domain() { 48 + let client = client(); 49 + let base = base_url_with_domain().await; 50 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 51 + let payload = json!({ 52 + "handle": short_handle, 53 + "email": format!("{}@example.com", short_handle), 54 + "password": "Testpass123!" 55 + }); 56 + let res = client 57 + .post(format!( 58 + "{}/xrpc/com.atproto.server.createAccount", 59 + base 60 + )) 61 + .json(&payload) 62 + .send() 63 + .await 64 + .expect("createAccount request failed"); 65 + assert_eq!(res.status(), StatusCode::OK); 66 + let body: Value = res.json().await.expect("Invalid JSON"); 67 + let handle = body["handle"].as_str().expect("No handle in response"); 68 + let expected_suffix = format!(".{}", HANDLE_DOMAIN); 69 + assert!( 70 + handle.ends_with(&expected_suffix), 71 + "Handle '{}' should end with '{}' (not PDS hostname)", 72 + handle, 73 + expected_suffix 74 + ); 75 + assert_eq!( 76 + handle, 77 + format!("{}.{}", short_handle, HANDLE_DOMAIN), 78 + "Handle should be short_handle.configured_domain" 79 + ); 80 + } 81 + 82 + #[tokio::test] 83 + async fn full_handle_with_configured_domain_accepted() { 84 + let client = client(); 85 + let base = base_url_with_domain().await; 86 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 87 + let full_handle = format!("{}.{}", short_handle, HANDLE_DOMAIN); 88 + let payload = json!({ 89 + "handle": full_handle, 90 + "email": format!("{}@example.com", short_handle), 91 + "password": "Testpass123!" 92 + }); 93 + let res = client 94 + .post(format!( 95 + "{}/xrpc/com.atproto.server.createAccount", 96 + base 97 + )) 98 + .json(&payload) 99 + .send() 100 + .await 101 + .expect("createAccount request failed"); 102 + assert_eq!(res.status(), StatusCode::OK); 103 + let body: Value = res.json().await.expect("Invalid JSON"); 104 + let handle = body["handle"].as_str().expect("No handle in response"); 105 + assert_eq!( 106 + handle, full_handle, 107 + "Handle should match the full handle submitted" 108 + ); 109 + } 110 + 111 + #[tokio::test] 112 + async fn handle_with_pds_hostname_treated_as_custom() { 113 + let client = client(); 114 + let base = base_url_with_domain().await; 115 + let pds_hostname = pds_hostname(); 116 + let pds_host_no_port = pds_hostname.split(':').next().unwrap_or(&pds_hostname); 117 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 118 + let handle_with_hostname = format!("{}.{}", short_handle, pds_host_no_port); 119 + let payload = json!({ 120 + "handle": handle_with_hostname, 121 + "email": format!("{}@example.com", short_handle), 122 + "password": "Testpass123!" 123 + }); 124 + let res = client 125 + .post(format!( 126 + "{}/xrpc/com.atproto.server.createAccount", 127 + base 128 + )) 129 + .json(&payload) 130 + .send() 131 + .await 132 + .expect("createAccount request failed"); 133 + assert_eq!(res.status(), StatusCode::OK); 134 + let body: Value = res.json().await.expect("Invalid JSON"); 135 + let handle = body["handle"].as_str().expect("No handle in response"); 136 + assert_eq!( 137 + handle, handle_with_hostname, 138 + "Handle with non-available domain suffix should be treated as custom handle (passed through)" 139 + ); 140 + } 141 + 142 + #[tokio::test] 143 + async fn resolve_handle_works_with_configured_domain() { 144 + let client = client(); 145 + let base = base_url_with_domain().await; 146 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 147 + let payload = json!({ 148 + "handle": short_handle, 149 + "email": format!("{}@example.com", short_handle), 150 + "password": "Testpass123!" 151 + }); 152 + let res = client 153 + .post(format!( 154 + "{}/xrpc/com.atproto.server.createAccount", 155 + base 156 + )) 157 + .json(&payload) 158 + .send() 159 + .await 160 + .expect("createAccount request failed"); 161 + assert_eq!(res.status(), StatusCode::OK); 162 + let body: Value = res.json().await.expect("Invalid JSON"); 163 + let did = body["did"].as_str().expect("No DID").to_string(); 164 + let full_handle = body["handle"].as_str().expect("No handle").to_string(); 165 + 166 + let res = client 167 + .get(format!( 168 + "{}/xrpc/com.atproto.identity.resolveHandle", 169 + base 170 + )) 171 + .query(&[("handle", full_handle.as_str())]) 172 + .send() 173 + .await 174 + .expect("resolveHandle request failed"); 175 + assert_eq!(res.status(), StatusCode::OK); 176 + let body: Value = res.json().await.expect("Invalid JSON"); 177 + assert_eq!(body["did"], did); 178 + } 179 + 180 + #[tokio::test] 181 + async fn admin_update_handle_uses_configured_domain() { 182 + let client = client(); 183 + let base = base_url_with_domain().await; 184 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 185 + let (_, target_did) = create_account_and_login(&client).await; 186 + 187 + let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 188 + let res = client 189 + .post(format!( 190 + "{}/xrpc/com.atproto.admin.updateAccountHandle", 191 + base 192 + )) 193 + .bearer_auth(&admin_jwt) 194 + .json(&json!({ 195 + "did": target_did, 196 + "handle": new_short, 197 + })) 198 + .send() 199 + .await 200 + .expect("admin updateAccountHandle request failed"); 201 + assert_eq!(res.status(), StatusCode::OK); 202 + 203 + let res = client 204 + .get(format!( 205 + "{}/xrpc/com.atproto.identity.resolveHandle", 206 + base 207 + )) 208 + .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) 209 + .send() 210 + .await 211 + .expect("resolveHandle request failed"); 212 + assert_eq!(res.status(), StatusCode::OK); 213 + let body: Value = res.json().await.expect("Invalid JSON"); 214 + assert_eq!( 215 + body["did"], target_did, 216 + "Admin bare handle update should use configured domain, not PDS hostname" 217 + ); 218 + } 219 + 220 + #[tokio::test] 221 + async fn update_handle_bare_uses_configured_domain() { 222 + let client = client(); 223 + let base = base_url_with_domain().await; 224 + let short_handle = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 225 + let payload = json!({ 226 + "handle": short_handle, 227 + "email": format!("{}@example.com", short_handle), 228 + "password": "Testpass123!" 229 + }); 230 + let res = client 231 + .post(format!( 232 + "{}/xrpc/com.atproto.server.createAccount", 233 + base 234 + )) 235 + .json(&payload) 236 + .send() 237 + .await 238 + .expect("createAccount request failed"); 239 + assert_eq!(res.status(), StatusCode::OK); 240 + let body: Value = res.json().await.expect("Invalid JSON"); 241 + let did = body["did"].as_str().expect("No DID").to_string(); 242 + let access_jwt = verify_new_account(&client, &did).await; 243 + 244 + let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 245 + let res = client 246 + .post(format!( 247 + "{}/xrpc/com.atproto.identity.updateHandle", 248 + base 249 + )) 250 + .bearer_auth(&access_jwt) 251 + .header(header::CONTENT_TYPE, "application/json") 252 + .json(&json!({ "handle": new_short })) 253 + .send() 254 + .await 255 + .expect("updateHandle request failed"); 256 + assert_eq!( 257 + res.status(), 258 + StatusCode::OK, 259 + "updateHandle failed: {:?}", 260 + res.text().await 261 + ); 262 + 263 + let res = client 264 + .get(format!( 265 + "{}/xrpc/com.atproto.identity.resolveHandle", 266 + base 267 + )) 268 + .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) 269 + .send() 270 + .await 271 + .expect("resolveHandle request failed"); 272 + assert_eq!(res.status(), StatusCode::OK); 273 + let body: Value = res.json().await.expect("Invalid JSON"); 274 + assert_eq!( 275 + body["did"], did, 276 + "updateHandle with bare handle should use configured domain, not PDS hostname" 277 + ); 278 + }
+3 -10
frontend/public/homepage.html
··· 428 428 429 429 <footer class="site-footer"> 430 430 <span>Made by people who don't take themselves too seriously</span> 431 - <span>Open Source: issues & PRs welcome</span> 431 + <span>Open source & open hearts</span> 432 432 </footer> 433 433 </div> 434 434 ··· 485 485 }) 486 486 .then(function (info) { 487 487 var hostnameEl = document.getElementById("hostname"); 488 - if ( 489 - info.availableUserDomains && 490 - info.availableUserDomains.length 491 - ) { 492 - hostnameEl.textContent = info.availableUserDomains[0]; 493 - } else { 494 - hostnameEl.textContent = "Tranquil PDS"; 495 - } 488 + hostnameEl.textContent = window.location.hostname; 496 489 hostnameEl.classList.remove("placeholder"); 497 490 if (info.version) { 498 491 document.getElementById("version").textContent = ··· 501 494 }) 502 495 .catch(function () { 503 496 var hostnameEl = document.getElementById("hostname"); 504 - hostnameEl.textContent = "Tranquil PDS"; 497 + hostnameEl.textContent = window.location.hostname; 505 498 hostnameEl.classList.remove("placeholder"); 506 499 }); 507 500
+71
frontend/src/components/HandleInput.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + value: string 4 + domains: string[] 5 + selectedDomain: string 6 + disabled?: boolean 7 + placeholder?: string 8 + id?: string 9 + autocomplete?: string 10 + onInput: (value: string) => void 11 + onDomainChange: (domain: string) => void 12 + } 13 + 14 + let { 15 + value, 16 + domains, 17 + selectedDomain, 18 + disabled = false, 19 + placeholder = 'username', 20 + id = 'handle', 21 + autocomplete = 'off', 22 + onInput, 23 + onDomainChange, 24 + }: Props = $props() 25 + 26 + const showDomainSelect = $derived(domains.length > 1 && !value.includes('.')) 27 + </script> 28 + 29 + <div class="handle-input-group"> 30 + <input 31 + {id} 32 + type="text" 33 + value={value} 34 + {placeholder} 35 + {disabled} 36 + autocomplete={autocomplete} 37 + required 38 + oninput={(e) => onInput((e.target as HTMLInputElement).value)} 39 + /> 40 + {#if showDomainSelect} 41 + <select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}> 42 + {#each domains as domain} 43 + <option value={domain}>.{domain}</option> 44 + {/each} 45 + </select> 46 + {:else if domains.length === 1 && !value.includes('.')} 47 + <span class="domain-suffix">.{domains[0]}</span> 48 + {/if} 49 + </div> 50 + 51 + <style> 52 + .handle-input-group { 53 + display: flex; 54 + gap: var(--space-2); 55 + align-items: center; 56 + } 57 + 58 + .handle-input-group input { 59 + flex: 1; 60 + } 61 + 62 + .handle-input-group select { 63 + width: auto; 64 + } 65 + 66 + .domain-suffix { 67 + color: var(--text-secondary); 68 + font-size: var(--text-sm); 69 + white-space: nowrap; 70 + } 71 + </style>
+18 -38
frontend/src/components/dashboard/SettingsContent.svelte
··· 9 9 import { getSessionEmail } from '../../lib/types/api' 10 10 import { formatDate } from '../../lib/date' 11 11 import { navigate, routes } from '../../lib/router.svelte' 12 + import HandleInput from '../HandleInput.svelte' 12 13 13 14 interface Props { 14 15 session: Session ··· 17 18 let { session }: Props = $props() 18 19 19 20 const supportedLocales = getSupportedLocales() 20 - let pdsHostname = $state<string | null>(null) 21 + let availableDomains = $state<string[]>([]) 22 + let selectedDomain = $state('') 23 + let pdsHostname = $derived(selectedDomain || null) 21 24 22 25 onMount(() => { 23 26 const init = async () => { 24 27 try { 25 28 const info = await api.describeServer() 26 29 if (info.availableUserDomains?.length) { 27 - pdsHostname = info.availableUserDomains[0] 30 + availableDomains = info.availableUserDomains 31 + selectedDomain = info.availableUserDomains[0] 28 32 } 29 33 } catch {} 30 34 loadBackups() ··· 150 154 if (!newHandle) return 151 155 handleLoading = true 152 156 try { 153 - const fullHandle = showBYOHandle ? newHandle : `${newHandle}.${pdsHostname}` 157 + const fullHandle = showBYOHandle ? newHandle : `${newHandle}.${selectedDomain}` 154 158 await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle)) 155 159 await refreshSession() 156 160 toast.success($_('settings.messages.handleUpdated')) ··· 481 485 <form onsubmit={handleUpdateHandle}> 482 486 <div class="field"> 483 487 <label for="new-handle">{$_('settings.newHandle')}</label> 484 - <div class="handle-input-wrapper"> 485 - <input id="new-handle" type="text" bind:value={newHandle} placeholder={$_('settings.newHandlePlaceholder')} disabled={handleLoading} required /> 486 - <span class="handle-suffix">.{pdsHostname ?? '...'}</span> 487 - </div> 488 + <HandleInput 489 + id="new-handle" 490 + value={newHandle} 491 + domains={availableDomains} 492 + {selectedDomain} 493 + placeholder={$_('settings.newHandlePlaceholder')} 494 + disabled={handleLoading} 495 + onInput={(v) => { newHandle = v }} 496 + onDomainChange={(d) => { selectedDomain = d }} 497 + /> 488 498 </div> 489 - <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}> 499 + <button type="submit" disabled={handleLoading || !newHandle || !selectedDomain}> 490 500 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 491 501 </button> 492 502 </form> ··· 689 699 color: var(--text-inverse); 690 700 } 691 701 692 - .handle-input-wrapper { 693 - display: flex; 694 - align-items: center; 695 - background: var(--bg-input); 696 - border: 1px solid var(--border-color); 697 - border-radius: var(--radius-md); 698 - overflow: hidden; 699 - } 700 - 701 - .handle-input-wrapper input { 702 - flex: 1; 703 - border: none; 704 - border-radius: 0; 705 - background: transparent; 706 - } 707 - 708 - .handle-input-wrapper input:focus { 709 - outline: none; 710 - box-shadow: none; 711 - } 712 - 713 - .handle-suffix { 714 - padding: 0 var(--space-3); 715 - color: var(--text-secondary); 716 - font-size: var(--text-sm); 717 - white-space: nowrap; 718 - border-left: 1px solid var(--border-color); 719 - background: var(--bg-card); 720 - } 721 - 722 702 .loading, 723 703 .empty { 724 704 color: var(--text-secondary);
+10 -17
frontend/src/components/migration/ChooseHandleStep.svelte
··· 1 1 <script lang="ts"> 2 2 import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types' 3 3 import { _ } from '../../lib/i18n' 4 + import HandleInput from '../HandleInput.svelte' 4 5 5 6 interface Props { 6 7 handleInput: string ··· 171 172 {:else} 172 173 <div class="field"> 173 174 <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 174 - <div class="handle-input-group"> 175 - <input 176 - id="new-handle" 177 - type="text" 178 - placeholder="username" 179 - value={handleInput} 180 - oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)} 181 - onblur={onCheckHandle} 182 - /> 183 - {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 184 - <select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}> 185 - {#each serverInfo.availableUserDomains as domain} 186 - <option value={domain}>.{domain}</option> 187 - {/each} 188 - </select> 189 - {/if} 190 - </div> 175 + <HandleInput 176 + id="new-handle" 177 + value={handleInput} 178 + domains={serverInfo?.availableUserDomains ?? []} 179 + {selectedDomain} 180 + placeholder="username" 181 + onInput={onHandleChange} 182 + onDomainChange={onDomainChange} 183 + /> 191 184 192 185 {#if handleTooShort} 193 186 <p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
+11 -7
frontend/src/routes/OAuthRegister.svelte
··· 16 16 type WebAuthnCreationOptionsResponse, 17 17 } from '../lib/webauthn' 18 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 + import HandleInput from '../components/HandleInput.svelte' 19 20 20 21 let serverInfo = $state<{ 21 22 availableUserDomains: string[] ··· 30 31 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 31 32 let passkeyName = $state('') 32 33 let clientName = $state<string | null>(null) 34 + let selectedDomain = $state('') 33 35 34 36 function getRequestUri(): string | null { 35 37 const params = new URLSearchParams(window.location.search) ··· 99 101 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 100 102 flow = createRegistrationFlow('passkey', hostname) 101 103 } 104 + selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 102 105 } catch (e) { 103 106 console.error('Failed to load server info:', e) 104 107 } finally { ··· 262 265 263 266 let fullHandle = $derived(() => { 264 267 if (!flow?.info.handle.trim()) return '' 265 - return `${flow.info.handle.trim()}.${flow.state.pdsHostname}` 268 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 269 + return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() 266 270 }) 267 271 268 272 async function handleCancel() { ··· 342 346 <form onsubmit={handleInfoSubmit}> 343 347 <div class="field"> 344 348 <label for="handle">{$_('register.handle')}</label> 345 - <input 346 - id="handle" 347 - type="text" 348 - bind:value={flow.info.handle} 349 + <HandleInput 350 + value={flow.info.handle} 351 + domains={serverInfo?.availableUserDomains ?? []} 352 + {selectedDomain} 349 353 placeholder={$_('register.handlePlaceholder')} 350 354 disabled={flow.state.submitting} 351 - required 352 - autocomplete="off" 355 + onInput={(v) => { flow!.info.handle = v }} 356 + onDomainChange={(d) => { selectedDomain = d }} 353 357 /> 354 358 {#if fullHandle()} 355 359 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
+11 -8
frontend/src/routes/OAuthSsoRegister.svelte
··· 3 3 import { _ } from '../lib/i18n' 4 4 import { toast } from '../lib/toast.svelte' 5 5 import SsoIcon from '../components/SsoIcon.svelte' 6 + import HandleInput from '../components/HandleInput.svelte' 6 7 7 8 interface PendingRegistration { 8 9 request_uri: string ··· 37 38 let handleAvailable = $state<boolean | null>(null) 38 39 let checkingHandle = $state(false) 39 40 let handleError = $state<string | null>(null) 41 + let selectedDomain = $state('') 40 42 41 43 let didType = $state<'plc' | 'web' | 'web-external'>('plc') 42 44 let externalDid = $state('') ··· 80 82 81 83 let fullHandle = $derived(() => { 82 84 if (!handle.trim()) return '' 83 - const domain = serverInfo?.availableUserDomains?.[0] 84 - return domain ? `${handle.trim()}.${domain}` : handle.trim() 85 + if (handle.includes('.')) return handle.trim() 86 + return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim() 85 87 }) 86 88 87 89 onMount(() => { ··· 106 108 telegram: available.includes('telegram'), 107 109 signal: available.includes('signal'), 108 110 } 111 + selectedDomain = data.availableUserDomains?.[0] || window.location.hostname 109 112 } 110 113 } catch { 111 114 serverInfo = null ··· 317 320 <form onsubmit={handleSubmit}> 318 321 <div class="field"> 319 322 <label for="handle">{$_('sso_register.handle_label')}</label> 320 - <input 321 - id="handle" 322 - type="text" 323 - bind:value={handle} 323 + <HandleInput 324 + value={handle} 325 + domains={serverInfo?.availableUserDomains ?? []} 326 + {selectedDomain} 324 327 placeholder={$_('register.handlePlaceholder')} 325 328 disabled={submitting} 326 - required 327 - autocomplete="off" 329 + onInput={(v) => { handle = v }} 330 + onDomainChange={(d) => { selectedDomain = d }} 328 331 /> 329 332 {#if checkingHandle} 330 333 <p class="hint">{$_('common.checking')}</p>
+11 -7
frontend/src/routes/Register.svelte
··· 16 16 type WebAuthnCreationOptionsResponse, 17 17 } from '../lib/webauthn' 18 18 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 19 + import HandleInput from '../components/HandleInput.svelte' 19 20 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 20 21 21 22 let serverInfo = $state<{ ··· 31 32 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 32 33 let passkeyName = $state('') 33 34 let clientName = $state<string | null>(null) 35 + let selectedDomain = $state('') 34 36 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 35 37 36 38 $effect(() => { ··· 112 114 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 113 115 flow = createRegistrationFlow('passkey', hostname) 114 116 } 117 + selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 115 118 } catch (e) { 116 119 console.error('Failed to load server info:', e) 117 120 } finally { ··· 276 279 277 280 let fullHandle = $derived(() => { 278 281 if (!flow?.info.handle.trim()) return '' 279 - return `${flow.info.handle.trim()}.${flow.state.pdsHostname}` 282 + if (flow.info.handle.includes('.')) return flow.info.handle.trim() 283 + return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() 280 284 }) 281 285 282 286 async function handleCancel() { ··· 357 361 <form class="register-form" onsubmit={handleInfoSubmit}> 358 362 <div class="field"> 359 363 <label for="handle">{$_('register.handle')}</label> 360 - <input 361 - id="handle" 362 - type="text" 363 - bind:value={flow.info.handle} 364 + <HandleInput 365 + value={flow.info.handle} 366 + domains={serverInfo?.availableUserDomains ?? []} 367 + {selectedDomain} 364 368 placeholder={$_('register.handlePlaceholder')} 365 369 disabled={flow.state.submitting} 366 - required 367 - autocomplete="off" 370 + onInput={(v) => { flow!.info.handle = v }} 371 + onDomainChange={(d) => { selectedDomain = d }} 368 372 /> 369 373 {#if flow.info.handle.includes('.')} 370 374 <p class="hint warning">{$_('register.handleDotWarning')}</p>
+10 -8
frontend/src/routes/RegisterPassword.svelte
··· 10 10 DidDocStep, 11 11 } from '../lib/registration' 12 12 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 13 + import HandleInput from '../components/HandleInput.svelte' 13 14 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 14 15 15 16 let serverInfo = $state<{ ··· 25 26 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 26 27 let confirmPassword = $state('') 27 28 let clientName = $state<string | null>(null) 29 + let selectedDomain = $state('') 28 30 let checkHandleTimeout: ReturnType<typeof setTimeout> | null = null 29 31 30 32 $effect(() => { ··· 106 108 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 107 109 flow = createRegistrationFlow('password', hostname) 108 110 } 111 + selectedDomain = serverInfo?.availableUserDomains?.[0] || window.location.hostname 109 112 } catch (e) { 110 113 console.error('Failed to load server info:', e) 111 114 } finally { ··· 229 232 let fullHandle = $derived(() => { 230 233 if (!flow?.info.handle.trim()) return '' 231 234 if (flow.info.handle.includes('.')) return flow.info.handle.trim() 232 - const domain = serverInfo?.availableUserDomains?.[0] 233 - if (domain) return `${flow.info.handle.trim()}.${domain}` 234 - return flow.info.handle.trim() 235 + return selectedDomain ? `${flow.info.handle.trim()}.${selectedDomain}` : flow.info.handle.trim() 235 236 }) 236 237 237 238 function extractDomain(did: string): string { ··· 305 306 <form class="register-form" onsubmit={handleInfoSubmit}> 306 307 <div class="field"> 307 308 <label for="handle">{$_('register.handle')}</label> 308 - <input 309 - id="handle" 310 - type="text" 311 - bind:value={flow.info.handle} 309 + <HandleInput 310 + value={flow.info.handle} 311 + domains={serverInfo?.availableUserDomains ?? []} 312 + {selectedDomain} 312 313 placeholder={$_('register.handlePlaceholder')} 313 314 disabled={flow.state.submitting} 314 - required 315 + onInput={(v) => { flow!.info.handle = v }} 316 + onDomainChange={(d) => { selectedDomain = d }} 315 317 /> 316 318 {#if flow.info.handle.includes('.')} 317 319 <p class="hint warning">{$_('register.handleDotWarning')}</p>
+11 -8
frontend/src/routes/SsoRegisterComplete.svelte
··· 3 3 import { _ } from '../lib/i18n' 4 4 import { toast } from '../lib/toast.svelte' 5 5 import SsoIcon from '../components/SsoIcon.svelte' 6 + import HandleInput from '../components/HandleInput.svelte' 6 7 7 8 interface PendingRegistration { 8 9 request_uri: string ··· 47 48 let handleAvailable = $state<boolean | null>(null) 48 49 let checkingHandle = $state(false) 49 50 let handleError = $state<string | null>(null) 51 + let selectedDomain = $state('') 50 52 51 53 let didType = $state<'plc' | 'web' | 'web-external'>('plc') 52 54 let externalDid = $state('') ··· 95 97 96 98 let fullHandle = $derived(() => { 97 99 if (!handle.trim()) return '' 98 - const domain = serverInfo?.availableUserDomains?.[0] 99 - return domain ? `${handle.trim()}.${domain}` : handle.trim() 100 + if (handle.includes('.')) return handle.trim() 101 + return selectedDomain ? `${handle.trim()}.${selectedDomain}` : handle.trim() 100 102 }) 101 103 102 104 onMount(() => { ··· 121 123 telegram: available.includes('telegram'), 122 124 signal: available.includes('signal'), 123 125 } 126 + selectedDomain = data.availableUserDomains?.[0] || window.location.hostname 124 127 } 125 128 } catch { 126 129 serverInfo = null ··· 390 393 <form onsubmit={handleSubmit}> 391 394 <div class="field"> 392 395 <label for="handle">{$_('sso_register.handle_label')}</label> 393 - <input 394 - id="handle" 395 - type="text" 396 - bind:value={handle} 396 + <HandleInput 397 + value={handle} 398 + domains={serverInfo?.availableUserDomains ?? []} 399 + {selectedDomain} 397 400 placeholder={$_('register.handlePlaceholder')} 398 401 disabled={submitting} 399 - required 400 - autocomplete="off" 402 + onInput={(v) => { handle = v }} 403 + onDomainChange={(d) => { selectedDomain = d }} 401 404 /> 402 405 {#if checkingHandle} 403 406 <p class="hint">{$_('common.checking')}</p>
-13
frontend/src/styles/migration.css
··· 170 170 margin-top: var(--space-5); 171 171 } 172 172 173 - .handle-input-group { 174 - display: flex; 175 - gap: var(--space-2); 176 - } 177 - 178 - .handle-input-group input { 179 - flex: 1; 180 - } 181 - 182 - .handle-input-group select { 183 - width: auto; 184 - } 185 - 186 173 .current-info { 187 174 background: var(--bg-primary); 188 175 border-radius: var(--radius-lg);

History

2 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
fix: user handle domains upgrade
expand 0 comments
pull request successfully merged
lewis.moe submitted #0
1 commit
expand
fix: user handle domains upgrade
expand 0 comments