This change is that our login page and stuff can send a new verification notification if the user isn't verified yet, rather than being locked out forever. And some touchups to oauth consent/flow in general. And also some leftover toml config changes required to get tests working.
+58
.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json
+58
.sqlx/query-85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "preferred_comms_channel: CommsChannel",
19
+
"type_info": {
20
+
"Custom": {
21
+
"name": "comms_channel",
22
+
"kind": {
23
+
"Enum": [
24
+
"email",
25
+
"discord",
26
+
"telegram",
27
+
"signal"
28
+
]
29
+
}
30
+
}
31
+
}
32
+
},
33
+
{
34
+
"ordinal": 3,
35
+
"name": "recovery_token",
36
+
"type_info": "Text"
37
+
},
38
+
{
39
+
"ordinal": 4,
40
+
"name": "recovery_token_expires_at",
41
+
"type_info": "Timestamptz"
42
+
}
43
+
],
44
+
"parameters": {
45
+
"Left": [
46
+
"Text"
47
+
]
48
+
},
49
+
"nullable": [
50
+
false,
51
+
false,
52
+
false,
53
+
true,
54
+
true
55
+
]
56
+
},
57
+
"hash": "85882f1c27888b695582395b798b8e4994ed1d761a598f938f2271e5ba320eea"
58
+
}
-40
.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
-40
.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "did",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "recovery_token",
19
-
"type_info": "Text"
20
-
},
21
-
{
22
-
"ordinal": 3,
23
-
"name": "recovery_token_expires_at",
24
-
"type_info": "Timestamptz"
25
-
}
26
-
],
27
-
"parameters": {
28
-
"Left": [
29
-
"Text"
30
-
]
31
-
},
32
-
"nullable": [
33
-
false,
34
-
false,
35
-
true,
36
-
true
37
-
]
38
-
},
39
-
"hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
40
-
}
···
+15
.sqlx/query-bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf.json
+15
.sqlx/query-bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n UPDATE oauth_authorization_request\n SET expires_at = $2\n WHERE id = $1 AND did IS NOT NULL AND code IS NULL\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Timestamptz"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "bd5861c7ed2021d025e78d63ef6a35b2bb07d2c11f88f3945fbcf099b9c7c1cf"
15
+
}
-28
.sqlx/query-e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6.json
-28
.sqlx/query-e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "password_reset_code_expires_at",
14
-
"type_info": "Timestamptz"
15
-
}
16
-
],
17
-
"parameters": {
18
-
"Left": [
19
-
"Text"
20
-
]
21
-
},
22
-
"nullable": [
23
-
false,
24
-
true
25
-
]
26
-
},
27
-
"hash": "e3e4b6131b7692edf87fcf3b67b59127d3a218afb7a34a4bcb3c56765f8cd4c6"
28
-
}
···
+52
.sqlx/query-eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b.json
+52
.sqlx/query-eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "preferred_comms_channel: CommsChannel",
19
+
"type_info": {
20
+
"Custom": {
21
+
"name": "comms_channel",
22
+
"kind": {
23
+
"Enum": [
24
+
"email",
25
+
"discord",
26
+
"telegram",
27
+
"signal"
28
+
]
29
+
}
30
+
}
31
+
}
32
+
},
33
+
{
34
+
"ordinal": 3,
35
+
"name": "password_reset_code_expires_at",
36
+
"type_info": "Timestamptz"
37
+
}
38
+
],
39
+
"parameters": {
40
+
"Left": [
41
+
"Text"
42
+
]
43
+
},
44
+
"nullable": [
45
+
false,
46
+
false,
47
+
false,
48
+
true
49
+
]
50
+
},
51
+
"hash": "eb3029b84fb58576a94987da1984cbe152f5ee7aa55b2b3f678603fe1e1c906b"
52
+
}
+14
-7
crates/tranquil-auth/src/types.rs
+14
-7
crates/tranquil-auth/src/types.rs
···
265
266
#[test]
267
fn token_type_accepts_bluesky_uppercase_jwt() {
268
-
let result: Result<Header, _> =
269
-
serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
270
let header = result.expect("should parse uppercase JWT from bluesky reference pds");
271
assert_eq!(header.typ, TokenType::Service);
272
assert_eq!(header.alg, SigningAlgorithm::ES256K);
···
274
275
#[test]
276
fn token_type_accepts_lowercase_jwt() {
277
-
let result: Result<Header, _> =
278
-
serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
279
let header = result.expect("should parse lowercase jwt");
280
assert_eq!(header.typ, TokenType::Service);
281
}
···
294
295
#[test]
296
fn signing_algorithm_case_insensitive() {
297
-
assert_eq!(SigningAlgorithm::from_str("ES256K").unwrap(), SigningAlgorithm::ES256K);
298
-
assert_eq!(SigningAlgorithm::from_str("es256k").unwrap(), SigningAlgorithm::ES256K);
299
-
assert_eq!(SigningAlgorithm::from_str("hs256").unwrap(), SigningAlgorithm::HS256);
300
}
301
}
···
265
266
#[test]
267
fn token_type_accepts_bluesky_uppercase_jwt() {
268
+
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"JWT"}"#);
269
let header = result.expect("should parse uppercase JWT from bluesky reference pds");
270
assert_eq!(header.typ, TokenType::Service);
271
assert_eq!(header.alg, SigningAlgorithm::ES256K);
···
273
274
#[test]
275
fn token_type_accepts_lowercase_jwt() {
276
+
let result: Result<Header, _> = serde_json::from_str(r#"{"alg":"ES256K","typ":"jwt"}"#);
277
let header = result.expect("should parse lowercase jwt");
278
assert_eq!(header.typ, TokenType::Service);
279
}
···
292
293
#[test]
294
fn signing_algorithm_case_insensitive() {
295
+
assert_eq!(
296
+
SigningAlgorithm::from_str("ES256K").unwrap(),
297
+
SigningAlgorithm::ES256K
298
+
);
299
+
assert_eq!(
300
+
SigningAlgorithm::from_str("es256k").unwrap(),
301
+
SigningAlgorithm::ES256K
302
+
);
303
+
assert_eq!(
304
+
SigningAlgorithm::from_str("hs256").unwrap(),
305
+
SigningAlgorithm::HS256
306
+
);
307
}
308
}
+29
crates/tranquil-config/src/lib.rs
+29
crates/tranquil-config/src/lib.rs
···
44
CONFIG.get()
45
}
46
47
+
/// Initialize with minimal defaults for unit tests.
48
+
/// Noop if already initialized.
49
+
pub fn ensure_test_defaults() {
50
+
use std::env;
51
+
let _ = CONFIG.get_or_init(|| {
52
+
unsafe {
53
+
if env::var("PDS_HOSTNAME").is_err() {
54
+
env::set_var("PDS_HOSTNAME", "test.local");
55
+
}
56
+
if env::var("DATABASE_URL").is_err() {
57
+
env::set_var("DATABASE_URL", "postgres://localhost/test");
58
+
}
59
+
if env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
60
+
env::set_var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS", "1");
61
+
}
62
+
if env::var("INVITE_CODE_REQUIRED").is_err() {
63
+
env::set_var("INVITE_CODE_REQUIRED", "false");
64
+
}
65
+
if env::var("ENABLE_PDS_HOSTED_DID_WEB").is_err() {
66
+
env::set_var("ENABLE_PDS_HOSTED_DID_WEB", "true");
67
+
}
68
+
}
69
+
TranquilConfig::builder()
70
+
.env()
71
+
.load()
72
+
.expect("failed to load test config defaults")
73
+
});
74
+
}
75
+
76
/// Load configuration from an optional TOML file path, with environment
77
/// variable overrides applied on top. Fields annotated with `#[config(env)]`
78
/// are read from the corresponding environment variables when the `.env()`
+5
crates/tranquil-db-traits/src/oauth.rs
+5
crates/tranquil-db-traits/src/oauth.rs
···
192
) -> Result<Option<RequestData>, DbError>;
193
async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>;
194
async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>;
195
async fn mark_request_authenticated(
196
&self,
197
request_id: &RequestId,
···
192
) -> Result<Option<RequestData>, DbError>;
193
async fn delete_authorization_request(&self, request_id: &RequestId) -> Result<(), DbError>;
194
async fn delete_expired_authorization_requests(&self) -> Result<u64, DbError>;
195
+
async fn extend_authorization_request_expiry(
196
+
&self,
197
+
request_id: &RequestId,
198
+
new_expires_at: DateTime<Utc>,
199
+
) -> Result<bool, DbError>;
200
async fn mark_request_authenticated(
201
&self,
202
request_id: &RequestId,
+3
crates/tranquil-db-traits/src/user.rs
+3
crates/tranquil-db-traits/src/user.rs
···
886
#[derive(Debug, Clone)]
887
pub struct UserResetCodeInfo {
888
pub id: Uuid,
889
pub expires_at: Option<DateTime<Utc>>,
890
}
891
···
956
pub struct UserForRecovery {
957
pub id: Uuid,
958
pub did: Did,
959
pub recovery_token: Option<String>,
960
pub recovery_token_expires_at: Option<DateTime<Utc>>,
961
}
···
886
#[derive(Debug, Clone)]
887
pub struct UserResetCodeInfo {
888
pub id: Uuid,
889
+
pub did: Did,
890
+
pub preferred_comms_channel: CommsChannel,
891
pub expires_at: Option<DateTime<Utc>>,
892
}
893
···
958
pub struct UserForRecovery {
959
pub id: Uuid,
960
pub did: Did,
961
+
pub preferred_comms_channel: CommsChannel,
962
pub recovery_token: Option<String>,
963
pub recovery_token_expires_at: Option<DateTime<Utc>>,
964
}
+20
crates/tranquil-db/src/postgres/oauth.rs
+20
crates/tranquil-db/src/postgres/oauth.rs
···
615
Ok(result.rows_affected())
616
}
617
618
+
async fn extend_authorization_request_expiry(
619
+
&self,
620
+
request_id: &RequestId,
621
+
new_expires_at: DateTime<Utc>,
622
+
) -> Result<bool, DbError> {
623
+
let result = sqlx::query!(
624
+
r#"
625
+
UPDATE oauth_authorization_request
626
+
SET expires_at = $2
627
+
WHERE id = $1 AND did IS NOT NULL AND code IS NULL
628
+
"#,
629
+
request_id.as_str(),
630
+
new_expires_at
631
+
)
632
+
.execute(&self.pool)
633
+
.await
634
+
.map_err(map_sqlx_error)?;
635
+
Ok(result.rows_affected() > 0)
636
+
}
637
+
638
async fn mark_request_authenticated(
639
&self,
640
request_id: &RequestId,
+5
-2
crates/tranquil-db/src/postgres/user.rs
+5
-2
crates/tranquil-db/src/postgres/user.rs
···
1715
code: &str,
1716
) -> Result<Option<UserResetCodeInfo>, DbError> {
1717
sqlx::query!(
1718
-
"SELECT id, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
1719
code
1720
)
1721
.fetch_optional(&self.pool)
···
1724
.map(|opt| {
1725
opt.map(|row| UserResetCodeInfo {
1726
id: row.id,
1727
expires_at: row.password_reset_code_expires_at,
1728
})
1729
})
···
2202
2203
async fn get_user_for_recovery(&self, did: &Did) -> Result<Option<UserForRecovery>, DbError> {
2204
let row = sqlx::query!(
2205
-
"SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
2206
did.as_str()
2207
)
2208
.fetch_optional(&self.pool)
···
2212
Ok(row.map(|r| UserForRecovery {
2213
id: r.id,
2214
did: Did::from(r.did),
2215
recovery_token: r.recovery_token,
2216
recovery_token_expires_at: r.recovery_token_expires_at,
2217
}))
···
1715
code: &str,
1716
) -> Result<Option<UserResetCodeInfo>, DbError> {
1717
sqlx::query!(
1718
+
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
1719
code
1720
)
1721
.fetch_optional(&self.pool)
···
1724
.map(|opt| {
1725
opt.map(|row| UserResetCodeInfo {
1726
id: row.id,
1727
+
did: Did::from(row.did),
1728
+
preferred_comms_channel: row.preferred_comms_channel,
1729
expires_at: row.password_reset_code_expires_at,
1730
})
1731
})
···
2204
2205
async fn get_user_for_recovery(&self, did: &Did) -> Result<Option<UserForRecovery>, DbError> {
2206
let row = sqlx::query!(
2207
+
"SELECT id, did, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\", recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
2208
did.as_str()
2209
)
2210
.fetch_optional(&self.pool)
···
2214
Ok(row.map(|r| UserForRecovery {
2215
id: r.id,
2216
did: Did::from(r.did),
2217
+
preferred_comms_channel: r.preferred_comms_channel,
2218
recovery_token: r.recovery_token,
2219
recovery_token_expires_at: r.recovery_token_expires_at,
2220
}))
+1
-1
crates/tranquil-pds/src/api/proxy_client.rs
+1
-1
crates/tranquil-pds/src/api/proxy_client.rs
···
63
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
64
let scheme = parsed.scheme();
65
if scheme != "https" {
66
-
let allow_http = tranquil_config::get().server.allow_http_proxy
67
|| url.starts_with("http://127.0.0.1")
68
|| url.starts_with("http://localhost");
69
if !allow_http {
···
63
let parsed = Url::parse(url).map_err(|_| SsrfError::InvalidUrl)?;
64
let scheme = parsed.scheme();
65
if scheme != "https" {
66
+
let allow_http = tranquil_config::try_get().is_some_and(|c| c.server.allow_http_proxy)
67
|| url.starts_with("http://127.0.0.1")
68
|| url.starts_with("http://localhost");
69
if !allow_http {
+8
-1
crates/tranquil-pds/src/api/repo/import.rs
+8
-1
crates/tranquil-pds/src/api/repo/import.rs
···
100
commit_did, did
101
)));
102
}
103
+
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
104
+
.ok()
105
+
.map(|v| v == "true" || v == "1")
106
+
.unwrap_or_else(|| {
107
+
tranquil_config::try_get()
108
+
.map(|c| c.import.skip_verification)
109
+
.unwrap_or(false)
110
+
});
111
let is_migration = user.deactivated_at.is_some();
112
if skip_verification {
113
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
+3
-3
crates/tranquil-pds/src/api/server/mod.rs
+3
-3
crates/tranquil-pds/src/api/server/mod.rs
···
50
};
51
pub use service_auth::get_service_auth;
52
pub use session::{
53
-
confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session,
54
-
list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session,
55
-
update_legacy_login_preference, update_locale,
56
};
57
pub use signing_key::reserve_signing_key;
58
pub use totp::{
···
50
};
51
pub use service_auth::get_service_auth;
52
pub use session::{
53
+
auto_resend_verification, confirm_signup, create_session, delete_session,
54
+
get_legacy_login_preference, get_session, list_sessions, refresh_session, resend_verification,
55
+
revoke_all_sessions, revoke_session, update_legacy_login_preference, update_locale,
56
};
57
pub use signing_key::reserve_signing_key;
58
pub use totp::{
+14
crates/tranquil-pds/src/api/server/passkey_account.rs
+14
crates/tranquil-pds/src/api/server/passkey_account.rs
···
946
if result.passkeys_deleted > 0 {
947
info!(did = %input.did, count = result.passkeys_deleted, "Deleted lost passkeys during account recovery");
948
}
949
+
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user.id).await {
950
+
let actual_channel =
951
+
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
952
+
if let Err(e) = state
953
+
.user_repo
954
+
.set_channel_verified(&input.did, actual_channel)
955
+
.await
956
+
{
957
+
warn!(
958
+
"Failed to implicitly verify channel on passkey recovery: {:?}",
959
+
e
960
+
);
961
+
}
962
+
}
963
info!(did = %input.did, "Passkey-only account recovered with temporary password");
964
SuccessResponse::ok().into_response()
965
}
+14
crates/tranquil-pds/src/api/server/password.rs
+14
crates/tranquil-pds/src/api/server/password.rs
···
182
}
183
}))
184
.await;
185
+
if let Ok(Some(prefs)) = state.user_repo.get_comms_prefs(user_id).await {
186
+
let actual_channel =
187
+
crate::comms::resolve_delivery_channel(&prefs, user.preferred_comms_channel);
188
+
if let Err(e) = state
189
+
.user_repo
190
+
.set_channel_verified(&user.did, actual_channel)
191
+
.await
192
+
{
193
+
warn!(
194
+
"Failed to implicitly verify channel on password reset: {:?}",
195
+
e
196
+
);
197
+
}
198
+
}
199
info!("Password reset completed for user {}", user_id);
200
EmptyResponse::ok().into_response()
201
}
+86
-2
crates/tranquil-pds/src/api/server/session.rs
+86
-2
crates/tranquil-pds/src/api/server/session.rs
···
149
.unwrap_or(false);
150
if !is_verified && !is_delegated {
151
warn!("Login attempt for unverified account: {}", row.did);
152
return (
153
StatusCode::FORBIDDEN,
154
Json(json!({
155
-
"error": "AccountNotVerified",
156
"message": "Please verify your account before logging in",
157
-
"did": row.did
158
})),
159
)
160
.into_response();
···
730
.into_response()
731
}
732
733
#[derive(Deserialize)]
734
#[serde(rename_all = "camelCase")]
735
pub struct ResendVerificationInput {
···
149
.unwrap_or(false);
150
if !is_verified && !is_delegated {
151
warn!("Login attempt for unverified account: {}", row.did);
152
+
let resend_info = auto_resend_verification(&state, &row.did).await;
153
+
let handle = resend_info
154
+
.as_ref()
155
+
.map(|r| r.handle.to_string())
156
+
.unwrap_or_else(|| row.handle.to_string());
157
+
let channel = resend_info
158
+
.as_ref()
159
+
.map(|r| r.channel.as_str())
160
+
.unwrap_or(row.preferred_comms_channel.as_str());
161
return (
162
StatusCode::FORBIDDEN,
163
Json(json!({
164
+
"error": "account_not_verified",
165
"message": "Please verify your account before logging in",
166
+
"did": row.did,
167
+
"handle": handle,
168
+
"channel": channel
169
})),
170
)
171
.into_response();
···
741
.into_response()
742
}
743
744
+
const AUTO_VERIFY_DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(120);
745
+
746
+
pub struct AutoResendResult {
747
+
pub handle: tranquil_types::Handle,
748
+
pub channel: tranquil_db_traits::CommsChannel,
749
+
}
750
+
751
+
pub async fn auto_resend_verification(state: &AppState, did: &Did) -> Option<AutoResendResult> {
752
+
let debounce_key = crate::cache_keys::auto_verify_sent_key(did.as_str());
753
+
let debounced = state.cache.get(&debounce_key).await.is_some();
754
+
let row = match state.user_repo.get_resend_verification_by_did(did).await {
755
+
Ok(Some(row)) => row,
756
+
Ok(None) => return None,
757
+
Err(e) => {
758
+
warn!(
759
+
"Failed to fetch resend verification info for {}: {:?}",
760
+
did, e
761
+
);
762
+
return None;
763
+
}
764
+
};
765
+
if row.channel_verification.has_any_verified() {
766
+
return None;
767
+
}
768
+
let result = AutoResendResult {
769
+
handle: row.handle.clone(),
770
+
channel: row.channel,
771
+
};
772
+
let is_bot_channel = matches!(
773
+
row.channel,
774
+
tranquil_db_traits::CommsChannel::Telegram | tranquil_db_traits::CommsChannel::Discord
775
+
);
776
+
if is_bot_channel || debounced {
777
+
return Some(result);
778
+
}
779
+
let recipient = match row.channel {
780
+
tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(),
781
+
tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(),
782
+
_ => return Some(result),
783
+
};
784
+
if recipient.is_empty() {
785
+
warn!(
786
+
"No recipient configured for auto-resend verification: {}",
787
+
did
788
+
);
789
+
return Some(result);
790
+
}
791
+
let verification_token =
792
+
crate::auth::verification_token::generate_signup_token(did, row.channel, &recipient);
793
+
let formatted_token =
794
+
crate::auth::verification_token::format_token_for_display(&verification_token);
795
+
let hostname = &tranquil_config::get().server.hostname;
796
+
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
797
+
state.user_repo.as_ref(),
798
+
state.infra_repo.as_ref(),
799
+
row.id,
800
+
row.channel,
801
+
&recipient,
802
+
&formatted_token,
803
+
hostname,
804
+
)
805
+
.await
806
+
{
807
+
warn!("Failed to auto-resend verification for {}: {:?}", did, e);
808
+
return Some(result);
809
+
}
810
+
let _ = state
811
+
.cache
812
+
.set(&debounce_key, "1", AUTO_VERIFY_DEBOUNCE)
813
+
.await;
814
+
Some(result)
815
+
}
816
+
817
#[derive(Deserialize)]
818
#[serde(rename_all = "camelCase")]
819
pub struct ResendVerificationInput {
+13
crates/tranquil-pds/src/auth/verification_token.rs
+13
crates/tranquil-pds/src/auth/verification_token.rs
···
321
mod tests {
322
use super::*;
323
324
#[test]
325
fn test_signup_token() {
326
let did: Did = "did:plc:test123".parse().unwrap();
327
let channel = CommsChannel::Email;
328
let identifier = "test@example.com";
···
337
338
#[test]
339
fn test_migration_token() {
340
let did: Did = "did:plc:test123".parse().unwrap();
341
let email = "test@example.com";
342
let token = generate_migration_token(&did, email);
···
349
350
#[test]
351
fn test_token_case_insensitive() {
352
let did: Did = "did:plc:test123".parse().unwrap();
353
let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM");
354
let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com");
···
357
358
#[test]
359
fn test_token_wrong_identifier() {
360
let did: Did = "did:plc:test123".parse().unwrap();
361
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
362
let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com");
···
365
366
#[test]
367
fn test_token_wrong_channel() {
368
let did: Did = "did:plc:test123".parse().unwrap();
369
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
370
let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com");
···
373
374
#[test]
375
fn test_expired_token() {
376
let did: Did = "did:plc:test123".parse().unwrap();
377
let token = generate_token_with_expiry(
378
&did,
···
388
389
#[test]
390
fn test_invalid_token() {
391
let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com");
392
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
393
}
394
395
#[test]
396
fn test_purpose_mismatch() {
397
let did: Did = "did:plc:test123".parse().unwrap();
398
let email = "test@example.com";
399
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
···
403
404
#[test]
405
fn test_discord_channel() {
406
let did: Did = "did:plc:test123".parse().unwrap();
407
let discord_id = "123456789012345678";
408
let token = generate_signup_token(&did, CommsChannel::Discord, discord_id);
···
321
mod tests {
322
use super::*;
323
324
+
fn init() {
325
+
tranquil_config::ensure_test_defaults();
326
+
}
327
+
328
#[test]
329
fn test_signup_token() {
330
+
init();
331
let did: Did = "did:plc:test123".parse().unwrap();
332
let channel = CommsChannel::Email;
333
let identifier = "test@example.com";
···
342
343
#[test]
344
fn test_migration_token() {
345
+
init();
346
let did: Did = "did:plc:test123".parse().unwrap();
347
let email = "test@example.com";
348
let token = generate_migration_token(&did, email);
···
355
356
#[test]
357
fn test_token_case_insensitive() {
358
+
init();
359
let did: Did = "did:plc:test123".parse().unwrap();
360
let token = generate_signup_token(&did, CommsChannel::Email, "Test@Example.COM");
361
let result = verify_signup_token(&token, CommsChannel::Email, "test@example.com");
···
364
365
#[test]
366
fn test_token_wrong_identifier() {
367
+
init();
368
let did: Did = "did:plc:test123".parse().unwrap();
369
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
370
let result = verify_signup_token(&token, CommsChannel::Email, "other@example.com");
···
373
374
#[test]
375
fn test_token_wrong_channel() {
376
+
init();
377
let did: Did = "did:plc:test123".parse().unwrap();
378
let token = generate_signup_token(&did, CommsChannel::Email, "test@example.com");
379
let result = verify_signup_token(&token, CommsChannel::Discord, "test@example.com");
···
382
383
#[test]
384
fn test_expired_token() {
385
+
init();
386
let did: Did = "did:plc:test123".parse().unwrap();
387
let token = generate_token_with_expiry(
388
&did,
···
398
399
#[test]
400
fn test_invalid_token() {
401
+
init();
402
let result = verify_signup_token("invalid-token", CommsChannel::Email, "test@example.com");
403
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
404
}
405
406
#[test]
407
fn test_purpose_mismatch() {
408
+
init();
409
let did: Did = "did:plc:test123".parse().unwrap();
410
let email = "test@example.com";
411
let signup_token = generate_signup_token(&did, CommsChannel::Email, email);
···
415
416
#[test]
417
fn test_discord_channel() {
418
+
init();
419
let did: Did = "did:plc:test123".parse().unwrap();
420
let discord_id = "123456789012345678";
421
let token = generate_signup_token(&did, CommsChannel::Discord, discord_id);
+4
crates/tranquil-pds/src/cache_keys.rs
+4
crates/tranquil-pds/src/cache_keys.rs
+1
-1
crates/tranquil-pds/src/comms/mod.rs
+1
-1
crates/tranquil-pds/src/comms/mod.rs
+7
crates/tranquil-pds/src/comms/service.rs
+7
crates/tranquil-pds/src/comms/service.rs
···
169
recipient: String,
170
}
171
172
+
pub fn resolve_delivery_channel(
173
+
prefs: &UserCommsPrefs,
174
+
channel: tranquil_db_traits::CommsChannel,
175
+
) -> tranquil_db_traits::CommsChannel {
176
+
resolve_recipient(prefs, channel).channel
177
+
}
178
+
179
fn resolve_recipient(
180
prefs: &UserCommsPrefs,
181
channel: tranquil_db_traits::CommsChannel,
+4
-2
crates/tranquil-pds/src/handle/mod.rs
+4
-2
crates/tranquil-pds/src/handle/mod.rs
···
87
}
88
}
89
90
-
pub fn is_service_domain_handle(handle: &str, _hostname: &str) -> bool {
91
if !handle.contains('.') {
92
return true;
93
}
94
-
let service_domains = tranquil_config::get().server.user_handle_domain_list();
95
service_domains
96
.iter()
97
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
···
87
}
88
}
89
90
+
pub fn is_service_domain_handle(handle: &str, hostname: &str) -> bool {
91
if !handle.contains('.') {
92
return true;
93
}
94
+
let service_domains = tranquil_config::try_get()
95
+
.map(|c| c.server.user_handle_domain_list())
96
+
.unwrap_or_else(|| vec![hostname.to_string()]);
97
service_domains
98
.iter()
99
.any(|domain| handle.ends_with(&format!(".{}", domain)) || handle == domain)
+1
crates/tranquil-pds/src/lib.rs
+1
crates/tranquil-pds/src/lib.rs
···
589
)
590
.route("/authorize/consent", get(oauth::endpoints::consent_get))
591
.route("/authorize/consent", post(oauth::endpoints::consent_post))
592
+
.route("/authorize/renew", post(oauth::endpoints::authorize_renew))
593
.route(
594
"/authorize/redirect",
595
get(oauth::endpoints::authorize_redirect),
+5
-1
crates/tranquil-pds/src/moderation/mod.rs
+5
-1
crates/tranquil-pds/src/moderation/mod.rs
+9
-4
crates/tranquil-pds/src/plc/mod.rs
+9
-4
crates/tranquil-pds/src/plc/mod.rs
···
124
}
125
126
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self {
127
-
let cfg = tranquil_config::get();
128
-
let base_url = base_url.unwrap_or_else(|| cfg.plc.directory_url.clone());
129
-
let timeout_secs = cfg.plc.timeout_secs;
130
-
let connect_timeout_secs = cfg.plc.connect_timeout_secs;
131
let client = Client::builder()
132
.timeout(Duration::from_secs(timeout_secs))
133
.connect_timeout(Duration::from_secs(connect_timeout_secs))
···
124
}
125
126
pub fn with_cache(base_url: Option<String>, cache: Option<Arc<dyn Cache>>) -> Self {
127
+
let cfg = tranquil_config::try_get();
128
+
let base_url = base_url
129
+
.or_else(|| std::env::var("PLC_DIRECTORY_URL").ok())
130
+
.unwrap_or_else(|| {
131
+
cfg.map(|c| c.plc.directory_url.clone())
132
+
.unwrap_or_else(|| "https://plc.directory".to_string())
133
+
});
134
+
let timeout_secs = cfg.map_or(10, |c| c.plc.timeout_secs);
135
+
let connect_timeout_secs = cfg.map_or(5, |c| c.plc.connect_timeout_secs);
136
let client = Client::builder()
137
.timeout(Duration::from_secs(timeout_secs))
138
.connect_timeout(Duration::from_secs(connect_timeout_secs))
+3
-3
crates/tranquil-pds/src/state.rs
+3
-3
crates/tranquil-pds/src/state.rs
···
1
use crate::appview::DidResolver;
2
use crate::auth::webauthn::WebAuthnConfig;
3
-
use crate::cache::{create_cache, Cache, DistributedRateLimiter};
4
use crate::circuit_breaker::CircuitBreakers;
5
use crate::config::AuthConfig;
6
use crate::rate_limit::RateLimiters;
7
use crate::repo::PostgresBlockStore;
8
use crate::repo_write_lock::RepoWriteLocks;
9
use crate::sso::{SsoConfig, SsoManager};
10
-
use crate::storage::{create_backup_storage, create_blob_storage, BackupStorage, BlobStorage};
11
use crate::sync::firehose::SequencedEvent;
12
use sqlx::PgPool;
13
use std::error::Error;
14
-
use std::sync::atomic::{AtomicBool, Ordering};
15
use std::sync::Arc;
16
use tokio::sync::broadcast;
17
use tokio_util::sync::CancellationToken;
18
use tranquil_db::{
···
1
use crate::appview::DidResolver;
2
use crate::auth::webauthn::WebAuthnConfig;
3
+
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
4
use crate::circuit_breaker::CircuitBreakers;
5
use crate::config::AuthConfig;
6
use crate::rate_limit::RateLimiters;
7
use crate::repo::PostgresBlockStore;
8
use crate::repo_write_lock::RepoWriteLocks;
9
use crate::sso::{SsoConfig, SsoManager};
10
+
use crate::storage::{BackupStorage, BlobStorage, create_backup_storage, create_blob_storage};
11
use crate::sync::firehose::SequencedEvent;
12
use sqlx::PgPool;
13
use std::error::Error;
14
use std::sync::Arc;
15
+
use std::sync::atomic::{AtomicBool, Ordering};
16
use tokio::sync::broadcast;
17
use tokio_util::sync::CancellationToken;
18
use tranquil_db::{
+2
-1
crates/tranquil-pds/src/sync/verify.rs
+2
-1
crates/tranquil-pds/src/sync/verify.rs
···
145
}
146
147
async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
148
+
let plc_url = std::env::var("PLC_DIRECTORY_URL")
149
+
.unwrap_or_else(|_| tranquil_config::get().plc.directory_url.clone());
150
let url = format!("{}/{}", plc_url, urlencoding::encode(did));
151
let response = self
152
.http_client
+1
crates/tranquil-pds/src/util.rs
+1
crates/tranquil-pds/src/util.rs
···
374
#[test]
375
fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() {
376
unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") };
377
+
tranquil_config::ensure_test_defaults();
378
assert_eq!(
379
build_full_url("/com.atproto.server.getSession"),
380
"https://example.com/xrpc/com.atproto.server.getSession"
+1
crates/tranquil-pds/tests/common/mod.rs
+1
crates/tranquil-pds/tests/common/mod.rs
+5
-5
crates/tranquil-pds/tests/import_with_verification.rs
+5
-5
crates/tranquil-pds/tests/import_with_verification.rs
···
64
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
65
unsafe {
66
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
67
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
68
}
69
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
70
let import_res = client
···
108
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
109
unsafe {
110
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
111
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
112
}
113
let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key);
114
let import_res = client
···
157
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
158
unsafe {
159
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
160
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
161
}
162
let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key);
163
let import_res = client
···
202
.await;
203
unsafe {
204
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
205
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
206
}
207
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
208
let import_res = client
···
248
let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await;
249
unsafe {
250
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
251
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
252
}
253
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
254
let import_res = client
···
64
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
65
unsafe {
66
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
67
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
68
}
69
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
70
let import_res = client
···
108
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
109
unsafe {
110
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
111
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
112
}
113
let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key);
114
let import_res = client
···
157
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
158
unsafe {
159
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
160
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
161
}
162
let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key);
163
let import_res = client
···
202
.await;
203
unsafe {
204
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
205
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
206
}
207
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
208
let import_res = client
···
248
let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await;
249
unsafe {
250
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
251
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
252
}
253
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
254
let import_res = client
+3
-3
crates/tranquil-pds/tests/plc_migration.rs
+3
-3
crates/tranquil-pds/tests/plc_migration.rs
···
698
.await;
699
unsafe {
700
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
701
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
702
}
703
let import_res = client
704
.post(format!(
···
775
.await;
776
unsafe {
777
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
778
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
779
}
780
let import_res = client
781
.post(format!(
···
931
.expect("Submit failed");
932
assert_eq!(submit_res.status(), StatusCode::OK);
933
unsafe {
934
-
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
935
}
936
let import_res = client
937
.post(format!(
···
698
.await;
699
unsafe {
700
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
701
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
702
}
703
let import_res = client
704
.post(format!(
···
775
.await;
776
unsafe {
777
std::env::set_var("PLC_DIRECTORY_URL", mock_server.uri());
778
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
779
}
780
let import_res = client
781
.post(format!(
···
931
.expect("Submit failed");
932
assert_eq!(submit_res.status(), StatusCode::OK);
933
unsafe {
934
+
std::env::set_var("SKIP_IMPORT_VERIFICATION", "false");
935
}
936
let import_res = client
937
.post(format!(
+12
-12
crates/tranquil-storage/src/lib.rs
+12
-12
crates/tranquil-storage/src/lib.rs
···
767
_ => {
768
let path = cfg.backup.path.clone();
769
FilesystemBackupStorage::new(path).await.map_or_else(
770
-
|e| {
771
-
tracing::error!(
772
-
"Failed to initialize filesystem backup storage: {}. \
773
Set BACKUP_STORAGE_PATH to a valid directory path. \
774
Backups will be disabled.",
775
-
e
776
-
);
777
-
None
778
-
},
779
-
|storage| {
780
-
tracing::info!("Initialized filesystem backup storage");
781
-
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
782
-
},
783
-
)
784
}
785
}
786
}
···
767
_ => {
768
let path = cfg.backup.path.clone();
769
FilesystemBackupStorage::new(path).await.map_or_else(
770
+
|e| {
771
+
tracing::error!(
772
+
"Failed to initialize filesystem backup storage: {}. \
773
Set BACKUP_STORAGE_PATH to a valid directory path. \
774
Backups will be disabled.",
775
+
e
776
+
);
777
+
None
778
+
},
779
+
|storage| {
780
+
tracing::info!("Initialized filesystem backup storage");
781
+
Some(Arc::new(storage) as Arc<dyn BackupStorage>)
782
+
},
783
+
)
784
}
785
}
786
}
+7
-1
frontend/src/components/dashboard/SecurityContent.svelte
+7
-1
frontend/src/components/dashboard/SecurityContent.svelte
···
457
showSetPasswordForm = false
458
} catch (e) {
459
if (e instanceof ApiError) {
460
+
if (e.error === 'ReauthRequired') {
461
+
reauthMethods = e.reauthMethods || ['passkey']
462
+
pendingAction = () => handleSetPassword(new Event('submit'))
463
+
showReauthModal = true
464
+
} else {
465
+
toast.error(e.message)
466
+
}
467
} else {
468
toast.error($_('security.failedToSetPassword'))
469
}
+2
-1
frontend/src/locales/en.json
+2
-1
frontend/src/locales/en.json
+2
-1
frontend/src/locales/fi.json
+2
-1
frontend/src/locales/fi.json
+2
-1
frontend/src/locales/ja.json
+2
-1
frontend/src/locales/ja.json
+2
-1
frontend/src/locales/ko.json
+2
-1
frontend/src/locales/ko.json
+2
-1
frontend/src/locales/sv.json
+2
-1
frontend/src/locales/sv.json
+2
-1
frontend/src/locales/zh.json
+2
-1
frontend/src/locales/zh.json
+39
-5
frontend/src/routes/OAuthConsent.svelte
+39
-5
frontend/src/routes/OAuthConsent.svelte
···
62
return params.get('request_uri')
63
}
64
65
async function fetchConsentData() {
66
const requestUri = getRequestUri()
67
if (!requestUri) {
···
72
}
73
74
try {
75
-
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
76
if (!response.ok) {
77
const data = await response.json()
78
-
console.error('[OAuthConsent] Consent fetch failed:', data)
79
-
error = data.error_description || data.error || $_('oauth.error.genericError')
80
-
loading = false
81
-
return
82
}
83
const data: ConsentData = await response.json()
84
···
62
return params.get('request_uri')
63
}
64
65
+
async function tryRenewRequest(requestUri: string): Promise<boolean> {
66
+
try {
67
+
const response = await fetch('/oauth/authorize/renew', {
68
+
method: 'POST',
69
+
headers: { 'Content-Type': 'application/json' },
70
+
body: JSON.stringify({ request_uri: requestUri }),
71
+
})
72
+
if (!response.ok) return false
73
+
const data = await response.json()
74
+
return data.renewed === true
75
+
} catch {
76
+
return false
77
+
}
78
+
}
79
+
80
async function fetchConsentData() {
81
const requestUri = getRequestUri()
82
if (!requestUri) {
···
87
}
88
89
try {
90
+
let response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
91
if (!response.ok) {
92
const data = await response.json()
93
+
if (data.error === 'expired_request') {
94
+
const renewed = await tryRenewRequest(requestUri)
95
+
if (renewed) {
96
+
response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
97
+
if (!response.ok) {
98
+
const retryData = await response.json()
99
+
console.error('[OAuthConsent] Consent fetch failed after renewal:', retryData)
100
+
error = retryData.error_description || retryData.error || $_('oauth.error.genericError')
101
+
loading = false
102
+
return
103
+
}
104
+
} else {
105
+
console.error('[OAuthConsent] Consent fetch failed:', data)
106
+
error = data.error_description || data.error || $_('oauth.error.genericError')
107
+
loading = false
108
+
return
109
+
}
110
+
} else {
111
+
console.error('[OAuthConsent] Consent fetch failed:', data)
112
+
error = data.error_description || data.error || $_('oauth.error.genericError')
113
+
loading = false
114
+
return
115
+
}
116
}
117
const data: ConsentData = await response.json()
118
+44
-2
frontend/src/routes/OAuthLogin.svelte
+44
-2
frontend/src/routes/OAuthLogin.svelte
···
18
icon: string
19
}
20
21
let username = $state('')
22
let ssoProviders = $state<SsoProvider[]>([])
23
let ssoLoading = $state<string | null>(null)
···
25
let rememberDevice = $state(false)
26
let submitting = $state(false)
27
let error = $state<string | null>(null)
28
let hasPasskeys = $state(false)
29
let hasTotp = $state(false)
30
let hasPassword = $state(true)
···
52
$effect(() => {
53
const urlError = getErrorFromUrl()
54
if (urlError) {
55
-
error = urlError
56
}
57
})
58
···
200
201
submitting = true
202
error = null
203
204
try {
205
const startResponse = await fetch('/oauth/passkey/start', {
···
216
217
if (!startResponse.ok) {
218
const data = await startResponse.json()
219
error = data.error_description || data.error || 'Failed to start passkey login'
220
submitting = false
221
return
···
251
const data = await finishResponse.json()
252
253
if (!finishResponse.ok) {
254
error = data.error_description || data.error || 'Passkey authentication failed'
255
submitting = false
256
return
···
294
295
submitting = true
296
error = null
297
298
try {
299
const response = await fetch('/oauth/authorize', {
···
313
const data = await response.json()
314
315
if (!response.ok) {
316
error = data.error_description || data.error || 'Login failed'
317
submitting = false
318
return
···
354
{/if}
355
</header>
356
357
-
{#if error}
358
<div class="message error">{error}</div>
359
{/if}
360
···
18
icon: string
19
}
20
21
+
const PENDING_VERIFICATION_KEY = 'tranquil_pds_pending_verification'
22
+
23
+
function storePendingVerification(data: { did?: string; handle?: string; channel?: string }) {
24
+
if (data.did) {
25
+
localStorage.setItem(PENDING_VERIFICATION_KEY, JSON.stringify({
26
+
did: data.did,
27
+
handle: data.handle ?? '',
28
+
channel: data.channel ?? '',
29
+
}))
30
+
}
31
+
}
32
+
33
let username = $state('')
34
let ssoProviders = $state<SsoProvider[]>([])
35
let ssoLoading = $state<string | null>(null)
···
37
let rememberDevice = $state(false)
38
let submitting = $state(false)
39
let error = $state<string | null>(null)
40
+
let verificationResent = $state(false)
41
let hasPasskeys = $state(false)
42
let hasTotp = $state(false)
43
let hasPassword = $state(true)
···
65
$effect(() => {
66
const urlError = getErrorFromUrl()
67
if (urlError) {
68
+
if (urlError === 'account_not_verified') {
69
+
verificationResent = true
70
+
} else {
71
+
error = urlError
72
+
}
73
}
74
})
75
···
217
218
submitting = true
219
error = null
220
+
verificationResent = false
221
222
try {
223
const startResponse = await fetch('/oauth/passkey/start', {
···
234
235
if (!startResponse.ok) {
236
const data = await startResponse.json()
237
+
if (data.error === 'account_not_verified') {
238
+
verificationResent = true
239
+
storePendingVerification(data)
240
+
submitting = false
241
+
return
242
+
}
243
error = data.error_description || data.error || 'Failed to start passkey login'
244
submitting = false
245
return
···
275
const data = await finishResponse.json()
276
277
if (!finishResponse.ok) {
278
+
if (data.error === 'account_not_verified') {
279
+
verificationResent = true
280
+
storePendingVerification(data)
281
+
submitting = false
282
+
return
283
+
}
284
error = data.error_description || data.error || 'Passkey authentication failed'
285
submitting = false
286
return
···
324
325
submitting = true
326
error = null
327
+
verificationResent = false
328
329
try {
330
const response = await fetch('/oauth/authorize', {
···
344
const data = await response.json()
345
346
if (!response.ok) {
347
+
if (data.error === 'account_not_verified') {
348
+
verificationResent = true
349
+
storePendingVerification(data)
350
+
submitting = false
351
+
return
352
+
}
353
error = data.error_description || data.error || 'Login failed'
354
submitting = false
355
return
···
391
{/if}
392
</header>
393
394
+
{#if verificationResent}
395
+
<div class="message warning">
396
+
<p>{$_('oauth.login.verificationResent')}</p>
397
+
<a href={`${getFullUrl(routes.verify)}${getRequestUri() ? `?request_uri=${encodeURIComponent(getRequestUri()!)}` : ''}`}>{$_('verify.tokenTitle')}</a>
398
+
</div>
399
+
{:else if error}
400
<div class="message error">{error}</div>
401
{/if}
402