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