+14
.sqlx/query-00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e.json
+14
.sqlx/query-00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET telegram_verified = TRUE WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "00e2443c853791978e20e590a54721e44bbf7df1285acc27b0b658841fb55c7e"
14
+
}
-14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
-14
.sqlx/query-0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "0e837bf8eb303dbd2d0ffad0167da75c15fb1859c658fc5957ab28ef674108a8"
14
-
}
+2
-1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
+2
-1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
+14
.sqlx/query-1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af.json
+14
.sqlx/query-1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET signal_verified = TRUE WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "1baf4c087c31d0e2af8f607fb3476db6b34925a0c8902fdd9c9a5a607f19b3af"
14
+
}
+2
-1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+2
-1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
-15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
-15
.sqlx/query-30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text",
9
-
"Uuid"
10
-
]
11
-
},
12
-
"nullable": []
13
-
},
14
-
"hash": "30aa8003262b82ee58f1819f1a74c195768dce6d4c8d297e8b7eab1d990f61c5"
15
-
}
+2
-1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
+2
-1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
-30
.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
-30
.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid",
9
-
{
10
-
"Custom": {
11
-
"name": "comms_channel",
12
-
"kind": {
13
-
"Enum": [
14
-
"email",
15
-
"discord",
16
-
"telegram",
17
-
"signal"
18
-
]
19
-
}
20
-
}
21
-
},
22
-
"Text",
23
-
"Text",
24
-
"Timestamptz"
25
-
]
26
-
},
27
-
"nullable": []
28
-
},
29
-
"hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483"
30
-
}
-27
.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json
-27
.sqlx/query-57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid",
9
-
{
10
-
"Custom": {
11
-
"name": "comms_channel",
12
-
"kind": {
13
-
"Enum": [
14
-
"email",
15
-
"discord",
16
-
"telegram",
17
-
"signal"
18
-
]
19
-
}
20
-
}
21
-
}
22
-
]
23
-
},
24
-
"nullable": []
25
-
},
26
-
"hash": "57229564a518b14dca6fecef677d4b58b5ab6892846e65a4f1549ae5f147c13e"
27
-
}
-41
.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json
-41
.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "code",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "expires_at",
14
-
"type_info": "Timestamptz"
15
-
}
16
-
],
17
-
"parameters": {
18
-
"Left": [
19
-
"Uuid",
20
-
{
21
-
"Custom": {
22
-
"name": "comms_channel",
23
-
"kind": {
24
-
"Enum": [
25
-
"email",
26
-
"discord",
27
-
"telegram",
28
-
"signal"
29
-
]
30
-
}
31
-
}
32
-
}
33
-
]
34
-
},
35
-
"nullable": [
36
-
false,
37
-
false
38
-
]
39
-
},
40
-
"hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1"
41
-
}
+34
.sqlx/query-5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082.json
+34
.sqlx/query-5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, email, email_verified 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": "email",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email_verified",
19
+
"type_info": "Bool"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
true,
30
+
false
31
+
]
32
+
},
33
+
"hash": "5e1ed2edc81e4f2560b3f8f0a8f04e0fb78548402715fc88e2b34a8ccdb80082"
34
+
}
+46
.sqlx/query-5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922.json
+46
.sqlx/query-5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $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": "email",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "email_verified",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "handle",
29
+
"type_info": "Text"
30
+
}
31
+
],
32
+
"parameters": {
33
+
"Left": [
34
+
"Text"
35
+
]
36
+
},
37
+
"nullable": [
38
+
false,
39
+
false,
40
+
true,
41
+
false,
42
+
false
43
+
]
44
+
},
45
+
"hash": "5ee0976fbff885ad19482b3b4d54ebca7a6cde24c411597c9df6e94b8be1f922"
46
+
}
-14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
-14
.sqlx/query-9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "9af373d1bee5d79419f131a44c6e346a95bd3cafe2454dbe08411abb11f42161"
14
-
}
-34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
-34
.sqlx/query-a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "code",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "pending_identifier",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "expires_at",
19
-
"type_info": "Timestamptz"
20
-
}
21
-
],
22
-
"parameters": {
23
-
"Left": [
24
-
"Uuid"
25
-
]
26
-
},
27
-
"nullable": [
28
-
false,
29
-
true,
30
-
false
31
-
]
32
-
},
33
-
"hash": "a1464e8d15e8e46a3ebc21e591afb89b8f937469f8e33a43f2cd8e121526f3d3"
34
-
}
+15
.sqlx/query-a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005.json
+15
.sqlx/query-a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Uuid"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "a51d2a4af488164421cf669302c896720a4745bfb913a18e4829a8edd73ea005"
15
+
}
-14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
-14
.sqlx/query-af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "af3010ff76e52b7cb495091f2f36547360523b12f83e4eb178242edec052a0f2"
14
-
}
+14
.sqlx/query-b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json
+14
.sqlx/query-b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email_verified = true WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "b1a4e2dc9578c3aad054ebacf00a7e804dc0aa4f0a4a283683ad1ce6a77d4f6a"
14
+
}
+58
.sqlx/query-c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6.json
+58
.sqlx/query-c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified 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": "handle",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "email_verified",
24
+
"type_info": "Bool"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "discord_verified",
29
+
"type_info": "Bool"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "telegram_verified",
34
+
"type_info": "Bool"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "signal_verified",
39
+
"type_info": "Bool"
40
+
}
41
+
],
42
+
"parameters": {
43
+
"Left": [
44
+
"Text"
45
+
]
46
+
},
47
+
"nullable": [
48
+
false,
49
+
false,
50
+
true,
51
+
false,
52
+
false,
53
+
false,
54
+
false
55
+
]
56
+
},
57
+
"hash": "c12a8bbd82bd9caf8ad92f21da7517275989b41befa82347883945f77e8630f6"
58
+
}
-30
.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json
-30
.sqlx/query-c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, $2::comms_channel, $3, $4, $5)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()\n ",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid",
9
-
{
10
-
"Custom": {
11
-
"name": "comms_channel",
12
-
"kind": {
13
-
"Enum": [
14
-
"email",
15
-
"discord",
16
-
"telegram",
17
-
"signal"
18
-
]
19
-
}
20
-
}
21
-
},
22
-
"Text",
23
-
"Text",
24
-
"Timestamptz"
25
-
]
26
-
},
27
-
"nullable": []
28
-
},
29
-
"hash": "c4db3853b2f3b6363ab0e2c10a1820dd37741e9c506d85fc2608a3a6e376c5e6"
30
-
}
+14
.sqlx/query-cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9.json
+14
.sqlx/query-cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email_verified = TRUE WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9"
14
+
}
+14
.sqlx/query-d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87.json
+14
.sqlx/query-d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET discord_verified = TRUE WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "d09a8b0ab3abd19a09b7588b99335ec3857ca22e0707ef8911251c4c69e74c87"
14
+
}
-14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
-14
.sqlx/query-ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "ee95f960c0df276fd936ceaef8b75d341a4c84322e5de2b3630573dbef387839"
14
-
}
+21
-3
.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json
.sqlx/query-9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a.json
+21
-3
.sqlx/query-efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a.json
.sqlx/query-9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.email,\n u.preferred_comms_channel as \"channel: crate::comms::CommsChannel\",\n u.discord_id, u.telegram_username, u.signal_number,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
42
42
},
43
43
{
44
44
"ordinal": 5,
45
+
"name": "discord_id",
46
+
"type_info": "Text"
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "telegram_username",
51
+
"type_info": "Text"
52
+
},
53
+
{
54
+
"ordinal": 7,
55
+
"name": "signal_number",
56
+
"type_info": "Text"
57
+
},
58
+
{
59
+
"ordinal": 8,
45
60
"name": "key_bytes",
46
61
"type_info": "Bytea"
47
62
},
48
63
{
49
-
"ordinal": 6,
64
+
"ordinal": 9,
50
65
"name": "encryption_version",
51
66
"type_info": "Int4"
52
67
}
···
62
77
false,
63
78
true,
64
79
false,
80
+
true,
81
+
true,
82
+
true,
65
83
false,
66
84
true
67
85
]
68
86
},
69
-
"hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a"
87
+
"hash": "9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a"
70
88
}
-47
.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json
-47
.sqlx/query-f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT code, pending_identifier, expires_at FROM channel_verifications\n WHERE user_id = $1 AND channel = $2::comms_channel\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "code",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "pending_identifier",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "expires_at",
19
-
"type_info": "Timestamptz"
20
-
}
21
-
],
22
-
"parameters": {
23
-
"Left": [
24
-
"Uuid",
25
-
{
26
-
"Custom": {
27
-
"name": "comms_channel",
28
-
"kind": {
29
-
"Enum": [
30
-
"email",
31
-
"discord",
32
-
"telegram",
33
-
"signal"
34
-
]
35
-
}
36
-
}
37
-
}
38
-
]
39
-
},
40
-
"nullable": [
41
-
false,
42
-
true,
43
-
false
44
-
]
45
-
},
46
-
"hash": "f48c982a2bf52a2f2de6d70043108ac148363e2f98b301dbeeb1caac330528c5"
47
-
}
+2
-1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
+2
-1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
+27
frontend/deno.lock
+27
frontend/deno.lock
···
8
8
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
9
9
"npm:jsdom@^25.0.1": "25.0.1",
10
10
"npm:multiformats@^13.3.1": "13.4.2",
11
+
"npm:svelte-check@*": "4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3",
11
12
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
12
13
"npm:svelte@5": "5.45.10_acorn@8.15.0",
13
14
"npm:vite@*": "6.4.1_picomatch@4.0.3",
···
794
795
"check-error@2.1.1": {
795
796
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
796
797
},
798
+
"chokidar@4.0.3": {
799
+
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
800
+
"dependencies": [
801
+
"readdirp"
802
+
]
803
+
},
797
804
"cli-color@2.0.4": {
798
805
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
799
806
"dependencies": [
···
1339
1346
"react-is@17.0.2": {
1340
1347
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1341
1348
},
1349
+
"readdirp@4.1.2": {
1350
+
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
1351
+
},
1342
1352
"redent@3.0.0": {
1343
1353
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1344
1354
"dependencies": [
···
1416
1426
"dependencies": [
1417
1427
"min-indent"
1418
1428
]
1429
+
},
1430
+
"svelte-check@4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3": {
1431
+
"integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
1432
+
"dependencies": [
1433
+
"@jridgewell/trace-mapping",
1434
+
"chokidar",
1435
+
"fdir",
1436
+
"picocolors",
1437
+
"sade",
1438
+
"svelte",
1439
+
"typescript"
1440
+
],
1441
+
"bin": true
1419
1442
},
1420
1443
"svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": {
1421
1444
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
···
1517
1540
},
1518
1541
"type@2.7.3": {
1519
1542
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
1543
+
},
1544
+
"typescript@5.9.3": {
1545
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1546
+
"bin": true
1520
1547
},
1521
1548
"vite-node@2.1.9": {
1522
1549
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+2
-1
frontend/src/components/ui/Input.svelte
+2
-1
frontend/src/components/ui/Input.svelte
-4
frontend/src/components/ui/Section.svelte
-4
frontend/src/components/ui/Section.svelte
+29
-2
frontend/src/lib/api.ts
+29
-2
frontend/src/lib/api.ts
···
319
319
})
320
320
},
321
321
322
-
async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> {
322
+
async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> {
323
323
return xrpc('com.tranquil.account.confirmChannelVerification', {
324
324
method: 'POST',
325
325
token,
326
-
body: { channel, code },
326
+
body: { channel, identifier, code },
327
327
})
328
328
},
329
329
···
852
852
return xrpc('com.tranquil.account.recoverPasskeyAccount', {
853
853
method: 'POST',
854
854
body: { did, recoveryToken, newPassword },
855
+
})
856
+
},
857
+
858
+
async verifyMigrationEmail(token: string, email: string): Promise<{ success: boolean; did: string }> {
859
+
return xrpc('com.atproto.server.verifyMigrationEmail', {
860
+
method: 'POST',
861
+
body: { token, email },
862
+
})
863
+
},
864
+
865
+
async resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
866
+
return xrpc('com.atproto.server.resendMigrationVerification', {
867
+
method: 'POST',
868
+
body: { email },
869
+
})
870
+
},
871
+
872
+
async verifyToken(token: string, identifier: string, accessToken?: string): Promise<{
873
+
success: boolean
874
+
did: string
875
+
purpose: string
876
+
channel: string
877
+
}> {
878
+
return xrpc('com.tranquil.account.verifyToken', {
879
+
method: 'POST',
880
+
body: { token, identifier },
881
+
token: accessToken,
855
882
})
856
883
},
857
884
}
+16
-3
frontend/src/lib/registration/VerificationStep.svelte
+16
-3
frontend/src/lib/registration/VerificationStep.svelte
···
70
70
id="verification-code"
71
71
type="text"
72
72
bind:value={verificationCode}
73
-
placeholder="Enter 6-digit code"
73
+
placeholder="XXXX-XXXX-XXXX-XXXX"
74
74
disabled={flow.state.submitting}
75
75
required
76
-
maxlength="6"
77
-
inputmode="numeric"
78
76
autocomplete="one-time-code"
77
+
class="code-input"
79
78
/>
79
+
<span class="hint">Copy the entire code from your message, including dashes.</span>
80
80
</div>
81
81
82
82
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
···
99
99
.info-text {
100
100
color: var(--text-secondary);
101
101
margin: 0;
102
+
}
103
+
104
+
.code-input {
105
+
font-family: var(--font-mono, monospace);
106
+
font-size: var(--text-base);
107
+
letter-spacing: 0.05em;
108
+
}
109
+
110
+
.hint {
111
+
display: block;
112
+
color: var(--text-secondary);
113
+
font-size: var(--text-sm);
114
+
margin-top: var(--space-1);
102
115
}
103
116
</style>
+130
-8
frontend/src/locales/en.json
+130
-8
frontend/src/locales/en.json
···
164
164
"changeEmailButton": "Change Email",
165
165
"requesting": "Requesting...",
166
166
"verificationCode": "Verification Code",
167
-
"verificationCodePlaceholder": "Enter code from email",
167
+
"verificationCodePlaceholder": "Enter verification code",
168
168
"confirmEmailChange": "Confirm Email Change",
169
169
"updating": "Updating...",
170
170
"changeHandle": "Change Handle",
···
202
202
"deleteAccount": "Delete Account",
203
203
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
204
204
"requestDeletion": "Request Account Deletion",
205
-
"confirmationCode": "Confirmation Code (from email)",
205
+
"confirmationCode": "Confirmation Code",
206
206
"confirmationCodePlaceholder": "Enter confirmation code",
207
207
"yourPassword": "Your Password",
208
208
"yourPasswordPlaceholder": "Enter your password",
209
209
"permanentlyDelete": "Permanently Delete Account",
210
210
"deleting": "Deleting...",
211
211
"messages": {
212
-
"emailCodeSent": "Verification code sent to your current email",
212
+
"emailCodeSent": "Verification code sent to your notification channel",
213
213
"emailUpdated": "Email updated successfully",
214
214
"handleUpdated": "Handle updated successfully",
215
215
"passwordChanged": "Password changed successfully",
···
451
451
},
452
452
"admin": {
453
453
"title": "Admin Panel",
454
+
"loading": "Loading...",
455
+
"serverConfig": "Server Configuration",
456
+
"serverName": "Server Name",
457
+
"serverNamePlaceholder": "My PDS",
458
+
"serverNameHelp": "Displayed in the browser tab and other places",
459
+
"serverLogo": "Server Logo",
460
+
"logoPreview": "Logo preview",
461
+
"removeLogo": "Remove",
462
+
"logoHelp": "Used as favicon and shown in the navbar",
463
+
"themeColors": "Theme Colors",
464
+
"themeColorsHint": "Leave blank to use default colors.",
465
+
"primaryLight": "Primary (Light Mode)",
466
+
"primaryLightDefault": "#2c00ff (default)",
467
+
"primaryDark": "Primary (Dark Mode)",
468
+
"primaryDarkDefault": "#7b6bff (default)",
469
+
"secondaryLight": "Secondary (Light Mode)",
470
+
"secondaryLightDefault": "#ff2400 (default)",
471
+
"secondaryDark": "Secondary (Dark Mode)",
472
+
"secondaryDarkDefault": "#ff6b5b (default)",
473
+
"configSaved": "Server configuration saved",
474
+
"saving": "Saving...",
475
+
"saveConfig": "Save Configuration",
454
476
"serverStats": "Server Statistics",
455
477
"users": "Users",
456
478
"repos": "Repositories",
···
580
602
"verify": {
581
603
"title": "Verify Your Account",
582
604
"subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.",
583
-
"codePlaceholder": "Enter 6-digit code",
605
+
"tokenSubtitle": "Enter the verification code and the identifier it was sent to.",
606
+
"tokenTitle": "Verify",
607
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
584
608
"codeLabel": "Verification Code",
609
+
"codeHelp": "Copy the entire code from your message, including dashes",
585
610
"verifyButton": "Verify Account",
611
+
"verify": "Verify",
586
612
"verifying": "Verifying...",
613
+
"pleaseWait": "Please wait...",
587
614
"resendCode": "Resend Code",
588
615
"resending": "Resending...",
616
+
"sending": "Sending...",
589
617
"codeResent": "Verification code resent!",
618
+
"codeResentDetail": "Verification code sent! Check your inbox.",
590
619
"backToLogin": "Back to Login",
591
620
"verifyingAccount": "Verifying account: @{handle}",
592
621
"startOver": "Start over with a different account",
593
622
"noPending": "No pending verification found.",
594
623
"noPendingInfo": "If you recently created an account and need to verify it, you may need to create a new account. If you already verified your account, you can sign in.",
595
624
"createAccount": "Create Account",
596
-
"signIn": "Sign In"
625
+
"signIn": "Sign In",
626
+
"verified": "Verified!",
627
+
"channelVerified": "Your {channel} has been verified successfully.",
628
+
"canNowSignIn": "You can now sign in to your account.",
629
+
"continue": "Continue",
630
+
"identifierLabel": "Email or Identifier",
631
+
"identifierPlaceholder": "you@example.com",
632
+
"identifierHelp": "The email address or identifier the code was sent to"
597
633
},
598
634
"resetPassword": {
599
635
"title": "Reset Password",
···
605
641
"sendCode": "Send Reset Code",
606
642
"sending": "Sending...",
607
643
"codeSent": "Password reset code sent! Check your preferred notification channel.",
608
-
"enterCode": "Enter the code from your email and your new password.",
644
+
"enterCode": "Enter the code you received and your new password.",
609
645
"code": "Reset Code",
610
646
"codePlaceholder": "Enter reset code",
611
647
"newPassword": "New Password",
···
664
700
},
665
701
"registerPasskey": {
666
702
"title": "Create Passkey Account",
667
-
"subtitle": "Create a passwordless account using a passkey.",
703
+
"subtitle": "Create an ultra-secure account using a passkey instead of a password.",
704
+
"subtitleKeyChoice": "Choose how to set up your external did:web identity.",
705
+
"subtitleInitialDidDoc": "Upload your DID document to continue.",
706
+
"subtitleCreating": "Creating your account...",
707
+
"subtitlePasskey": "Register your passkey to secure your account.",
708
+
"subtitleAppPassword": "Save your app password for third-party apps.",
709
+
"subtitleVerify": "Verify your {channel} to continue.",
710
+
"subtitleUpdatedDidDoc": "Update your DID document with the PDS signing key.",
711
+
"subtitleActivating": "Activating your account...",
712
+
"subtitleComplete": "Your account has been created successfully!",
668
713
"handle": "Handle",
669
714
"handlePlaceholder": "yourname",
670
715
"handleHint": "Your full handle will be: @{handle}",
716
+
"handleDotWarning": "Custom domain handles can be set up after account creation.",
671
717
"email": "Email Address",
672
718
"emailPlaceholder": "you@example.com",
673
719
"inviteCode": "Invite Code",
674
720
"inviteCodePlaceholder": "Enter your invite code",
675
721
"createButton": "Create Account",
676
722
"creating": "Creating...",
723
+
"continue": "Continue",
724
+
"back": "Back",
677
725
"alreadyHaveAccount": "Already have an account?",
678
726
"signIn": "Sign in",
679
727
"wantPassword": "Want to use a password?",
680
-
"createPasswordAccount": "Create a password account"
728
+
"createPasswordAccount": "Create a password account",
729
+
"wantTraditional": "Want a traditional password?",
730
+
"registerWithPassword": "Register with password",
731
+
"contactMethod": "Contact Method",
732
+
"contactMethodHint": "Choose how you'd like to verify your account and receive notifications.",
733
+
"verificationMethod": "Verification Method",
734
+
"identityType": "Identity Type",
735
+
"identityTypeHint": "Choose how your decentralized identity will be managed.",
736
+
"didPlcRecommended": "did:plc (Recommended)",
737
+
"didPlcHint": "Portable identity managed by PLC Directory",
738
+
"didWeb": "did:web",
739
+
"didWebHint": "Identity hosted on this PDS (read warning below)",
740
+
"didWebBYOD": "did:web (BYOD)",
741
+
"didWebBYODHint": "Bring your own domain",
742
+
"didWebWarningTitle": "Important: Understand the trade-offs",
743
+
"didWebWarning1": "Permanent tie to this PDS:",
744
+
"didWebWarning2": "No recovery mechanism:",
745
+
"didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.",
746
+
"didWebWarning3": "We commit to you:",
747
+
"didWebWarning3Detail": "If you migrate away, we will continue serving a minimal DID document.",
748
+
"didWebWarning4": "Recommendation:",
749
+
"didWebWarning4Detail": "Choose did:plc unless you have a specific reason to prefer did:web.",
750
+
"externalDid": "Your did:web",
751
+
"externalDidPlaceholder": "did:web:yourdomain.com",
752
+
"externalDidHint": "You'll need to serve a DID document at",
753
+
"whyPasskeyOnly": "Why passkey-only?",
754
+
"whyPasskeyOnlyDesc": "Passkey accounts are more secure than password-based accounts because they:",
755
+
"whyPasskeyBullet1": "Cannot be phished or stolen in data breaches",
756
+
"whyPasskeyBullet2": "Use hardware-backed cryptographic keys",
757
+
"whyPasskeyBullet3": "Require your biometric or device PIN to use",
758
+
"passkeyNameLabel": "Passkey Name (optional)",
759
+
"passkeyNamePlaceholder": "e.g., MacBook Touch ID",
760
+
"passkeyNameHint": "A friendly name to identify this passkey",
761
+
"passkeyPrompt": "Click the button below to create your passkey. You'll be prompted to use:",
762
+
"passkeyPromptBullet1": "Touch ID or Face ID",
763
+
"passkeyPromptBullet2": "Your device PIN or password",
764
+
"passkeyPromptBullet3": "A security key (if you have one)",
765
+
"createPasskey": "Create Passkey",
766
+
"creatingPasskey": "Creating Passkey...",
767
+
"redirecting": "Redirecting to dashboard...",
768
+
"loading": "Loading...",
769
+
"errors": {
770
+
"handleRequired": "Handle is required",
771
+
"handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.",
772
+
"inviteRequired": "Invite code is required",
773
+
"externalDidRequired": "External did:web is required",
774
+
"externalDidFormat": "External DID must start with did:web:",
775
+
"emailRequired": "Email is required for email verification",
776
+
"discordRequired": "Discord ID is required for Discord verification",
777
+
"telegramRequired": "Telegram username is required for Telegram verification",
778
+
"signalRequired": "Phone number is required for Signal verification",
779
+
"passkeysNotSupported": "Passkeys are not supported in this browser. Please use a different browser or register with a password instead.",
780
+
"passkeyCancelled": "Passkey creation was cancelled",
781
+
"passkeyFailed": "Passkey registration failed"
782
+
}
681
783
},
682
784
"trustedDevices": {
683
785
"title": "Trusted Devices",
···
710
812
"verify": "Verify",
711
813
"verifying": "Verifying...",
712
814
"cancel": "Cancel"
815
+
},
816
+
"verifyChannel": {
817
+
"title": "Verify Channel",
818
+
"subtitle": "Enter the verification code sent to your notification channel.",
819
+
"signInRequired": "Sign In Required",
820
+
"signInRequiredDesc": "You must be signed in to verify a channel.",
821
+
"signIn": "Sign In",
822
+
"verifying": "Verifying...",
823
+
"pleaseWait": "Please wait while we verify your channel.",
824
+
"successTitle": "Verified!",
825
+
"successDesc": "Your {channel} has been verified successfully.",
826
+
"backToSettings": "Back to Settings",
827
+
"channelLabel": "Channel",
828
+
"selectChannel": "Select channel...",
829
+
"identifierLabel": "Identifier",
830
+
"identifierPlaceholder": "Email, Discord ID, etc.",
831
+
"identifierHelp": "The email address, Discord ID, Telegram username, or Signal number being verified.",
832
+
"codeLabel": "Verification Code",
833
+
"codeHelp": "Copy the entire code from your message, including dashes.",
834
+
"verifyButton": "Verify"
713
835
}
714
836
}
+91
-7
frontend/src/locales/fi.json
+91
-7
frontend/src/locales/fi.json
···
164
164
"changeEmailButton": "Vaihda sähköposti",
165
165
"requesting": "Pyydetään...",
166
166
"verificationCode": "Vahvistuskoodi",
167
-
"verificationCodePlaceholder": "Syötä koodi sähköpostista",
167
+
"verificationCodePlaceholder": "Syötä vahvistuskoodi",
168
168
"confirmEmailChange": "Vahvista sähköpostin vaihto",
169
169
"updating": "Päivitetään...",
170
170
"changeHandle": "Vaihda käyttäjänimi",
···
202
202
"deleteAccount": "Poista tili",
203
203
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
204
204
"requestDeletion": "Pyydä tilin poistoa",
205
-
"confirmationCode": "Vahvistuskoodi (sähköpostista)",
205
+
"confirmationCode": "Vahvistuskoodi",
206
206
"confirmationCodePlaceholder": "Syötä vahvistuskoodi",
207
207
"yourPassword": "Salasanasi",
208
208
"yourPasswordPlaceholder": "Syötä salasanasi",
209
209
"permanentlyDelete": "Poista tili pysyvästi",
210
210
"deleting": "Poistetaan...",
211
211
"messages": {
212
-
"emailCodeSent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiisi",
212
+
"emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi",
213
213
"emailUpdated": "Sähköposti päivitetty",
214
214
"handleUpdated": "Käyttäjänimi päivitetty",
215
215
"passwordChanged": "Salasana vaihdettu",
···
451
451
},
452
452
"admin": {
453
453
"title": "Ylläpitopaneeli",
454
+
"loading": "Ladataan...",
455
+
"serverConfig": "Palvelinasetukset",
456
+
"serverName": "Palvelimen nimi",
457
+
"serverNamePlaceholder": "Oma PDS",
458
+
"serverNameHelp": "Näytetään selaimen välilehdessä ja muualla",
459
+
"serverLogo": "Palvelimen logo",
460
+
"logoPreview": "Logon esikatselu",
461
+
"removeLogo": "Poista",
462
+
"logoHelp": "Käytetään faviconina ja näytetään navigointipalkissa",
463
+
"themeColors": "Teemavärit",
464
+
"themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.",
465
+
"primaryLight": "Ensisijainen (vaalea tila)",
466
+
"primaryDark": "Ensisijainen (tumma tila)",
467
+
"accentLight": "Korostus (vaalea tila)",
468
+
"accentDark": "Korostus (tumma tila)",
469
+
"faviconExample": "Favicon-esimerkki",
470
+
"configSaved": "Palvelinasetukset tallennettu",
471
+
"saving": "Tallennetaan...",
472
+
"saveConfig": "Tallenna asetukset",
454
473
"serverStats": "Palvelintilastot",
455
474
"users": "Käyttäjät",
456
475
"repos": "Tietovarastot",
···
580
599
"verify": {
581
600
"title": "Vahvista tilisi",
582
601
"subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.",
583
-
"codePlaceholder": "Syötä 6-numeroinen koodi",
602
+
"tokenTitle": "Vahvista",
603
+
"tokenSubtitle": "Syötä vahvistuskoodi ja tunniste, johon se lähetettiin.",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
584
605
"codeLabel": "Vahvistuskoodi",
606
+
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
585
607
"verifyButton": "Vahvista tili",
608
+
"verify": "Vahvista",
586
609
"verifying": "Vahvistetaan...",
610
+
"pleaseWait": "Odota...",
611
+
"sending": "Lähetetään...",
587
612
"resendCode": "Lähetä koodi uudelleen",
588
613
"resending": "Lähetetään uudelleen...",
589
614
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
615
+
"codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.",
616
+
"verified": "Vahvistettu!",
617
+
"channelVerified": "{channel} on vahvistettu onnistuneesti.",
618
+
"canNowSignIn": "Voit nyt kirjautua tilillesi.",
619
+
"continue": "Jatka",
620
+
"identifierLabel": "Sähköposti tai tunniste",
621
+
"identifierPlaceholder": "sinä@esimerkki.fi",
622
+
"identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin",
590
623
"backToLogin": "Takaisin kirjautumiseen",
591
624
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
592
625
"startOver": "Aloita alusta toisella tilillä",
···
605
638
"sendCode": "Lähetä palautuskoodi",
606
639
"sending": "Lähetetään...",
607
640
"codeSent": "Palautuskoodi lähetetty! Tarkista ensisijainen ilmoituskanavasi.",
608
-
"enterCode": "Syötä koodi sähköpostistasi ja uusi salasanasi.",
641
+
"enterCode": "Syötä saamasi koodi ja uusi salasanasi.",
609
642
"code": "Palautuskoodi",
610
643
"codePlaceholder": "Syötä palautuskoodi",
611
644
"newPassword": "Uusi salasana",
···
664
697
},
665
698
"registerPasskey": {
666
699
"title": "Luo pääsyavaintili",
667
-
"subtitle": "Luo salasanaton tili pääsyavaimella.",
700
+
"subtitle": "Luo erittäin turvallinen tili käyttämällä pääsyavainta salasanan sijaan.",
701
+
"subtitleKeyChoice": "Valitse, miten haluat määrittää ulkoisen did:web-identiteettisi.",
702
+
"subtitleVerify": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä koodi jatkaaksesi.",
703
+
"subtitlePasskey": "Luo pääsyavain viimeistelläksesi tilin määrityksen.",
668
704
"handle": "Käyttäjänimi",
669
705
"handlePlaceholder": "nimesi",
670
706
"handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
707
+
"contactMethod": "Yhteysmenetelmä",
708
+
"contactMethodHint": "Valitse, miten haluat vahvistaa tilisi ja vastaanottaa ilmoituksia.",
709
+
"verificationMethod": "Vahvistusmenetelmä",
671
710
"email": "Sähköpostiosoite",
672
711
"emailPlaceholder": "sinä@esimerkki.fi",
712
+
"discord": "Discord",
713
+
"discordId": "Discord-käyttäjätunnus",
714
+
"discordIdPlaceholder": "Discord-käyttäjätunnuksesi",
715
+
"discordIdHint": "Numeerinen Discord-käyttäjätunnuksesi (ota Kehittäjätila käyttöön löytääksesi sen)",
716
+
"telegram": "Telegram",
717
+
"telegramUsername": "Telegram-käyttäjänimi",
718
+
"telegramUsernamePlaceholder": "@käyttäjänimesi",
719
+
"signal": "Signal",
720
+
"signalNumber": "Signal-puhelinnumero",
721
+
"signalNumberPlaceholder": "+358401234567",
722
+
"signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)",
673
723
"inviteCode": "Kutsukoodi",
674
724
"inviteCodePlaceholder": "Syötä kutsukoodisi",
725
+
"inviteCodeRequired": "vaaditaan",
726
+
"didWebDescription": "Käytä DID-identiteettiä, jota isännöidään omalla verkkotunnuksellasi.",
727
+
"didWebToggle": "Käytä ulkoista did:web",
728
+
"externalDid": "Sinun did:web",
729
+
"externalDidPlaceholder": "did:web:verkkotunnuksesi.fi",
730
+
"dnsVerificationInstructions": "Vahvistaaksesi verkkotunnuksesi, lisää tämä TXT-tietue:",
731
+
"copyDid": "Kopioi DID",
675
732
"createButton": "Luo tili",
676
733
"creating": "Luodaan...",
677
734
"alreadyHaveAccount": "Onko sinulla jo tili?",
678
735
"signIn": "Kirjaudu sisään",
679
736
"wantPassword": "Haluatko käyttää salasanaa?",
680
-
"createPasswordAccount": "Luo salasanatili"
737
+
"createPasswordAccount": "Luo salasanatili",
738
+
"errors": {
739
+
"handleRequired": "Käyttäjänimi vaaditaan",
740
+
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
741
+
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
742
+
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
743
+
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui"
744
+
}
681
745
},
682
746
"trustedDevices": {
683
747
"title": "Luotetut laitteet",
···
710
774
"verify": "Vahvista",
711
775
"verifying": "Vahvistetaan...",
712
776
"cancel": "Peruuta"
777
+
},
778
+
"verifyChannel": {
779
+
"title": "Vahvista kanava",
780
+
"subtitle": "Syötä ilmoituskanavallesi lähetetty vahvistuskoodi.",
781
+
"signInRequired": "Kirjautuminen vaaditaan",
782
+
"signInRequiredDesc": "Sinun on kirjauduttava sisään vahvistaaksesi kanavan.",
783
+
"signIn": "Kirjaudu sisään",
784
+
"verifying": "Vahvistetaan...",
785
+
"pleaseWait": "Odota, vahvistamme kanavaasi.",
786
+
"successTitle": "Vahvistettu!",
787
+
"successDesc": "{channel} on vahvistettu onnistuneesti.",
788
+
"backToSettings": "Takaisin asetuksiin",
789
+
"channelLabel": "Kanava",
790
+
"selectChannel": "Valitse kanava...",
791
+
"identifierLabel": "Tunniste",
792
+
"identifierPlaceholder": "Sähköposti, Discord ID jne.",
793
+
"identifierHelp": "Vahvistettava sähköpostiosoite, Discord ID, Telegram-käyttäjänimi tai Signal-numero.",
794
+
"codeLabel": "Vahvistuskoodi",
795
+
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat.",
796
+
"verifyButton": "Vahvista"
713
797
}
714
798
}
+92
-8
frontend/src/locales/ja.json
+92
-8
frontend/src/locales/ja.json
···
65
65
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
66
66
"didWeb": "did:web",
67
67
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
68
-
"didWebBYOD": "did:web (BYOD)",
68
+
"didWebBYOD": "did:web (自前ドメイン)",
69
69
"didWebBYODHint": "独自ドメインを持ち込む",
70
70
"didWebWarningTitle": "重要: トレードオフをご理解ください",
71
71
"didWebWarning1": "この PDS への永続的な紐付け:",
···
164
164
"changeEmailButton": "メールを変更",
165
165
"requesting": "リクエスト中...",
166
166
"verificationCode": "確認コード",
167
-
"verificationCodePlaceholder": "メールから受け取ったコードを入力",
167
+
"verificationCodePlaceholder": "認証コードを入力",
168
168
"confirmEmailChange": "メール変更を確認",
169
169
"updating": "更新中...",
170
170
"changeHandle": "ハンドル変更",
···
202
202
"deleteAccount": "アカウント削除",
203
203
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
204
204
"requestDeletion": "アカウント削除をリクエスト",
205
-
"confirmationCode": "確認コード(メールから)",
205
+
"confirmationCode": "確認コード",
206
206
"confirmationCodePlaceholder": "確認コードを入力",
207
207
"yourPassword": "パスワード",
208
208
"yourPasswordPlaceholder": "パスワードを入力",
209
209
"permanentlyDelete": "アカウントを完全に削除",
210
210
"deleting": "削除中...",
211
211
"messages": {
212
-
"emailCodeSent": "現在のメールに確認コードを送信しました",
212
+
"emailCodeSent": "通知チャンネルに確認コードを送信しました",
213
213
"emailUpdated": "メールを更新しました",
214
214
"handleUpdated": "ハンドルを更新しました",
215
215
"passwordChanged": "パスワードを変更しました",
···
451
451
},
452
452
"admin": {
453
453
"title": "管理パネル",
454
+
"loading": "読み込み中...",
455
+
"serverConfig": "サーバー設定",
456
+
"serverName": "サーバー名",
457
+
"serverNamePlaceholder": "マイ PDS",
458
+
"serverNameHelp": "ブラウザのタブやその他の場所に表示されます",
459
+
"serverLogo": "サーバーロゴ",
460
+
"logoPreview": "ロゴプレビュー",
461
+
"removeLogo": "削除",
462
+
"logoHelp": "ファビコンとして使用され、ナビバーに表示されます",
463
+
"themeColors": "テーマカラー",
464
+
"themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。",
465
+
"primaryLight": "プライマリ(ライトモード)",
466
+
"primaryDark": "プライマリ(ダークモード)",
467
+
"accentLight": "アクセント(ライトモード)",
468
+
"accentDark": "アクセント(ダークモード)",
469
+
"faviconExample": "ファビコン例",
470
+
"configSaved": "サーバー設定を保存しました",
471
+
"saving": "保存中...",
472
+
"saveConfig": "設定を保存",
454
473
"serverStats": "サーバー統計",
455
474
"users": "ユーザー",
456
475
"repos": "リポジトリ",
···
580
599
"verify": {
581
600
"title": "アカウント確認",
582
601
"subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。",
583
-
"codePlaceholder": "6桁のコードを入力",
602
+
"tokenTitle": "確認",
603
+
"tokenSubtitle": "確認コードと送信先の識別子を入力してください。",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
584
605
"codeLabel": "確認コード",
606
+
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
585
607
"verifyButton": "アカウントを確認",
608
+
"verify": "確認",
586
609
"verifying": "確認中...",
610
+
"pleaseWait": "お待ちください...",
611
+
"sending": "送信中...",
587
612
"resendCode": "コードを再送信",
588
613
"resending": "送信中...",
589
614
"codeResent": "確認コードを再送信しました!",
615
+
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
616
+
"verified": "確認完了!",
617
+
"channelVerified": "{channel} が正常に確認されました。",
618
+
"canNowSignIn": "アカウントにサインインできるようになりました。",
619
+
"continue": "続行",
620
+
"identifierLabel": "メールまたは識別子",
621
+
"identifierPlaceholder": "you@example.com",
622
+
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
590
623
"backToLogin": "ログインに戻る",
591
624
"verifyingAccount": "確認中のアカウント: @{handle}",
592
625
"startOver": "別のアカウントでやり直す",
···
605
638
"sendCode": "リセットコードを送信",
606
639
"sending": "送信中...",
607
640
"codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。",
608
-
"enterCode": "メールからのコードと新しいパスワードを入力してください。",
641
+
"enterCode": "受け取ったコードと新しいパスワードを入力してください。",
609
642
"code": "リセットコード",
610
643
"codePlaceholder": "リセットコードを入力",
611
644
"newPassword": "新しいパスワード",
···
664
697
},
665
698
"registerPasskey": {
666
699
"title": "パスキーアカウントを作成",
667
-
"subtitle": "パスキーを使用してパスワードレスアカウントを作成します。",
700
+
"subtitle": "パスワードの代わりにパスキーを使用して超安全なアカウントを作成します。",
701
+
"subtitleKeyChoice": "外部 did:web アイデンティティの設定方法を選択してください。",
702
+
"subtitleVerify": "{channel} に確認コードを送信しました。コードを入力して続行してください。",
703
+
"subtitlePasskey": "パスキーを作成してアカウント設定を完了します。",
668
704
"handle": "ハンドル",
669
705
"handlePlaceholder": "あなたの名前",
670
706
"handleHint": "完全なハンドル: @{handle}",
707
+
"contactMethod": "連絡方法",
708
+
"contactMethodHint": "アカウントの確認と通知の受信方法を選択してください。",
709
+
"verificationMethod": "確認方法",
671
710
"email": "メールアドレス",
672
711
"emailPlaceholder": "you@example.com",
712
+
"discord": "Discord",
713
+
"discordId": "Discord ユーザー ID",
714
+
"discordIdPlaceholder": "Discord ユーザー ID",
715
+
"discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)",
716
+
"telegram": "Telegram",
717
+
"telegramUsername": "Telegram ユーザー名",
718
+
"telegramUsernamePlaceholder": "@yourusername",
719
+
"signal": "Signal",
720
+
"signalNumber": "Signal 電話番号",
721
+
"signalNumberPlaceholder": "+81XXXXXXXXXX",
722
+
"signalNumberHint": "国番号を含めてください(例: 日本は +81)",
673
723
"inviteCode": "招待コード",
674
724
"inviteCodePlaceholder": "招待コードを入力",
725
+
"inviteCodeRequired": "必須",
726
+
"didWebDescription": "独自ドメインでホストされる DID アイデンティティを使用します。",
727
+
"didWebToggle": "外部 did:web を使用",
728
+
"externalDid": "あなたの did:web",
729
+
"externalDidPlaceholder": "did:web:yourdomain.com",
730
+
"dnsVerificationInstructions": "ドメインを確認するには、この TXT レコードを追加してください:",
731
+
"copyDid": "DID をコピー",
675
732
"createButton": "アカウントを作成",
676
733
"creating": "作成中...",
677
734
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
678
735
"signIn": "サインイン",
679
736
"wantPassword": "パスワードを使用しますか?",
680
-
"createPasswordAccount": "パスワードアカウントを作成"
737
+
"createPasswordAccount": "パスワードアカウントを作成",
738
+
"errors": {
739
+
"handleRequired": "ハンドルは必須です",
740
+
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
741
+
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
742
+
"passkeyCancelled": "パスキーの作成がキャンセルされました",
743
+
"passkeyFailed": "パスキーの登録に失敗しました"
744
+
}
681
745
},
682
746
"trustedDevices": {
683
747
"title": "信頼済みデバイス",
···
710
774
"verify": "確認",
711
775
"verifying": "確認中...",
712
776
"cancel": "キャンセル"
777
+
},
778
+
"verifyChannel": {
779
+
"title": "チャンネル認証",
780
+
"subtitle": "通知チャンネルに送信された認証コードを入力してください。",
781
+
"signInRequired": "ログインが必要です",
782
+
"signInRequiredDesc": "チャンネルを認証するにはログインが必要です。",
783
+
"signIn": "ログイン",
784
+
"verifying": "認証中...",
785
+
"pleaseWait": "チャンネルを認証しています。しばらくお待ちください。",
786
+
"successTitle": "認証完了!",
787
+
"successDesc": "{channel} が正常に認証されました。",
788
+
"backToSettings": "設定に戻る",
789
+
"channelLabel": "チャンネル",
790
+
"selectChannel": "チャンネルを選択...",
791
+
"identifierLabel": "識別子",
792
+
"identifierPlaceholder": "メール、Discord ID など",
793
+
"identifierHelp": "認証するメールアドレス、Discord ID、Telegram ユーザー名、または Signal 番号。",
794
+
"codeLabel": "認証コード",
795
+
"codeHelp": "メッセージからハイフンを含む完全なコードをコピーしてください。",
796
+
"verifyButton": "認証"
713
797
}
714
798
}
+92
-8
frontend/src/locales/ko.json
+92
-8
frontend/src/locales/ko.json
···
65
65
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
66
66
"didWeb": "did:web",
67
67
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
68
-
"didWebBYOD": "did:web (BYOD)",
68
+
"didWebBYOD": "did:web (자체 도메인)",
69
69
"didWebBYODHint": "자체 도메인 사용",
70
70
"didWebWarningTitle": "중요: 장단점을 이해하세요",
71
71
"didWebWarning1": "이 PDS에 영구 연결:",
···
164
164
"changeEmailButton": "이메일 변경",
165
165
"requesting": "요청 중...",
166
166
"verificationCode": "인증 코드",
167
-
"verificationCodePlaceholder": "이메일의 코드 입력",
167
+
"verificationCodePlaceholder": "인증 코드 입력",
168
168
"confirmEmailChange": "이메일 변경 확인",
169
169
"updating": "업데이트 중...",
170
170
"changeHandle": "핸들 변경",
···
202
202
"deleteAccount": "계정 삭제",
203
203
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
204
204
"requestDeletion": "계정 삭제 요청",
205
-
"confirmationCode": "확인 코드 (이메일에서)",
205
+
"confirmationCode": "확인 코드",
206
206
"confirmationCodePlaceholder": "확인 코드 입력",
207
207
"yourPassword": "비밀번호",
208
208
"yourPasswordPlaceholder": "비밀번호 입력",
209
209
"permanentlyDelete": "계정 영구 삭제",
210
210
"deleting": "삭제 중...",
211
211
"messages": {
212
-
"emailCodeSent": "현재 이메일로 인증 코드를 보냈습니다",
212
+
"emailCodeSent": "알림 채널로 인증 코드를 보냈습니다",
213
213
"emailUpdated": "이메일이 업데이트되었습니다",
214
214
"handleUpdated": "핸들이 업데이트되었습니다",
215
215
"passwordChanged": "비밀번호가 변경되었습니다",
···
451
451
},
452
452
"admin": {
453
453
"title": "관리 패널",
454
+
"loading": "로딩 중...",
455
+
"serverConfig": "서버 설정",
456
+
"serverName": "서버 이름",
457
+
"serverNamePlaceholder": "내 PDS",
458
+
"serverNameHelp": "브라우저 탭 및 다른 곳에 표시됩니다",
459
+
"serverLogo": "서버 로고",
460
+
"logoPreview": "로고 미리보기",
461
+
"removeLogo": "삭제",
462
+
"logoHelp": "파비콘으로 사용되며 네비게이션 바에 표시됩니다",
463
+
"themeColors": "테마 색상",
464
+
"themeColorsHint": "기본 색상을 사용하려면 비워 두세요.",
465
+
"primaryLight": "기본 (라이트 모드)",
466
+
"primaryDark": "기본 (다크 모드)",
467
+
"accentLight": "강조 (라이트 모드)",
468
+
"accentDark": "강조 (다크 모드)",
469
+
"faviconExample": "파비콘 예시",
470
+
"configSaved": "서버 설정이 저장되었습니다",
471
+
"saving": "저장 중...",
472
+
"saveConfig": "설정 저장",
454
473
"serverStats": "서버 통계",
455
474
"users": "사용자",
456
475
"repos": "저장소",
···
580
599
"verify": {
581
600
"title": "계정 인증",
582
601
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.",
583
-
"codePlaceholder": "6자리 코드 입력",
602
+
"tokenTitle": "인증",
603
+
"tokenSubtitle": "인증 코드와 전송된 식별자를 입력하세요.",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
584
605
"codeLabel": "인증 코드",
606
+
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
585
607
"verifyButton": "계정 인증",
608
+
"verify": "인증",
586
609
"verifying": "인증 중...",
610
+
"pleaseWait": "잠시 기다려 주세요...",
611
+
"sending": "전송 중...",
587
612
"resendCode": "코드 다시 보내기",
588
613
"resending": "전송 중...",
589
614
"codeResent": "인증 코드를 다시 보냈습니다!",
615
+
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
616
+
"verified": "인증 완료!",
617
+
"channelVerified": "{channel}이(가) 성공적으로 인증되었습니다.",
618
+
"canNowSignIn": "이제 계정에 로그인할 수 있습니다.",
619
+
"continue": "계속",
620
+
"identifierLabel": "이메일 또는 식별자",
621
+
"identifierPlaceholder": "you@example.com",
622
+
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
590
623
"backToLogin": "로그인으로 돌아가기",
591
624
"verifyingAccount": "인증 중인 계정: @{handle}",
592
625
"startOver": "다른 계정으로 다시 시작",
···
605
638
"sendCode": "재설정 코드 보내기",
606
639
"sending": "전송 중...",
607
640
"codeSent": "비밀번호 재설정 코드를 보냈습니다! 선호하는 알림 채널을 확인하세요.",
608
-
"enterCode": "이메일의 코드와 새 비밀번호를 입력하세요.",
641
+
"enterCode": "받은 코드와 새 비밀번호를 입력하세요.",
609
642
"code": "재설정 코드",
610
643
"codePlaceholder": "재설정 코드 입력",
611
644
"newPassword": "새 비밀번호",
···
664
697
},
665
698
"registerPasskey": {
666
699
"title": "패스키 계정 만들기",
667
-
"subtitle": "패스키를 사용하여 비밀번호 없는 계정을 만듭니다.",
700
+
"subtitle": "비밀번호 대신 패스키를 사용하여 초안전 계정을 만듭니다.",
701
+
"subtitleKeyChoice": "외부 did:web 아이덴티티 설정 방법을 선택하세요.",
702
+
"subtitleVerify": "{channel}(으)로 인증 코드를 보냈습니다. 코드를 입력하여 계속하세요.",
703
+
"subtitlePasskey": "패스키를 만들어 계정 설정을 완료하세요.",
668
704
"handle": "핸들",
669
705
"handlePlaceholder": "사용자 이름",
670
706
"handleHint": "전체 핸들: @{handle}",
707
+
"contactMethod": "연락 방법",
708
+
"contactMethodHint": "계정 인증 및 알림 수신 방법을 선택하세요.",
709
+
"verificationMethod": "인증 방법",
671
710
"email": "이메일 주소",
672
711
"emailPlaceholder": "you@example.com",
712
+
"discord": "Discord",
713
+
"discordId": "Discord 사용자 ID",
714
+
"discordIdPlaceholder": "Discord 사용자 ID",
715
+
"discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)",
716
+
"telegram": "Telegram",
717
+
"telegramUsername": "Telegram 사용자 이름",
718
+
"telegramUsernamePlaceholder": "@yourusername",
719
+
"signal": "Signal",
720
+
"signalNumber": "Signal 전화번호",
721
+
"signalNumberPlaceholder": "+821012345678",
722
+
"signalNumberHint": "국가 코드 포함 (예: 한국 +82)",
673
723
"inviteCode": "초대 코드",
674
724
"inviteCodePlaceholder": "초대 코드 입력",
725
+
"inviteCodeRequired": "필수",
726
+
"didWebDescription": "자체 도메인에서 호스팅되는 DID 아이덴티티를 사용합니다.",
727
+
"didWebToggle": "외부 did:web 사용",
728
+
"externalDid": "귀하의 did:web",
729
+
"externalDidPlaceholder": "did:web:yourdomain.com",
730
+
"dnsVerificationInstructions": "도메인을 인증하려면 이 TXT 레코드를 추가하세요:",
731
+
"copyDid": "DID 복사",
675
732
"createButton": "계정 만들기",
676
733
"creating": "생성 중...",
677
734
"alreadyHaveAccount": "이미 계정이 있으신가요?",
678
735
"signIn": "로그인",
679
736
"wantPassword": "비밀번호를 사용하시겠습니까?",
680
-
"createPasswordAccount": "비밀번호 계정 만들기"
737
+
"createPasswordAccount": "비밀번호 계정 만들기",
738
+
"errors": {
739
+
"handleRequired": "핸들은 필수입니다",
740
+
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
741
+
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
742
+
"passkeyCancelled": "패스키 생성이 취소되었습니다",
743
+
"passkeyFailed": "패스키 등록에 실패했습니다"
744
+
}
681
745
},
682
746
"trustedDevices": {
683
747
"title": "신뢰할 수 있는 기기",
···
710
774
"verify": "확인",
711
775
"verifying": "확인 중...",
712
776
"cancel": "취소"
777
+
},
778
+
"verifyChannel": {
779
+
"title": "채널 인증",
780
+
"subtitle": "알림 채널로 전송된 인증 코드를 입력하세요.",
781
+
"signInRequired": "로그인 필요",
782
+
"signInRequiredDesc": "채널을 인증하려면 로그인해야 합니다.",
783
+
"signIn": "로그인",
784
+
"verifying": "인증 중...",
785
+
"pleaseWait": "채널을 인증하는 중입니다. 잠시 기다려 주세요.",
786
+
"successTitle": "인증 완료!",
787
+
"successDesc": "{channel}이(가) 성공적으로 인증되었습니다.",
788
+
"backToSettings": "설정으로 돌아가기",
789
+
"channelLabel": "채널",
790
+
"selectChannel": "채널 선택...",
791
+
"identifierLabel": "식별자",
792
+
"identifierPlaceholder": "이메일, Discord ID 등",
793
+
"identifierHelp": "인증할 이메일 주소, Discord ID, Telegram 사용자 이름 또는 Signal 번호.",
794
+
"codeLabel": "인증 코드",
795
+
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요.",
796
+
"verifyButton": "인증"
713
797
}
714
798
}
+98
-14
frontend/src/locales/sv.json
+98
-14
frontend/src/locales/sv.json
···
80
80
"externalDidPlaceholder": "did:web:dindomän.se",
81
81
"externalDidHint": "Din domän måste tillhandahålla ett giltigt DID-dokument på /.well-known/did.json som pekar på denna PDS",
82
82
"contactMethod": "Kontaktmetod",
83
-
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot notiser. Du behöver bara en.",
83
+
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot meddelanden. Du behöver bara en.",
84
84
"verificationMethod": "Verifieringsmetod",
85
85
"email": "E-post",
86
86
"emailAddress": "E-postadress",
···
164
164
"changeEmailButton": "Ändra e-post",
165
165
"requesting": "Begär...",
166
166
"verificationCode": "Verifieringskod",
167
-
"verificationCodePlaceholder": "Ange kod från e-post",
167
+
"verificationCodePlaceholder": "Ange verifieringskod",
168
168
"confirmEmailChange": "Bekräfta e-poständring",
169
169
"updating": "Uppdaterar...",
170
170
"changeHandle": "Ändra användarnamn",
···
202
202
"deleteAccount": "Radera konto",
203
203
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
204
204
"requestDeletion": "Begär kontoradering",
205
-
"confirmationCode": "Bekräftelsekod (från e-post)",
205
+
"confirmationCode": "Bekräftelsekod",
206
206
"confirmationCodePlaceholder": "Ange bekräftelsekod",
207
207
"yourPassword": "Ditt lösenord",
208
208
"yourPasswordPlaceholder": "Ange ditt lösenord",
209
209
"permanentlyDelete": "Radera konto permanent",
210
210
"deleting": "Raderar...",
211
211
"messages": {
212
-
"emailCodeSent": "Verifieringskod skickad till din nuvarande e-post",
212
+
"emailCodeSent": "Verifieringskod skickad till din meddelandekanal",
213
213
"emailUpdated": "E-post uppdaterad",
214
214
"handleUpdated": "Användarnamn uppdaterat",
215
215
"passwordChanged": "Lösenord ändrat",
···
350
350
"lastUsed": "Senast använd",
351
351
"passwordDescription": "Hantera ditt kontolösenord. Om du har nycklar konfigurerade kan du valfritt ta bort ditt lösenord för en helt lösenordsfri upplevelse.",
352
352
"disableTotpWarning": "Detta gör ditt konto mindre säkert.",
353
-
"removePasswordWarning": "Detta gör ditt konto till endast nyckelkonto. Du kan endast logga in med dina registrerade nycklar. Om du förlorar tillgång till alla dina nycklar kan du återställa ditt konto via din notifieringskanal.",
353
+
"removePasswordWarning": "Detta gör ditt konto till endast nyckelkonto. Du kan endast logga in med dina registrerade nycklar. Om du förlorar tillgång till alla dina nycklar kan du återställa ditt konto via din meddelandekanal.",
354
354
"beforeProceeding": "Innan du fortsätter:",
355
355
"beforeProceedingItem1": "Se till att du har minst en pålitlig nyckel registrerad",
356
356
"beforeProceedingItem2": "Överväg att registrera nycklar på flera enheter",
357
-
"beforeProceedingItem3": "Se till att din återställningsnotifieringskanal är uppdaterad",
357
+
"beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad",
358
358
"addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.",
359
359
"passkeyOnlyHint": "Du loggar in med endast nycklar. Om du förlorar tillgång till dina nycklar kan du återställa ditt konto med länken \"Tappat bort nyckeln?\" på inloggningssidan.",
360
360
"trustedDevices": "Betrodda enheter",
···
451
451
},
452
452
"admin": {
453
453
"title": "Adminpanel",
454
+
"loading": "Laddar...",
455
+
"serverConfig": "Serverkonfiguration",
456
+
"serverName": "Servernamn",
457
+
"serverNamePlaceholder": "Min PDS",
458
+
"serverNameHelp": "Visas i webbläsarfliken och på andra ställen",
459
+
"serverLogo": "Serverlogotyp",
460
+
"logoPreview": "Förhandsgranskning av logotyp",
461
+
"removeLogo": "Ta bort",
462
+
"logoHelp": "Används som favicon och visas i navigeringsfältet",
463
+
"themeColors": "Temafärger",
464
+
"themeColorsHint": "Lämna tomt för att använda standardfärger.",
465
+
"primaryLight": "Primär (ljust läge)",
466
+
"primaryDark": "Primär (mörkt läge)",
467
+
"accentLight": "Accent (ljust läge)",
468
+
"accentDark": "Accent (mörkt läge)",
469
+
"faviconExample": "Favicon-exempel",
470
+
"configSaved": "Serverkonfiguration sparad",
471
+
"saving": "Sparar...",
472
+
"saveConfig": "Spara konfiguration",
454
473
"serverStats": "Serverstatistik",
455
474
"users": "Användare",
456
475
"repos": "Dataförvar",
···
514
533
"readProfile": "Läsa din profilinformation",
515
534
"readPosts": "Läsa dina inlägg och innehåll",
516
535
"writePosts": "Skapa och radera inlägg för din räkning",
517
-
"readNotifications": "Läsa dina notiser",
536
+
"readNotifications": "Läsa dina aviseringar",
518
537
"fullAccess": "Full tillgång till ditt konto",
519
538
"authorize": "Auktorisera",
520
539
"deny": "Neka",
···
580
599
"verify": {
581
600
"title": "Verifiera ditt konto",
582
601
"subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.",
583
-
"codePlaceholder": "Ange 6-siffrig kod",
602
+
"tokenTitle": "Verifiera",
603
+
"tokenSubtitle": "Ange verifieringskoden och identifieraren den skickades till.",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
584
605
"codeLabel": "Verifieringskod",
606
+
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
585
607
"verifyButton": "Verifiera konto",
608
+
"verify": "Verifiera",
586
609
"verifying": "Verifierar...",
610
+
"pleaseWait": "Vänta...",
611
+
"sending": "Skickar...",
587
612
"resendCode": "Skicka kod igen",
588
613
"resending": "Skickar igen...",
589
614
"codeResent": "Verifieringskod skickad igen!",
615
+
"codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.",
616
+
"verified": "Verifierad!",
617
+
"channelVerified": "Din {channel} har verifierats.",
618
+
"canNowSignIn": "Du kan nu logga in på ditt konto.",
619
+
"continue": "Fortsätt",
620
+
"identifierLabel": "E-post eller identifierare",
621
+
"identifierPlaceholder": "du@exempel.se",
622
+
"identifierHelp": "E-postadressen eller identifieraren koden skickades till",
590
623
"backToLogin": "Tillbaka till inloggning",
591
624
"verifyingAccount": "Verifierar konto: @{handle}",
592
625
"startOver": "Börja om med ett annat konto",
···
604
637
"emailPlaceholder": "användarnamn eller du@exempel.se",
605
638
"sendCode": "Skicka återställningskod",
606
639
"sending": "Skickar...",
607
-
"codeSent": "Återställningskod skickad! Kontrollera din föredragna notifieringskanal.",
608
-
"enterCode": "Ange koden från din e-post och ditt nya lösenord.",
640
+
"codeSent": "Återställningskod skickad! Kontrollera din föredragna meddelandekanal.",
641
+
"enterCode": "Ange koden du fick och ditt nya lösenord.",
609
642
"code": "Återställningskod",
610
643
"codePlaceholder": "Ange återställningskod",
611
644
"newPassword": "Nytt lösenord",
···
652
685
"title": "Återställ nyckelkonto",
653
686
"subtitle": "Förlorat tillgång till din nyckel? Ange ditt användarnamn eller e-post så skickar vi dig en återställningslänk.",
654
687
"successTitle": "Återställningslänk skickad",
655
-
"successMessage": "Om ditt konto finns och är ett endast nyckelkonto får du en återställningslänk på din föredragna notifieringskanal.",
688
+
"successMessage": "Om ditt konto finns och är ett endast nyckelkonto får du en återställningslänk på din föredragna meddelandekanal.",
656
689
"successInfo": "Länken upphör om 1 timme. Kontrollera din e-post, Discord, Telegram eller Signal beroende på dina kontoinställningar.",
657
690
"handleOrEmail": "Användarnamn eller e-post",
658
691
"emailPlaceholder": "användarnamn eller du@exempel.se",
659
692
"howItWorks": "Så fungerar det",
660
-
"howItWorksDetail": "Vi skickar en säker länk till din registrerade notifieringskanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
693
+
"howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
661
694
"sendRecoveryLink": "Skicka återställningslänk",
662
695
"sending": "Skickar...",
663
696
"backToLogin": "Tillbaka till inloggning"
664
697
},
665
698
"registerPasskey": {
666
699
"title": "Skapa nyckelkonto",
667
-
"subtitle": "Skapa ett lösenordsfritt konto med en nyckel.",
700
+
"subtitle": "Skapa ett ultrasäkert konto med en nyckel istället för ett lösenord.",
701
+
"subtitleKeyChoice": "Välj hur du vill konfigurera din externa did:web-identitet.",
702
+
"subtitleVerify": "Vi har skickat en verifieringskod till din {channel}. Ange koden för att fortsätta.",
703
+
"subtitlePasskey": "Skapa din nyckel för att slutföra kontokonfigurationen.",
668
704
"handle": "Användarnamn",
669
705
"handlePlaceholder": "dittnamn",
670
706
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
707
+
"contactMethod": "Kontaktmetod",
708
+
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot meddelanden.",
709
+
"verificationMethod": "Verifieringsmetod",
671
710
"email": "E-postadress",
672
711
"emailPlaceholder": "du@exempel.se",
712
+
"discord": "Discord",
713
+
"discordId": "Discord användar-ID",
714
+
"discordIdPlaceholder": "Ditt Discord användar-ID",
715
+
"discordIdHint": "Ditt numeriska Discord användar-ID (aktivera Utvecklarläge för att hitta det)",
716
+
"telegram": "Telegram",
717
+
"telegramUsername": "Telegram-användarnamn",
718
+
"telegramUsernamePlaceholder": "@dittanvändarnamn",
719
+
"signal": "Signal",
720
+
"signalNumber": "Signal-telefonnummer",
721
+
"signalNumberPlaceholder": "+46701234567",
722
+
"signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)",
673
723
"inviteCode": "Inbjudningskod",
674
724
"inviteCodePlaceholder": "Ange din inbjudningskod",
725
+
"inviteCodeRequired": "krävs",
726
+
"didWebDescription": "Använd en DID-identitet som är lagrad på din egen domän.",
727
+
"didWebToggle": "Använd extern did:web",
728
+
"externalDid": "Din did:web",
729
+
"externalDidPlaceholder": "did:web:dindomän.se",
730
+
"dnsVerificationInstructions": "För att verifiera din domän, lägg till denna TXT-post:",
731
+
"copyDid": "Kopiera DID",
675
732
"createButton": "Skapa konto",
676
733
"creating": "Skapar...",
677
734
"alreadyHaveAccount": "Har du redan ett konto?",
678
735
"signIn": "Logga in",
679
736
"wantPassword": "Vill du använda ett lösenord?",
680
-
"createPasswordAccount": "Skapa ett lösenordskonto"
737
+
"createPasswordAccount": "Skapa ett lösenordskonto",
738
+
"errors": {
739
+
"handleRequired": "Användarnamn krävs",
740
+
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
741
+
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
742
+
"passkeyCancelled": "Nyckelskapande avbröts",
743
+
"passkeyFailed": "Nyckelregistrering misslyckades"
744
+
}
681
745
},
682
746
"trustedDevices": {
683
747
"title": "Betrodda enheter",
···
710
774
"verify": "Verifiera",
711
775
"verifying": "Verifierar...",
712
776
"cancel": "Avbryt"
777
+
},
778
+
"verifyChannel": {
779
+
"title": "Verifiera kanal",
780
+
"subtitle": "Ange verifieringskoden som skickades till din meddelandekanal.",
781
+
"signInRequired": "Inloggning krävs",
782
+
"signInRequiredDesc": "Du måste vara inloggad för att verifiera en kanal.",
783
+
"signIn": "Logga in",
784
+
"verifying": "Verifierar...",
785
+
"pleaseWait": "Vänta medan vi verifierar din kanal.",
786
+
"successTitle": "Verifierad!",
787
+
"successDesc": "Din {channel} har verifierats.",
788
+
"backToSettings": "Tillbaka till inställningar",
789
+
"channelLabel": "Kanal",
790
+
"selectChannel": "Välj kanal...",
791
+
"identifierLabel": "Identifierare",
792
+
"identifierPlaceholder": "E-post, Discord ID, etc.",
793
+
"identifierHelp": "E-postadressen, Discord ID, Telegram-användarnamn eller Signal-nummer som verifieras.",
794
+
"codeLabel": "Verifieringskod",
795
+
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck.",
796
+
"verifyButton": "Verifiera"
713
797
}
714
798
}
+130
-8
frontend/src/locales/zh.json
+130
-8
frontend/src/locales/zh.json
···
164
164
"changeEmailButton": "更改邮箱",
165
165
"requesting": "请求中...",
166
166
"verificationCode": "验证码",
167
-
"verificationCodePlaceholder": "输入邮件中的验证码",
167
+
"verificationCodePlaceholder": "输入验证码",
168
168
"confirmEmailChange": "确认更改邮箱",
169
169
"updating": "更新中...",
170
170
"changeHandle": "更改用户名",
···
202
202
"deleteAccount": "删除账户",
203
203
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
204
204
"requestDeletion": "请求删除账户",
205
-
"confirmationCode": "确认码(来自邮件)",
205
+
"confirmationCode": "确认码",
206
206
"confirmationCodePlaceholder": "输入确认码",
207
207
"yourPassword": "您的密码",
208
208
"yourPasswordPlaceholder": "输入您的密码",
209
209
"permanentlyDelete": "永久删除账户",
210
210
"deleting": "删除中...",
211
211
"messages": {
212
-
"emailCodeSent": "验证码已发送到您当前的邮箱",
212
+
"emailCodeSent": "验证码已发送到您的通知渠道",
213
213
"emailUpdated": "邮箱更新成功",
214
214
"handleUpdated": "用户名更新成功",
215
215
"passwordChanged": "密码更改成功",
···
451
451
},
452
452
"admin": {
453
453
"title": "管理后台",
454
+
"loading": "加载中...",
455
+
"serverConfig": "服务器配置",
456
+
"serverName": "服务器名称",
457
+
"serverNamePlaceholder": "我的 PDS",
458
+
"serverNameHelp": "显示在浏览器标签和其他地方",
459
+
"serverLogo": "服务器图标",
460
+
"logoPreview": "图标预览",
461
+
"removeLogo": "移除",
462
+
"logoHelp": "用作网站图标和导航栏显示",
463
+
"themeColors": "主题颜色",
464
+
"themeColorsHint": "留空使用默认颜色。",
465
+
"primaryLight": "主色(浅色模式)",
466
+
"primaryLightDefault": "#2c00ff(默认)",
467
+
"primaryDark": "主色(深色模式)",
468
+
"primaryDarkDefault": "#7b6bff(默认)",
469
+
"secondaryLight": "副色(浅色模式)",
470
+
"secondaryLightDefault": "#ff2400(默认)",
471
+
"secondaryDark": "副色(深色模式)",
472
+
"secondaryDarkDefault": "#ff6b5b(默认)",
473
+
"configSaved": "服务器配置已保存",
474
+
"saving": "保存中...",
475
+
"saveConfig": "保存配置",
454
476
"serverStats": "服务器统计",
455
477
"users": "用户",
456
478
"repos": "仓库",
···
580
602
"verify": {
581
603
"title": "验证账户",
582
604
"subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。",
583
-
"codePlaceholder": "输入6位验证码",
605
+
"tokenSubtitle": "输入验证码和接收验证码的标识符。",
606
+
"tokenTitle": "验证",
607
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
584
608
"codeLabel": "验证码",
609
+
"codeHelp": "复制消息中的完整验证码,包括横线",
585
610
"verifyButton": "验证账户",
611
+
"verify": "验证",
586
612
"verifying": "验证中...",
613
+
"pleaseWait": "请稍候...",
587
614
"resendCode": "重新发送验证码",
588
615
"resending": "发送中...",
616
+
"sending": "发送中...",
589
617
"codeResent": "验证码已重新发送!",
618
+
"codeResentDetail": "验证码已发送!请查收。",
590
619
"backToLogin": "返回登录",
591
620
"verifyingAccount": "正在验证账户:@{handle}",
592
621
"startOver": "使用其他账户重新开始",
593
622
"noPending": "未找到待验证的账户",
594
623
"noPendingInfo": "如果您最近创建了账户需要验证,可能需要重新创建账户。如果您已完成验证,可以直接登录。",
595
624
"createAccount": "创建账户",
596
-
"signIn": "登录"
625
+
"signIn": "登录",
626
+
"verified": "验证成功!",
627
+
"channelVerified": "您的{channel}已成功验证。",
628
+
"canNowSignIn": "您现在可以登录账户。",
629
+
"continue": "继续",
630
+
"identifierLabel": "邮箱或标识符",
631
+
"identifierPlaceholder": "you@example.com",
632
+
"identifierHelp": "接收验证码的邮箱地址或标识符"
597
633
},
598
634
"resetPassword": {
599
635
"title": "重置密码",
···
605
641
"sendCode": "发送重置验证码",
606
642
"sending": "发送中...",
607
643
"codeSent": "重置验证码已发送!请检查您的首选通知渠道。",
608
-
"enterCode": "输入邮件中的验证码和新密码。",
644
+
"enterCode": "输入您收到的验证码和新密码。",
609
645
"code": "重置验证码",
610
646
"codePlaceholder": "输入重置验证码",
611
647
"newPassword": "新密码",
···
664
700
},
665
701
"registerPasskey": {
666
702
"title": "创建通行密钥账户",
667
-
"subtitle": "使用通行密钥创建无密码账户。",
703
+
"subtitle": "使用通行密钥创建超安全账户,无需密码。",
704
+
"subtitleKeyChoice": "选择如何设置您的外部 did:web 身份。",
705
+
"subtitleInitialDidDoc": "上传您的 DID 文档以继续。",
706
+
"subtitleCreating": "正在创建您的账户...",
707
+
"subtitlePasskey": "注册通行密钥以保护您的账户。",
708
+
"subtitleAppPassword": "保存您的应用专用密码以使用第三方应用。",
709
+
"subtitleVerify": "验证您的{channel}以继续。",
710
+
"subtitleUpdatedDidDoc": "使用 PDS 签名密钥更新您的 DID 文档。",
711
+
"subtitleActivating": "正在激活您的账户...",
712
+
"subtitleComplete": "您的账户已成功创建!",
668
713
"handle": "用户名",
669
714
"handlePlaceholder": "您的用户名",
670
715
"handleHint": "您的完整用户名将是:@{handle}",
716
+
"handleDotWarning": "可以在创建账户后设置自定义域名。",
671
717
"email": "邮箱地址",
672
718
"emailPlaceholder": "you@example.com",
673
719
"inviteCode": "邀请码",
674
720
"inviteCodePlaceholder": "输入您的邀请码",
675
721
"createButton": "创建账户",
676
722
"creating": "创建中...",
723
+
"continue": "继续",
724
+
"back": "返回",
677
725
"alreadyHaveAccount": "已有账户?",
678
726
"signIn": "立即登录",
679
727
"wantPassword": "想使用密码?",
680
-
"createPasswordAccount": "创建密码账户"
728
+
"createPasswordAccount": "创建密码账户",
729
+
"wantTraditional": "想使用传统密码?",
730
+
"registerWithPassword": "使用密码注册",
731
+
"contactMethod": "联系方式",
732
+
"contactMethodHint": "选择您希望如何验证账户和接收通知。",
733
+
"verificationMethod": "验证方式",
734
+
"identityType": "身份类型",
735
+
"identityTypeHint": "选择如何管理您的去中心化身份。",
736
+
"didPlcRecommended": "did:plc(推荐)",
737
+
"didPlcHint": "由 PLC 目录管理的可迁移身份",
738
+
"didWeb": "did:web",
739
+
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
740
+
"didWebBYOD": "did:web(自带域名)",
741
+
"didWebBYODHint": "使用您自己的域名",
742
+
"didWebWarningTitle": "重要:了解利弊",
743
+
"didWebWarning1": "永久绑定此 PDS:",
744
+
"didWebWarning2": "无法恢复:",
745
+
"didWebWarning2Detail": "与 did:plc 不同,did:web 没有密钥轮换机制。",
746
+
"didWebWarning3": "我们的承诺:",
747
+
"didWebWarning3Detail": "如果您迁移到其他 PDS,我们将继续提供最小 DID 文档。",
748
+
"didWebWarning4": "建议:",
749
+
"didWebWarning4Detail": "除非有特定原因,否则请选择 did:plc。",
750
+
"externalDid": "您的 did:web",
751
+
"externalDidPlaceholder": "did:web:yourdomain.com",
752
+
"externalDidHint": "您需要在以下地址提供 DID 文档",
753
+
"whyPasskeyOnly": "为什么选择仅通行密钥?",
754
+
"whyPasskeyOnlyDesc": "通行密钥账户比密码账户更安全,因为它们:",
755
+
"whyPasskeyBullet1": "无法被钓鱼或在数据泄露中被盗",
756
+
"whyPasskeyBullet2": "使用硬件支持的加密密钥",
757
+
"whyPasskeyBullet3": "需要您的生物识别或设备 PIN 才能使用",
758
+
"passkeyNameLabel": "通行密钥名称(可选)",
759
+
"passkeyNamePlaceholder": "如 MacBook Touch ID",
760
+
"passkeyNameHint": "用于识别此通行密钥的友好名称",
761
+
"passkeyPrompt": "点击下方按钮创建通行密钥。系统会提示您使用:",
762
+
"passkeyPromptBullet1": "Touch ID 或 Face ID",
763
+
"passkeyPromptBullet2": "设备 PIN 或密码",
764
+
"passkeyPromptBullet3": "安全密钥(如果有的话)",
765
+
"createPasskey": "创建通行密钥",
766
+
"creatingPasskey": "正在创建通行密钥...",
767
+
"redirecting": "正在跳转到控制台...",
768
+
"loading": "加载中...",
769
+
"errors": {
770
+
"handleRequired": "请输入用户名",
771
+
"handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。",
772
+
"inviteRequired": "请输入邀请码",
773
+
"externalDidRequired": "请输入您的 did:web",
774
+
"externalDidFormat": "DID 必须以 did:web: 开头",
775
+
"emailRequired": "使用邮箱验证需要填写邮箱地址",
776
+
"discordRequired": "使用 Discord 验证需要填写 Discord ID",
777
+
"telegramRequired": "使用 Telegram 验证需要填写用户名",
778
+
"signalRequired": "使用 Signal 验证需要填写电话号码",
779
+
"passkeysNotSupported": "此浏览器不支持通行密钥。请使用其他浏览器或使用密码注册。",
780
+
"passkeyCancelled": "通行密钥创建已取消",
781
+
"passkeyFailed": "通行密钥注册失败"
782
+
}
681
783
},
682
784
"trustedDevices": {
683
785
"title": "受信任设备",
···
710
812
"verify": "验证",
711
813
"verifying": "验证中...",
712
814
"cancel": "取消"
815
+
},
816
+
"verifyChannel": {
817
+
"title": "验证通道",
818
+
"subtitle": "输入发送到您通知通道的验证码。",
819
+
"signInRequired": "需要登录",
820
+
"signInRequiredDesc": "您必须登录才能验证通道。",
821
+
"signIn": "登录",
822
+
"verifying": "验证中...",
823
+
"pleaseWait": "请稍候,正在验证您的通道。",
824
+
"successTitle": "验证成功!",
825
+
"successDesc": "您的 {channel} 已成功验证。",
826
+
"backToSettings": "返回设置",
827
+
"channelLabel": "通道",
828
+
"selectChannel": "选择通道...",
829
+
"identifierLabel": "标识符",
830
+
"identifierPlaceholder": "邮箱、Discord ID 等",
831
+
"identifierHelp": "正在验证的邮箱地址、Discord ID、Telegram 用户名或 Signal 号码。",
832
+
"codeLabel": "验证码",
833
+
"codeHelp": "复制消息中的完整验证码,包括横线。",
834
+
"verifyButton": "验证"
713
835
}
714
836
}
+71
-71
frontend/src/routes/Admin.svelte
+71
-71
frontend/src/routes/Admin.svelte
···
302
302
{#if auth.session?.isAdmin}
303
303
<div class="page">
304
304
<header>
305
-
<a href="#/dashboard" class="back">← Dashboard</a>
306
-
<h1>Admin Panel</h1>
305
+
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
306
+
<h1>{$_('admin.title')}</h1>
307
307
</header>
308
308
{#if loading}
309
-
<p class="loading">Loading...</p>
309
+
<p class="loading">{$_('admin.loading')}</p>
310
310
{:else}
311
311
{#if error}
312
312
<div class="message error">{error}</div>
313
313
{/if}
314
314
<section>
315
-
<h2>Server Configuration</h2>
315
+
<h2>{$_('admin.serverConfig')}</h2>
316
316
<form class="config-form" onsubmit={saveServerConfig}>
317
317
<div class="form-group">
318
-
<label for="serverName">Server Name</label>
318
+
<label for="serverName">{$_('admin.serverName')}</label>
319
319
<input
320
320
type="text"
321
321
id="serverName"
322
322
bind:value={serverNameInput}
323
-
placeholder="My PDS"
323
+
placeholder={$_('admin.serverNamePlaceholder')}
324
324
maxlength="100"
325
325
disabled={serverConfigLoading}
326
326
/>
327
-
<span class="help-text">Displayed in the browser tab and other places</span>
327
+
<span class="help-text">{$_('admin.serverNameHelp')}</span>
328
328
</div>
329
329
330
330
<div class="form-group">
331
-
<label for="serverLogo">Server Logo</label>
331
+
<label for="serverLogo">{$_('admin.serverLogo')}</label>
332
332
<div class="logo-upload">
333
333
{#if logoPreview}
334
334
<div class="logo-preview">
335
-
<img src={logoPreview} alt="Logo preview" />
336
-
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button>
335
+
<img src={logoPreview} alt={$_('admin.logoPreview')} />
336
+
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>{$_('admin.removeLogo')}</button>
337
337
</div>
338
338
{:else}
339
339
<input
···
345
345
/>
346
346
{/if}
347
347
</div>
348
-
<span class="help-text">Used as favicon and shown in the navbar</span>
348
+
<span class="help-text">{$_('admin.logoHelp')}</span>
349
349
</div>
350
350
351
-
<h3 class="subsection-title">Theme Colors</h3>
352
-
<p class="theme-hint">Leave blank to use default colors.</p>
351
+
<h3 class="subsection-title">{$_('admin.themeColors')}</h3>
352
+
<p class="theme-hint">{$_('admin.themeColorsHint')}</p>
353
353
354
354
<div class="color-grid">
355
355
<div class="color-group">
356
-
<label for="primaryColor">Primary (Light Mode)</label>
356
+
<label for="primaryColor">{$_('admin.primaryLight')}</label>
357
357
<div class="color-input-row">
358
358
<input
359
359
type="color"
···
364
364
type="text"
365
365
id="primaryColor"
366
366
bind:value={primaryColorInput}
367
-
placeholder="#2c00ff (default)"
367
+
placeholder={$_('admin.primaryLightDefault')}
368
368
disabled={serverConfigLoading}
369
369
/>
370
370
</div>
371
371
</div>
372
372
<div class="color-group">
373
-
<label for="primaryColorDark">Primary (Dark Mode)</label>
373
+
<label for="primaryColorDark">{$_('admin.primaryDark')}</label>
374
374
<div class="color-input-row">
375
375
<input
376
376
type="color"
···
381
381
type="text"
382
382
id="primaryColorDark"
383
383
bind:value={primaryColorDarkInput}
384
-
placeholder="#7b6bff (default)"
384
+
placeholder={$_('admin.primaryDarkDefault')}
385
385
disabled={serverConfigLoading}
386
386
/>
387
387
</div>
388
388
</div>
389
389
<div class="color-group">
390
-
<label for="secondaryColor">Secondary (Light Mode)</label>
390
+
<label for="secondaryColor">{$_('admin.secondaryLight')}</label>
391
391
<div class="color-input-row">
392
392
<input
393
393
type="color"
···
398
398
type="text"
399
399
id="secondaryColor"
400
400
bind:value={secondaryColorInput}
401
-
placeholder="#ff2400 (default)"
401
+
placeholder={$_('admin.secondaryLightDefault')}
402
402
disabled={serverConfigLoading}
403
403
/>
404
404
</div>
405
405
</div>
406
406
<div class="color-group">
407
-
<label for="secondaryColorDark">Secondary (Dark Mode)</label>
407
+
<label for="secondaryColorDark">{$_('admin.secondaryDark')}</label>
408
408
<div class="color-input-row">
409
409
<input
410
410
type="color"
···
415
415
type="text"
416
416
id="secondaryColorDark"
417
417
bind:value={secondaryColorDarkInput}
418
-
placeholder="#ff6b5b (default)"
418
+
placeholder={$_('admin.secondaryDarkDefault')}
419
419
disabled={serverConfigLoading}
420
420
/>
421
421
</div>
···
426
426
<div class="message error">{serverConfigError}</div>
427
427
{/if}
428
428
{#if serverConfigSuccess}
429
-
<div class="message success">Server configuration saved</div>
429
+
<div class="message success">{$_('admin.configSaved')}</div>
430
430
{/if}
431
431
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
432
-
{serverConfigLoading ? 'Saving...' : 'Save Configuration'}
432
+
{serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')}
433
433
</button>
434
434
</form>
435
435
</section>
436
436
{#if stats}
437
437
<section>
438
-
<h2>Server Statistics</h2>
438
+
<h2>{$_('admin.serverStats')}</h2>
439
439
<div class="stats-grid">
440
440
<div class="stat-card">
441
441
<div class="stat-value">{formatNumber(stats.userCount)}</div>
442
-
<div class="stat-label">Users</div>
442
+
<div class="stat-label">{$_('admin.users')}</div>
443
443
</div>
444
444
<div class="stat-card">
445
445
<div class="stat-value">{formatNumber(stats.repoCount)}</div>
446
-
<div class="stat-label">Repositories</div>
446
+
<div class="stat-label">{$_('admin.repos')}</div>
447
447
</div>
448
448
<div class="stat-card">
449
449
<div class="stat-value">{formatNumber(stats.recordCount)}</div>
450
-
<div class="stat-label">Records</div>
450
+
<div class="stat-label">{$_('admin.records')}</div>
451
451
</div>
452
452
<div class="stat-card">
453
453
<div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
454
-
<div class="stat-label">Blob Storage</div>
454
+
<div class="stat-label">{$_('admin.blobStorage')}</div>
455
455
</div>
456
456
</div>
457
-
<button class="refresh-btn" onclick={loadStats}>Refresh Stats</button>
457
+
<button class="refresh-btn" onclick={loadStats}>{$_('admin.refreshStats')}</button>
458
458
</section>
459
459
{/if}
460
460
<section>
461
-
<h2>User Management</h2>
461
+
<h2>{$_('admin.userManagement')}</h2>
462
462
<form class="search-form" onsubmit={handleSearch}>
463
463
<input
464
464
type="text"
465
465
bind:value={handleSearchQuery}
466
-
placeholder="Search by handle (optional)"
466
+
placeholder={$_('admin.searchPlaceholder')}
467
467
disabled={usersLoading}
468
468
/>
469
469
<button type="submit" disabled={usersLoading}>
470
-
{usersLoading ? 'Loading...' : 'Search Users'}
470
+
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
471
471
</button>
472
472
</form>
473
473
{#if usersError}
···
476
476
{#if showUsers}
477
477
<div class="user-list">
478
478
{#if users.length === 0}
479
-
<p class="no-results">No users found</p>
479
+
<p class="no-results">{$_('admin.noUsers')}</p>
480
480
{:else}
481
481
<table>
482
482
<thead>
483
483
<tr>
484
-
<th>Handle</th>
485
-
<th>Email</th>
486
-
<th>Status</th>
487
-
<th>Created</th>
484
+
<th>{$_('admin.handle')}</th>
485
+
<th>{$_('admin.email')}</th>
486
+
<th>{$_('admin.status')}</th>
487
+
<th>{$_('admin.created')}</th>
488
488
</tr>
489
489
</thead>
490
490
<tbody>
···
494
494
<td class="email">{user.email || '-'}</td>
495
495
<td>
496
496
{#if user.deactivatedAt}
497
-
<span class="badge deactivated">Deactivated</span>
497
+
<span class="badge deactivated">{$_('admin.deactivated')}</span>
498
498
{:else if user.emailConfirmedAt}
499
-
<span class="badge verified">Verified</span>
499
+
<span class="badge verified">{$_('admin.verified')}</span>
500
500
{:else}
501
-
<span class="badge unverified">Unverified</span>
501
+
<span class="badge unverified">{$_('admin.unverified')}</span>
502
502
{/if}
503
503
</td>
504
504
<td class="date">{formatDate(user.indexedAt)}</td>
···
508
508
</table>
509
509
{#if usersCursor}
510
510
<button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
511
-
{usersLoading ? 'Loading...' : 'Load More'}
511
+
{usersLoading ? $_('admin.loading') : $_('admin.loadMore')}
512
512
</button>
513
513
{/if}
514
514
{/if}
···
516
516
{/if}
517
517
</section>
518
518
<section>
519
-
<h2>Invite Codes</h2>
519
+
<h2>{$_('admin.inviteCodes')}</h2>
520
520
<div class="section-actions">
521
521
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
522
-
{invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'}
522
+
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
523
523
</button>
524
524
</div>
525
525
{#if invitesError}
···
528
528
{#if showInvites}
529
529
<div class="invite-list">
530
530
{#if invites.length === 0}
531
-
<p class="no-results">No invite codes found</p>
531
+
<p class="no-results">{$_('admin.noInvites')}</p>
532
532
{:else}
533
533
<table>
534
534
<thead>
535
535
<tr>
536
-
<th>Code</th>
537
-
<th>Available</th>
538
-
<th>Uses</th>
539
-
<th>Status</th>
540
-
<th>Created</th>
541
-
<th>Actions</th>
536
+
<th>{$_('admin.code')}</th>
537
+
<th>{$_('admin.available')}</th>
538
+
<th>{$_('admin.uses')}</th>
539
+
<th>{$_('admin.status')}</th>
540
+
<th>{$_('admin.created')}</th>
541
+
<th>{$_('admin.actions')}</th>
542
542
</tr>
543
543
</thead>
544
544
<tbody>
···
549
549
<td>{invite.uses.length}</td>
550
550
<td>
551
551
{#if invite.disabled}
552
-
<span class="badge deactivated">Disabled</span>
552
+
<span class="badge deactivated">{$_('admin.disabled')}</span>
553
553
{:else if invite.available === 0}
554
-
<span class="badge unverified">Exhausted</span>
554
+
<span class="badge unverified">{$_('admin.exhausted')}</span>
555
555
{:else}
556
-
<span class="badge verified">Active</span>
556
+
<span class="badge verified">{$_('admin.active')}</span>
557
557
{/if}
558
558
</td>
559
559
<td class="date">{formatDate(invite.createdAt)}</td>
560
560
<td>
561
561
{#if !invite.disabled}
562
562
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
563
-
Disable
563
+
{$_('admin.disable')}
564
564
</button>
565
565
{:else}
566
566
<span class="muted">-</span>
···
572
572
</table>
573
573
{#if invitesCursor}
574
574
<button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
575
-
{invitesLoading ? 'Loading...' : 'Load More'}
575
+
{invitesLoading ? $_('admin.loading') : $_('admin.loadMore')}
576
576
</button>
577
577
{/if}
578
578
{/if}
···
585
585
<div class="modal-overlay" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
586
586
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
587
587
<div class="modal-header">
588
-
<h2>User Details</h2>
588
+
<h2>{$_('admin.userDetails')}</h2>
589
589
<button class="close-btn" onclick={closeUserDetail}>×</button>
590
590
</div>
591
591
{#if userDetailLoading}
592
-
<p class="loading">Loading...</p>
592
+
<p class="loading">{$_('admin.loading')}</p>
593
593
{:else}
594
594
<div class="modal-body">
595
595
<dl class="user-details">
596
-
<dt>Handle</dt>
596
+
<dt>{$_('admin.handle')}</dt>
597
597
<dd>@{selectedUser.handle}</dd>
598
-
<dt>DID</dt>
598
+
<dt>{$_('admin.did')}</dt>
599
599
<dd class="mono">{selectedUser.did}</dd>
600
-
<dt>Email</dt>
600
+
<dt>{$_('admin.email')}</dt>
601
601
<dd>{selectedUser.email || '-'}</dd>
602
-
<dt>Status</dt>
602
+
<dt>{$_('admin.status')}</dt>
603
603
<dd>
604
604
{#if selectedUser.deactivatedAt}
605
-
<span class="badge deactivated">Deactivated</span>
605
+
<span class="badge deactivated">{$_('admin.deactivated')}</span>
606
606
{:else if selectedUser.emailConfirmedAt}
607
-
<span class="badge verified">Verified</span>
607
+
<span class="badge verified">{$_('admin.verified')}</span>
608
608
{:else}
609
-
<span class="badge unverified">Unverified</span>
609
+
<span class="badge unverified">{$_('admin.unverified')}</span>
610
610
{/if}
611
611
</dd>
612
-
<dt>Created</dt>
612
+
<dt>{$_('admin.created')}</dt>
613
613
<dd>{formatDateTime(selectedUser.indexedAt)}</dd>
614
-
<dt>Invites</dt>
614
+
<dt>{$_('admin.invites')}</dt>
615
615
<dd>
616
616
{#if selectedUser.invitesDisabled}
617
-
<span class="badge deactivated">Disabled</span>
617
+
<span class="badge deactivated">{$_('admin.disabled')}</span>
618
618
{:else}
619
-
<span class="badge verified">Enabled</span>
619
+
<span class="badge verified">{$_('admin.enabled')}</span>
620
620
{/if}
621
621
</dd>
622
622
</dl>
···
626
626
onclick={toggleUserInvites}
627
627
disabled={userActionLoading}
628
628
>
629
-
{selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'}
629
+
{selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')}
630
630
</button>
631
631
<button
632
632
class="action-btn danger"
633
633
onclick={deleteUser}
634
634
disabled={userActionLoading}
635
635
>
636
-
Delete Account
636
+
{$_('admin.deleteAccount')}
637
637
</button>
638
638
</div>
639
639
</div>
···
642
642
</div>
643
643
{/if}
644
644
{:else if auth.loading}
645
-
<div class="loading">Loading...</div>
645
+
<div class="loading">{$_('admin.loading')}</div>
646
646
{/if}
647
647
<style>
648
648
.page {
+10
-1
frontend/src/routes/Comms.svelte
+10
-1
frontend/src/routes/Comms.svelte
···
93
93
if (!auth.session || !verificationCode) return
94
94
verificationError = null
95
95
verificationSuccess = null
96
+
97
+
let identifier = ''
98
+
switch (channel) {
99
+
case 'discord': identifier = discordId; break
100
+
case 'telegram': identifier = telegramUsername; break
101
+
case 'signal': identifier = signalNumber; break
102
+
}
103
+
if (!identifier) return
104
+
96
105
try {
97
-
await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode)
106
+
await api.confirmChannelVerification(auth.session.accessJwt, channel, identifier, verificationCode)
98
107
await refreshSession()
99
108
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
100
109
verificationCode = ''
+10
-4
frontend/src/routes/Register.svelte
+10
-4
frontend/src/routes/Register.svelte
···
33
33
}
34
34
})
35
35
36
+
let creatingStarted = false
37
+
$effect(() => {
38
+
if (flow?.state.step === 'creating' && !creatingStarted) {
39
+
creatingStarted = true
40
+
flow.createPasswordAccount()
41
+
}
42
+
})
43
+
36
44
async function loadServerInfo() {
37
45
try {
38
46
serverInfo = await api.describeServer()
···
140
148
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
141
149
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
142
150
case 'activating': return 'Activating your account...'
143
-
case 'complete': return 'Your account has been created successfully!'
151
+
case 'redirect-to-dashboard': return 'Your account has been created successfully!'
144
152
default: return ''
145
153
}
146
154
}
···
383
391
/>
384
392
385
393
{:else if flow.state.step === 'creating'}
386
-
{#await flow.createPasswordAccount()}
387
-
<p class="loading">{$_('register.creating')}</p>
388
-
{/await}
394
+
<p class="loading">{$_('register.creating')}</p>
389
395
390
396
{:else if flow.state.step === 'verify'}
391
397
<VerificationStep {flow} />
+96
-90
frontend/src/routes/RegisterPasskey.svelte
+96
-90
frontend/src/routes/RegisterPasskey.svelte
···
34
34
}
35
35
})
36
36
37
+
let creatingStarted = false
38
+
$effect(() => {
39
+
if (flow?.state.step === 'creating' && !creatingStarted) {
40
+
creatingStarted = true
41
+
flow.createPasskeyAccount()
42
+
}
43
+
})
44
+
37
45
async function loadServerInfo() {
38
46
try {
39
47
serverInfo = await api.describeServer()
···
49
57
function validateInfoStep(): string | null {
50
58
if (!flow) return 'Flow not initialized'
51
59
const info = flow.info
52
-
if (!info.handle.trim()) return 'Handle is required'
53
-
if (info.handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.'
60
+
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
61
+
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
54
62
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
55
-
return 'Invite code is required'
63
+
return $_('registerPasskey.errors.inviteRequired')
56
64
}
57
65
if (info.didType === 'web-external') {
58
-
if (!info.externalDid?.trim()) return 'External did:web is required'
59
-
if (!info.externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
66
+
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
67
+
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
60
68
}
61
69
switch (info.verificationChannel) {
62
70
case 'email':
63
-
if (!info.email.trim()) return 'Email is required for email verification'
71
+
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
64
72
break
65
73
case 'discord':
66
-
if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
74
+
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
67
75
break
68
76
case 'telegram':
69
-
if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
77
+
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
70
78
break
71
79
case 'signal':
72
-
if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
80
+
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
73
81
break
74
82
}
75
83
return null
···
121
129
}
122
130
123
131
if (!window.PublicKeyCredential) {
124
-
flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
132
+
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
125
133
return
126
134
}
127
135
···
153
161
})
154
162
155
163
if (!credential) {
156
-
flow.setError('Passkey creation was cancelled')
164
+
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
157
165
flow.setSubmitting(false)
158
166
return
159
167
}
···
180
188
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
181
189
} catch (err) {
182
190
if (err instanceof DOMException && err.name === 'NotAllowedError') {
183
-
flow.setError('Passkey creation was cancelled')
191
+
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
184
192
} else if (err instanceof ApiError) {
185
-
flow.setError(err.message || 'Passkey registration failed')
193
+
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
186
194
} else if (err instanceof Error) {
187
-
flow.setError(err.message || 'Passkey registration failed')
195
+
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
188
196
} else {
189
-
flow.setError('Passkey registration failed')
197
+
flow.setError($_('registerPasskey.errors.passkeyFailed'))
190
198
}
191
199
} finally {
192
200
flow.setSubmitting(false)
···
207
215
208
216
function channelLabel(ch: string): string {
209
217
switch (ch) {
210
-
case 'email': return 'Email'
211
-
case 'discord': return 'Discord'
212
-
case 'telegram': return 'Telegram'
213
-
case 'signal': return 'Signal'
218
+
case 'email': return $_('register.email')
219
+
case 'discord': return $_('register.discord')
220
+
case 'telegram': return $_('register.telegram')
221
+
case 'signal': return $_('register.signal')
214
222
default: return ch
215
223
}
216
224
}
···
230
238
function getSubtitle(): string {
231
239
if (!flow) return ''
232
240
switch (flow.state.step) {
233
-
case 'info': return 'Create an ultra-secure account using a passkey instead of a password.'
234
-
case 'key-choice': return 'Choose how to set up your external did:web identity.'
235
-
case 'initial-did-doc': return 'Upload your DID document to continue.'
236
-
case 'creating': return 'Creating your account...'
237
-
case 'passkey': return 'Register your passkey to secure your account.'
238
-
case 'app-password': return 'Save your app password for third-party apps.'
239
-
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
240
-
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
241
-
case 'activating': return 'Activating your account...'
242
-
case 'complete': return 'Your account has been created successfully!'
241
+
case 'info': return $_('registerPasskey.subtitle')
242
+
case 'key-choice': return $_('registerPasskey.subtitleKeyChoice')
243
+
case 'initial-did-doc': return $_('registerPasskey.subtitleInitialDidDoc')
244
+
case 'creating': return $_('registerPasskey.subtitleCreating')
245
+
case 'passkey': return $_('registerPasskey.subtitlePasskey')
246
+
case 'app-password': return $_('registerPasskey.subtitleAppPassword')
247
+
case 'verify': return $_('registerPasskey.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
248
+
case 'updated-did-doc': return $_('registerPasskey.subtitleUpdatedDidDoc')
249
+
case 'activating': return $_('registerPasskey.subtitleActivating')
250
+
case 'redirect-to-dashboard': return $_('registerPasskey.subtitleComplete')
243
251
default: return ''
244
252
}
245
253
}
···
259
267
</div>
260
268
{/if}
261
269
262
-
<h1>Create Passkey Account</h1>
270
+
<h1>{$_('registerPasskey.title')}</h1>
263
271
<p class="subtitle">{getSubtitle()}</p>
264
272
265
273
{#if flow?.state.error}
···
267
275
{/if}
268
276
269
277
{#if loadingServerInfo || !flow}
270
-
<p class="loading">Loading...</p>
278
+
<p class="loading">{$_('registerPasskey.loading')}</p>
271
279
272
280
{:else if flow.state.step === 'info'}
273
281
<form onsubmit={handleInfoSubmit}>
274
282
<div class="field">
275
-
<label for="handle">Handle</label>
283
+
<label for="handle">{$_('registerPasskey.handle')}</label>
276
284
<input
277
285
id="handle"
278
286
type="text"
279
287
bind:value={flow.info.handle}
280
-
placeholder="yourname"
288
+
placeholder={$_('registerPasskey.handlePlaceholder')}
281
289
disabled={flow.state.submitting}
282
290
required
283
291
/>
284
292
{#if flow.info.handle.includes('.')}
285
-
<p class="hint warning">Custom domain handles can be set up after account creation.</p>
293
+
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
286
294
{:else if fullHandle()}
287
-
<p class="hint">Your full handle will be: @{fullHandle()}</p>
295
+
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
288
296
{/if}
289
297
</div>
290
298
291
299
<fieldset class="section-fieldset">
292
-
<legend>Contact Method</legend>
293
-
<p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p>
300
+
<legend>{$_('registerPasskey.contactMethod')}</legend>
301
+
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
294
302
<div class="field">
295
-
<label for="verification-channel">Verification Method</label>
303
+
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
296
304
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
297
-
<option value="email">Email</option>
305
+
<option value="email">{$_('register.email')}</option>
298
306
<option value="discord" disabled={!isChannelAvailable('discord')}>
299
-
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
307
+
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
300
308
</option>
301
309
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
302
-
Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
310
+
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
303
311
</option>
304
312
<option value="signal" disabled={!isChannelAvailable('signal')}>
305
-
Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
313
+
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
306
314
</option>
307
315
</select>
308
316
</div>
309
317
{#if flow.info.verificationChannel === 'email'}
310
318
<div class="field">
311
-
<label for="email">Email Address</label>
312
-
<input id="email" type="email" bind:value={flow.info.email} placeholder="you@example.com" disabled={flow.state.submitting} required />
319
+
<label for="email">{$_('registerPasskey.email')}</label>
320
+
<input id="email" type="email" bind:value={flow.info.email} placeholder={$_('registerPasskey.emailPlaceholder')} disabled={flow.state.submitting} required />
313
321
</div>
314
322
{:else if flow.info.verificationChannel === 'discord'}
315
323
<div class="field">
316
-
<label for="discord-id">Discord User ID</label>
317
-
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder="Your Discord user ID" disabled={flow.state.submitting} required />
318
-
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
324
+
<label for="discord-id">{$_('register.discordId')}</label>
325
+
<input id="discord-id" type="text" bind:value={flow.info.discordId} placeholder={$_('register.discordIdPlaceholder')} disabled={flow.state.submitting} required />
326
+
<p class="hint">{$_('register.discordIdHint')}</p>
319
327
</div>
320
328
{:else if flow.info.verificationChannel === 'telegram'}
321
329
<div class="field">
322
-
<label for="telegram-username">Telegram Username</label>
323
-
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder="@yourusername" disabled={flow.state.submitting} required />
330
+
<label for="telegram-username">{$_('register.telegramUsername')}</label>
331
+
<input id="telegram-username" type="text" bind:value={flow.info.telegramUsername} placeholder={$_('register.telegramUsernamePlaceholder')} disabled={flow.state.submitting} required />
324
332
</div>
325
333
{:else if flow.info.verificationChannel === 'signal'}
326
334
<div class="field">
327
-
<label for="signal-number">Signal Phone Number</label>
328
-
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder="+1234567890" disabled={flow.state.submitting} required />
329
-
<p class="hint">Include country code (e.g., +1 for US)</p>
335
+
<label for="signal-number">{$_('register.signalNumber')}</label>
336
+
<input id="signal-number" type="tel" bind:value={flow.info.signalNumber} placeholder={$_('register.signalNumberPlaceholder')} disabled={flow.state.submitting} required />
337
+
<p class="hint">{$_('register.signalNumberHint')}</p>
330
338
</div>
331
339
{/if}
332
340
</fieldset>
333
341
334
342
<fieldset class="section-fieldset">
335
-
<legend>Identity Type</legend>
336
-
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
343
+
<legend>{$_('registerPasskey.identityType')}</legend>
344
+
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
337
345
<div class="radio-group">
338
346
<label class="radio-label">
339
347
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
340
348
<span class="radio-content">
341
-
<strong>did:plc</strong> (Recommended)
342
-
<span class="radio-hint">Portable identity managed by PLC Directory</span>
349
+
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
350
+
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
343
351
</span>
344
352
</label>
345
353
<label class="radio-label">
346
354
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
347
355
<span class="radio-content">
348
-
<strong>did:web</strong>
349
-
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
356
+
<strong>{$_('registerPasskey.didWeb')}</strong>
357
+
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
350
358
</span>
351
359
</label>
352
360
<label class="radio-label">
353
361
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
354
362
<span class="radio-content">
355
-
<strong>did:web (BYOD)</strong>
356
-
<span class="radio-hint">Bring your own domain</span>
363
+
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
364
+
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
357
365
</span>
358
366
</label>
359
367
</div>
360
368
{#if flow.info.didType === 'web'}
361
369
<div class="warning-box">
362
-
<strong>Important: Understand the trade-offs</strong>
370
+
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
363
371
<ul>
364
-
<li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
365
-
<li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys.</li>
366
-
<li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document.</li>
367
-
<li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
372
+
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
373
+
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
374
+
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
375
+
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
368
376
</ul>
369
377
</div>
370
378
{/if}
371
379
{#if flow.info.didType === 'web-external'}
372
380
<div class="field">
373
-
<label for="external-did">Your did:web</label>
374
-
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder="did:web:yourdomain.com" disabled={flow.state.submitting} required />
375
-
<p class="hint">You'll need to serve a DID document at <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
381
+
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
382
+
<input id="external-did" type="text" bind:value={flow.info.externalDid} placeholder={$_('registerPasskey.externalDidPlaceholder')} disabled={flow.state.submitting} required />
383
+
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
376
384
</div>
377
385
{/if}
378
386
</fieldset>
379
387
380
388
{#if serverInfo?.inviteCodeRequired}
381
389
<div class="field">
382
-
<label for="invite-code">Invite Code <span class="required">*</span></label>
383
-
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder="Enter your invite code" disabled={flow.state.submitting} required />
390
+
<label for="invite-code">{$_('registerPasskey.inviteCode')} <span class="required">*</span></label>
391
+
<input id="invite-code" type="text" bind:value={flow.info.inviteCode} placeholder={$_('registerPasskey.inviteCodePlaceholder')} disabled={flow.state.submitting} required />
384
392
</div>
385
393
{/if}
386
394
387
395
<div class="info-box">
388
-
<strong>Why passkey-only?</strong>
389
-
<p>Passkey accounts are more secure than password-based accounts because they:</p>
396
+
<strong>{$_('registerPasskey.whyPasskeyOnly')}</strong>
397
+
<p>{$_('registerPasskey.whyPasskeyOnlyDesc')}</p>
390
398
<ul>
391
-
<li>Cannot be phished or stolen in data breaches</li>
392
-
<li>Use hardware-backed cryptographic keys</li>
393
-
<li>Require your biometric or device PIN to use</li>
399
+
<li>{$_('registerPasskey.whyPasskeyBullet1')}</li>
400
+
<li>{$_('registerPasskey.whyPasskeyBullet2')}</li>
401
+
<li>{$_('registerPasskey.whyPasskeyBullet3')}</li>
394
402
</ul>
395
403
</div>
396
404
397
405
<button type="submit" disabled={flow.state.submitting}>
398
-
{flow.state.submitting ? 'Creating account...' : 'Continue'}
406
+
{flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')}
399
407
</button>
400
408
</form>
401
409
402
410
<p class="link-text">
403
-
Want a traditional password? <a href="#/register">Register with password</a>
411
+
{$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a>
404
412
</p>
405
413
406
414
{:else if flow.state.step === 'key-choice'}
···
415
423
/>
416
424
417
425
{:else if flow.state.step === 'creating'}
418
-
{#await flow.createPasskeyAccount()}
419
-
<p class="loading">Creating your account...</p>
420
-
{/await}
426
+
<p class="loading">{$_('registerPasskey.subtitleCreating')}</p>
421
427
422
428
{:else if flow.state.step === 'passkey'}
423
429
<div class="step-content">
424
430
<div class="field">
425
-
<label for="passkey-name">Passkey Name (optional)</label>
426
-
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={flow.state.submitting} />
427
-
<p class="hint">A friendly name to identify this passkey</p>
431
+
<label for="passkey-name">{$_('registerPasskey.passkeyNameLabel')}</label>
432
+
<input id="passkey-name" type="text" bind:value={passkeyName} placeholder={$_('registerPasskey.passkeyNamePlaceholder')} disabled={flow.state.submitting} />
433
+
<p class="hint">{$_('registerPasskey.passkeyNameHint')}</p>
428
434
</div>
429
435
430
436
<div class="info-box">
431
-
<p>Click the button below to create your passkey. You'll be prompted to use:</p>
437
+
<p>{$_('registerPasskey.passkeyPrompt')}</p>
432
438
<ul>
433
-
<li>Touch ID or Face ID</li>
434
-
<li>Your device PIN or password</li>
435
-
<li>A security key (if you have one)</li>
439
+
<li>{$_('registerPasskey.passkeyPromptBullet1')}</li>
440
+
<li>{$_('registerPasskey.passkeyPromptBullet2')}</li>
441
+
<li>{$_('registerPasskey.passkeyPromptBullet3')}</li>
436
442
</ul>
437
443
</div>
438
444
439
445
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
440
-
{flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
446
+
{flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')}
441
447
</button>
442
448
443
449
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
444
-
Back
450
+
{$_('registerPasskey.back')}
445
451
</button>
446
452
</div>
447
453
···
459
465
/>
460
466
461
467
{:else if flow.state.step === 'redirect-to-dashboard'}
462
-
<p class="loading">Redirecting to dashboard...</p>
468
+
<p class="loading">{$_('registerPasskey.redirecting')}</p>
463
469
{/if}
464
470
</div>
465
471
+1
-1
frontend/src/routes/Settings.svelte
+1
-1
frontend/src/routes/Settings.svelte
···
55
55
const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
56
56
emailTokenRequired = result.tokenRequired
57
57
if (emailTokenRequired) {
58
-
showMessage('success', $_('settings.messages.verificationCodeSent'))
58
+
showMessage('success', $_('settings.messages.emailCodeSent'))
59
59
} else {
60
60
await api.updateEmail(auth.session.accessJwt, newEmail)
61
61
await refreshSession()
+231
-28
frontend/src/routes/Verify.svelte
+231
-28
frontend/src/routes/Verify.svelte
···
1
1
<script lang="ts">
2
+
import { onMount } from 'svelte'
2
3
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
4
+
import { api, ApiError } from '../lib/api'
3
5
import { navigate } from '../lib/router.svelte'
4
6
import { _ } from '../lib/i18n'
5
7
···
11
13
channel: string
12
14
}
13
15
16
+
type VerificationMode = 'signup' | 'token'
17
+
18
+
let mode = $state<VerificationMode>('signup')
14
19
let pendingVerification = $state<PendingVerification | null>(null)
15
20
let verificationCode = $state('')
21
+
let identifier = $state('')
16
22
let submitting = $state(false)
17
23
let resendingCode = $state(false)
18
24
let error = $state<string | null>(null)
19
25
let resendMessage = $state<string | null>(null)
26
+
let success = $state(false)
27
+
let autoSubmitting = $state(false)
28
+
let successPurpose = $state<string | null>(null)
29
+
let successChannel = $state<string | null>(null)
20
30
21
31
const auth = getAuthState()
22
32
23
-
$effect(() => {
24
-
if (auth.session) {
25
-
clearPendingVerification()
26
-
navigate('/dashboard')
33
+
34
+
function parseQueryParams() {
35
+
const hash = window.location.hash
36
+
const queryIndex = hash.indexOf('?')
37
+
if (queryIndex === -1) return {}
38
+
39
+
const queryString = hash.slice(queryIndex + 1)
40
+
const params: Record<string, string> = {}
41
+
for (const pair of queryString.split('&')) {
42
+
const [key, value] = pair.split('=')
43
+
if (key && value) {
44
+
params[decodeURIComponent(key)] = decodeURIComponent(value)
45
+
}
46
+
}
47
+
return params
48
+
}
49
+
50
+
onMount(async () => {
51
+
const params = parseQueryParams()
52
+
53
+
if (params.token) {
54
+
mode = 'token'
55
+
verificationCode = params.token
56
+
if (params.identifier) {
57
+
identifier = params.identifier
58
+
}
59
+
if (verificationCode && identifier) {
60
+
autoSubmitting = true
61
+
await handleTokenVerification()
62
+
autoSubmitting = false
63
+
}
64
+
} else {
65
+
mode = 'signup'
66
+
const stored = localStorage.getItem(STORAGE_KEY)
67
+
if (stored) {
68
+
try {
69
+
pendingVerification = JSON.parse(stored)
70
+
} catch {
71
+
pendingVerification = null
72
+
}
73
+
}
27
74
}
28
75
})
29
76
30
77
$effect(() => {
31
-
const stored = localStorage.getItem(STORAGE_KEY)
32
-
if (stored) {
33
-
try {
34
-
pendingVerification = JSON.parse(stored)
35
-
} catch {
36
-
pendingVerification = null
37
-
}
78
+
if (mode === 'signup' && auth.session) {
79
+
clearPendingVerification()
80
+
navigate('/dashboard')
38
81
}
39
82
})
40
83
···
43
86
pendingVerification = null
44
87
}
45
88
46
-
async function handleVerification(e: Event) {
89
+
async function handleSignupVerification(e: Event) {
47
90
e.preventDefault()
48
91
if (!pendingVerification || !verificationCode.trim()) return
49
92
···
61
104
}
62
105
}
63
106
64
-
async function handleResendCode() {
65
-
if (!pendingVerification || resendingCode) return
107
+
async function handleTokenVerification() {
108
+
if (!verificationCode.trim() || !identifier.trim()) return
66
109
67
-
resendingCode = true
68
-
resendMessage = null
110
+
submitting = true
69
111
error = null
70
112
71
113
try {
72
-
await resendVerification(pendingVerification.did)
73
-
resendMessage = 'Verification code resent!'
114
+
const result = await api.verifyToken(
115
+
verificationCode.trim(),
116
+
identifier.trim(),
117
+
auth.session?.accessJwt
118
+
)
119
+
success = true
120
+
successPurpose = result.purpose
121
+
successChannel = result.channel
74
122
} catch (e: any) {
75
-
error = e.message || 'Failed to resend code'
123
+
if (e instanceof ApiError) {
124
+
if (e.error === 'AuthenticationRequired') {
125
+
error = 'You must be signed in to complete this verification. Please sign in and try again.'
126
+
} else {
127
+
error = e.message
128
+
}
129
+
} else {
130
+
error = 'Verification failed'
131
+
}
76
132
} finally {
77
-
resendingCode = false
133
+
submitting = false
134
+
}
135
+
}
136
+
137
+
async function handleResendCode() {
138
+
if (mode === 'signup') {
139
+
if (!pendingVerification || resendingCode) return
140
+
141
+
resendingCode = true
142
+
resendMessage = null
143
+
error = null
144
+
145
+
try {
146
+
await resendVerification(pendingVerification.did)
147
+
resendMessage = $_('verify.codeResent')
148
+
} catch (e: any) {
149
+
error = e.message || 'Failed to resend code'
150
+
} finally {
151
+
resendingCode = false
152
+
}
153
+
} else {
154
+
if (!identifier.trim() || resendingCode) return
155
+
156
+
resendingCode = true
157
+
resendMessage = null
158
+
error = null
159
+
160
+
try {
161
+
await api.resendMigrationVerification(identifier.trim())
162
+
resendMessage = $_('verify.codeResentDetail')
163
+
} catch (e: any) {
164
+
error = e.message || 'Failed to resend verification'
165
+
} finally {
166
+
resendingCode = false
167
+
}
78
168
}
79
169
}
80
170
···
87
177
default: return ch
88
178
}
89
179
}
180
+
181
+
function goToNextStep() {
182
+
if (successPurpose === 'migration') {
183
+
navigate('/login')
184
+
} else if (successChannel === 'email') {
185
+
navigate('/settings')
186
+
} else {
187
+
navigate('/comms')
188
+
}
189
+
}
90
190
</script>
91
191
92
192
<div class="verify-page">
93
-
{#if error}
94
-
<div class="message error">{error}</div>
95
-
{/if}
193
+
{#if autoSubmitting}
194
+
<div class="loading-container">
195
+
<h1>{$_('verify.verifying')}</h1>
196
+
<p class="subtitle">{$_('verify.pleaseWait')}</p>
197
+
</div>
198
+
{:else if success}
199
+
<div class="success-container">
200
+
<h1>{$_('verify.verified')}</h1>
201
+
{#if successPurpose === 'migration' || successPurpose === 'signup'}
202
+
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
203
+
<p class="info-text">{$_('verify.canNowSignIn')}</p>
204
+
<div class="actions">
205
+
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
206
+
</div>
207
+
{:else}
208
+
<p class="subtitle">
209
+
{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}
210
+
</p>
211
+
<div class="actions">
212
+
<button class="btn" onclick={goToNextStep}>{$_('verify.continue')}</button>
213
+
</div>
214
+
{/if}
215
+
</div>
216
+
{:else if mode === 'token'}
217
+
<h1>{$_('verify.tokenTitle')}</h1>
218
+
<p class="subtitle">{$_('verify.tokenSubtitle')}</p>
219
+
220
+
{#if error}
221
+
<div class="message error">{error}</div>
222
+
{/if}
223
+
224
+
{#if resendMessage}
225
+
<div class="message success">{resendMessage}</div>
226
+
{/if}
227
+
228
+
<form onsubmit={(e) => { e.preventDefault(); handleTokenVerification(); }}>
229
+
<div class="field">
230
+
<label for="identifier">{$_('verify.identifierLabel')}</label>
231
+
<input
232
+
id="identifier"
233
+
type="text"
234
+
bind:value={identifier}
235
+
placeholder={$_('verify.identifierPlaceholder')}
236
+
disabled={submitting}
237
+
required
238
+
autocomplete="email"
239
+
/>
240
+
<p class="field-help">{$_('verify.identifierHelp')}</p>
241
+
</div>
242
+
243
+
<div class="field">
244
+
<label for="verification-code">{$_('verify.codeLabel')}</label>
245
+
<input
246
+
id="verification-code"
247
+
type="text"
248
+
bind:value={verificationCode}
249
+
placeholder={$_('verify.codePlaceholder')}
250
+
disabled={submitting}
251
+
required
252
+
autocomplete="off"
253
+
class="token-input"
254
+
/>
255
+
<p class="field-help">{$_('verify.codeHelp')}</p>
256
+
</div>
96
257
97
-
{#if pendingVerification}
258
+
<button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
259
+
{submitting ? $_('verify.verifying') : $_('verify.verify')}
260
+
</button>
261
+
262
+
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
263
+
{resendingCode ? $_('verify.sending') : $_('verify.resendCode')}
264
+
</button>
265
+
</form>
266
+
267
+
<p class="link-text">
268
+
<a href="#/login">{$_('verify.backToLogin')}</a>
269
+
</p>
270
+
{:else if pendingVerification}
98
271
<h1>{$_('verify.title')}</h1>
99
272
<p class="subtitle">
100
273
{$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })}
101
274
</p>
102
275
<p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p>
103
276
277
+
{#if error}
278
+
<div class="message error">{error}</div>
279
+
{/if}
280
+
104
281
{#if resendMessage}
105
282
<div class="message success">{resendMessage}</div>
106
283
{/if}
107
284
108
-
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
285
+
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
109
286
<div class="field">
110
287
<label for="verification-code">{$_('verify.codeLabel')}</label>
111
288
<input
···
115
292
placeholder={$_('verify.codePlaceholder')}
116
293
disabled={submitting}
117
294
required
118
-
maxlength="6"
119
-
inputmode="numeric"
120
-
autocomplete="one-time-code"
295
+
autocomplete="off"
296
+
class="token-input"
121
297
/>
298
+
<p class="field-help">{$_('verify.codeHelp')}</p>
122
299
</div>
123
300
124
301
<button type="submit" disabled={submitting || !verificationCode.trim()}>
···
178
355
gap: var(--space-4);
179
356
}
180
357
358
+
.field-help {
359
+
font-size: var(--text-xs);
360
+
color: var(--text-secondary);
361
+
margin: var(--space-1) 0 0 0;
362
+
}
363
+
364
+
.token-input {
365
+
font-family: var(--font-mono);
366
+
letter-spacing: 0.05em;
367
+
}
368
+
181
369
.link-text {
182
370
text-align: center;
183
371
margin-top: var(--space-6);
···
222
410
.btn.secondary:hover {
223
411
background: var(--accent);
224
412
color: var(--text-inverse);
413
+
}
414
+
415
+
.success-container,
416
+
.loading-container {
417
+
text-align: center;
418
+
}
419
+
420
+
.success-container .actions {
421
+
justify-content: center;
422
+
margin-top: var(--space-6);
423
+
}
424
+
425
+
.success-container .btn {
426
+
flex: none;
427
+
padding: var(--space-4) var(--space-8);
225
428
}
226
429
</style>
+1
migrations/20251232_add_migration_verification_comms_type.sql
+1
migrations/20251232_add_migration_verification_comms_type.sql
···
1
+
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'migration_verification';
+1
migrations/20251233_remove_channel_verifications.sql
+1
migrations/20251233_remove_channel_verifications.sql
···
1
+
DROP TABLE IF EXISTS channel_verifications;
+34
-28
src/api/admin/config.rs
+34
-28
src/api/admin/config.rs
···
1
1
use crate::api::error::ApiError;
2
2
use crate::auth::BearerAuthAdmin;
3
3
use crate::state::AppState;
4
-
use axum::{extract::State, Json};
4
+
use axum::{Json, extract::State};
5
5
use serde::{Deserialize, Serialize};
6
6
use tracing::error;
7
7
···
80
80
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
81
81
sqlx::query(
82
82
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
83
-
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
83
+
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()",
84
84
)
85
85
.bind(key)
86
86
.bind(value)
···
105
105
if let Some(server_name) = req.server_name {
106
106
let trimmed = server_name.trim();
107
107
if trimmed.is_empty() || trimmed.len() > 100 {
108
-
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
108
+
return Err(ApiError::InvalidRequest(
109
+
"Server name must be 1-100 characters".into(),
110
+
));
109
111
}
110
112
upsert_config(&state.db, "server_name", trimmed).await?;
111
113
}
···
116
118
} else if is_valid_hex_color(color) {
117
119
upsert_config(&state.db, "primary_color", color).await?;
118
120
} else {
119
-
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
121
+
return Err(ApiError::InvalidRequest(
122
+
"Invalid primary color format (expected #RRGGBB)".into(),
123
+
));
120
124
}
121
125
}
122
126
···
126
130
} else if is_valid_hex_color(color) {
127
131
upsert_config(&state.db, "primary_color_dark", color).await?;
128
132
} else {
129
-
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
133
+
return Err(ApiError::InvalidRequest(
134
+
"Invalid primary dark color format (expected #RRGGBB)".into(),
135
+
));
130
136
}
131
137
}
132
138
···
136
142
} else if is_valid_hex_color(color) {
137
143
upsert_config(&state.db, "secondary_color", color).await?;
138
144
} else {
139
-
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
145
+
return Err(ApiError::InvalidRequest(
146
+
"Invalid secondary color format (expected #RRGGBB)".into(),
147
+
));
140
148
}
141
149
}
142
150
···
146
154
} else if is_valid_hex_color(color) {
147
155
upsert_config(&state.db, "secondary_color_dark", color).await?;
148
156
} else {
149
-
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
157
+
return Err(ApiError::InvalidRequest(
158
+
"Invalid secondary dark color format (expected #RRGGBB)".into(),
159
+
));
150
160
}
151
161
}
152
162
153
163
if let Some(ref logo_cid) = req.logo_cid {
154
-
let old_logo_cid: Option<String> = sqlx::query_scalar(
155
-
"SELECT value FROM server_config WHERE key = 'logo_cid'"
156
-
)
157
-
.fetch_optional(&state.db)
158
-
.await?;
164
+
let old_logo_cid: Option<String> =
165
+
sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'")
166
+
.fetch_optional(&state.db)
167
+
.await?;
159
168
160
169
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
161
170
(Some(old), true) => Some(old.clone()),
···
163
172
_ => None,
164
173
};
165
174
166
-
if let Some(old_cid) = should_delete_old {
167
-
if let Ok(Some(blob)) = sqlx::query!(
168
-
"SELECT storage_key FROM blobs WHERE cid = $1",
169
-
old_cid
170
-
)
171
-
.fetch_optional(&state.db)
172
-
.await
175
+
if let Some(old_cid) = should_delete_old
176
+
&& let Ok(Some(blob)) =
177
+
sqlx::query!("SELECT storage_key FROM blobs WHERE cid = $1", old_cid)
178
+
.fetch_optional(&state.db)
179
+
.await
180
+
{
181
+
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
182
+
error!("Failed to delete old logo blob from storage: {:?}", e);
183
+
}
184
+
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
185
+
.execute(&state.db)
186
+
.await
173
187
{
174
-
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
175
-
error!("Failed to delete old logo blob from storage: {:?}", e);
176
-
}
177
-
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
178
-
.execute(&state.db)
179
-
.await
180
-
{
181
-
error!("Failed to delete old logo blob record: {:?}", e);
182
-
}
188
+
error!("Failed to delete old logo blob record: {:?}", e);
183
189
}
184
190
}
185
191
+3
-1
src/api/error.rs
+3
-1
src/api/error.rs
···
94
94
fn error_name(&self) -> Cow<'static, str> {
95
95
match self {
96
96
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
97
-
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"),
97
+
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => {
98
+
Cow::Borrowed("UpstreamFailure")
99
+
}
98
100
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
99
101
Self::UpstreamError { error, .. } => {
100
102
if let Some(e) = error {
+75
-71
src/api/identity/account.rs
+75
-71
src/api/identity/account.rs
···
132
132
.map(|d| d.starts_with("did:plc:"))
133
133
.unwrap_or(false);
134
134
135
-
if is_migration || is_did_web_byod {
136
-
if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
137
-
{
138
-
if provided_did != auth_did {
139
-
return (
135
+
if (is_migration || is_did_web_byod)
136
+
&& let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
137
+
{
138
+
if provided_did != auth_did {
139
+
return (
140
140
StatusCode::FORBIDDEN,
141
141
Json(json!({
142
142
"error": "AuthorizationError",
···
144
144
})),
145
145
)
146
146
.into_response();
147
-
}
148
-
if is_did_web_byod {
149
-
info!(did = %provided_did, "Processing did:web BYOD account creation");
150
-
} else {
151
-
info!(did = %provided_did, "Processing account migration");
152
-
}
147
+
}
148
+
if is_did_web_byod {
149
+
info!(did = %provided_did, "Processing did:web BYOD account creation");
150
+
} else {
151
+
info!(did = %provided_did, "Processing account migration");
153
152
}
154
153
}
155
154
···
348
347
)
349
348
.into_response();
350
349
}
351
-
if !is_did_web_byod {
352
-
if let Err(e) =
350
+
if !is_did_web_byod
351
+
&& let Err(e) =
353
352
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
354
-
{
355
-
return (
356
-
StatusCode::BAD_REQUEST,
357
-
Json(json!({"error": "InvalidDid", "message": e})),
358
-
)
359
-
.into_response();
360
-
}
353
+
{
354
+
return (
355
+
StatusCode::BAD_REQUEST,
356
+
Json(json!({"error": "InvalidDid", "message": e})),
357
+
)
358
+
.into_response();
361
359
}
362
360
info!(did = %d, "Creating external did:web account");
363
361
d.clone()
···
368
366
info!(did = %d, "Migration with existing did:plc");
369
367
d.clone()
370
368
} else if d.starts_with("did:web:") {
371
-
if !is_did_web_byod {
372
-
if let Err(e) =
373
-
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref())
374
-
.await
375
-
{
376
-
return (
377
-
StatusCode::BAD_REQUEST,
378
-
Json(json!({"error": "InvalidDid", "message": e})),
379
-
)
380
-
.into_response();
381
-
}
369
+
if !is_did_web_byod
370
+
&& let Err(e) = verify_did_web(
371
+
d,
372
+
&hostname,
373
+
&input.handle,
374
+
input.signing_key.as_deref(),
375
+
)
376
+
.await
377
+
{
378
+
return (
379
+
StatusCode::BAD_REQUEST,
380
+
Json(json!({"error": "InvalidDid", "message": e})),
381
+
)
382
+
.into_response();
382
383
}
383
384
d.clone()
384
385
} else if !d.trim().is_empty() {
···
710
711
.into_response();
711
712
}
712
713
};
713
-
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
714
-
let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
715
714
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
716
715
.fetch_one(&mut *tx)
717
716
.await
···
758
757
)
759
758
.bind(is_first_user)
760
759
.bind(deactivated_at)
761
-
.bind(is_migration)
760
+
.bind(false)
762
761
.fetch_one(&mut *tx)
763
762
.await;
764
763
let user_id = match user_insert {
···
806
805
}
807
806
};
808
807
809
-
if !is_migration
810
-
&& let Some(ref recipient) = verification_recipient
811
-
&& let Err(e) = sqlx::query!(
812
-
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
813
-
user_id,
814
-
verification_channel as _,
815
-
verification_code,
816
-
recipient,
817
-
code_expires_at
818
-
)
819
-
.execute(&mut *tx)
820
-
.await {
821
-
error!("Error inserting verification code: {:?}", e);
822
-
return (
823
-
StatusCode::INTERNAL_SERVER_ERROR,
824
-
Json(json!({"error": "InternalError"})),
825
-
)
826
-
.into_response();
827
-
}
828
808
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
829
809
Ok(enc) => enc,
830
810
Err(e) => {
···
881
861
}
882
862
};
883
863
let rev = Tid::now(LimitedU32::MIN);
884
-
let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &signing_key) {
885
-
Ok(result) => result,
886
-
Err(e) => {
887
-
error!("Error creating genesis commit: {:?}", e);
888
-
return (
889
-
StatusCode::INTERNAL_SERVER_ERROR,
890
-
Json(json!({"error": "InternalError"})),
891
-
)
892
-
.into_response();
893
-
}
894
-
};
864
+
let (commit_bytes, _sig) =
865
+
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) {
866
+
Ok(result) => result,
867
+
Err(e) => {
868
+
error!("Error creating genesis commit: {:?}", e);
869
+
return (
870
+
StatusCode::INTERNAL_SERVER_ERROR,
871
+
Json(json!({"error": "InternalError"})),
872
+
)
873
+
.into_response();
874
+
}
875
+
};
895
876
let commit_cid = match state.block_store.put(&commit_bytes).await {
896
877
Ok(c) => c,
897
878
Err(e) => {
···
973
954
warn!("Failed to create default profile for {}: {}", did, e);
974
955
}
975
956
}
957
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
976
958
if !is_migration {
977
-
if let Some(ref recipient) = verification_recipient
978
-
&& let Err(e) = crate::comms::enqueue_signup_verification(
959
+
if let Some(ref recipient) = verification_recipient {
960
+
let verification_token = crate::auth::verification_token::generate_signup_token(
961
+
&did,
962
+
verification_channel,
963
+
recipient,
964
+
);
965
+
let formatted_token =
966
+
crate::auth::verification_token::format_token_for_display(&verification_token);
967
+
if let Err(e) = crate::comms::enqueue_signup_verification(
979
968
&state.db,
980
969
user_id,
981
970
verification_channel,
982
971
recipient,
983
-
&verification_code,
972
+
&formatted_token,
984
973
None,
985
974
)
986
975
.await
976
+
{
977
+
warn!(
978
+
"Failed to enqueue signup verification notification: {:?}",
979
+
e
980
+
);
981
+
}
982
+
}
983
+
} else if let Some(ref user_email) = email {
984
+
let token = crate::auth::verification_token::generate_migration_token(&did, user_email);
985
+
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
986
+
if let Err(e) = crate::comms::enqueue_migration_verification(
987
+
&state.db,
988
+
user_id,
989
+
user_email,
990
+
&formatted_token,
991
+
&hostname,
992
+
)
993
+
.await
987
994
{
988
-
warn!(
989
-
"Failed to enqueue signup verification notification: {:?}",
990
-
e
991
-
);
995
+
warn!("Failed to enqueue migration verification email: {:?}", e);
992
996
}
993
997
}
994
998
+33
-59
src/api/notification_prefs.rs
+33
-59
src/api/notification_prefs.rs
···
6
6
http::{HeaderMap, StatusCode},
7
7
response::{IntoResponse, Response},
8
8
};
9
-
use chrono::{Duration, Utc};
10
-
use rand::Rng;
11
9
use serde::{Deserialize, Serialize};
12
10
use serde_json::json;
13
11
use sqlx::Row;
14
12
use tracing::info;
15
-
16
-
fn generate_verification_code() -> String {
17
-
rand::thread_rng()
18
-
.sample_iter(&rand::distributions::Uniform::new(0, 10))
19
-
.take(6)
20
-
.map(|x| x.to_string())
21
-
.collect()
22
-
}
23
13
24
14
#[derive(Serialize)]
25
15
#[serde(rename_all = "camelCase")]
···
228
218
pub async fn request_channel_verification(
229
219
db: &sqlx::PgPool,
230
220
user_id: uuid::Uuid,
221
+
did: &str,
231
222
channel: &str,
232
223
identifier: &str,
233
224
handle: Option<&str>,
234
225
) -> Result<String, String> {
235
-
let code = generate_verification_code();
236
-
let expires_at = Utc::now() + Duration::minutes(10);
237
-
238
-
sqlx::query!(
239
-
r#"
240
-
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
241
-
VALUES ($1, $2::comms_channel, $3, $4, $5)
242
-
ON CONFLICT (user_id, channel) DO UPDATE
243
-
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
244
-
"#,
245
-
user_id,
246
-
channel as _,
247
-
code,
248
-
identifier,
249
-
expires_at
250
-
)
251
-
.execute(db)
252
-
.await
253
-
.map_err(|e| format!("Database error: {}", e))?;
226
+
let token =
227
+
crate::auth::verification_token::generate_channel_update_token(did, channel, identifier);
228
+
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
254
229
255
230
if channel == "email" {
256
231
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
257
232
let handle_str = handle.unwrap_or("user");
258
-
crate::comms::enqueue_email_update(db, user_id, identifier, handle_str, &code, &hostname)
259
-
.await
260
-
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
233
+
crate::comms::enqueue_email_update(
234
+
db,
235
+
user_id,
236
+
identifier,
237
+
handle_str,
238
+
&formatted_token,
239
+
&hostname,
240
+
)
241
+
.await
242
+
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
261
243
} else {
262
244
sqlx::query!(
263
245
r#"
···
267
249
user_id,
268
250
channel as _,
269
251
identifier,
270
-
format!("Your verification code is: {}", code),
271
-
json!({"code": code})
252
+
format!("Your verification code is: {}", formatted_token),
253
+
json!({"code": formatted_token})
272
254
)
273
255
.execute(db)
274
256
.await
275
257
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
276
258
}
277
259
278
-
Ok(code)
260
+
Ok(token)
279
261
}
280
262
281
263
pub async fn update_notification_prefs(
···
397
379
if let Err(e) = request_channel_verification(
398
380
&state.db,
399
381
user_id,
382
+
&user.did,
400
383
"email",
401
384
&email_clean,
402
385
Some(&handle),
···
429
412
)
430
413
.into_response();
431
414
}
432
-
let _ = sqlx::query!(
433
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
434
-
user_id
435
-
)
436
-
.execute(&state.db)
437
-
.await;
438
415
info!(did = %user.did, "Cleared Discord ID");
439
416
} else {
440
-
if let Err(e) =
441
-
request_channel_verification(&state.db, user_id, "discord", discord_id, None).await
417
+
if let Err(e) = request_channel_verification(
418
+
&state.db, user_id, &user.did, "discord", discord_id, None,
419
+
)
420
+
.await
442
421
{
443
422
return (
444
423
StatusCode::INTERNAL_SERVER_ERROR,
···
467
446
)
468
447
.into_response();
469
448
}
470
-
let _ = sqlx::query!(
471
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'telegram'",
472
-
user_id
473
-
)
474
-
.execute(&state.db)
475
-
.await;
476
449
info!(did = %user.did, "Cleared Telegram username");
477
450
} else {
478
-
if let Err(e) =
479
-
request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None)
480
-
.await
451
+
if let Err(e) = request_channel_verification(
452
+
&state.db,
453
+
user_id,
454
+
&user.did,
455
+
"telegram",
456
+
telegram_clean,
457
+
None,
458
+
)
459
+
.await
481
460
{
482
461
return (
483
462
StatusCode::INTERNAL_SERVER_ERROR,
···
505
484
)
506
485
.into_response();
507
486
}
508
-
let _ = sqlx::query!(
509
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'signal'",
510
-
user_id
511
-
)
512
-
.execute(&state.db)
513
-
.await;
514
487
info!(did = %user.did, "Cleared Signal number");
515
488
} else {
516
489
if let Err(e) =
517
-
request_channel_verification(&state.db, user_id, "signal", signal, None).await
490
+
request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None)
491
+
.await
518
492
{
519
493
return (
520
494
StatusCode::INTERNAL_SERVER_ERROR,
+1
-1
src/api/repo/record/utils.rs
+1
-1
src/api/repo/record/utils.rs
···
3
3
use cid::Cid;
4
4
use jacquard::types::{integer::LimitedU32, string::Tid};
5
5
use jacquard_repo::storage::BlockStore;
6
-
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
6
+
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
7
7
use serde::Serialize;
8
8
use serde_json::json;
9
9
use uuid::Uuid;
+64
-108
src/api/server/email.rs
+64
-108
src/api/server/email.rs
···
6
6
http::StatusCode,
7
7
response::{IntoResponse, Response},
8
8
};
9
-
use chrono::Utc;
10
9
use serde::Deserialize;
11
10
use serde_json::json;
12
11
use tracing::{error, info, warn};
···
66
65
return e;
67
66
}
68
67
69
-
let did = auth_user.did;
68
+
let did = auth_user.did.clone();
70
69
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
71
70
.fetch_optional(&state.db)
72
71
.await
···
117
116
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
118
117
&state.db,
119
118
user_id,
119
+
&did,
120
120
"email",
121
121
&email,
122
122
Some(&handle),
···
206
206
}
207
207
};
208
208
209
-
let verification = match sqlx::query!(
210
-
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
211
-
user_id
212
-
)
213
-
.fetch_optional(&state.db)
214
-
.await
215
-
{
216
-
Ok(Some(row)) => row,
217
-
_ => {
209
+
let email = input.email.trim().to_lowercase();
210
+
let confirmation_code =
211
+
crate::auth::verification_token::normalize_token_input(input.token.trim());
212
+
213
+
let verified = crate::auth::verification_token::verify_channel_update_token(
214
+
&confirmation_code,
215
+
"email",
216
+
&email,
217
+
);
218
+
219
+
match verified {
220
+
Ok(token_data) => {
221
+
if token_data.did != did {
222
+
return (
223
+
StatusCode::BAD_REQUEST,
224
+
Json(
225
+
json!({"error": "InvalidToken", "message": "Token does not match account"}),
226
+
),
227
+
)
228
+
.into_response();
229
+
}
230
+
}
231
+
Err(crate::auth::verification_token::VerifyError::Expired) => {
218
232
return (
219
233
StatusCode::BAD_REQUEST,
220
-
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
234
+
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
221
235
)
222
236
.into_response();
223
237
}
224
-
};
225
-
226
-
let pending_email = verification.pending_identifier.unwrap_or_default();
227
-
let email = input.email.trim().to_lowercase();
228
-
let confirmation_code = input.token.trim();
229
-
230
-
if pending_email != email {
231
-
return (
232
-
StatusCode::BAD_REQUEST,
233
-
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
234
-
)
235
-
.into_response();
236
-
}
237
-
238
-
if verification.code != confirmation_code {
239
-
return (
240
-
StatusCode::BAD_REQUEST,
241
-
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
242
-
)
243
-
.into_response();
238
+
Err(_) => {
239
+
return (
240
+
StatusCode::BAD_REQUEST,
241
+
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
242
+
)
243
+
.into_response();
244
+
}
244
245
}
245
246
246
-
if Utc::now() > verification.expires_at {
247
-
return (
248
-
StatusCode::BAD_REQUEST,
249
-
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
250
-
)
251
-
.into_response();
252
-
}
253
-
254
-
let mut tx = match state.db.begin().await {
255
-
Ok(tx) => tx,
256
-
Err(_) => return ApiError::InternalError.into_response(),
257
-
};
258
-
259
247
let update = sqlx::query!(
260
-
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
261
-
pending_email,
248
+
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
249
+
email,
262
250
user_id
263
251
)
264
-
.execute(&mut *tx)
252
+
.execute(&state.db)
265
253
.await;
266
254
267
255
if let Err(e) = update {
···
283
271
.into_response();
284
272
}
285
273
286
-
if let Err(e) = sqlx::query!(
287
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
288
-
user_id
289
-
)
290
-
.execute(&mut *tx)
291
-
.await
292
-
{
293
-
error!("Failed to delete verification record: {:?}", e);
294
-
return ApiError::InternalError.into_response();
295
-
}
296
-
297
-
if tx.commit().await.is_err() {
298
-
return ApiError::InternalError.into_response();
299
-
}
300
-
301
274
info!("Email updated for user {}", user_id);
302
275
(StatusCode::OK, Json(json!({}))).into_response()
303
276
}
···
377
350
return (StatusCode::OK, Json(json!({}))).into_response();
378
351
}
379
352
380
-
let verification = sqlx::query!(
381
-
"SELECT code, pending_identifier, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
382
-
user_id
383
-
)
384
-
.fetch_optional(&state.db)
385
-
.await
386
-
.unwrap_or(None);
353
+
let confirmation_token = match &input.token {
354
+
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
355
+
None => {
356
+
return (
357
+
StatusCode::BAD_REQUEST,
358
+
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
359
+
)
360
+
.into_response();
361
+
}
362
+
};
363
+
364
+
let verified = crate::auth::verification_token::verify_channel_update_token(
365
+
&confirmation_token,
366
+
"email",
367
+
&new_email,
368
+
);
387
369
388
-
if let Some(ver) = verification {
389
-
let confirmation_token = match &input.token {
390
-
Some(t) => t.trim(),
391
-
None => {
370
+
match verified {
371
+
Ok(token_data) => {
372
+
if token_data.did != did {
392
373
return (
393
374
StatusCode::BAD_REQUEST,
394
-
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
375
+
Json(
376
+
json!({"error": "InvalidToken", "message": "Token does not match account"}),
377
+
),
395
378
)
396
379
.into_response();
397
380
}
398
-
};
399
-
400
-
let pending_email = ver.pending_identifier.unwrap_or_default();
401
-
if pending_email.to_lowercase() != new_email {
381
+
}
382
+
Err(crate::auth::verification_token::VerifyError::Expired) => {
402
383
return (
403
384
StatusCode::BAD_REQUEST,
404
-
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
385
+
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
405
386
)
406
387
.into_response();
407
388
}
408
-
409
-
if ver.code != confirmation_token {
389
+
Err(_) => {
410
390
return (
411
391
StatusCode::BAD_REQUEST,
412
392
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
413
393
)
414
394
.into_response();
415
395
}
416
-
417
-
if Utc::now() > ver.expires_at {
418
-
return (
419
-
StatusCode::BAD_REQUEST,
420
-
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
421
-
)
422
-
.into_response();
423
-
}
424
396
}
425
397
426
398
let exists = sqlx::query!(
···
438
410
)
439
411
.into_response();
440
412
}
441
-
442
-
let mut tx = match state.db.begin().await {
443
-
Ok(tx) => tx,
444
-
Err(_) => return ApiError::InternalError.into_response(),
445
-
};
446
413
447
414
let update = sqlx::query!(
448
-
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
415
+
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
449
416
new_email,
450
417
user_id
451
418
)
452
-
.execute(&mut *tx)
419
+
.execute(&state.db)
453
420
.await;
454
421
455
422
if let Err(e) = update {
···
469
436
Json(json!({"error": "InternalError"})),
470
437
)
471
438
.into_response();
472
-
}
473
-
474
-
let _ = sqlx::query!(
475
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
476
-
user_id
477
-
)
478
-
.execute(&mut *tx)
479
-
.await;
480
-
481
-
if tx.commit().await.is_err() {
482
-
return ApiError::InternalError.into_response();
483
439
}
484
440
485
441
match sqlx::query!(
+11
-12
src/api/server/logo.rs
+11
-12
src/api/server/logo.rs
···
9
9
use tracing::error;
10
10
11
11
pub async fn get_logo(State(state): State<AppState>) -> Response {
12
-
let logo_cid: Option<String> = match sqlx::query_scalar(
13
-
"SELECT value FROM server_config WHERE key = 'logo_cid'"
14
-
)
15
-
.fetch_optional(&state.db)
16
-
.await
17
-
{
18
-
Ok(cid) => cid,
19
-
Err(e) => {
20
-
error!("DB error fetching logo_cid: {:?}", e);
21
-
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
22
-
}
23
-
};
12
+
let logo_cid: Option<String> =
13
+
match sqlx::query_scalar("SELECT value FROM server_config WHERE key = 'logo_cid'")
14
+
.fetch_optional(&state.db)
15
+
.await
16
+
{
17
+
Ok(cid) => cid,
18
+
Err(e) => {
19
+
error!("DB error fetching logo_cid: {:?}", e);
20
+
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
21
+
}
22
+
};
24
23
25
24
let cid = match logo_cid {
26
25
Some(c) if !c.is_empty() => c,
+7
-3
src/api/server/mod.rs
+7
-3
src/api/server/mod.rs
···
13
13
pub mod signing_key;
14
14
pub mod totp;
15
15
pub mod trusted_devices;
16
+
pub mod verify_email;
17
+
pub mod verify_token;
16
18
17
19
pub use account_status::{
18
20
activate_account, check_account_status, deactivate_account, delete_account,
···
35
37
change_password, get_password_status, remove_password, request_password_reset, reset_password,
36
38
};
37
39
pub use reauth::{
38
-
check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response,
39
-
reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response,
40
-
reauth_totp, update_mfa_verified,
40
+
check_legacy_session_mfa, check_reauth_required, get_reauth_status,
41
+
legacy_mfa_required_response, reauth_passkey_finish, reauth_passkey_start, reauth_password,
42
+
reauth_required_response, reauth_totp, update_mfa_verified,
41
43
};
42
44
pub use service_auth::get_service_auth;
43
45
pub use session::{
···
54
56
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
55
57
trust_device, update_trusted_device,
56
58
};
59
+
pub use verify_email::{resend_migration_verification, verify_migration_email};
60
+
pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
+36
-51
src/api/server/passkey_account.rs
+36
-51
src/api/server/passkey_account.rs
···
117
117
.await
118
118
{
119
119
Ok(claims) => {
120
-
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
120
+
debug!(
121
+
"Service token verified for BYOD did:web: iss={}",
122
+
claims.iss
123
+
);
121
124
Some(claims.iss)
122
125
}
123
126
Err(e) => {
···
342
345
.into_response();
343
346
}
344
347
if is_byod_did_web {
345
-
if let Some(ref auth_did) = byod_auth {
346
-
if d != auth_did {
347
-
return (
348
+
if let Some(ref auth_did) = byod_auth
349
+
&& d != auth_did
350
+
{
351
+
return (
348
352
StatusCode::FORBIDDEN,
349
353
Json(json!({
350
354
"error": "AuthorizationError",
···
352
356
})),
353
357
)
354
358
.into_response();
355
-
}
356
359
}
357
360
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
358
361
} else {
···
415
418
};
416
419
417
420
info!(did = %did, handle = %handle, "Created DID for passkey-only account");
418
-
419
-
let verification_code = format!(
420
-
"{:06}",
421
-
rand::Rng::gen_range(&mut rand::thread_rng(), 0..1_000_000u32)
422
-
);
423
-
let verification_code_expires_at = Utc::now() + Duration::minutes(30);
424
421
425
422
let setup_token = generate_setup_token();
426
423
let setup_token_hash = match hash(&setup_token, DEFAULT_COST) {
···
591
588
}
592
589
};
593
590
let rev = Tid::now(LimitedU32::MIN);
594
-
let (commit_bytes, _sig) = match create_signed_commit(&did, mst_root, &rev.to_string(), None, &secret_key) {
595
-
Ok(result) => result,
596
-
Err(e) => {
597
-
error!("Error creating genesis commit: {:?}", e);
598
-
return (
599
-
StatusCode::INTERNAL_SERVER_ERROR,
600
-
Json(json!({"error": "InternalError"})),
601
-
)
602
-
.into_response();
603
-
}
604
-
};
591
+
let (commit_bytes, _sig) =
592
+
match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) {
593
+
Ok(result) => result,
594
+
Err(e) => {
595
+
error!("Error creating genesis commit: {:?}", e);
596
+
return (
597
+
StatusCode::INTERNAL_SERVER_ERROR,
598
+
Json(json!({"error": "InternalError"})),
599
+
)
600
+
.into_response();
601
+
}
602
+
};
605
603
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
606
604
Ok(c) => c,
607
605
Err(e) => {
···
647
645
.await;
648
646
}
649
647
650
-
if let Err(e) = sqlx::query!(
651
-
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
652
-
user_id,
653
-
verification_channel as _,
654
-
verification_code,
655
-
verification_recipient,
656
-
verification_code_expires_at
657
-
)
658
-
.execute(&mut *tx)
659
-
.await
660
-
{
661
-
error!("Error inserting channel verification: {:?}", e);
662
-
return (
663
-
StatusCode::INTERNAL_SERVER_ERROR,
664
-
Json(json!({"error": "InternalError"})),
665
-
)
666
-
.into_response();
667
-
}
668
-
669
648
if let Err(e) = tx.commit().await {
670
649
error!("Error committing transaction: {:?}", e);
671
650
return (
···
703
682
}
704
683
}
705
684
685
+
let verification_token = crate::auth::verification_token::generate_signup_token(
686
+
&did,
687
+
verification_channel,
688
+
&verification_recipient,
689
+
);
690
+
let formatted_token =
691
+
crate::auth::verification_token::format_token_for_display(&verification_token);
706
692
if let Err(e) = crate::comms::enqueue_signup_verification(
707
693
&state.db,
708
694
user_id,
709
695
verification_channel,
710
696
&verification_recipient,
711
-
&verification_code,
697
+
&formatted_token,
712
698
None,
713
699
)
714
700
.await
···
847
833
}
848
834
};
849
835
850
-
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential = match serde_json::from_value(
851
-
input.passkey_credential,
852
-
) {
853
-
Ok(c) => c,
854
-
Err(e) => {
855
-
warn!("Failed to parse credential: {:?}", e);
856
-
return (
836
+
let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
837
+
match serde_json::from_value(input.passkey_credential) {
838
+
Ok(c) => c,
839
+
Err(e) => {
840
+
warn!("Failed to parse credential: {:?}", e);
841
+
return (
857
842
StatusCode::BAD_REQUEST,
858
843
Json(
859
844
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
860
845
),
861
846
)
862
847
.into_response();
863
-
}
864
-
};
848
+
}
849
+
};
865
850
866
851
let security_key = match webauthn.finish_registration(&credential, ®_state) {
867
852
Ok(sk) => sk,
+7
-1
src/api/server/password.rs
+7
-1
src/api/server/password.rs
···
471
471
.await;
472
472
}
473
473
474
-
if crate::api::server::reauth::check_reauth_required_cached(&state.db, &state.cache, &auth.0.did).await {
474
+
if crate::api::server::reauth::check_reauth_required_cached(
475
+
&state.db,
476
+
&state.cache,
477
+
&auth.0.did,
478
+
)
479
+
.await
480
+
{
475
481
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
476
482
}
477
483
+10
-9
src/api/server/reauth.rs
+10
-9
src/api/server/reauth.rs
···
376
376
{
377
377
Ok(false) => {
378
378
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
379
-
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
379
+
let _ =
380
+
crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
380
381
return (
381
382
StatusCode::UNAUTHORIZED,
382
383
Json(json!({
···
494
495
did: &str,
495
496
) -> bool {
496
497
let cache_key = format!("reauth:{}", did);
497
-
if let Some(timestamp_str) = cache.get(&cache_key).await {
498
-
if let Ok(timestamp) = timestamp_str.parse::<i64>() {
499
-
let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
500
-
if let Some(t) = reauth_time {
501
-
let elapsed = Utc::now().signed_duration_since(t);
502
-
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
503
-
return false;
504
-
}
498
+
if let Some(timestamp_str) = cache.get(&cache_key).await
499
+
&& let Ok(timestamp) = timestamp_str.parse::<i64>()
500
+
{
501
+
let reauth_time = chrono::DateTime::from_timestamp(timestamp, 0);
502
+
if let Some(t) = reauth_time {
503
+
let elapsed = Utc::now().signed_duration_since(t);
504
+
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
505
+
return false;
505
506
}
506
507
}
507
508
}
+20
-16
src/api/server/service_auth.rs
+20
-16
src/api/server/service_auth.rs
···
66
66
}
67
67
};
68
68
69
-
let (token, is_dpop) = if auth_header.len() >= 7 && auth_header[..7].eq_ignore_ascii_case("bearer ") {
69
+
let (token, is_dpop) = if auth_header.len() >= 7
70
+
&& auth_header[..7].eq_ignore_ascii_case("bearer ")
71
+
{
70
72
(auth_header[7..].trim().to_string(), false)
71
73
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
72
74
(auth_header[5..].trim().to_string(), true)
···
81
83
&token,
82
84
dpop_proof,
83
85
"GET",
84
-
&format!("/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
85
-
params.aud,
86
-
params.lxm.as_deref().unwrap_or("")),
87
-
).await {
86
+
&format!(
87
+
"/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
88
+
params.aud,
89
+
params.lxm.as_deref().unwrap_or("")
90
+
),
91
+
)
92
+
.await
93
+
{
88
94
Ok(result) => crate::auth::AuthenticatedUser {
89
95
did: result.did,
90
96
is_oauth: true,
···
100
106
"error": "use_dpop_nonce",
101
107
"message": "DPoP nonce required"
102
108
})),
103
-
).into_response();
109
+
)
110
+
.into_response();
104
111
}
105
112
Err(e) => {
106
113
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
···
110
117
"error": "AuthenticationFailed",
111
118
"message": format!("{:?}", e)
112
119
})),
113
-
).into_response();
120
+
)
121
+
.into_response();
114
122
}
115
123
}
116
124
} else {
···
136
144
"SELECT k.key_bytes, k.encryption_version
137
145
FROM users u
138
146
JOIN user_keys k ON u.id = k.user_id
139
-
WHERE u.did = $1"
147
+
WHERE u.did = $1",
140
148
)
141
149
.bind(&auth_user.did)
142
150
.fetch_optional(&state.db)
···
155
163
}
156
164
}
157
165
Ok(None) => {
158
-
return ApiError::AuthenticationFailedMsg(
159
-
"User has no signing key".into(),
160
-
)
161
-
.into_response();
166
+
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
167
+
.into_response();
162
168
}
163
169
Err(e) => {
164
170
error!(error = ?e, "DB error fetching user key");
165
-
return ApiError::AuthenticationFailedMsg(
166
-
"Failed to get signing key".into(),
167
-
)
168
-
.into_response();
171
+
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
172
+
.into_response();
169
173
}
170
174
}
171
175
}
+51
-73
src/api/server/session.rs
+51
-73
src/api/server/session.rs
···
8
8
response::{IntoResponse, Response},
9
9
};
10
10
use bcrypt::verify;
11
-
use chrono::Utc;
12
11
use serde::{Deserialize, Serialize};
13
12
use serde_json::json;
14
13
use tracing::{error, info, warn};
···
167
166
let has_totp = row.totp_enabled.unwrap_or(false);
168
167
let is_legacy_login = has_totp;
169
168
if has_totp && !row.allow_legacy_login {
170
-
warn!(
171
-
"Legacy login blocked for TOTP-enabled account: {}",
172
-
row.did
173
-
);
169
+
warn!("Legacy login blocked for TOTP-enabled account: {}", row.did);
174
170
return (
175
171
StatusCode::FORBIDDEN,
176
172
Json(json!({
···
556
552
r#"SELECT
557
553
u.id, u.did, u.handle, u.email,
558
554
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
555
+
u.discord_id, u.telegram_username, u.signal_number,
559
556
k.key_bytes, k.encryption_version
560
557
FROM users u
561
558
JOIN user_keys k ON u.id = k.user_id
···
577
574
}
578
575
};
579
576
580
-
let channel_str = match row.channel {
581
-
crate::comms::CommsChannel::Email => "email",
582
-
crate::comms::CommsChannel::Discord => "discord",
583
-
crate::comms::CommsChannel::Telegram => "telegram",
584
-
crate::comms::CommsChannel::Signal => "signal",
585
-
};
586
-
let verification = match sqlx::query!(
587
-
"SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
588
-
row.id,
589
-
channel_str as _
590
-
)
591
-
.fetch_optional(&state.db)
592
-
.await
593
-
{
594
-
Ok(Some(v)) => v,
595
-
Ok(None) => {
596
-
warn!("No verification code found for user: {}", input.did);
597
-
return ApiError::InvalidRequest("No pending verification".into()).into_response();
577
+
let (channel_str, identifier) = match row.channel {
578
+
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
579
+
crate::comms::CommsChannel::Discord => {
580
+
("discord", row.discord_id.clone().unwrap_or_default())
598
581
}
599
-
Err(e) => {
600
-
error!("Database error fetching verification: {:?}", e);
601
-
return ApiError::InternalError.into_response();
582
+
crate::comms::CommsChannel::Telegram => (
583
+
"telegram",
584
+
row.telegram_username.clone().unwrap_or_default(),
585
+
),
586
+
crate::comms::CommsChannel::Signal => {
587
+
("signal", row.signal_number.clone().unwrap_or_default())
602
588
}
603
589
};
604
590
605
-
if verification.code != input.verification_code {
606
-
warn!("Invalid verification code for user: {}", input.did);
607
-
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
608
-
}
609
-
if verification.expires_at < Utc::now() {
610
-
warn!("Verification code expired for user: {}", input.did);
611
-
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
591
+
let normalized_token =
592
+
crate::auth::verification_token::normalize_token_input(&input.verification_code);
593
+
match crate::auth::verification_token::verify_signup_token(
594
+
&normalized_token,
595
+
channel_str,
596
+
&identifier,
597
+
) {
598
+
Ok(token_data) => {
599
+
if token_data.did != input.did {
600
+
warn!(
601
+
"Token DID mismatch for confirm_signup: expected {}, got {}",
602
+
input.did, token_data.did
603
+
);
604
+
return ApiError::InvalidRequest("Invalid verification code".into())
605
+
.into_response();
606
+
}
607
+
}
608
+
Err(crate::auth::verification_token::VerifyError::Expired) => {
609
+
warn!("Verification code expired for user: {}", input.did);
610
+
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
611
+
.into_response();
612
+
}
613
+
Err(e) => {
614
+
warn!("Invalid verification code for user {}: {:?}", input.did, e);
615
+
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
616
+
}
612
617
}
613
618
614
619
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
···
632
637
{
633
638
error!("Failed to update verification status: {:?}", e);
634
639
return ApiError::InternalError.into_response();
635
-
}
636
-
637
-
if let Err(e) = sqlx::query!(
638
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
639
-
row.id,
640
-
channel_str as _
641
-
)
642
-
.execute(&state.db)
643
-
.await
644
-
{
645
-
error!("Failed to delete verification record: {:?}", e);
646
640
}
647
641
648
642
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···
737
731
if is_verified {
738
732
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
739
733
}
740
-
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
741
-
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
742
734
743
735
let (channel_str, recipient) = match row.channel {
744
736
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
···
754
746
}
755
747
};
756
748
757
-
if let Err(e) = sqlx::query!(
758
-
r#"
759
-
INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
760
-
VALUES ($1, $2::comms_channel, $3, $4, $5)
761
-
ON CONFLICT (user_id, channel) DO UPDATE
762
-
SET code = $3, pending_identifier = $4, expires_at = $5, created_at = NOW()
763
-
"#,
764
-
row.id,
765
-
channel_str as _,
766
-
verification_code,
767
-
recipient,
768
-
code_expires_at
769
-
)
770
-
.execute(&state.db)
771
-
.await
772
-
{
773
-
error!("Failed to update verification code: {:?}", e);
774
-
return ApiError::InternalError.into_response();
775
-
}
749
+
let verification_token =
750
+
crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient);
751
+
let formatted_token =
752
+
crate::auth::verification_token::format_token_for_display(&verification_token);
753
+
776
754
if let Err(e) = crate::comms::enqueue_signup_verification(
777
755
&state.db,
778
756
row.id,
779
757
channel_str,
780
758
&recipient,
781
-
&verification_code,
759
+
&formatted_token,
782
760
None,
783
761
)
784
762
.await
···
886
864
Ok(rows) => {
887
865
for (id, token_id, created_at, expires_at, client_id) in rows {
888
866
let client_name = extract_client_name(&client_id);
889
-
let is_current_oauth = auth.0.is_oauth
890
-
&& current_jti.as_ref() == Some(&token_id);
867
+
let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id);
891
868
sessions.push(SessionInfo {
892
869
id: format!("oauth:{}", id),
893
870
session_type: "oauth".to_string(),
···
1071
1048
.into_response();
1072
1049
}
1073
1050
} else {
1074
-
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
1075
-
.bind(&auth.0.did)
1076
-
.bind(jti)
1077
-
.execute(&state.db)
1078
-
.await
1051
+
if let Err(e) =
1052
+
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
1053
+
.bind(&auth.0.did)
1054
+
.bind(jti)
1055
+
.execute(&state.db)
1056
+
.await
1079
1057
{
1080
1058
error!("DB error revoking JWT sessions: {:?}", e);
1081
1059
return (
+101
src/api/server/verify_email.rs
+101
src/api/server/verify_email.rs
···
1
+
use axum::{Json, extract::State, http::StatusCode};
2
+
use serde::{Deserialize, Serialize};
3
+
use serde_json::json;
4
+
use tracing::{info, warn};
5
+
6
+
use crate::state::AppState;
7
+
8
+
#[derive(Deserialize)]
9
+
#[serde(rename_all = "camelCase")]
10
+
pub struct VerifyMigrationEmailInput {
11
+
pub token: String,
12
+
pub email: String,
13
+
}
14
+
15
+
#[derive(Serialize)]
16
+
#[serde(rename_all = "camelCase")]
17
+
pub struct VerifyMigrationEmailOutput {
18
+
pub success: bool,
19
+
pub did: String,
20
+
}
21
+
22
+
pub async fn verify_migration_email(
23
+
State(state): State<AppState>,
24
+
Json(input): Json<VerifyMigrationEmailInput>,
25
+
) -> Result<Json<VerifyMigrationEmailOutput>, (StatusCode, Json<serde_json::Value>)> {
26
+
let token_input = super::verify_token::VerifyTokenInput {
27
+
token: input.token,
28
+
identifier: input.email,
29
+
};
30
+
31
+
let result = super::verify_token::verify_token_internal(&state, None, token_input).await?;
32
+
33
+
Ok(Json(VerifyMigrationEmailOutput {
34
+
success: result.success,
35
+
did: result.did.clone(),
36
+
}))
37
+
}
38
+
39
+
#[derive(Deserialize)]
40
+
#[serde(rename_all = "camelCase")]
41
+
pub struct ResendMigrationVerificationInput {
42
+
pub email: String,
43
+
}
44
+
45
+
#[derive(Serialize)]
46
+
#[serde(rename_all = "camelCase")]
47
+
pub struct ResendMigrationVerificationOutput {
48
+
pub sent: bool,
49
+
}
50
+
51
+
pub async fn resend_migration_verification(
52
+
State(state): State<AppState>,
53
+
Json(input): Json<ResendMigrationVerificationInput>,
54
+
) -> Result<Json<ResendMigrationVerificationOutput>, (StatusCode, Json<serde_json::Value>)> {
55
+
let email = input.email.trim().to_lowercase();
56
+
57
+
let user = sqlx::query!(
58
+
"SELECT id, did, email, email_verified, handle FROM users WHERE LOWER(email) = $1",
59
+
email
60
+
)
61
+
.fetch_optional(&state.db)
62
+
.await
63
+
.map_err(|e| {
64
+
warn!(error = %e, "Database error during resend verification");
65
+
(
66
+
StatusCode::INTERNAL_SERVER_ERROR,
67
+
Json(json!({ "error": "InternalError", "message": "Database error" })),
68
+
)
69
+
})?;
70
+
71
+
let user = match user {
72
+
Some(u) => u,
73
+
None => {
74
+
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
75
+
}
76
+
};
77
+
78
+
if user.email_verified {
79
+
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
80
+
}
81
+
82
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
83
+
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
84
+
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
85
+
86
+
if let Err(e) = crate::comms::enqueue_migration_verification(
87
+
&state.db,
88
+
user.id,
89
+
&email,
90
+
&formatted_token,
91
+
&hostname,
92
+
)
93
+
.await
94
+
{
95
+
warn!(error = %e, "Failed to enqueue migration verification email");
96
+
}
97
+
98
+
info!(did = %user.did, "Resent migration verification email");
99
+
100
+
Ok(Json(ResendMigrationVerificationOutput { sent: true }))
101
+
}
+391
src/api/server/verify_token.rs
+391
src/api/server/verify_token.rs
···
1
+
use axum::{
2
+
Json,
3
+
extract::State,
4
+
http::{HeaderMap, StatusCode},
5
+
};
6
+
use serde::{Deserialize, Serialize};
7
+
use serde_json::json;
8
+
use tracing::{error, info, warn};
9
+
10
+
use crate::auth::verification_token::{
11
+
VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature,
12
+
};
13
+
use crate::state::AppState;
14
+
15
+
#[derive(Deserialize, Clone)]
16
+
#[serde(rename_all = "camelCase")]
17
+
pub struct VerifyTokenInput {
18
+
pub token: String,
19
+
pub identifier: String,
20
+
}
21
+
22
+
#[derive(Serialize, Clone)]
23
+
#[serde(rename_all = "camelCase")]
24
+
pub struct VerifyTokenOutput {
25
+
pub success: bool,
26
+
pub did: String,
27
+
pub purpose: String,
28
+
pub channel: String,
29
+
}
30
+
31
+
pub async fn verify_token(
32
+
State(state): State<AppState>,
33
+
headers: HeaderMap,
34
+
Json(input): Json<VerifyTokenInput>,
35
+
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
36
+
verify_token_internal(&state, Some(&headers), input).await
37
+
}
38
+
39
+
pub async fn verify_token_internal(
40
+
state: &AppState,
41
+
headers: Option<&HeaderMap>,
42
+
input: VerifyTokenInput,
43
+
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
44
+
let normalized_token = normalize_token_input(&input.token);
45
+
let identifier = input.identifier.trim().to_lowercase();
46
+
47
+
let token_data = match verify_token_signature(&normalized_token) {
48
+
Ok(data) => data,
49
+
Err(e) => {
50
+
let (status, error, message) = match e {
51
+
VerifyError::InvalidFormat => (
52
+
StatusCode::BAD_REQUEST,
53
+
"InvalidToken",
54
+
"The verification token is invalid or malformed",
55
+
),
56
+
VerifyError::UnsupportedVersion => (
57
+
StatusCode::BAD_REQUEST,
58
+
"InvalidToken",
59
+
"This verification token version is not supported",
60
+
),
61
+
VerifyError::Expired => (
62
+
StatusCode::BAD_REQUEST,
63
+
"ExpiredToken",
64
+
"The verification token has expired. Please request a new one.",
65
+
),
66
+
VerifyError::InvalidSignature => (
67
+
StatusCode::BAD_REQUEST,
68
+
"InvalidToken",
69
+
"The verification token signature is invalid",
70
+
),
71
+
_ => (
72
+
StatusCode::BAD_REQUEST,
73
+
"InvalidToken",
74
+
"The verification token is not valid",
75
+
),
76
+
};
77
+
warn!(error = ?e, "Token verification failed");
78
+
return Err((status, Json(json!({ "error": error, "message": message }))));
79
+
}
80
+
};
81
+
82
+
let expected_hash = crate::auth::verification_token::hash_identifier(&identifier);
83
+
if token_data.identifier_hash != expected_hash {
84
+
return Err((
85
+
StatusCode::BAD_REQUEST,
86
+
Json(
87
+
json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }),
88
+
),
89
+
));
90
+
}
91
+
92
+
match token_data.purpose {
93
+
VerificationPurpose::Migration => {
94
+
handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier)
95
+
.await
96
+
}
97
+
VerificationPurpose::ChannelUpdate => {
98
+
let auth_did = extract_and_validate_auth(state, headers).await?;
99
+
if auth_did != token_data.did {
100
+
return Err((
101
+
StatusCode::BAD_REQUEST,
102
+
Json(
103
+
json!({ "error": "InvalidToken", "message": "Token does not match authenticated account" }),
104
+
),
105
+
));
106
+
}
107
+
handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await
108
+
}
109
+
VerificationPurpose::Signup => {
110
+
handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier)
111
+
.await
112
+
}
113
+
}
114
+
}
115
+
116
+
async fn extract_and_validate_auth(
117
+
state: &AppState,
118
+
headers: Option<&HeaderMap>,
119
+
) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
120
+
let headers = headers.ok_or_else(|| {
121
+
(
122
+
StatusCode::UNAUTHORIZED,
123
+
Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })),
124
+
)
125
+
})?;
126
+
127
+
let token = crate::auth::extract_bearer_token_from_header(
128
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
129
+
)
130
+
.ok_or_else(|| {
131
+
(
132
+
StatusCode::UNAUTHORIZED,
133
+
Json(json!({ "error": "AuthenticationRequired", "message": "Authentication required for this verification" })),
134
+
)
135
+
})?;
136
+
137
+
let user = crate::auth::validate_bearer_token(&state.db, &token)
138
+
.await
139
+
.map_err(|_| {
140
+
(
141
+
StatusCode::UNAUTHORIZED,
142
+
Json(json!({ "error": "AuthenticationFailed", "message": "Invalid authentication token" })),
143
+
)
144
+
})?;
145
+
146
+
Ok(user.did)
147
+
}
148
+
149
+
async fn handle_migration_verification(
150
+
state: &AppState,
151
+
did: &str,
152
+
channel: &str,
153
+
identifier: &str,
154
+
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
155
+
if channel != "email" {
156
+
return Err((
157
+
StatusCode::BAD_REQUEST,
158
+
Json(
159
+
json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }),
160
+
),
161
+
));
162
+
}
163
+
164
+
let user = sqlx::query!(
165
+
"SELECT id, email, email_verified FROM users WHERE did = $1",
166
+
did
167
+
)
168
+
.fetch_optional(&state.db)
169
+
.await
170
+
.map_err(|e| {
171
+
warn!(error = %e, "Database error during migration verification");
172
+
(
173
+
StatusCode::INTERNAL_SERVER_ERROR,
174
+
Json(json!({ "error": "InternalError", "message": "Database error" })),
175
+
)
176
+
})?;
177
+
178
+
let user = user.ok_or_else(|| {
179
+
(
180
+
StatusCode::NOT_FOUND,
181
+
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
182
+
)
183
+
})?;
184
+
185
+
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
186
+
return Err((
187
+
StatusCode::BAD_REQUEST,
188
+
Json(
189
+
json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }),
190
+
),
191
+
));
192
+
}
193
+
194
+
if !user.email_verified {
195
+
sqlx::query!(
196
+
"UPDATE users SET email_verified = true WHERE id = $1",
197
+
user.id
198
+
)
199
+
.execute(&state.db)
200
+
.await
201
+
.map_err(|e| {
202
+
warn!(error = %e, "Failed to update email_verified status");
203
+
(
204
+
StatusCode::INTERNAL_SERVER_ERROR,
205
+
Json(json!({ "error": "InternalError", "message": "Failed to verify email" })),
206
+
)
207
+
})?;
208
+
}
209
+
210
+
info!(did = %did, "Migration email verified successfully");
211
+
212
+
Ok(Json(VerifyTokenOutput {
213
+
success: true,
214
+
did: did.to_string(),
215
+
purpose: "migration".to_string(),
216
+
channel: channel.to_string(),
217
+
}))
218
+
}
219
+
220
+
async fn handle_channel_update(
221
+
state: &AppState,
222
+
did: &str,
223
+
channel: &str,
224
+
identifier: &str,
225
+
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
226
+
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
227
+
.fetch_one(&state.db)
228
+
.await
229
+
.map_err(|_| {
230
+
(
231
+
StatusCode::INTERNAL_SERVER_ERROR,
232
+
Json(json!({ "error": "InternalError", "message": "User not found" })),
233
+
)
234
+
})?;
235
+
236
+
let update_result = match channel {
237
+
"email" => sqlx::query!(
238
+
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
239
+
identifier,
240
+
user_id
241
+
).execute(&state.db).await,
242
+
"discord" => sqlx::query!(
243
+
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
244
+
identifier,
245
+
user_id
246
+
).execute(&state.db).await,
247
+
"telegram" => sqlx::query!(
248
+
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
249
+
identifier,
250
+
user_id
251
+
).execute(&state.db).await,
252
+
"signal" => sqlx::query!(
253
+
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
254
+
identifier,
255
+
user_id
256
+
).execute(&state.db).await,
257
+
_ => {
258
+
return Err((
259
+
StatusCode::BAD_REQUEST,
260
+
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
261
+
));
262
+
}
263
+
};
264
+
265
+
if let Err(e) = update_result {
266
+
error!("Failed to update user channel: {:?}", e);
267
+
if channel == "email"
268
+
&& e.as_database_error()
269
+
.map(|db| db.is_unique_violation())
270
+
.unwrap_or(false)
271
+
{
272
+
return Err((
273
+
StatusCode::BAD_REQUEST,
274
+
Json(json!({ "error": "EmailTaken", "message": "Email already in use" })),
275
+
));
276
+
}
277
+
return Err((
278
+
StatusCode::INTERNAL_SERVER_ERROR,
279
+
Json(json!({ "error": "InternalError", "message": "Failed to update channel" })),
280
+
));
281
+
}
282
+
283
+
info!(did = %did, channel = %channel, "Channel verified successfully");
284
+
285
+
Ok(Json(VerifyTokenOutput {
286
+
success: true,
287
+
did: did.to_string(),
288
+
purpose: "channel_update".to_string(),
289
+
channel: channel.to_string(),
290
+
}))
291
+
}
292
+
293
+
async fn handle_signup_verification(
294
+
state: &AppState,
295
+
did: &str,
296
+
channel: &str,
297
+
_identifier: &str,
298
+
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
299
+
let user = sqlx::query!(
300
+
"SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1",
301
+
did
302
+
)
303
+
.fetch_optional(&state.db)
304
+
.await
305
+
.map_err(|e| {
306
+
warn!(error = %e, "Database error during signup verification");
307
+
(
308
+
StatusCode::INTERNAL_SERVER_ERROR,
309
+
Json(json!({ "error": "InternalError", "message": "Database error" })),
310
+
)
311
+
})?;
312
+
313
+
let user = user.ok_or_else(|| {
314
+
(
315
+
StatusCode::NOT_FOUND,
316
+
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
317
+
)
318
+
})?;
319
+
320
+
let is_verified = user.email_verified
321
+
|| user.discord_verified
322
+
|| user.telegram_verified
323
+
|| user.signal_verified;
324
+
if is_verified {
325
+
info!(did = %did, "Account already verified");
326
+
return Ok(Json(VerifyTokenOutput {
327
+
success: true,
328
+
did: did.to_string(),
329
+
purpose: "signup".to_string(),
330
+
channel: channel.to_string(),
331
+
}));
332
+
}
333
+
334
+
let update_result = match channel {
335
+
"email" => {
336
+
sqlx::query!(
337
+
"UPDATE users SET email_verified = TRUE WHERE id = $1",
338
+
user.id
339
+
)
340
+
.execute(&state.db)
341
+
.await
342
+
}
343
+
"discord" => {
344
+
sqlx::query!(
345
+
"UPDATE users SET discord_verified = TRUE WHERE id = $1",
346
+
user.id
347
+
)
348
+
.execute(&state.db)
349
+
.await
350
+
}
351
+
"telegram" => {
352
+
sqlx::query!(
353
+
"UPDATE users SET telegram_verified = TRUE WHERE id = $1",
354
+
user.id
355
+
)
356
+
.execute(&state.db)
357
+
.await
358
+
}
359
+
"signal" => {
360
+
sqlx::query!(
361
+
"UPDATE users SET signal_verified = TRUE WHERE id = $1",
362
+
user.id
363
+
)
364
+
.execute(&state.db)
365
+
.await
366
+
}
367
+
_ => {
368
+
return Err((
369
+
StatusCode::BAD_REQUEST,
370
+
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
371
+
));
372
+
}
373
+
};
374
+
375
+
update_result.map_err(|e| {
376
+
warn!(error = %e, "Failed to update channel verified status");
377
+
(
378
+
StatusCode::INTERNAL_SERVER_ERROR,
379
+
Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })),
380
+
)
381
+
})?;
382
+
383
+
info!(did = %did, channel = %channel, "Signup verified successfully");
384
+
385
+
Ok(Json(VerifyTokenOutput {
386
+
success: true,
387
+
did: did.to_string(),
388
+
purpose: "signup".to_string(),
389
+
channel: channel.to_string(),
390
+
}))
391
+
}
+8
-8
src/api/validation.rs
+8
-8
src/api/validation.rs
···
64
64
return Err(HandleValidationError::TooLong);
65
65
}
66
66
67
-
if let Some(first_char) = handle.chars().next() {
68
-
if first_char == '-' || first_char == '_' {
69
-
return Err(HandleValidationError::StartsWithInvalidChar);
70
-
}
67
+
if let Some(first_char) = handle.chars().next()
68
+
&& (first_char == '-' || first_char == '_')
69
+
{
70
+
return Err(HandleValidationError::StartsWithInvalidChar);
71
71
}
72
72
73
-
if let Some(last_char) = handle.chars().last() {
74
-
if last_char == '-' || last_char == '_' {
75
-
return Err(HandleValidationError::EndsWithInvalidChar);
76
-
}
73
+
if let Some(last_char) = handle.chars().last()
74
+
&& (last_char == '-' || last_char == '_')
75
+
{
76
+
return Err(HandleValidationError::EndsWithInvalidChar);
77
77
}
78
78
79
79
for c in handle.chars() {
+8
-176
src/api/verification.rs
+8
-176
src/api/verification.rs
···
1
-
use crate::auth::validate_bearer_token;
2
1
use crate::state::AppState;
3
2
use axum::{
4
3
Json,
5
4
extract::State,
6
-
http::{HeaderMap, StatusCode},
5
+
http::HeaderMap,
7
6
response::{IntoResponse, Response},
8
7
};
9
-
use chrono::Utc;
10
8
use serde::Deserialize;
11
9
use serde_json::json;
12
-
use tracing::{error, info};
13
10
14
11
#[derive(Deserialize)]
15
12
#[serde(rename_all = "camelCase")]
16
13
pub struct ConfirmChannelVerificationInput {
17
14
pub channel: String,
15
+
pub identifier: String,
18
16
pub code: String,
19
17
}
20
18
···
23
21
headers: HeaderMap,
24
22
Json(input): Json<ConfirmChannelVerificationInput>,
25
23
) -> Response {
26
-
let token = match crate::auth::extract_bearer_token_from_header(
27
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
28
-
) {
29
-
Some(t) => t,
30
-
None => return (
31
-
StatusCode::UNAUTHORIZED,
32
-
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
33
-
)
34
-
.into_response(),
35
-
};
36
-
let user = match validate_bearer_token(&state.db, &token).await {
37
-
Ok(u) => u,
38
-
Err(_) => {
39
-
return (
40
-
StatusCode::UNAUTHORIZED,
41
-
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
42
-
)
43
-
.into_response();
44
-
}
45
-
};
46
-
47
-
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
48
-
.fetch_one(&state.db)
49
-
.await
50
-
{
51
-
Ok(id) => id,
52
-
Err(_) => {
53
-
return (
54
-
StatusCode::INTERNAL_SERVER_ERROR,
55
-
Json(json!({"error": "InternalError", "message": "User not found"})),
56
-
)
57
-
.into_response();
58
-
}
59
-
};
60
-
61
-
let channel_str = input.channel.as_str();
62
-
if !["email", "discord", "telegram", "signal"].contains(&channel_str) {
63
-
return (
64
-
StatusCode::BAD_REQUEST,
65
-
Json(json!({"error": "InvalidRequest", "message": "Invalid channel"})),
66
-
)
67
-
.into_response();
68
-
}
69
-
70
-
let record = match sqlx::query!(
71
-
r#"
72
-
SELECT code, pending_identifier, expires_at FROM channel_verifications
73
-
WHERE user_id = $1 AND channel = $2::comms_channel
74
-
"#,
75
-
user_id,
76
-
channel_str as _
77
-
)
78
-
.fetch_optional(&state.db)
79
-
.await {
80
-
Ok(Some(r)) => r,
81
-
Ok(None) => return (
82
-
StatusCode::BAD_REQUEST,
83
-
Json(json!({"error": "InvalidRequest", "message": "No pending verification found. Update notification preferences first."})),
84
-
)
85
-
.into_response(),
86
-
Err(e) => return (
87
-
StatusCode::INTERNAL_SERVER_ERROR,
88
-
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
89
-
)
90
-
.into_response(),
91
-
};
92
-
93
-
let pending_identifier =
94
-
match record.pending_identifier {
95
-
Some(p) => p,
96
-
None => return (
97
-
StatusCode::BAD_REQUEST,
98
-
Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})),
99
-
)
100
-
.into_response(),
101
-
};
102
-
103
-
if record.expires_at < Utc::now() {
104
-
return (
105
-
StatusCode::BAD_REQUEST,
106
-
Json(json!({"error": "ExpiredToken", "message": "Verification code expired"})),
107
-
)
108
-
.into_response();
109
-
}
110
-
111
-
if record.code != input.code {
112
-
return (
113
-
StatusCode::BAD_REQUEST,
114
-
Json(json!({"error": "InvalidCode", "message": "Invalid verification code"})),
115
-
)
116
-
.into_response();
117
-
}
118
-
119
-
let mut tx = match state.db.begin().await {
120
-
Ok(tx) => tx,
121
-
Err(_) => {
122
-
return (
123
-
StatusCode::INTERNAL_SERVER_ERROR,
124
-
Json(json!({"error": "InternalError"})),
125
-
)
126
-
.into_response();
127
-
}
128
-
};
129
-
130
-
let update_result = match channel_str {
131
-
"email" => sqlx::query!(
132
-
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
133
-
pending_identifier,
134
-
user_id
135
-
).execute(&mut *tx).await,
136
-
"discord" => sqlx::query!(
137
-
"UPDATE users SET discord_id = $1, discord_verified = TRUE, updated_at = NOW() WHERE id = $2",
138
-
pending_identifier,
139
-
user_id
140
-
).execute(&mut *tx).await,
141
-
"telegram" => sqlx::query!(
142
-
"UPDATE users SET telegram_username = $1, telegram_verified = TRUE, updated_at = NOW() WHERE id = $2",
143
-
pending_identifier,
144
-
user_id
145
-
).execute(&mut *tx).await,
146
-
"signal" => sqlx::query!(
147
-
"UPDATE users SET signal_number = $1, signal_verified = TRUE, updated_at = NOW() WHERE id = $2",
148
-
pending_identifier,
149
-
user_id
150
-
).execute(&mut *tx).await,
151
-
_ => unreachable!(),
24
+
let token_input = crate::api::server::VerifyTokenInput {
25
+
token: input.code,
26
+
identifier: input.identifier,
152
27
};
153
28
154
-
if let Err(e) = update_result {
155
-
error!("Failed to update user channel: {:?}", e);
156
-
if channel_str == "email"
157
-
&& e.as_database_error()
158
-
.map(|db| db.is_unique_violation())
159
-
.unwrap_or(false)
160
-
{
161
-
return (
162
-
StatusCode::BAD_REQUEST,
163
-
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
164
-
)
165
-
.into_response();
166
-
}
167
-
return (
168
-
StatusCode::INTERNAL_SERVER_ERROR,
169
-
Json(json!({"error": "InternalError", "message": "Failed to update channel"})),
170
-
)
171
-
.into_response();
172
-
}
173
-
174
-
if let Err(e) = sqlx::query!(
175
-
"DELETE FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
176
-
user_id,
177
-
channel_str as _
178
-
)
179
-
.execute(&mut *tx)
180
-
.await
181
-
{
182
-
error!("Failed to delete verification record: {:?}", e);
183
-
return (
184
-
StatusCode::INTERNAL_SERVER_ERROR,
185
-
Json(json!({"error": "InternalError"})),
186
-
)
187
-
.into_response();
29
+
match crate::api::server::verify_token_internal(&state, Some(&headers), token_input).await {
30
+
Ok(output) => Json(json!({"success": output.success})).into_response(),
31
+
Err((status, err_json)) => (status, err_json).into_response(),
188
32
}
189
-
190
-
if tx.commit().await.is_err() {
191
-
return (
192
-
StatusCode::INTERNAL_SERVER_ERROR,
193
-
Json(json!({"error": "InternalError"})),
194
-
)
195
-
.into_response();
196
-
}
197
-
198
-
info!(did = %user.did, channel = %channel_str, "Channel verified successfully");
199
-
200
-
Json(json!({"success": true})).into_response()
201
33
}
+1
src/auth/mod.rs
+1
src/auth/mod.rs
+423
src/auth/verification_token.rs
+423
src/auth/verification_token.rs
···
1
+
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2
+
use hmac::Mac;
3
+
use sha2::{Digest, Sha256};
4
+
5
+
type HmacSha256 = hmac::Hmac<Sha256>;
6
+
7
+
const TOKEN_VERSION: u8 = 1;
8
+
const DEFAULT_SIGNUP_EXPIRY_MINUTES: u64 = 30;
9
+
const DEFAULT_MIGRATION_EXPIRY_HOURS: u64 = 48;
10
+
const DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES: u64 = 10;
11
+
12
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13
+
pub enum VerificationPurpose {
14
+
Signup,
15
+
Migration,
16
+
ChannelUpdate,
17
+
}
18
+
19
+
impl VerificationPurpose {
20
+
fn as_str(&self) -> &'static str {
21
+
match self {
22
+
Self::Signup => "signup",
23
+
Self::Migration => "migration",
24
+
Self::ChannelUpdate => "channel_update",
25
+
}
26
+
}
27
+
28
+
fn from_str(s: &str) -> Option<Self> {
29
+
match s {
30
+
"signup" => Some(Self::Signup),
31
+
"migration" => Some(Self::Migration),
32
+
"channel_update" => Some(Self::ChannelUpdate),
33
+
_ => None,
34
+
}
35
+
}
36
+
37
+
fn default_expiry_seconds(&self) -> u64 {
38
+
match self {
39
+
Self::Signup => DEFAULT_SIGNUP_EXPIRY_MINUTES * 60,
40
+
Self::Migration => DEFAULT_MIGRATION_EXPIRY_HOURS * 3600,
41
+
Self::ChannelUpdate => DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES * 60,
42
+
}
43
+
}
44
+
}
45
+
46
+
#[derive(Debug)]
47
+
pub struct VerificationToken {
48
+
pub did: String,
49
+
pub purpose: VerificationPurpose,
50
+
pub channel: String,
51
+
pub identifier_hash: String,
52
+
pub expires_at: u64,
53
+
}
54
+
55
+
fn derive_verification_key() -> [u8; 32] {
56
+
use hkdf::Hkdf;
57
+
let master_key = std::env::var("MASTER_KEY").unwrap_or_else(|_| {
58
+
if cfg!(test) || std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_ok() {
59
+
"test-master-key-not-for-production".to_string()
60
+
} else {
61
+
panic!("MASTER_KEY must be set");
62
+
}
63
+
});
64
+
let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
65
+
let mut key = [0u8; 32];
66
+
hk.expand(b"tranquil-pds-verification-token-v1", &mut key)
67
+
.expect("HKDF expansion failed");
68
+
key
69
+
}
70
+
71
+
pub fn hash_identifier(identifier: &str) -> String {
72
+
let mut hasher = Sha256::new();
73
+
hasher.update(identifier.to_lowercase().as_bytes());
74
+
let result = hasher.finalize();
75
+
URL_SAFE_NO_PAD.encode(&result[..16])
76
+
}
77
+
78
+
pub fn generate_signup_token(did: &str, channel: &str, identifier: &str) -> String {
79
+
generate_token(did, VerificationPurpose::Signup, channel, identifier)
80
+
}
81
+
82
+
pub fn generate_migration_token(did: &str, email: &str) -> String {
83
+
generate_token(did, VerificationPurpose::Migration, "email", email)
84
+
}
85
+
86
+
pub fn generate_channel_update_token(did: &str, channel: &str, identifier: &str) -> String {
87
+
generate_token(did, VerificationPurpose::ChannelUpdate, channel, identifier)
88
+
}
89
+
90
+
pub fn generate_token(
91
+
did: &str,
92
+
purpose: VerificationPurpose,
93
+
channel: &str,
94
+
identifier: &str,
95
+
) -> String {
96
+
generate_token_with_expiry(
97
+
did,
98
+
purpose,
99
+
channel,
100
+
identifier,
101
+
purpose.default_expiry_seconds(),
102
+
)
103
+
}
104
+
105
+
pub fn generate_token_with_expiry(
106
+
did: &str,
107
+
purpose: VerificationPurpose,
108
+
channel: &str,
109
+
identifier: &str,
110
+
expiry_seconds: u64,
111
+
) -> String {
112
+
let key = derive_verification_key();
113
+
let identifier_hash = hash_identifier(identifier);
114
+
let expires_at = std::time::SystemTime::now()
115
+
.duration_since(std::time::UNIX_EPOCH)
116
+
.unwrap_or_default()
117
+
.as_secs()
118
+
+ expiry_seconds;
119
+
120
+
let payload = format!(
121
+
"{}|{}|{}|{}|{}",
122
+
did,
123
+
purpose.as_str(),
124
+
channel,
125
+
identifier_hash,
126
+
expires_at
127
+
);
128
+
129
+
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
130
+
mac.update(payload.as_bytes());
131
+
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
132
+
133
+
let token_data = format!(
134
+
"{}|{}|{}|{}|{}|{}|{}",
135
+
TOKEN_VERSION,
136
+
did,
137
+
purpose.as_str(),
138
+
channel,
139
+
identifier_hash,
140
+
expires_at,
141
+
signature
142
+
);
143
+
URL_SAFE_NO_PAD.encode(token_data.as_bytes())
144
+
}
145
+
146
+
#[derive(Debug)]
147
+
pub enum VerifyError {
148
+
InvalidFormat,
149
+
UnsupportedVersion,
150
+
Expired,
151
+
InvalidSignature,
152
+
IdentifierMismatch,
153
+
PurposeMismatch,
154
+
ChannelMismatch,
155
+
}
156
+
157
+
impl std::fmt::Display for VerifyError {
158
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159
+
match self {
160
+
Self::InvalidFormat => write!(f, "Invalid token format"),
161
+
Self::UnsupportedVersion => write!(f, "Unsupported token version"),
162
+
Self::Expired => write!(f, "Token has expired"),
163
+
Self::InvalidSignature => write!(f, "Invalid token signature"),
164
+
Self::IdentifierMismatch => write!(f, "Identifier does not match token"),
165
+
Self::PurposeMismatch => write!(f, "Token purpose does not match"),
166
+
Self::ChannelMismatch => write!(f, "Token channel does not match"),
167
+
}
168
+
}
169
+
}
170
+
171
+
pub fn verify_signup_token(
172
+
token: &str,
173
+
expected_channel: &str,
174
+
expected_identifier: &str,
175
+
) -> Result<VerificationToken, VerifyError> {
176
+
let parsed = verify_token_signature(token)?;
177
+
if parsed.purpose != VerificationPurpose::Signup {
178
+
return Err(VerifyError::PurposeMismatch);
179
+
}
180
+
if parsed.channel != expected_channel {
181
+
return Err(VerifyError::ChannelMismatch);
182
+
}
183
+
let expected_hash = hash_identifier(expected_identifier);
184
+
if parsed.identifier_hash != expected_hash {
185
+
return Err(VerifyError::IdentifierMismatch);
186
+
}
187
+
Ok(parsed)
188
+
}
189
+
190
+
pub fn verify_migration_token(
191
+
token: &str,
192
+
expected_email: &str,
193
+
) -> Result<VerificationToken, VerifyError> {
194
+
let parsed = verify_token_signature(token)?;
195
+
if parsed.purpose != VerificationPurpose::Migration {
196
+
return Err(VerifyError::PurposeMismatch);
197
+
}
198
+
if parsed.channel != "email" {
199
+
return Err(VerifyError::ChannelMismatch);
200
+
}
201
+
let expected_hash = hash_identifier(expected_email);
202
+
if parsed.identifier_hash != expected_hash {
203
+
return Err(VerifyError::IdentifierMismatch);
204
+
}
205
+
Ok(parsed)
206
+
}
207
+
208
+
pub fn verify_channel_update_token(
209
+
token: &str,
210
+
expected_channel: &str,
211
+
expected_identifier: &str,
212
+
) -> Result<VerificationToken, VerifyError> {
213
+
let parsed = verify_token_signature(token)?;
214
+
if parsed.purpose != VerificationPurpose::ChannelUpdate {
215
+
return Err(VerifyError::PurposeMismatch);
216
+
}
217
+
if parsed.channel != expected_channel {
218
+
return Err(VerifyError::ChannelMismatch);
219
+
}
220
+
let expected_hash = hash_identifier(expected_identifier);
221
+
if parsed.identifier_hash != expected_hash {
222
+
return Err(VerifyError::IdentifierMismatch);
223
+
}
224
+
Ok(parsed)
225
+
}
226
+
227
+
pub fn verify_token_for_did(
228
+
token: &str,
229
+
expected_did: &str,
230
+
) -> Result<VerificationToken, VerifyError> {
231
+
let parsed = verify_token_signature(token)?;
232
+
if parsed.did != expected_did {
233
+
return Err(VerifyError::IdentifierMismatch);
234
+
}
235
+
Ok(parsed)
236
+
}
237
+
238
+
pub fn verify_token_signature(token: &str) -> Result<VerificationToken, VerifyError> {
239
+
let token_bytes = URL_SAFE_NO_PAD
240
+
.decode(token.trim())
241
+
.map_err(|_| VerifyError::InvalidFormat)?;
242
+
let token_str = String::from_utf8(token_bytes).map_err(|_| VerifyError::InvalidFormat)?;
243
+
244
+
let parts: Vec<&str> = token_str.split('|').collect();
245
+
if parts.len() != 7 {
246
+
return Err(VerifyError::InvalidFormat);
247
+
}
248
+
249
+
let version: u8 = parts[0].parse().map_err(|_| VerifyError::InvalidFormat)?;
250
+
if version != TOKEN_VERSION {
251
+
return Err(VerifyError::UnsupportedVersion);
252
+
}
253
+
254
+
let did = parts[1];
255
+
let purpose_str = parts[2];
256
+
let channel = parts[3];
257
+
let identifier_hash = parts[4];
258
+
let expires_at: u64 = parts[5].parse().map_err(|_| VerifyError::InvalidFormat)?;
259
+
let provided_signature = parts[6];
260
+
261
+
let purpose = VerificationPurpose::from_str(purpose_str).ok_or(VerifyError::InvalidFormat)?;
262
+
263
+
let now = std::time::SystemTime::now()
264
+
.duration_since(std::time::UNIX_EPOCH)
265
+
.unwrap_or_default()
266
+
.as_secs();
267
+
if now > expires_at {
268
+
return Err(VerifyError::Expired);
269
+
}
270
+
271
+
let key = derive_verification_key();
272
+
let payload = format!(
273
+
"{}|{}|{}|{}|{}",
274
+
did, purpose_str, channel, identifier_hash, expires_at
275
+
);
276
+
let mut mac = <HmacSha256 as Mac>::new_from_slice(&key).expect("HMAC key size is valid");
277
+
mac.update(payload.as_bytes());
278
+
let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
279
+
280
+
use subtle::ConstantTimeEq;
281
+
let sig_matches: bool = provided_signature
282
+
.as_bytes()
283
+
.ct_eq(expected_signature.as_bytes())
284
+
.into();
285
+
if !sig_matches {
286
+
return Err(VerifyError::InvalidSignature);
287
+
}
288
+
289
+
Ok(VerificationToken {
290
+
did: did.to_string(),
291
+
purpose,
292
+
channel: channel.to_string(),
293
+
identifier_hash: identifier_hash.to_string(),
294
+
expires_at,
295
+
})
296
+
}
297
+
298
+
pub fn format_token_for_display(token: &str) -> String {
299
+
let clean = token.replace(['-', ' '], "");
300
+
let mut result = String::new();
301
+
for (i, c) in clean.chars().enumerate() {
302
+
if i > 0 && i % 4 == 0 {
303
+
result.push('-');
304
+
}
305
+
result.push(c);
306
+
}
307
+
result
308
+
}
309
+
310
+
pub fn normalize_token_input(input: &str) -> String {
311
+
input
312
+
.chars()
313
+
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '=')
314
+
.collect()
315
+
}
316
+
317
+
#[cfg(test)]
318
+
mod tests {
319
+
use super::*;
320
+
321
+
#[test]
322
+
fn test_signup_token() {
323
+
let did = "did:plc:test123";
324
+
let channel = "email";
325
+
let identifier = "test@example.com";
326
+
let token = generate_signup_token(did, channel, identifier);
327
+
let result = verify_signup_token(&token, channel, identifier);
328
+
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
329
+
let parsed = result.unwrap();
330
+
assert_eq!(parsed.did, did);
331
+
assert_eq!(parsed.purpose, VerificationPurpose::Signup);
332
+
assert_eq!(parsed.channel, channel);
333
+
}
334
+
335
+
#[test]
336
+
fn test_migration_token() {
337
+
let did = "did:plc:test123";
338
+
let email = "test@example.com";
339
+
let token = generate_migration_token(did, email);
340
+
let result = verify_migration_token(&token, email);
341
+
assert!(result.is_ok(), "Expected Ok, got {:?}", result);
342
+
let parsed = result.unwrap();
343
+
assert_eq!(parsed.did, did);
344
+
assert_eq!(parsed.purpose, VerificationPurpose::Migration);
345
+
}
346
+
347
+
#[test]
348
+
fn test_token_case_insensitive() {
349
+
let did = "did:plc:test123";
350
+
let token = generate_signup_token(did, "email", "Test@Example.COM");
351
+
let result = verify_signup_token(&token, "email", "test@example.com");
352
+
assert!(result.is_ok());
353
+
}
354
+
355
+
#[test]
356
+
fn test_token_wrong_identifier() {
357
+
let did = "did:plc:test123";
358
+
let token = generate_signup_token(did, "email", "test@example.com");
359
+
let result = verify_signup_token(&token, "email", "other@example.com");
360
+
assert!(matches!(result, Err(VerifyError::IdentifierMismatch)));
361
+
}
362
+
363
+
#[test]
364
+
fn test_token_wrong_channel() {
365
+
let did = "did:plc:test123";
366
+
let token = generate_signup_token(did, "email", "test@example.com");
367
+
let result = verify_signup_token(&token, "discord", "test@example.com");
368
+
assert!(matches!(result, Err(VerifyError::ChannelMismatch)));
369
+
}
370
+
371
+
#[test]
372
+
fn test_expired_token() {
373
+
let did = "did:plc:test123";
374
+
let token = generate_token_with_expiry(
375
+
did,
376
+
VerificationPurpose::Signup,
377
+
"email",
378
+
"test@example.com",
379
+
0,
380
+
);
381
+
std::thread::sleep(std::time::Duration::from_millis(1100));
382
+
let result = verify_signup_token(&token, "email", "test@example.com");
383
+
assert!(matches!(result, Err(VerifyError::Expired)));
384
+
}
385
+
386
+
#[test]
387
+
fn test_invalid_token() {
388
+
let result = verify_signup_token("invalid-token", "email", "test@example.com");
389
+
assert!(matches!(result, Err(VerifyError::InvalidFormat)));
390
+
}
391
+
392
+
#[test]
393
+
fn test_purpose_mismatch() {
394
+
let did = "did:plc:test123";
395
+
let email = "test@example.com";
396
+
let signup_token = generate_signup_token(did, "email", email);
397
+
let result = verify_migration_token(&signup_token, email);
398
+
assert!(matches!(result, Err(VerifyError::PurposeMismatch)));
399
+
}
400
+
401
+
#[test]
402
+
fn test_discord_channel() {
403
+
let did = "did:plc:test123";
404
+
let discord_id = "123456789012345678";
405
+
let token = generate_signup_token(did, "discord", discord_id);
406
+
let result = verify_signup_token(&token, "discord", discord_id);
407
+
assert!(result.is_ok());
408
+
}
409
+
410
+
#[test]
411
+
fn test_format_token_for_display() {
412
+
let token = "ABCDEFGHIJKLMNOP";
413
+
let formatted = format_token_for_display(token);
414
+
assert_eq!(formatted, "ABCD-EFGH-IJKL-MNOP");
415
+
}
416
+
417
+
#[test]
418
+
fn test_normalize_token_input() {
419
+
let input = "ABCD-EFGH IJKL-MNOP";
420
+
let normalized = normalize_token_input(input);
421
+
assert_eq!(normalized, "ABCDEFGHIJKLMNOP");
422
+
}
423
+
}
+28
-28
src/comms/locale.rs
+28
-28
src/comms/locale.rs
···
12
12
pub struct NotificationStrings {
13
13
pub welcome_subject: &'static str,
14
14
pub welcome_body: &'static str,
15
-
pub email_verification_subject: &'static str,
16
-
pub email_verification_body: &'static str,
17
15
pub password_reset_subject: &'static str,
18
16
pub password_reset_body: &'static str,
19
17
pub email_update_subject: &'static str,
···
30
28
pub signup_verification_body: &'static str,
31
29
pub legacy_login_subject: &'static str,
32
30
pub legacy_login_body: &'static str,
31
+
pub migration_verification_subject: &'static str,
32
+
pub migration_verification_body: &'static str,
33
33
}
34
34
35
35
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
···
46
46
static STRINGS_EN: NotificationStrings = NotificationStrings {
47
47
welcome_subject: "Welcome to {hostname}",
48
48
welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.",
49
-
email_verification_subject: "Verify your email - {hostname}",
50
-
email_verification_body: "Hello @{handle},\n\nYour email verification code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
51
49
password_reset_subject: "Password Reset - {hostname}",
52
50
password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
53
51
email_update_subject: "Confirm your new email - {hostname}",
54
-
email_update_body: "Hello @{handle},\n\nYour email update confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
52
+
email_update_body: "Hello @{handle},\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})",
55
53
account_deletion_subject: "Account Deletion Request - {hostname}",
56
54
account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
57
55
plc_operation_subject: "{hostname} - PLC Operation Token",
···
61
59
passkey_recovery_subject: "Account Recovery - {hostname}",
62
60
passkey_recovery_body: "Hello @{handle},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{url}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.",
63
61
signup_verification_subject: "Verify your account - {hostname}",
64
-
signup_verification_body: "Welcome! Your account verification code is: {code}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {hostname}.",
62
+
signup_verification_body: "Welcome! Your verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 30 minutes.\n\nIf you did not create an account on {hostname}, please ignore this message.\n\n(Or if you like to live dangerously: {verify_link})",
65
63
legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}",
66
64
legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}",
65
+
migration_verification_subject: "Verify your email - {hostname}",
66
+
migration_verification_body: "Welcome to {hostname}!\n\nYour account has been migrated successfully. To complete the setup, please verify your email address.\n\nYour verification code is:\n{code}\n\nCopy the code above and enter it at:\n{verify_page}\n\nThis code will expire in 48 hours.\n\nIf you did not migrate your account, please ignore this email.\n\n(Or if you like to live dangerously: {verify_link})",
67
67
};
68
68
69
69
static STRINGS_ZH: NotificationStrings = NotificationStrings {
70
70
welcome_subject: "欢迎加入 {hostname}",
71
71
welcome_body: "欢迎加入 {hostname}!\n\n您的用户名是:@{handle}\n\n感谢您的加入。",
72
-
email_verification_subject: "验证您的邮箱 - {hostname}",
73
-
email_verification_body: "您好 @{handle},\n\n您的邮箱验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
74
72
password_reset_subject: "密码重置 - {hostname}",
75
73
password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。",
76
74
email_update_subject: "确认您的新邮箱 - {hostname}",
77
-
email_update_body: "您好 @{handle},\n\n您的邮箱更新确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
75
+
email_update_body: "您好 @{handle},\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})",
78
76
account_deletion_subject: "账户删除请求 - {hostname}",
79
77
account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。",
80
78
plc_operation_subject: "{hostname} - PLC 操作令牌",
···
84
82
passkey_recovery_subject: "账户恢复 - {hostname}",
85
83
passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。",
86
84
signup_verification_subject: "验证您的账户 - {hostname}",
87
-
signup_verification_body: "欢迎!您的账户验证码是:{code}\n\n此验证码将在30分钟后过期。\n\n请输入此验证码完成在 {hostname} 上的注册。",
85
+
signup_verification_body: "欢迎!您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在30分钟后过期。\n\n如果您没有在 {hostname} 上创建账户,请忽略此消息。\n\n(或者直接点击链接:{verify_link})",
88
86
legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}",
89
87
legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}",
88
+
migration_verification_subject: "验证您的邮箱 - {hostname}",
89
+
migration_verification_body: "欢迎来到 {hostname}!\n\n您的账户已成功迁移。要完成设置,请验证您的邮箱地址。\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在 48 小时后过期。\n\n如果您没有迁移账户,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})",
90
90
};
91
91
92
92
static STRINGS_JA: NotificationStrings = NotificationStrings {
93
93
welcome_subject: "{hostname} へようこそ",
94
94
welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。",
95
-
email_verification_subject: "メール認証 - {hostname}",
96
-
email_verification_body: "@{handle} 様\n\nメール認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
97
95
password_reset_subject: "パスワードリセット - {hostname}",
98
96
password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。",
99
97
email_update_subject: "新しいメールアドレスの確認 - {hostname}",
100
-
email_update_body: "@{handle} 様\n\nメールアドレス更新の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
98
+
email_update_body: "@{handle} 様\n\n確認コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
101
99
account_deletion_subject: "アカウント削除リクエスト - {hostname}",
102
100
account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。",
103
101
plc_operation_subject: "{hostname} - PLC 操作トークン",
···
107
105
passkey_recovery_subject: "アカウント復旧 - {hostname}",
108
106
passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。",
109
107
signup_verification_subject: "アカウント認証 - {hostname}",
110
-
signup_verification_body: "ようこそ!アカウント認証コードは:{code}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} への登録を完了するには、このコードを入力してください。",
108
+
signup_verification_body: "ようこそ!認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} でアカウントを作成していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
111
109
legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}",
112
110
legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}",
111
+
migration_verification_subject: "メールアドレスの認証 - {hostname}",
112
+
migration_verification_body: "{hostname} へようこそ!\n\nアカウントの移行が完了しました。設定を完了するには、メールアドレスを認証してください。\n\n認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは48時間後に期限切れとなります。\n\nアカウントを移行していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
113
113
};
114
114
115
115
static STRINGS_KO: NotificationStrings = NotificationStrings {
116
116
welcome_subject: "{hostname}에 오신 것을 환영합니다",
117
117
welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.",
118
-
email_verification_subject: "이메일 인증 - {hostname}",
119
-
email_verification_body: "안녕하세요 @{handle}님,\n\n이메일 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.",
120
118
password_reset_subject: "비밀번호 재설정 - {hostname}",
121
119
password_reset_body: "안녕하세요 @{handle}님,\n\n비밀번호 재설정 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.",
122
-
email_update_subject: "새 이메일 확인 - {hostname}",
123
-
email_update_body: "안녕하세요 @{handle}님,\n\n이메일 업데이트 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.",
120
+
email_update_subject: "새 이메일 주소 확인 - {hostname}",
121
+
email_update_body: "안녕하세요 @{handle}님,\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
124
122
account_deletion_subject: "계정 삭제 요청 - {hostname}",
125
123
account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.",
126
124
plc_operation_subject: "{hostname} - PLC 작업 토큰",
···
130
128
passkey_recovery_subject: "계정 복구 - {hostname}",
131
129
passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.",
132
130
signup_verification_subject: "계정 인증 - {hostname}",
133
-
signup_verification_body: "환영합니다! 계정 인증 코드는: {code}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 등록을 완료하려면 이 코드를 입력하세요.",
131
+
signup_verification_body: "환영합니다! 인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
134
132
legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}",
135
133
legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림",
134
+
migration_verification_subject: "이메일 인증 - {hostname}",
135
+
migration_verification_body: "{hostname}에 오신 것을 환영합니다!\n\n계정 마이그레이션이 완료되었습니다. 설정을 완료하려면 이메일 주소를 인증하세요.\n\n인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 48시간 후에 만료됩니다.\n\n계정을 마이그레이션하지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
136
136
};
137
137
138
138
static STRINGS_SV: NotificationStrings = NotificationStrings {
139
139
welcome_subject: "Välkommen till {hostname}",
140
140
welcome_body: "Välkommen till {hostname}!\n\nDitt användarnamn är: @{handle}\n\nTack för att du gick med.",
141
-
email_verification_subject: "Verifiera din e-post - {hostname}",
142
-
email_verification_body: "Hej @{handle},\n\nDin e-postverifieringskod är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
143
141
password_reset_subject: "Lösenordsåterställning - {hostname}",
144
142
password_reset_body: "Hej @{handle},\n\nDin kod för lösenordsåterställning är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
145
143
email_update_subject: "Bekräfta din nya e-post - {hostname}",
146
-
email_update_body: "Hej @{handle},\n\nDin bekräftelsekod för e-postuppdatering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.",
144
+
email_update_body: "Hej @{handle},\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
147
145
account_deletion_subject: "Begäran om kontoradering - {hostname}",
148
146
account_deletion_body: "Hej @{handle},\n\nDin bekräftelsekod för kontoradering är: {code}\n\nDenna kod upphör om 10 minuter.\n\nOm du inte begärde detta, skydda ditt konto omedelbart.",
149
147
plc_operation_subject: "{hostname} - PLC-operationstoken",
···
153
151
passkey_recovery_subject: "Kontoåterställning - {hostname}",
154
152
passkey_recovery_body: "Hej @{handle},\n\nDu begärde att återställa ditt endast nyckelkonto.\n\nKlicka på länken nedan för att ställa in ett tillfälligt lösenord och återfå åtkomst:\n{url}\n\nDenna länk upphör om 1 timme.\n\nOm du inte begärde detta kan du ignorera detta meddelande. Ditt konto förblir säkert.",
155
153
signup_verification_subject: "Verifiera ditt konto - {hostname}",
156
-
signup_verification_body: "Välkommen! Din kontoverifieringskod är: {code}\n\nDenna kod upphör om 30 minuter.\n\nAnge denna kod för att slutföra din registrering på {hostname}.",
154
+
signup_verification_body: "Välkommen! Din verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 30 minuter.\n\nOm du inte skapade ett konto på {hostname}, ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
157
155
legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}",
158
156
legacy_login_body: "Hej @{handle},\n\nEn inloggning till ditt konto upptäcktes med en äldre app (som Bluesky) som inte stöder TOTP-verifiering.\n\nDetaljer:\n- Tid: {timestamp}\n- IP-adress: {ip}\n\nDitt TOTP-skydd kringgicks för denna inloggning. Sessionen har begränsade behörigheter för känsliga operationer.\n\nOm detta inte var du:\n1. Ändra ditt lösenord omedelbart\n2. Granska dina aktiva sessioner\n3. Överväg att inaktivera äldre appinloggningar i dina säkerhetsinställningar\n\nVar försiktig,\n{hostname}",
157
+
migration_verification_subject: "Verifiera din e-post - {hostname}",
158
+
migration_verification_body: "Välkommen till {hostname}!\n\nDitt konto har migrerats framgångsrikt. För att slutföra installationen, verifiera din e-postadress.\n\nDin verifieringskod är:\n{code}\n\nKopiera koden ovan och ange den på:\n{verify_page}\n\nDenna kod upphör om 48 timmar.\n\nOm du inte migrerade ditt konto kan du ignorera detta meddelande.\n\n(Eller om du gillar att leva farligt: {verify_link})",
159
159
};
160
160
161
161
static STRINGS_FI: NotificationStrings = NotificationStrings {
162
162
welcome_subject: "Tervetuloa palveluun {hostname}",
163
163
welcome_body: "Tervetuloa palveluun {hostname}!\n\nKäyttäjänimesi on: @{handle}\n\nKiitos liittymisestä.",
164
-
email_verification_subject: "Vahvista sähköpostisi - {hostname}",
165
-
email_verification_body: "Hei @{handle},\n\nSähköpostin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
166
164
password_reset_subject: "Salasanan palautus - {hostname}",
167
165
password_reset_body: "Hei @{handle},\n\nSalasanan palautuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
168
-
email_update_subject: "Vahvista uusi sähköpostiosoitteesi - {hostname}",
169
-
email_update_body: "Hei @{handle},\n\nSähköpostin päivityksen vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.",
166
+
email_update_subject: "Vahvista uusi sähköpostisi - {hostname}",
167
+
email_update_body: "Hei @{handle},\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
170
168
account_deletion_subject: "Tilin poistopyyntö - {hostname}",
171
169
account_deletion_body: "Hei @{handle},\n\nTilin poiston vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 10 minuutissa.\n\nJos et pyytänyt tätä, suojaa tilisi välittömästi.",
172
170
plc_operation_subject: "{hostname} - PLC-toimintotunniste",
···
176
174
passkey_recovery_subject: "Tilin palautus - {hostname}",
177
175
passkey_recovery_body: "Hei @{handle},\n\nPyysit palauttamaan vain pääsyavaintilisi.\n\nKlikkaa alla olevaa linkkiä asettaaksesi väliaikaisen salasanan ja saadaksesi pääsyn takaisin:\n{url}\n\nTämä linkki vanhenee tunnissa.\n\nJos et pyytänyt tätä, voit jättää tämän viestin huomiotta. Tilisi pysyy turvassa.",
178
176
signup_verification_subject: "Vahvista tilisi - {hostname}",
179
-
signup_verification_body: "Tervetuloa! Tilin vahvistuskoodisi on: {code}\n\nTämä koodi vanhenee 30 minuutissa.\n\nSyötä tämä koodi viimeistelläksesi rekisteröintisi palveluun {hostname}.",
177
+
signup_verification_body: "Tervetuloa! Vahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 30 minuutissa.\n\nJos et luonut tiliä palveluun {hostname}, jätä tämä viesti huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
180
178
legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}",
181
179
legacy_login_body: "Hei @{handle},\n\nTilillesi havaittiin kirjautuminen vanhalla sovelluksella (kuten Bluesky), joka ei tue TOTP-vahvistusta.\n\nTiedot:\n- Aika: {timestamp}\n- IP-osoite: {ip}\n\nTOTP-suojauksesi ohitettiin tässä kirjautumisessa. Istunnolla on rajoitetut oikeudet arkaluontoisiin toimintoihin.\n\nJos tämä et ollut sinä:\n1. Vaihda salasanasi välittömästi\n2. Tarkista aktiiviset istuntosi\n3. Harkitse vanhojen sovellusten kirjautumisen poistamista käytöstä turvallisuusasetuksissa\n\nOle varovainen,\n{hostname}",
180
+
migration_verification_subject: "Vahvista sähköpostisi - {hostname}",
181
+
migration_verification_body: "Tervetuloa palveluun {hostname}!\n\nTilisi on siirretty onnistuneesti. Viimeistele asennus vahvistamalla sähköpostiosoitteesi.\n\nVahvistuskoodisi on:\n{code}\n\nKopioi koodi yllä ja syötä se osoitteessa:\n{verify_page}\n\nTämä koodi vanhenee 48 tunnissa.\n\nJos et siirtänyt tiliäsi, voit jättää tämän viestin huomiotta.\n\n(Tai jos pidät vaarallisesta elämästä: {verify_link})",
182
182
};
183
183
184
184
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+1
-1
src/comms/mod.rs
+1
-1
src/comms/mod.rs
···
10
10
11
11
pub use service::{
12
12
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
13
-
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
13
+
enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery,
14
14
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
15
15
queue_legacy_login_notification,
16
16
};
+81
-34
src/comms/service.rs
+81
-34
src/comms/service.rs
···
313
313
.await
314
314
}
315
315
316
-
pub async fn enqueue_email_verification(
317
-
db: &PgPool,
318
-
user_id: Uuid,
319
-
email: &str,
320
-
handle: &str,
321
-
code: &str,
322
-
hostname: &str,
323
-
) -> Result<Uuid, sqlx::Error> {
324
-
let prefs = get_user_comms_prefs(db, user_id).await?;
325
-
let strings = get_strings(&prefs.locale);
326
-
let body = format_message(
327
-
strings.email_verification_body,
328
-
&[("handle", handle), ("code", code)],
329
-
);
330
-
let subject = format_message(strings.email_verification_subject, &[("hostname", hostname)]);
331
-
enqueue_comms(
332
-
db,
333
-
NewComms::email(
334
-
user_id,
335
-
super::types::CommsType::EmailVerification,
336
-
email.to_string(),
337
-
subject,
338
-
body,
339
-
),
340
-
)
341
-
.await
342
-
}
343
-
344
316
pub async fn enqueue_password_reset(
345
317
db: &PgPool,
346
318
user_id: Uuid,
···
378
350
) -> Result<Uuid, sqlx::Error> {
379
351
let prefs = get_user_comms_prefs(db, user_id).await?;
380
352
let strings = get_strings(&prefs.locale);
353
+
let encoded_email = urlencoding::encode(new_email);
354
+
let encoded_token = urlencoding::encode(code);
355
+
let verify_page = format!("https://{}/#/verify", hostname);
356
+
let verify_link = format!(
357
+
"https://{}/#/verify?token={}&identifier={}",
358
+
hostname, encoded_token, encoded_email
359
+
);
381
360
let body = format_message(
382
361
strings.email_update_body,
383
-
&[("handle", handle), ("code", code)],
362
+
&[
363
+
("handle", handle),
364
+
("code", code),
365
+
("verify_page", &verify_page),
366
+
("verify_link", &verify_link),
367
+
],
384
368
);
385
369
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
386
370
enqueue_comms(
···
530
514
_ => CommsChannel::Email,
531
515
};
532
516
let strings = get_strings(locale.unwrap_or("en"));
517
+
let (verify_page, verify_link) = if comms_channel == CommsChannel::Email {
518
+
let encoded_email = urlencoding::encode(recipient);
519
+
let encoded_token = urlencoding::encode(code);
520
+
(
521
+
format!("https://{}/#/verify", hostname),
522
+
format!(
523
+
"https://{}/#/verify?token={}&identifier={}",
524
+
hostname, encoded_token, encoded_email
525
+
),
526
+
)
527
+
} else {
528
+
(String::new(), String::new())
529
+
};
533
530
let body = format_message(
534
531
strings.signup_verification_body,
535
-
&[("code", code), ("hostname", &hostname)],
532
+
&[
533
+
("code", code),
534
+
("hostname", &hostname),
535
+
("verify_page", &verify_page),
536
+
("verify_link", &verify_link),
537
+
],
536
538
);
537
539
let subject = match comms_channel {
538
-
CommsChannel::Email => {
539
-
Some(format_message(strings.signup_verification_subject, &[("hostname", &hostname)]))
540
-
}
540
+
CommsChannel::Email => Some(format_message(
541
+
strings.signup_verification_subject,
542
+
&[("hostname", &hostname)],
543
+
)),
541
544
_ => None,
542
545
};
543
546
enqueue_comms(
···
554
557
.await
555
558
}
556
559
560
+
pub async fn enqueue_migration_verification(
561
+
db: &PgPool,
562
+
user_id: Uuid,
563
+
email: &str,
564
+
token: &str,
565
+
hostname: &str,
566
+
) -> Result<Uuid, sqlx::Error> {
567
+
let prefs = get_user_comms_prefs(db, user_id).await?;
568
+
let strings = get_strings(&prefs.locale);
569
+
let encoded_email = urlencoding::encode(email);
570
+
let encoded_token = urlencoding::encode(token);
571
+
let verify_page = format!("https://{}/#/verify", hostname);
572
+
let verify_link = format!(
573
+
"https://{}/#/verify?token={}&identifier={}",
574
+
hostname, encoded_token, encoded_email
575
+
);
576
+
let body = format_message(
577
+
strings.migration_verification_body,
578
+
&[
579
+
("code", token),
580
+
("hostname", hostname),
581
+
("verify_page", &verify_page),
582
+
("verify_link", &verify_link),
583
+
],
584
+
);
585
+
let subject = format_message(
586
+
strings.migration_verification_subject,
587
+
&[("hostname", hostname)],
588
+
);
589
+
enqueue_comms(
590
+
db,
591
+
NewComms::email(
592
+
user_id,
593
+
super::types::CommsType::MigrationVerification,
594
+
email.to_string(),
595
+
subject,
596
+
body,
597
+
),
598
+
)
599
+
.await
600
+
}
601
+
557
602
pub async fn queue_legacy_login_notification(
558
603
db: &PgPool,
559
604
user_id: Uuid,
···
563
608
) -> Result<Uuid, sqlx::Error> {
564
609
let prefs = get_user_comms_prefs(db, user_id).await?;
565
610
let strings = get_strings(&prefs.locale);
566
-
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
611
+
let timestamp = chrono::Utc::now()
612
+
.format("%Y-%m-%d %H:%M:%S UTC")
613
+
.to_string();
567
614
let body = format_message(
568
615
strings.legacy_login_body,
569
616
&[
+1
src/comms/types.rs
+1
src/comms/types.rs
+5
-2
src/config.rs
+5
-2
src/config.rs
···
114
114
.expect("HKDF expansion failed");
115
115
116
116
let mut device_cookie_key = [0u8; 32];
117
-
hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key)
118
-
.expect("HKDF expansion failed");
117
+
hk.expand(
118
+
b"tranquil-pds-device-cookie-signing",
119
+
&mut device_cookie_key,
120
+
)
121
+
.expect("HKDF expansion failed");
119
122
120
123
AuthConfig {
121
124
jwt_secret,
+12
src/lib.rs
+12
src/lib.rs
···
296
296
post(api::server::reserve_signing_key),
297
297
)
298
298
.route(
299
+
"/xrpc/com.atproto.server.verifyMigrationEmail",
300
+
post(api::server::verify_migration_email),
301
+
)
302
+
.route(
303
+
"/xrpc/com.atproto.server.resendMigrationVerification",
304
+
post(api::server::resend_migration_verification),
305
+
)
306
+
.route(
299
307
"/xrpc/com.atproto.identity.updateHandle",
300
308
post(api::identity::update_handle),
301
309
)
···
549
557
.route(
550
558
"/xrpc/com.tranquil.account.confirmChannelVerification",
551
559
post(api::verification::confirm_channel_verification),
560
+
)
561
+
.route(
562
+
"/xrpc/com.tranquil.account.verifyToken",
563
+
post(api::server::verify_token),
552
564
)
553
565
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
554
566
.layer(middleware::from_fn(metrics::metrics_middleware))
+2
-1
src/oauth/endpoints/metadata.rs
+2
-1
src/oauth/endpoints/metadata.rs
···
172
172
"refresh_token".to_string(),
173
173
],
174
174
response_types: vec!["code".to_string()],
175
-
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
175
+
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"
176
+
.to_string(),
176
177
token_endpoint_auth_method: "none".to_string(),
177
178
application_type: "web".to_string(),
178
179
dpop_bound_access_tokens: true,
+4
-3
src/rate_limit.rs
+4
-3
src/rate_limit.rs
···
74
74
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
75
75
NonZeroU32::new(5).unwrap(),
76
76
))),
77
-
totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60))
78
-
.unwrap()
79
-
.allow_burst(NonZeroU32::new(5).unwrap()),
77
+
totp_verify: Arc::new(RateLimiter::keyed(
78
+
Quota::with_period(std::time::Duration::from_secs(60))
79
+
.unwrap()
80
+
.allow_burst(NonZeroU32::new(5).unwrap()),
80
81
)),
81
82
}
82
83
}
+51
-13
src/validation/mod.rs
+51
-13
src/validation/mod.rs
···
458
458
459
459
fn is_common_password(password: &str) -> bool {
460
460
const COMMON_PASSWORDS: &[&str] = &[
461
-
"password", "Password1", "Password123", "Passw0rd", "Passw0rd!",
462
-
"12345678", "123456789", "1234567890",
463
-
"qwerty123", "Qwerty123", "qwertyui", "Qwertyui",
464
-
"letmein1", "Letmein1", "welcome1", "Welcome1",
465
-
"admin123", "Admin123", "password1", "Password1!",
466
-
"iloveyou", "Iloveyou1", "monkey123", "Monkey123",
467
-
"dragon12", "Dragon123", "master12", "Master123",
468
-
"login123", "Login123", "abc12345", "Abc12345",
469
-
"football", "Football1", "baseball", "Baseball1",
470
-
"trustno1", "Trustno1", "sunshine", "Sunshine1",
471
-
"princess", "Princess1", "computer", "Computer1",
472
-
"whatever", "Whatever1", "nintendo", "Nintendo1",
473
-
"bluesky1", "Bluesky1", "Bluesky123",
461
+
"password",
462
+
"Password1",
463
+
"Password123",
464
+
"Passw0rd",
465
+
"Passw0rd!",
466
+
"12345678",
467
+
"123456789",
468
+
"1234567890",
469
+
"qwerty123",
470
+
"Qwerty123",
471
+
"qwertyui",
472
+
"Qwertyui",
473
+
"letmein1",
474
+
"Letmein1",
475
+
"welcome1",
476
+
"Welcome1",
477
+
"admin123",
478
+
"Admin123",
479
+
"password1",
480
+
"Password1!",
481
+
"iloveyou",
482
+
"Iloveyou1",
483
+
"monkey123",
484
+
"Monkey123",
485
+
"dragon12",
486
+
"Dragon123",
487
+
"master12",
488
+
"Master123",
489
+
"login123",
490
+
"Login123",
491
+
"abc12345",
492
+
"Abc12345",
493
+
"football",
494
+
"Football1",
495
+
"baseball",
496
+
"Baseball1",
497
+
"trustno1",
498
+
"Trustno1",
499
+
"sunshine",
500
+
"Sunshine1",
501
+
"princess",
502
+
"Princess1",
503
+
"computer",
504
+
"Computer1",
505
+
"whatever",
506
+
"Whatever1",
507
+
"nintendo",
508
+
"Nintendo1",
509
+
"bluesky1",
510
+
"Bluesky1",
511
+
"Bluesky123",
474
512
];
475
513
476
514
let lower = password.to_lowercase();
+44
-8
tests/account_notifications.rs
+44
-8
tests/account_notifications.rs
···
92
92
.await
93
93
.expect("User not found");
94
94
95
-
let code: String = sqlx::query_scalar!(
96
-
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
95
+
let row = sqlx::query!(
96
+
"SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
97
97
user_id
98
98
)
99
99
.fetch_one(&pool)
100
100
.await
101
101
.expect("Verification code not found");
102
102
103
+
let code = row
104
+
.metadata
105
+
.as_ref()
106
+
.and_then(|m| m.get("code"))
107
+
.and_then(|c| c.as_str())
108
+
.expect("No code in metadata");
109
+
103
110
let input = json!({
104
111
"channel": "discord",
112
+
"identifier": "123456789",
105
113
"code": code
106
114
});
107
115
let resp = client
···
153
161
154
162
let input = json!({
155
163
"channel": "telegram",
156
-
"code": "000000"
164
+
"identifier": "testuser",
165
+
"code": "XXXX-XXXX-XXXX-XXXX"
157
166
});
158
167
let resp = client
159
168
.post(format!(
···
165
174
.send()
166
175
.await
167
176
.unwrap();
168
-
assert_eq!(resp.status(), 400);
177
+
assert!(
178
+
resp.status() == 400 || resp.status() == 422,
179
+
"Expected 400 or 422, got {}",
180
+
resp.status()
181
+
);
169
182
}
170
183
171
184
#[tokio::test]
···
176
189
177
190
let input = json!({
178
191
"channel": "signal",
179
-
"code": "123456"
192
+
"identifier": "123456",
193
+
"code": "XXXX-XXXX-XXXX-XXXX"
180
194
});
181
195
let resp = client
182
196
.post(format!(
···
188
202
.send()
189
203
.await
190
204
.unwrap();
191
-
assert_eq!(resp.status(), 400);
205
+
assert!(
206
+
resp.status() == 400 || resp.status() == 422,
207
+
"Expected 400 or 422, got {}",
208
+
resp.status()
209
+
);
192
210
}
193
211
194
212
#[tokio::test]
···
226
244
.await
227
245
.expect("User not found");
228
246
229
-
let code: String = sqlx::query_scalar!(
230
-
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
247
+
let body_text: String = sqlx::query_scalar!(
248
+
"SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
231
249
user_id
232
250
)
233
251
.fetch_one(&pool)
234
252
.await
235
253
.expect("Verification code not found");
236
254
255
+
let code = body_text
256
+
.lines()
257
+
.skip_while(|line| !line.contains("verification code"))
258
+
.nth(1)
259
+
.map(|line| line.trim().to_string())
260
+
.filter(|line| !line.is_empty() && line.contains('-'))
261
+
.unwrap_or_else(|| {
262
+
body_text
263
+
.lines()
264
+
.find(|line| {
265
+
let trimmed = line.trim();
266
+
trimmed.starts_with("MX") && trimmed.contains('-')
267
+
})
268
+
.map(|s| s.trim().to_string())
269
+
.unwrap_or_default()
270
+
});
271
+
237
272
let input = json!({
238
273
"channel": "email",
274
+
"identifier": unique_email,
239
275
"code": code
240
276
});
241
277
let resp = client
+48
-5
tests/common/mod.rs
+48
-5
tests/common/mod.rs
···
297
297
.connect(&conn_str)
298
298
.await
299
299
.expect("Failed to connect to test database");
300
-
let verification_code: String = sqlx::query_scalar!(
301
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
300
+
let body_text: String = sqlx::query_scalar!(
301
+
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
302
302
did
303
303
)
304
304
.fetch_one(&pool)
305
305
.await
306
306
.expect("Failed to get verification code");
307
+
308
+
let verification_code = body_text
309
+
.lines()
310
+
.find(|line| line.contains("verification code:") || line.contains("code is:"))
311
+
.and_then(|line| {
312
+
if line.contains("verification code:") {
313
+
line.split("verification code:")
314
+
.nth(1)
315
+
.map(|s| s.trim().to_string())
316
+
} else {
317
+
line.split("code is:").nth(1).map(|s| s.trim().to_string())
318
+
}
319
+
})
320
+
.unwrap_or_else(|| {
321
+
body_text
322
+
.lines()
323
+
.find(|line| line.trim().starts_with("MX") && line.contains('-'))
324
+
.map(|s| s.trim().to_string())
325
+
.unwrap_or_default()
326
+
});
307
327
308
328
let confirm_payload = json!({
309
329
"did": did,
···
453
473
if let Some(access_jwt) = body["accessJwt"].as_str() {
454
474
return (access_jwt.to_string(), did);
455
475
}
456
-
let verification_code: String = sqlx::query_scalar!(
457
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
476
+
let body_text: String = sqlx::query_scalar!(
477
+
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
458
478
&did
459
479
)
460
480
.fetch_one(&pool)
461
481
.await
462
-
.expect("Failed to get verification code");
482
+
.expect("Failed to get verification from comms_queue");
483
+
let verification_code = body_text
484
+
.lines()
485
+
.find(|line| line.contains("verification code:") || line.contains("code is:"))
486
+
.and_then(|line| {
487
+
if line.contains("verification code:") {
488
+
line.split("verification code:")
489
+
.nth(1)
490
+
.map(|s| s.trim().to_string())
491
+
} else if line.contains("code is:") {
492
+
line.split("code is:").nth(1).map(|s| s.trim().to_string())
493
+
} else {
494
+
None
495
+
}
496
+
})
497
+
.unwrap_or_else(|| {
498
+
body_text
499
+
.split_whitespace()
500
+
.find(|word| {
501
+
word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3
502
+
})
503
+
.unwrap_or(&body_text)
504
+
.to_string()
505
+
});
463
506
464
507
let confirm_payload = json!({
465
508
"did": did,
+18
-14
tests/did_web.rs
+18
-14
tests/did_web.rs
···
1
1
mod common;
2
-
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3
2
use base64::Engine;
3
+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4
4
use common::*;
5
5
use k256::ecdsa::{SigningKey, signature::Signer};
6
6
use reqwest::StatusCode;
···
387
387
let mock_uri = mock_server.uri();
388
388
let mock_addr = mock_uri.trim_start_matches("http://");
389
389
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "");
390
-
let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id);
390
+
let did = format!(
391
+
"did:web:{}:byod:{}",
392
+
mock_addr.replace(":", "%3A"),
393
+
unique_id
394
+
);
391
395
let handle = format!("byod_{}", uuid::Uuid::new_v4());
392
396
let pds_endpoint = base_url().await.replace("http://", "https://");
393
-
let pds_did = format!(
394
-
"did:web:{}",
395
-
pds_endpoint.trim_start_matches("https://")
396
-
);
397
+
let pds_did = format!("did:web:{}", pds_endpoint.trim_start_matches("https://"));
397
398
398
399
let temp_key = SigningKey::random(&mut rand::thread_rng());
399
400
let public_key_multibase = signing_key_to_multibase(&temp_key);
···
443
444
let body: Value = res.json().await.expect("Response was not JSON");
444
445
let returned_did = body["did"].as_str().expect("No DID in response");
445
446
assert_eq!(returned_did, did, "Returned DID should match requested DID");
446
-
let access_jwt = body["accessJwt"]
447
-
.as_str()
448
-
.expect("No accessJwt in response");
447
+
assert_eq!(
448
+
body["verificationRequired"], true,
449
+
"BYOD accounts should require verification"
450
+
);
451
+
452
+
let access_jwt = common::verify_new_account(&client, returned_did).await;
449
453
450
454
let res = client
451
455
.get(format!(
452
456
"{}/xrpc/com.atproto.server.checkAccountStatus",
453
457
base_url().await
454
458
))
455
-
.bearer_auth(access_jwt)
459
+
.bearer_auth(&access_jwt)
456
460
.send()
457
461
.await
458
462
.expect("Failed to check account status");
···
468
472
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
469
473
base_url().await
470
474
))
471
-
.bearer_auth(access_jwt)
475
+
.bearer_auth(&access_jwt)
472
476
.send()
473
477
.await
474
478
.expect("Failed to get recommended credentials");
···
491
495
"{}/xrpc/com.atproto.server.activateAccount",
492
496
base_url().await
493
497
))
494
-
.bearer_auth(access_jwt)
498
+
.bearer_auth(&access_jwt)
495
499
.send()
496
500
.await
497
501
.expect("Failed to activate account");
···
506
510
"{}/xrpc/com.atproto.server.checkAccountStatus",
507
511
base_url().await
508
512
))
509
-
.bearer_auth(access_jwt)
513
+
.bearer_auth(&access_jwt)
510
514
.send()
511
515
.await
512
516
.expect("Failed to check account status");
···
522
526
"{}/xrpc/com.atproto.repo.createRecord",
523
527
base_url().await
524
528
))
525
-
.bearer_auth(access_jwt)
529
+
.bearer_auth(&access_jwt)
526
530
.json(&json!({
527
531
"repo": did,
528
532
"collection": "app.bsky.feed.post",
+40
-57
tests/email_update.rs
+40
-57
tests/email_update.rs
···
12
12
.expect("Failed to connect to test database")
13
13
}
14
14
15
+
async fn get_email_update_token(pool: &PgPool, did: &str) -> String {
16
+
let body_text: String = sqlx::query_scalar!(
17
+
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
18
+
did
19
+
)
20
+
.fetch_one(pool)
21
+
.await
22
+
.expect("Verification not found");
23
+
24
+
body_text
25
+
.lines()
26
+
.skip_while(|line| !line.contains("verification code"))
27
+
.nth(1)
28
+
.map(|line| line.trim().to_string())
29
+
.filter(|line| !line.is_empty() && line.contains('-'))
30
+
.unwrap_or_else(|| {
31
+
body_text
32
+
.lines()
33
+
.find(|line| line.trim().starts_with("MX") && line.contains('-'))
34
+
.map(|s| s.trim().to_string())
35
+
.unwrap_or_default()
36
+
})
37
+
}
38
+
15
39
async fn create_verified_account(
16
40
client: &reqwest::Client,
17
41
base_url: &str,
···
61
85
let body: Value = res.json().await.expect("Invalid JSON");
62
86
assert_eq!(body["tokenRequired"], true);
63
87
64
-
let verification = sqlx::query!(
65
-
"SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
66
-
did
67
-
)
68
-
.fetch_one(&pool)
69
-
.await
70
-
.expect("Verification not found");
71
-
72
-
assert_eq!(
73
-
verification.pending_identifier.as_deref(),
74
-
Some(new_email.as_str())
75
-
);
76
-
let code = verification.code;
88
+
let code = get_email_update_token(&pool, &did).await;
77
89
let res = client
78
90
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
79
91
.bearer_auth(&access_jwt)
···
90
102
.await
91
103
.expect("User not found");
92
104
assert_eq!(user.email, Some(new_email));
93
-
94
-
let verification = sqlx::query!(
95
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
96
-
did
97
-
)
98
-
.fetch_optional(&pool)
99
-
.await
100
-
.expect("DB error");
101
-
assert!(verification.is_none());
102
105
}
103
106
104
107
#[tokio::test]
···
180
183
.await
181
184
.expect("Failed to request email update");
182
185
assert_eq!(res.status(), StatusCode::OK);
183
-
let verification = sqlx::query!(
184
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
185
-
did
186
-
)
187
-
.fetch_one(&pool)
188
-
.await
189
-
.expect("Verification not found");
190
-
let code = verification.code;
186
+
let code = get_email_update_token(&pool, &did).await;
191
187
let res = client
192
188
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
193
189
.bearer_auth(&access_jwt)
···
200
196
.expect("Failed to confirm email");
201
197
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
202
198
let body: Value = res.json().await.expect("Invalid JSON");
203
-
assert_eq!(body["message"], "Email does not match pending update");
199
+
assert!(
200
+
body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken"
201
+
);
204
202
}
205
203
206
204
#[tokio::test]
207
-
async fn test_update_email_success_no_token_required() {
205
+
async fn test_update_email_requires_token() {
208
206
let client = common::client();
209
207
let base_url = common::base_url().await;
210
-
let pool = get_pool().await;
211
208
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
212
209
let email = format!("{}@example.com", handle);
213
-
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
210
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
214
211
let new_email = format!("direct_{}@example.com", handle);
215
212
let res = client
216
213
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
···
219
216
.send()
220
217
.await
221
218
.expect("Failed to update email");
222
-
assert_eq!(res.status(), StatusCode::OK);
223
-
let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did)
224
-
.fetch_one(&pool)
225
-
.await
226
-
.expect("User not found");
227
-
assert_eq!(user.email, Some(new_email));
219
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
220
+
let body: Value = res.json().await.expect("Invalid JSON");
221
+
assert_eq!(body["error"], "TokenRequired");
228
222
}
229
223
230
224
#[tokio::test]
···
299
293
.await
300
294
.expect("Failed to request email update");
301
295
assert_eq!(res.status(), StatusCode::OK);
302
-
let verification = sqlx::query!(
303
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
304
-
did
305
-
)
306
-
.fetch_one(&pool)
307
-
.await
308
-
.expect("Verification not found");
309
-
let code = verification.code;
296
+
let code = get_email_update_token(&pool, &did).await;
310
297
let res = client
311
298
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
312
299
.bearer_auth(&access_jwt)
···
323
310
.await
324
311
.expect("User not found");
325
312
assert_eq!(user.email, Some(new_email));
326
-
let verification = sqlx::query!(
327
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
328
-
did
329
-
)
330
-
.fetch_optional(&pool)
331
-
.await
332
-
.expect("DB error");
333
-
assert!(verification.is_none());
334
313
}
335
314
336
315
#[tokio::test]
···
387
366
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
388
367
let body: Value = res.json().await.expect("Invalid JSON");
389
368
assert!(
390
-
body["message"].as_str().unwrap().contains("already in use")
369
+
body["error"] == "TokenRequired"
370
+
|| body["message"]
371
+
.as_str()
372
+
.unwrap_or("")
373
+
.contains("already in use")
391
374
|| body["error"] == "InvalidRequest"
392
375
);
393
376
}
+21
-2
tests/jwt_security.rs
+21
-2
tests/jwt_security.rs
···
688
688
.connect(&get_db_connection_string().await)
689
689
.await
690
690
.unwrap();
691
-
let code: String = sqlx::query_scalar!(
692
-
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
691
+
let body_text: String = sqlx::query_scalar!(
692
+
"SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
693
693
did
694
694
).fetch_one(&pool).await.unwrap();
695
+
let code = body_text
696
+
.lines()
697
+
.find(|line| line.contains("verification code:") || line.contains("code is:"))
698
+
.and_then(|line| {
699
+
if line.contains("verification code:") {
700
+
line.split("verification code:")
701
+
.nth(1)
702
+
.map(|s| s.trim().to_string())
703
+
} else {
704
+
line.split("code is:").nth(1).map(|s| s.trim().to_string())
705
+
}
706
+
})
707
+
.unwrap_or_else(|| {
708
+
body_text
709
+
.lines()
710
+
.find(|line| line.trim().starts_with("MX") && line.contains('-'))
711
+
.map(|s| s.trim().to_string())
712
+
.unwrap_or_default()
713
+
});
695
714
696
715
let confirm = http_client
697
716
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))