Actually use the user handle domains
+8
.config/nextest.toml
+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
+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
+1
-1
Cargo.toml
-128
TODO.md
-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
-
+8
-1
crates/tranquil-config/src/lib.rs
+8
-1
crates/tranquil-config/src/lib.rs
···
468
468
/// Returns the user handle domains, falling back to `[hostname_without_port]`.
469
469
pub fn user_handle_domain_list(&self) -> Vec<String> {
470
470
self.user_handle_domains
471
-
.clone()
471
+
.as_deref()
472
+
.filter(|v| !v.is_empty())
473
+
.map(|v| v.to_vec())
472
474
.unwrap_or_else(|| vec![self.hostname_without_port().to_string()])
473
475
}
476
+
477
+
/// Alias for `user_handle_domain_list` (for callers that were using the now-removed `available_user_domains` field).
478
+
pub fn available_user_domain_list(&self) -> Vec<String> {
479
+
self.user_handle_domain_list()
480
+
}
474
481
}
475
482
476
483
#[derive(Debug, Config)]
+2
-2
crates/tranquil-pds/src/api/admin/account/update.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
-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
expand 0 comments
pull request successfully merged