+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
{
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",
4
"describe": {
5
"columns": [
6
{
···
42
},
43
{
44
"ordinal": 5,
45
"name": "key_bytes",
46
"type_info": "Bytea"
47
},
48
{
49
-
"ordinal": 6,
50
"name": "encryption_version",
51
"type_info": "Int4"
52
}
···
62
false,
63
true,
64
false,
65
false,
66
true
67
]
68
},
69
-
"hash": "efc26a1202b1bbf72da1c06b59b47e560dfb5912db8e40ee92cf91846f306e1a"
70
}
···
1
{
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 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
"describe": {
5
"columns": [
6
{
···
42
},
43
{
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,
60
"name": "key_bytes",
61
"type_info": "Bytea"
62
},
63
{
64
+
"ordinal": 9,
65
"name": "encryption_version",
66
"type_info": "Int4"
67
}
···
77
false,
78
true,
79
false,
80
+
true,
81
+
true,
82
+
true,
83
false,
84
true
85
]
86
},
87
+
"hash": "9b895b9db78ec8a5f13c0b55ea0115e4653e01fd6f5c41f156c844381347cb8a"
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
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
9
"npm:jsdom@^25.0.1": "25.0.1",
10
"npm:multiformats@^13.3.1": "13.4.2",
11
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
12
"npm:svelte@5": "5.45.10_acorn@8.15.0",
13
"npm:vite@*": "6.4.1_picomatch@4.0.3",
···
794
"check-error@2.1.1": {
795
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
796
},
797
"cli-color@2.0.4": {
798
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
799
"dependencies": [
···
1339
"react-is@17.0.2": {
1340
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1341
},
1342
"redent@3.0.0": {
1343
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1344
"dependencies": [
···
1416
"dependencies": [
1417
"min-indent"
1418
]
1419
},
1420
"svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": {
1421
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
···
1517
},
1518
"type@2.7.3": {
1519
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
1520
},
1521
"vite-node@2.1.9": {
1522
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
···
8
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
9
"npm:jsdom@^25.0.1": "25.0.1",
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",
12
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
13
"npm:svelte@5": "5.45.10_acorn@8.15.0",
14
"npm:vite@*": "6.4.1_picomatch@4.0.3",
···
795
"check-error@2.1.1": {
796
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
797
},
798
+
"chokidar@4.0.3": {
799
+
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
800
+
"dependencies": [
801
+
"readdirp"
802
+
]
803
+
},
804
"cli-color@2.0.4": {
805
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
806
"dependencies": [
···
1346
"react-is@17.0.2": {
1347
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1348
},
1349
+
"readdirp@4.1.2": {
1350
+
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
1351
+
},
1352
"redent@3.0.0": {
1353
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1354
"dependencies": [
···
1426
"dependencies": [
1427
"min-indent"
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
1442
},
1443
"svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": {
1444
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
···
1540
},
1541
"type@2.7.3": {
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
1547
},
1548
"vite-node@2.1.9": {
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
})
320
},
321
322
-
async confirmChannelVerification(token: string, channel: string, code: string): Promise<{ success: boolean }> {
323
return xrpc('com.tranquil.account.confirmChannelVerification', {
324
method: 'POST',
325
token,
326
-
body: { channel, code },
327
})
328
},
329
···
852
return xrpc('com.tranquil.account.recoverPasskeyAccount', {
853
method: 'POST',
854
body: { did, recoveryToken, newPassword },
855
})
856
},
857
}
···
319
})
320
},
321
322
+
async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> {
323
return xrpc('com.tranquil.account.confirmChannelVerification', {
324
method: 'POST',
325
token,
326
+
body: { channel, identifier, code },
327
})
328
},
329
···
852
return xrpc('com.tranquil.account.recoverPasskeyAccount', {
853
method: 'POST',
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,
882
})
883
},
884
}
+16
-3
frontend/src/lib/registration/VerificationStep.svelte
+16
-3
frontend/src/lib/registration/VerificationStep.svelte
···
70
id="verification-code"
71
type="text"
72
bind:value={verificationCode}
73
-
placeholder="Enter 6-digit code"
74
disabled={flow.state.submitting}
75
required
76
-
maxlength="6"
77
-
inputmode="numeric"
78
autocomplete="one-time-code"
79
/>
80
</div>
81
82
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
···
99
.info-text {
100
color: var(--text-secondary);
101
margin: 0;
102
}
103
</style>
···
70
id="verification-code"
71
type="text"
72
bind:value={verificationCode}
73
+
placeholder="XXXX-XXXX-XXXX-XXXX"
74
disabled={flow.state.submitting}
75
required
76
autocomplete="one-time-code"
77
+
class="code-input"
78
/>
79
+
<span class="hint">Copy the entire code from your message, including dashes.</span>
80
</div>
81
82
<button type="submit" disabled={flow.state.submitting || !verificationCode.trim()}>
···
99
.info-text {
100
color: var(--text-secondary);
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);
115
}
116
</style>
+130
-8
frontend/src/locales/en.json
+130
-8
frontend/src/locales/en.json
···
164
"changeEmailButton": "Change Email",
165
"requesting": "Requesting...",
166
"verificationCode": "Verification Code",
167
-
"verificationCodePlaceholder": "Enter code from email",
168
"confirmEmailChange": "Confirm Email Change",
169
"updating": "Updating...",
170
"changeHandle": "Change Handle",
···
202
"deleteAccount": "Delete Account",
203
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
204
"requestDeletion": "Request Account Deletion",
205
-
"confirmationCode": "Confirmation Code (from email)",
206
"confirmationCodePlaceholder": "Enter confirmation code",
207
"yourPassword": "Your Password",
208
"yourPasswordPlaceholder": "Enter your password",
209
"permanentlyDelete": "Permanently Delete Account",
210
"deleting": "Deleting...",
211
"messages": {
212
-
"emailCodeSent": "Verification code sent to your current email",
213
"emailUpdated": "Email updated successfully",
214
"handleUpdated": "Handle updated successfully",
215
"passwordChanged": "Password changed successfully",
···
451
},
452
"admin": {
453
"title": "Admin Panel",
454
"serverStats": "Server Statistics",
455
"users": "Users",
456
"repos": "Repositories",
···
580
"verify": {
581
"title": "Verify Your Account",
582
"subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.",
583
-
"codePlaceholder": "Enter 6-digit code",
584
"codeLabel": "Verification Code",
585
"verifyButton": "Verify Account",
586
"verifying": "Verifying...",
587
"resendCode": "Resend Code",
588
"resending": "Resending...",
589
"codeResent": "Verification code resent!",
590
"backToLogin": "Back to Login",
591
"verifyingAccount": "Verifying account: @{handle}",
592
"startOver": "Start over with a different account",
593
"noPending": "No pending verification found.",
594
"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
"createAccount": "Create Account",
596
-
"signIn": "Sign In"
597
},
598
"resetPassword": {
599
"title": "Reset Password",
···
605
"sendCode": "Send Reset Code",
606
"sending": "Sending...",
607
"codeSent": "Password reset code sent! Check your preferred notification channel.",
608
-
"enterCode": "Enter the code from your email and your new password.",
609
"code": "Reset Code",
610
"codePlaceholder": "Enter reset code",
611
"newPassword": "New Password",
···
664
},
665
"registerPasskey": {
666
"title": "Create Passkey Account",
667
-
"subtitle": "Create a passwordless account using a passkey.",
668
"handle": "Handle",
669
"handlePlaceholder": "yourname",
670
"handleHint": "Your full handle will be: @{handle}",
671
"email": "Email Address",
672
"emailPlaceholder": "you@example.com",
673
"inviteCode": "Invite Code",
674
"inviteCodePlaceholder": "Enter your invite code",
675
"createButton": "Create Account",
676
"creating": "Creating...",
677
"alreadyHaveAccount": "Already have an account?",
678
"signIn": "Sign in",
679
"wantPassword": "Want to use a password?",
680
-
"createPasswordAccount": "Create a password account"
681
},
682
"trustedDevices": {
683
"title": "Trusted Devices",
···
710
"verify": "Verify",
711
"verifying": "Verifying...",
712
"cancel": "Cancel"
713
}
714
}
···
164
"changeEmailButton": "Change Email",
165
"requesting": "Requesting...",
166
"verificationCode": "Verification Code",
167
+
"verificationCodePlaceholder": "Enter verification code",
168
"confirmEmailChange": "Confirm Email Change",
169
"updating": "Updating...",
170
"changeHandle": "Change Handle",
···
202
"deleteAccount": "Delete Account",
203
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
204
"requestDeletion": "Request Account Deletion",
205
+
"confirmationCode": "Confirmation Code",
206
"confirmationCodePlaceholder": "Enter confirmation code",
207
"yourPassword": "Your Password",
208
"yourPasswordPlaceholder": "Enter your password",
209
"permanentlyDelete": "Permanently Delete Account",
210
"deleting": "Deleting...",
211
"messages": {
212
+
"emailCodeSent": "Verification code sent to your notification channel",
213
"emailUpdated": "Email updated successfully",
214
"handleUpdated": "Handle updated successfully",
215
"passwordChanged": "Password changed successfully",
···
451
},
452
"admin": {
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",
476
"serverStats": "Server Statistics",
477
"users": "Users",
478
"repos": "Repositories",
···
602
"verify": {
603
"title": "Verify Your Account",
604
"subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.",
605
+
"tokenSubtitle": "Enter the verification code and the identifier it was sent to.",
606
+
"tokenTitle": "Verify",
607
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
608
"codeLabel": "Verification Code",
609
+
"codeHelp": "Copy the entire code from your message, including dashes",
610
"verifyButton": "Verify Account",
611
+
"verify": "Verify",
612
"verifying": "Verifying...",
613
+
"pleaseWait": "Please wait...",
614
"resendCode": "Resend Code",
615
"resending": "Resending...",
616
+
"sending": "Sending...",
617
"codeResent": "Verification code resent!",
618
+
"codeResentDetail": "Verification code sent! Check your inbox.",
619
"backToLogin": "Back to Login",
620
"verifyingAccount": "Verifying account: @{handle}",
621
"startOver": "Start over with a different account",
622
"noPending": "No pending verification found.",
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.",
624
"createAccount": "Create Account",
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"
633
},
634
"resetPassword": {
635
"title": "Reset Password",
···
641
"sendCode": "Send Reset Code",
642
"sending": "Sending...",
643
"codeSent": "Password reset code sent! Check your preferred notification channel.",
644
+
"enterCode": "Enter the code you received and your new password.",
645
"code": "Reset Code",
646
"codePlaceholder": "Enter reset code",
647
"newPassword": "New Password",
···
700
},
701
"registerPasskey": {
702
"title": "Create Passkey Account",
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!",
713
"handle": "Handle",
714
"handlePlaceholder": "yourname",
715
"handleHint": "Your full handle will be: @{handle}",
716
+
"handleDotWarning": "Custom domain handles can be set up after account creation.",
717
"email": "Email Address",
718
"emailPlaceholder": "you@example.com",
719
"inviteCode": "Invite Code",
720
"inviteCodePlaceholder": "Enter your invite code",
721
"createButton": "Create Account",
722
"creating": "Creating...",
723
+
"continue": "Continue",
724
+
"back": "Back",
725
"alreadyHaveAccount": "Already have an account?",
726
"signIn": "Sign in",
727
"wantPassword": "Want to use a password?",
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
+
}
783
},
784
"trustedDevices": {
785
"title": "Trusted Devices",
···
812
"verify": "Verify",
813
"verifying": "Verifying...",
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"
835
}
836
}
+91
-7
frontend/src/locales/fi.json
+91
-7
frontend/src/locales/fi.json
···
164
"changeEmailButton": "Vaihda sähköposti",
165
"requesting": "Pyydetään...",
166
"verificationCode": "Vahvistuskoodi",
167
-
"verificationCodePlaceholder": "Syötä koodi sähköpostista",
168
"confirmEmailChange": "Vahvista sähköpostin vaihto",
169
"updating": "Päivitetään...",
170
"changeHandle": "Vaihda käyttäjänimi",
···
202
"deleteAccount": "Poista tili",
203
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
204
"requestDeletion": "Pyydä tilin poistoa",
205
-
"confirmationCode": "Vahvistuskoodi (sähköpostista)",
206
"confirmationCodePlaceholder": "Syötä vahvistuskoodi",
207
"yourPassword": "Salasanasi",
208
"yourPasswordPlaceholder": "Syötä salasanasi",
209
"permanentlyDelete": "Poista tili pysyvästi",
210
"deleting": "Poistetaan...",
211
"messages": {
212
-
"emailCodeSent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiisi",
213
"emailUpdated": "Sähköposti päivitetty",
214
"handleUpdated": "Käyttäjänimi päivitetty",
215
"passwordChanged": "Salasana vaihdettu",
···
451
},
452
"admin": {
453
"title": "Ylläpitopaneeli",
454
"serverStats": "Palvelintilastot",
455
"users": "Käyttäjät",
456
"repos": "Tietovarastot",
···
580
"verify": {
581
"title": "Vahvista tilisi",
582
"subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.",
583
-
"codePlaceholder": "Syötä 6-numeroinen koodi",
584
"codeLabel": "Vahvistuskoodi",
585
"verifyButton": "Vahvista tili",
586
"verifying": "Vahvistetaan...",
587
"resendCode": "Lähetä koodi uudelleen",
588
"resending": "Lähetetään uudelleen...",
589
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
590
"backToLogin": "Takaisin kirjautumiseen",
591
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
592
"startOver": "Aloita alusta toisella tilillä",
···
605
"sendCode": "Lähetä palautuskoodi",
606
"sending": "Lähetetään...",
607
"codeSent": "Palautuskoodi lähetetty! Tarkista ensisijainen ilmoituskanavasi.",
608
-
"enterCode": "Syötä koodi sähköpostistasi ja uusi salasanasi.",
609
"code": "Palautuskoodi",
610
"codePlaceholder": "Syötä palautuskoodi",
611
"newPassword": "Uusi salasana",
···
664
},
665
"registerPasskey": {
666
"title": "Luo pääsyavaintili",
667
-
"subtitle": "Luo salasanaton tili pääsyavaimella.",
668
"handle": "Käyttäjänimi",
669
"handlePlaceholder": "nimesi",
670
"handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
671
"email": "Sähköpostiosoite",
672
"emailPlaceholder": "sinä@esimerkki.fi",
673
"inviteCode": "Kutsukoodi",
674
"inviteCodePlaceholder": "Syötä kutsukoodisi",
675
"createButton": "Luo tili",
676
"creating": "Luodaan...",
677
"alreadyHaveAccount": "Onko sinulla jo tili?",
678
"signIn": "Kirjaudu sisään",
679
"wantPassword": "Haluatko käyttää salasanaa?",
680
-
"createPasswordAccount": "Luo salasanatili"
681
},
682
"trustedDevices": {
683
"title": "Luotetut laitteet",
···
710
"verify": "Vahvista",
711
"verifying": "Vahvistetaan...",
712
"cancel": "Peruuta"
713
}
714
}
···
164
"changeEmailButton": "Vaihda sähköposti",
165
"requesting": "Pyydetään...",
166
"verificationCode": "Vahvistuskoodi",
167
+
"verificationCodePlaceholder": "Syötä vahvistuskoodi",
168
"confirmEmailChange": "Vahvista sähköpostin vaihto",
169
"updating": "Päivitetään...",
170
"changeHandle": "Vaihda käyttäjänimi",
···
202
"deleteAccount": "Poista tili",
203
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
204
"requestDeletion": "Pyydä tilin poistoa",
205
+
"confirmationCode": "Vahvistuskoodi",
206
"confirmationCodePlaceholder": "Syötä vahvistuskoodi",
207
"yourPassword": "Salasanasi",
208
"yourPasswordPlaceholder": "Syötä salasanasi",
209
"permanentlyDelete": "Poista tili pysyvästi",
210
"deleting": "Poistetaan...",
211
"messages": {
212
+
"emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi",
213
"emailUpdated": "Sähköposti päivitetty",
214
"handleUpdated": "Käyttäjänimi päivitetty",
215
"passwordChanged": "Salasana vaihdettu",
···
451
},
452
"admin": {
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",
473
"serverStats": "Palvelintilastot",
474
"users": "Käyttäjät",
475
"repos": "Tietovarastot",
···
599
"verify": {
600
"title": "Vahvista tilisi",
601
"subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.",
602
+
"tokenTitle": "Vahvista",
603
+
"tokenSubtitle": "Syötä vahvistuskoodi ja tunniste, johon se lähetettiin.",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
605
"codeLabel": "Vahvistuskoodi",
606
+
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
607
"verifyButton": "Vahvista tili",
608
+
"verify": "Vahvista",
609
"verifying": "Vahvistetaan...",
610
+
"pleaseWait": "Odota...",
611
+
"sending": "Lähetetään...",
612
"resendCode": "Lähetä koodi uudelleen",
613
"resending": "Lähetetään uudelleen...",
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",
623
"backToLogin": "Takaisin kirjautumiseen",
624
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
625
"startOver": "Aloita alusta toisella tilillä",
···
638
"sendCode": "Lähetä palautuskoodi",
639
"sending": "Lähetetään...",
640
"codeSent": "Palautuskoodi lähetetty! Tarkista ensisijainen ilmoituskanavasi.",
641
+
"enterCode": "Syötä saamasi koodi ja uusi salasanasi.",
642
"code": "Palautuskoodi",
643
"codePlaceholder": "Syötä palautuskoodi",
644
"newPassword": "Uusi salasana",
···
697
},
698
"registerPasskey": {
699
"title": "Luo pääsyavaintili",
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.",
704
"handle": "Käyttäjänimi",
705
"handlePlaceholder": "nimesi",
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ä",
710
"email": "Sähköpostiosoite",
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)",
723
"inviteCode": "Kutsukoodi",
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",
732
"createButton": "Luo tili",
733
"creating": "Luodaan...",
734
"alreadyHaveAccount": "Onko sinulla jo tili?",
735
"signIn": "Kirjaudu sisään",
736
"wantPassword": "Haluatko käyttää salasanaa?",
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
+
}
745
},
746
"trustedDevices": {
747
"title": "Luotetut laitteet",
···
774
"verify": "Vahvista",
775
"verifying": "Vahvistetaan...",
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"
797
}
798
}
+92
-8
frontend/src/locales/ja.json
+92
-8
frontend/src/locales/ja.json
···
65
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
66
"didWeb": "did:web",
67
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
68
-
"didWebBYOD": "did:web (BYOD)",
69
"didWebBYODHint": "独自ドメインを持ち込む",
70
"didWebWarningTitle": "重要: トレードオフをご理解ください",
71
"didWebWarning1": "この PDS への永続的な紐付け:",
···
164
"changeEmailButton": "メールを変更",
165
"requesting": "リクエスト中...",
166
"verificationCode": "確認コード",
167
-
"verificationCodePlaceholder": "メールから受け取ったコードを入力",
168
"confirmEmailChange": "メール変更を確認",
169
"updating": "更新中...",
170
"changeHandle": "ハンドル変更",
···
202
"deleteAccount": "アカウント削除",
203
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
204
"requestDeletion": "アカウント削除をリクエスト",
205
-
"confirmationCode": "確認コード(メールから)",
206
"confirmationCodePlaceholder": "確認コードを入力",
207
"yourPassword": "パスワード",
208
"yourPasswordPlaceholder": "パスワードを入力",
209
"permanentlyDelete": "アカウントを完全に削除",
210
"deleting": "削除中...",
211
"messages": {
212
-
"emailCodeSent": "現在のメールに確認コードを送信しました",
213
"emailUpdated": "メールを更新しました",
214
"handleUpdated": "ハンドルを更新しました",
215
"passwordChanged": "パスワードを変更しました",
···
451
},
452
"admin": {
453
"title": "管理パネル",
454
"serverStats": "サーバー統計",
455
"users": "ユーザー",
456
"repos": "リポジトリ",
···
580
"verify": {
581
"title": "アカウント確認",
582
"subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。",
583
-
"codePlaceholder": "6桁のコードを入力",
584
"codeLabel": "確認コード",
585
"verifyButton": "アカウントを確認",
586
"verifying": "確認中...",
587
"resendCode": "コードを再送信",
588
"resending": "送信中...",
589
"codeResent": "確認コードを再送信しました!",
590
"backToLogin": "ログインに戻る",
591
"verifyingAccount": "確認中のアカウント: @{handle}",
592
"startOver": "別のアカウントでやり直す",
···
605
"sendCode": "リセットコードを送信",
606
"sending": "送信中...",
607
"codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。",
608
-
"enterCode": "メールからのコードと新しいパスワードを入力してください。",
609
"code": "リセットコード",
610
"codePlaceholder": "リセットコードを入力",
611
"newPassword": "新しいパスワード",
···
664
},
665
"registerPasskey": {
666
"title": "パスキーアカウントを作成",
667
-
"subtitle": "パスキーを使用してパスワードレスアカウントを作成します。",
668
"handle": "ハンドル",
669
"handlePlaceholder": "あなたの名前",
670
"handleHint": "完全なハンドル: @{handle}",
671
"email": "メールアドレス",
672
"emailPlaceholder": "you@example.com",
673
"inviteCode": "招待コード",
674
"inviteCodePlaceholder": "招待コードを入力",
675
"createButton": "アカウントを作成",
676
"creating": "作成中...",
677
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
678
"signIn": "サインイン",
679
"wantPassword": "パスワードを使用しますか?",
680
-
"createPasswordAccount": "パスワードアカウントを作成"
681
},
682
"trustedDevices": {
683
"title": "信頼済みデバイス",
···
710
"verify": "確認",
711
"verifying": "確認中...",
712
"cancel": "キャンセル"
713
}
714
}
···
65
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
66
"didWeb": "did:web",
67
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
68
+
"didWebBYOD": "did:web (自前ドメイン)",
69
"didWebBYODHint": "独自ドメインを持ち込む",
70
"didWebWarningTitle": "重要: トレードオフをご理解ください",
71
"didWebWarning1": "この PDS への永続的な紐付け:",
···
164
"changeEmailButton": "メールを変更",
165
"requesting": "リクエスト中...",
166
"verificationCode": "確認コード",
167
+
"verificationCodePlaceholder": "認証コードを入力",
168
"confirmEmailChange": "メール変更を確認",
169
"updating": "更新中...",
170
"changeHandle": "ハンドル変更",
···
202
"deleteAccount": "アカウント削除",
203
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
204
"requestDeletion": "アカウント削除をリクエスト",
205
+
"confirmationCode": "確認コード",
206
"confirmationCodePlaceholder": "確認コードを入力",
207
"yourPassword": "パスワード",
208
"yourPasswordPlaceholder": "パスワードを入力",
209
"permanentlyDelete": "アカウントを完全に削除",
210
"deleting": "削除中...",
211
"messages": {
212
+
"emailCodeSent": "通知チャンネルに確認コードを送信しました",
213
"emailUpdated": "メールを更新しました",
214
"handleUpdated": "ハンドルを更新しました",
215
"passwordChanged": "パスワードを変更しました",
···
451
},
452
"admin": {
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": "設定を保存",
473
"serverStats": "サーバー統計",
474
"users": "ユーザー",
475
"repos": "リポジトリ",
···
599
"verify": {
600
"title": "アカウント確認",
601
"subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。",
602
+
"tokenTitle": "確認",
603
+
"tokenSubtitle": "確認コードと送信先の識別子を入力してください。",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
605
"codeLabel": "確認コード",
606
+
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
607
"verifyButton": "アカウントを確認",
608
+
"verify": "確認",
609
"verifying": "確認中...",
610
+
"pleaseWait": "お待ちください...",
611
+
"sending": "送信中...",
612
"resendCode": "コードを再送信",
613
"resending": "送信中...",
614
"codeResent": "確認コードを再送信しました!",
615
+
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
616
+
"verified": "確認完了!",
617
+
"channelVerified": "{channel} が正常に確認されました。",
618
+
"canNowSignIn": "アカウントにサインインできるようになりました。",
619
+
"continue": "続行",
620
+
"identifierLabel": "メールまたは識別子",
621
+
"identifierPlaceholder": "you@example.com",
622
+
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
623
"backToLogin": "ログインに戻る",
624
"verifyingAccount": "確認中のアカウント: @{handle}",
625
"startOver": "別のアカウントでやり直す",
···
638
"sendCode": "リセットコードを送信",
639
"sending": "送信中...",
640
"codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。",
641
+
"enterCode": "受け取ったコードと新しいパスワードを入力してください。",
642
"code": "リセットコード",
643
"codePlaceholder": "リセットコードを入力",
644
"newPassword": "新しいパスワード",
···
697
},
698
"registerPasskey": {
699
"title": "パスキーアカウントを作成",
700
+
"subtitle": "パスワードの代わりにパスキーを使用して超安全なアカウントを作成します。",
701
+
"subtitleKeyChoice": "外部 did:web アイデンティティの設定方法を選択してください。",
702
+
"subtitleVerify": "{channel} に確認コードを送信しました。コードを入力して続行してください。",
703
+
"subtitlePasskey": "パスキーを作成してアカウント設定を完了します。",
704
"handle": "ハンドル",
705
"handlePlaceholder": "あなたの名前",
706
"handleHint": "完全なハンドル: @{handle}",
707
+
"contactMethod": "連絡方法",
708
+
"contactMethodHint": "アカウントの確認と通知の受信方法を選択してください。",
709
+
"verificationMethod": "確認方法",
710
"email": "メールアドレス",
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)",
723
"inviteCode": "招待コード",
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 をコピー",
732
"createButton": "アカウントを作成",
733
"creating": "作成中...",
734
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
735
"signIn": "サインイン",
736
"wantPassword": "パスワードを使用しますか?",
737
+
"createPasswordAccount": "パスワードアカウントを作成",
738
+
"errors": {
739
+
"handleRequired": "ハンドルは必須です",
740
+
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
741
+
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
742
+
"passkeyCancelled": "パスキーの作成がキャンセルされました",
743
+
"passkeyFailed": "パスキーの登録に失敗しました"
744
+
}
745
},
746
"trustedDevices": {
747
"title": "信頼済みデバイス",
···
774
"verify": "確認",
775
"verifying": "確認中...",
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": "認証"
797
}
798
}
+92
-8
frontend/src/locales/ko.json
+92
-8
frontend/src/locales/ko.json
···
65
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
66
"didWeb": "did:web",
67
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
68
-
"didWebBYOD": "did:web (BYOD)",
69
"didWebBYODHint": "자체 도메인 사용",
70
"didWebWarningTitle": "중요: 장단점을 이해하세요",
71
"didWebWarning1": "이 PDS에 영구 연결:",
···
164
"changeEmailButton": "이메일 변경",
165
"requesting": "요청 중...",
166
"verificationCode": "인증 코드",
167
-
"verificationCodePlaceholder": "이메일의 코드 입력",
168
"confirmEmailChange": "이메일 변경 확인",
169
"updating": "업데이트 중...",
170
"changeHandle": "핸들 변경",
···
202
"deleteAccount": "계정 삭제",
203
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
204
"requestDeletion": "계정 삭제 요청",
205
-
"confirmationCode": "확인 코드 (이메일에서)",
206
"confirmationCodePlaceholder": "확인 코드 입력",
207
"yourPassword": "비밀번호",
208
"yourPasswordPlaceholder": "비밀번호 입력",
209
"permanentlyDelete": "계정 영구 삭제",
210
"deleting": "삭제 중...",
211
"messages": {
212
-
"emailCodeSent": "현재 이메일로 인증 코드를 보냈습니다",
213
"emailUpdated": "이메일이 업데이트되었습니다",
214
"handleUpdated": "핸들이 업데이트되었습니다",
215
"passwordChanged": "비밀번호가 변경되었습니다",
···
451
},
452
"admin": {
453
"title": "관리 패널",
454
"serverStats": "서버 통계",
455
"users": "사용자",
456
"repos": "저장소",
···
580
"verify": {
581
"title": "계정 인증",
582
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.",
583
-
"codePlaceholder": "6자리 코드 입력",
584
"codeLabel": "인증 코드",
585
"verifyButton": "계정 인증",
586
"verifying": "인증 중...",
587
"resendCode": "코드 다시 보내기",
588
"resending": "전송 중...",
589
"codeResent": "인증 코드를 다시 보냈습니다!",
590
"backToLogin": "로그인으로 돌아가기",
591
"verifyingAccount": "인증 중인 계정: @{handle}",
592
"startOver": "다른 계정으로 다시 시작",
···
605
"sendCode": "재설정 코드 보내기",
606
"sending": "전송 중...",
607
"codeSent": "비밀번호 재설정 코드를 보냈습니다! 선호하는 알림 채널을 확인하세요.",
608
-
"enterCode": "이메일의 코드와 새 비밀번호를 입력하세요.",
609
"code": "재설정 코드",
610
"codePlaceholder": "재설정 코드 입력",
611
"newPassword": "새 비밀번호",
···
664
},
665
"registerPasskey": {
666
"title": "패스키 계정 만들기",
667
-
"subtitle": "패스키를 사용하여 비밀번호 없는 계정을 만듭니다.",
668
"handle": "핸들",
669
"handlePlaceholder": "사용자 이름",
670
"handleHint": "전체 핸들: @{handle}",
671
"email": "이메일 주소",
672
"emailPlaceholder": "you@example.com",
673
"inviteCode": "초대 코드",
674
"inviteCodePlaceholder": "초대 코드 입력",
675
"createButton": "계정 만들기",
676
"creating": "생성 중...",
677
"alreadyHaveAccount": "이미 계정이 있으신가요?",
678
"signIn": "로그인",
679
"wantPassword": "비밀번호를 사용하시겠습니까?",
680
-
"createPasswordAccount": "비밀번호 계정 만들기"
681
},
682
"trustedDevices": {
683
"title": "신뢰할 수 있는 기기",
···
710
"verify": "확인",
711
"verifying": "확인 중...",
712
"cancel": "취소"
713
}
714
}
···
65
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
66
"didWeb": "did:web",
67
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
68
+
"didWebBYOD": "did:web (자체 도메인)",
69
"didWebBYODHint": "자체 도메인 사용",
70
"didWebWarningTitle": "중요: 장단점을 이해하세요",
71
"didWebWarning1": "이 PDS에 영구 연결:",
···
164
"changeEmailButton": "이메일 변경",
165
"requesting": "요청 중...",
166
"verificationCode": "인증 코드",
167
+
"verificationCodePlaceholder": "인증 코드 입력",
168
"confirmEmailChange": "이메일 변경 확인",
169
"updating": "업데이트 중...",
170
"changeHandle": "핸들 변경",
···
202
"deleteAccount": "계정 삭제",
203
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
204
"requestDeletion": "계정 삭제 요청",
205
+
"confirmationCode": "확인 코드",
206
"confirmationCodePlaceholder": "확인 코드 입력",
207
"yourPassword": "비밀번호",
208
"yourPasswordPlaceholder": "비밀번호 입력",
209
"permanentlyDelete": "계정 영구 삭제",
210
"deleting": "삭제 중...",
211
"messages": {
212
+
"emailCodeSent": "알림 채널로 인증 코드를 보냈습니다",
213
"emailUpdated": "이메일이 업데이트되었습니다",
214
"handleUpdated": "핸들이 업데이트되었습니다",
215
"passwordChanged": "비밀번호가 변경되었습니다",
···
451
},
452
"admin": {
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": "설정 저장",
473
"serverStats": "서버 통계",
474
"users": "사용자",
475
"repos": "저장소",
···
599
"verify": {
600
"title": "계정 인증",
601
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.",
602
+
"tokenTitle": "인증",
603
+
"tokenSubtitle": "인증 코드와 전송된 식별자를 입력하세요.",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
605
"codeLabel": "인증 코드",
606
+
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
607
"verifyButton": "계정 인증",
608
+
"verify": "인증",
609
"verifying": "인증 중...",
610
+
"pleaseWait": "잠시 기다려 주세요...",
611
+
"sending": "전송 중...",
612
"resendCode": "코드 다시 보내기",
613
"resending": "전송 중...",
614
"codeResent": "인증 코드를 다시 보냈습니다!",
615
+
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
616
+
"verified": "인증 완료!",
617
+
"channelVerified": "{channel}이(가) 성공적으로 인증되었습니다.",
618
+
"canNowSignIn": "이제 계정에 로그인할 수 있습니다.",
619
+
"continue": "계속",
620
+
"identifierLabel": "이메일 또는 식별자",
621
+
"identifierPlaceholder": "you@example.com",
622
+
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
623
"backToLogin": "로그인으로 돌아가기",
624
"verifyingAccount": "인증 중인 계정: @{handle}",
625
"startOver": "다른 계정으로 다시 시작",
···
638
"sendCode": "재설정 코드 보내기",
639
"sending": "전송 중...",
640
"codeSent": "비밀번호 재설정 코드를 보냈습니다! 선호하는 알림 채널을 확인하세요.",
641
+
"enterCode": "받은 코드와 새 비밀번호를 입력하세요.",
642
"code": "재설정 코드",
643
"codePlaceholder": "재설정 코드 입력",
644
"newPassword": "새 비밀번호",
···
697
},
698
"registerPasskey": {
699
"title": "패스키 계정 만들기",
700
+
"subtitle": "비밀번호 대신 패스키를 사용하여 초안전 계정을 만듭니다.",
701
+
"subtitleKeyChoice": "외부 did:web 아이덴티티 설정 방법을 선택하세요.",
702
+
"subtitleVerify": "{channel}(으)로 인증 코드를 보냈습니다. 코드를 입력하여 계속하세요.",
703
+
"subtitlePasskey": "패스키를 만들어 계정 설정을 완료하세요.",
704
"handle": "핸들",
705
"handlePlaceholder": "사용자 이름",
706
"handleHint": "전체 핸들: @{handle}",
707
+
"contactMethod": "연락 방법",
708
+
"contactMethodHint": "계정 인증 및 알림 수신 방법을 선택하세요.",
709
+
"verificationMethod": "인증 방법",
710
"email": "이메일 주소",
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)",
723
"inviteCode": "초대 코드",
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 복사",
732
"createButton": "계정 만들기",
733
"creating": "생성 중...",
734
"alreadyHaveAccount": "이미 계정이 있으신가요?",
735
"signIn": "로그인",
736
"wantPassword": "비밀번호를 사용하시겠습니까?",
737
+
"createPasswordAccount": "비밀번호 계정 만들기",
738
+
"errors": {
739
+
"handleRequired": "핸들은 필수입니다",
740
+
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
741
+
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
742
+
"passkeyCancelled": "패스키 생성이 취소되었습니다",
743
+
"passkeyFailed": "패스키 등록에 실패했습니다"
744
+
}
745
},
746
"trustedDevices": {
747
"title": "신뢰할 수 있는 기기",
···
774
"verify": "확인",
775
"verifying": "확인 중...",
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": "인증"
797
}
798
}
+98
-14
frontend/src/locales/sv.json
+98
-14
frontend/src/locales/sv.json
···
80
"externalDidPlaceholder": "did:web:dindomän.se",
81
"externalDidHint": "Din domän måste tillhandahålla ett giltigt DID-dokument på /.well-known/did.json som pekar på denna PDS",
82
"contactMethod": "Kontaktmetod",
83
-
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot notiser. Du behöver bara en.",
84
"verificationMethod": "Verifieringsmetod",
85
"email": "E-post",
86
"emailAddress": "E-postadress",
···
164
"changeEmailButton": "Ändra e-post",
165
"requesting": "Begär...",
166
"verificationCode": "Verifieringskod",
167
-
"verificationCodePlaceholder": "Ange kod från e-post",
168
"confirmEmailChange": "Bekräfta e-poständring",
169
"updating": "Uppdaterar...",
170
"changeHandle": "Ändra användarnamn",
···
202
"deleteAccount": "Radera konto",
203
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
204
"requestDeletion": "Begär kontoradering",
205
-
"confirmationCode": "Bekräftelsekod (från e-post)",
206
"confirmationCodePlaceholder": "Ange bekräftelsekod",
207
"yourPassword": "Ditt lösenord",
208
"yourPasswordPlaceholder": "Ange ditt lösenord",
209
"permanentlyDelete": "Radera konto permanent",
210
"deleting": "Raderar...",
211
"messages": {
212
-
"emailCodeSent": "Verifieringskod skickad till din nuvarande e-post",
213
"emailUpdated": "E-post uppdaterad",
214
"handleUpdated": "Användarnamn uppdaterat",
215
"passwordChanged": "Lösenord ändrat",
···
350
"lastUsed": "Senast använd",
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
"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.",
354
"beforeProceeding": "Innan du fortsätter:",
355
"beforeProceedingItem1": "Se till att du har minst en pålitlig nyckel registrerad",
356
"beforeProceedingItem2": "Överväg att registrera nycklar på flera enheter",
357
-
"beforeProceedingItem3": "Se till att din återställningsnotifieringskanal är uppdaterad",
358
"addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.",
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
"trustedDevices": "Betrodda enheter",
···
451
},
452
"admin": {
453
"title": "Adminpanel",
454
"serverStats": "Serverstatistik",
455
"users": "Användare",
456
"repos": "Dataförvar",
···
514
"readProfile": "Läsa din profilinformation",
515
"readPosts": "Läsa dina inlägg och innehåll",
516
"writePosts": "Skapa och radera inlägg för din räkning",
517
-
"readNotifications": "Läsa dina notiser",
518
"fullAccess": "Full tillgång till ditt konto",
519
"authorize": "Auktorisera",
520
"deny": "Neka",
···
580
"verify": {
581
"title": "Verifiera ditt konto",
582
"subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.",
583
-
"codePlaceholder": "Ange 6-siffrig kod",
584
"codeLabel": "Verifieringskod",
585
"verifyButton": "Verifiera konto",
586
"verifying": "Verifierar...",
587
"resendCode": "Skicka kod igen",
588
"resending": "Skickar igen...",
589
"codeResent": "Verifieringskod skickad igen!",
590
"backToLogin": "Tillbaka till inloggning",
591
"verifyingAccount": "Verifierar konto: @{handle}",
592
"startOver": "Börja om med ett annat konto",
···
604
"emailPlaceholder": "användarnamn eller du@exempel.se",
605
"sendCode": "Skicka återställningskod",
606
"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.",
609
"code": "Återställningskod",
610
"codePlaceholder": "Ange återställningskod",
611
"newPassword": "Nytt lösenord",
···
652
"title": "Återställ nyckelkonto",
653
"subtitle": "Förlorat tillgång till din nyckel? Ange ditt användarnamn eller e-post så skickar vi dig en återställningslänk.",
654
"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.",
656
"successInfo": "Länken upphör om 1 timme. Kontrollera din e-post, Discord, Telegram eller Signal beroende på dina kontoinställningar.",
657
"handleOrEmail": "Användarnamn eller e-post",
658
"emailPlaceholder": "användarnamn eller du@exempel.se",
659
"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.",
661
"sendRecoveryLink": "Skicka återställningslänk",
662
"sending": "Skickar...",
663
"backToLogin": "Tillbaka till inloggning"
664
},
665
"registerPasskey": {
666
"title": "Skapa nyckelkonto",
667
-
"subtitle": "Skapa ett lösenordsfritt konto med en nyckel.",
668
"handle": "Användarnamn",
669
"handlePlaceholder": "dittnamn",
670
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
671
"email": "E-postadress",
672
"emailPlaceholder": "du@exempel.se",
673
"inviteCode": "Inbjudningskod",
674
"inviteCodePlaceholder": "Ange din inbjudningskod",
675
"createButton": "Skapa konto",
676
"creating": "Skapar...",
677
"alreadyHaveAccount": "Har du redan ett konto?",
678
"signIn": "Logga in",
679
"wantPassword": "Vill du använda ett lösenord?",
680
-
"createPasswordAccount": "Skapa ett lösenordskonto"
681
},
682
"trustedDevices": {
683
"title": "Betrodda enheter",
···
710
"verify": "Verifiera",
711
"verifying": "Verifierar...",
712
"cancel": "Avbryt"
713
}
714
}
···
80
"externalDidPlaceholder": "did:web:dindomän.se",
81
"externalDidHint": "Din domän måste tillhandahålla ett giltigt DID-dokument på /.well-known/did.json som pekar på denna PDS",
82
"contactMethod": "Kontaktmetod",
83
+
"contactMethodHint": "Välj hur du vill verifiera ditt konto och ta emot meddelanden. Du behöver bara en.",
84
"verificationMethod": "Verifieringsmetod",
85
"email": "E-post",
86
"emailAddress": "E-postadress",
···
164
"changeEmailButton": "Ändra e-post",
165
"requesting": "Begär...",
166
"verificationCode": "Verifieringskod",
167
+
"verificationCodePlaceholder": "Ange verifieringskod",
168
"confirmEmailChange": "Bekräfta e-poständring",
169
"updating": "Uppdaterar...",
170
"changeHandle": "Ändra användarnamn",
···
202
"deleteAccount": "Radera konto",
203
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
204
"requestDeletion": "Begär kontoradering",
205
+
"confirmationCode": "Bekräftelsekod",
206
"confirmationCodePlaceholder": "Ange bekräftelsekod",
207
"yourPassword": "Ditt lösenord",
208
"yourPasswordPlaceholder": "Ange ditt lösenord",
209
"permanentlyDelete": "Radera konto permanent",
210
"deleting": "Raderar...",
211
"messages": {
212
+
"emailCodeSent": "Verifieringskod skickad till din meddelandekanal",
213
"emailUpdated": "E-post uppdaterad",
214
"handleUpdated": "Användarnamn uppdaterat",
215
"passwordChanged": "Lösenord ändrat",
···
350
"lastUsed": "Senast använd",
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
"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 meddelandekanal.",
354
"beforeProceeding": "Innan du fortsätter:",
355
"beforeProceedingItem1": "Se till att du har minst en pålitlig nyckel registrerad",
356
"beforeProceedingItem2": "Överväg att registrera nycklar på flera enheter",
357
+
"beforeProceedingItem3": "Se till att din meddelandekanal för återställning är uppdaterad",
358
"addPasskeyFirst": "Lägg till minst en nyckel innan du kan ta bort ditt lösenord.",
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
"trustedDevices": "Betrodda enheter",
···
451
},
452
"admin": {
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",
473
"serverStats": "Serverstatistik",
474
"users": "Användare",
475
"repos": "Dataförvar",
···
533
"readProfile": "Läsa din profilinformation",
534
"readPosts": "Läsa dina inlägg och innehåll",
535
"writePosts": "Skapa och radera inlägg för din räkning",
536
+
"readNotifications": "Läsa dina aviseringar",
537
"fullAccess": "Full tillgång till ditt konto",
538
"authorize": "Auktorisera",
539
"deny": "Neka",
···
599
"verify": {
600
"title": "Verifiera ditt konto",
601
"subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.",
602
+
"tokenTitle": "Verifiera",
603
+
"tokenSubtitle": "Ange verifieringskoden och identifieraren den skickades till.",
604
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
605
"codeLabel": "Verifieringskod",
606
+
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
607
"verifyButton": "Verifiera konto",
608
+
"verify": "Verifiera",
609
"verifying": "Verifierar...",
610
+
"pleaseWait": "Vänta...",
611
+
"sending": "Skickar...",
612
"resendCode": "Skicka kod igen",
613
"resending": "Skickar igen...",
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",
623
"backToLogin": "Tillbaka till inloggning",
624
"verifyingAccount": "Verifierar konto: @{handle}",
625
"startOver": "Börja om med ett annat konto",
···
637
"emailPlaceholder": "användarnamn eller du@exempel.se",
638
"sendCode": "Skicka återställningskod",
639
"sending": "Skickar...",
640
+
"codeSent": "Återställningskod skickad! Kontrollera din föredragna meddelandekanal.",
641
+
"enterCode": "Ange koden du fick och ditt nya lösenord.",
642
"code": "Återställningskod",
643
"codePlaceholder": "Ange återställningskod",
644
"newPassword": "Nytt lösenord",
···
685
"title": "Återställ nyckelkonto",
686
"subtitle": "Förlorat tillgång till din nyckel? Ange ditt användarnamn eller e-post så skickar vi dig en återställningslänk.",
687
"successTitle": "Återställningslänk skickad",
688
+
"successMessage": "Om ditt konto finns och är ett endast nyckelkonto får du en återställningslänk på din föredragna meddelandekanal.",
689
"successInfo": "Länken upphör om 1 timme. Kontrollera din e-post, Discord, Telegram eller Signal beroende på dina kontoinställningar.",
690
"handleOrEmail": "Användarnamn eller e-post",
691
"emailPlaceholder": "användarnamn eller du@exempel.se",
692
"howItWorks": "Så fungerar det",
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.",
694
"sendRecoveryLink": "Skicka återställningslänk",
695
"sending": "Skickar...",
696
"backToLogin": "Tillbaka till inloggning"
697
},
698
"registerPasskey": {
699
"title": "Skapa nyckelkonto",
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.",
704
"handle": "Användarnamn",
705
"handlePlaceholder": "dittnamn",
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",
710
"email": "E-postadress",
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)",
723
"inviteCode": "Inbjudningskod",
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",
732
"createButton": "Skapa konto",
733
"creating": "Skapar...",
734
"alreadyHaveAccount": "Har du redan ett konto?",
735
"signIn": "Logga in",
736
"wantPassword": "Vill du använda ett lösenord?",
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
+
}
745
},
746
"trustedDevices": {
747
"title": "Betrodda enheter",
···
774
"verify": "Verifiera",
775
"verifying": "Verifierar...",
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"
797
}
798
}
+130
-8
frontend/src/locales/zh.json
+130
-8
frontend/src/locales/zh.json
···
164
"changeEmailButton": "更改邮箱",
165
"requesting": "请求中...",
166
"verificationCode": "验证码",
167
-
"verificationCodePlaceholder": "输入邮件中的验证码",
168
"confirmEmailChange": "确认更改邮箱",
169
"updating": "更新中...",
170
"changeHandle": "更改用户名",
···
202
"deleteAccount": "删除账户",
203
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
204
"requestDeletion": "请求删除账户",
205
-
"confirmationCode": "确认码(来自邮件)",
206
"confirmationCodePlaceholder": "输入确认码",
207
"yourPassword": "您的密码",
208
"yourPasswordPlaceholder": "输入您的密码",
209
"permanentlyDelete": "永久删除账户",
210
"deleting": "删除中...",
211
"messages": {
212
-
"emailCodeSent": "验证码已发送到您当前的邮箱",
213
"emailUpdated": "邮箱更新成功",
214
"handleUpdated": "用户名更新成功",
215
"passwordChanged": "密码更改成功",
···
451
},
452
"admin": {
453
"title": "管理后台",
454
"serverStats": "服务器统计",
455
"users": "用户",
456
"repos": "仓库",
···
580
"verify": {
581
"title": "验证账户",
582
"subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。",
583
-
"codePlaceholder": "输入6位验证码",
584
"codeLabel": "验证码",
585
"verifyButton": "验证账户",
586
"verifying": "验证中...",
587
"resendCode": "重新发送验证码",
588
"resending": "发送中...",
589
"codeResent": "验证码已重新发送!",
590
"backToLogin": "返回登录",
591
"verifyingAccount": "正在验证账户:@{handle}",
592
"startOver": "使用其他账户重新开始",
593
"noPending": "未找到待验证的账户",
594
"noPendingInfo": "如果您最近创建了账户需要验证,可能需要重新创建账户。如果您已完成验证,可以直接登录。",
595
"createAccount": "创建账户",
596
-
"signIn": "登录"
597
},
598
"resetPassword": {
599
"title": "重置密码",
···
605
"sendCode": "发送重置验证码",
606
"sending": "发送中...",
607
"codeSent": "重置验证码已发送!请检查您的首选通知渠道。",
608
-
"enterCode": "输入邮件中的验证码和新密码。",
609
"code": "重置验证码",
610
"codePlaceholder": "输入重置验证码",
611
"newPassword": "新密码",
···
664
},
665
"registerPasskey": {
666
"title": "创建通行密钥账户",
667
-
"subtitle": "使用通行密钥创建无密码账户。",
668
"handle": "用户名",
669
"handlePlaceholder": "您的用户名",
670
"handleHint": "您的完整用户名将是:@{handle}",
671
"email": "邮箱地址",
672
"emailPlaceholder": "you@example.com",
673
"inviteCode": "邀请码",
674
"inviteCodePlaceholder": "输入您的邀请码",
675
"createButton": "创建账户",
676
"creating": "创建中...",
677
"alreadyHaveAccount": "已有账户?",
678
"signIn": "立即登录",
679
"wantPassword": "想使用密码?",
680
-
"createPasswordAccount": "创建密码账户"
681
},
682
"trustedDevices": {
683
"title": "受信任设备",
···
710
"verify": "验证",
711
"verifying": "验证中...",
712
"cancel": "取消"
713
}
714
}
···
164
"changeEmailButton": "更改邮箱",
165
"requesting": "请求中...",
166
"verificationCode": "验证码",
167
+
"verificationCodePlaceholder": "输入验证码",
168
"confirmEmailChange": "确认更改邮箱",
169
"updating": "更新中...",
170
"changeHandle": "更改用户名",
···
202
"deleteAccount": "删除账户",
203
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
204
"requestDeletion": "请求删除账户",
205
+
"confirmationCode": "确认码",
206
"confirmationCodePlaceholder": "输入确认码",
207
"yourPassword": "您的密码",
208
"yourPasswordPlaceholder": "输入您的密码",
209
"permanentlyDelete": "永久删除账户",
210
"deleting": "删除中...",
211
"messages": {
212
+
"emailCodeSent": "验证码已发送到您的通知渠道",
213
"emailUpdated": "邮箱更新成功",
214
"handleUpdated": "用户名更新成功",
215
"passwordChanged": "密码更改成功",
···
451
},
452
"admin": {
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": "保存配置",
476
"serverStats": "服务器统计",
477
"users": "用户",
478
"repos": "仓库",
···
602
"verify": {
603
"title": "验证账户",
604
"subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。",
605
+
"tokenSubtitle": "输入验证码和接收验证码的标识符。",
606
+
"tokenTitle": "验证",
607
+
"codePlaceholder": "XXXX-XXXX-XXXX-XXXX...",
608
"codeLabel": "验证码",
609
+
"codeHelp": "复制消息中的完整验证码,包括横线",
610
"verifyButton": "验证账户",
611
+
"verify": "验证",
612
"verifying": "验证中...",
613
+
"pleaseWait": "请稍候...",
614
"resendCode": "重新发送验证码",
615
"resending": "发送中...",
616
+
"sending": "发送中...",
617
"codeResent": "验证码已重新发送!",
618
+
"codeResentDetail": "验证码已发送!请查收。",
619
"backToLogin": "返回登录",
620
"verifyingAccount": "正在验证账户:@{handle}",
621
"startOver": "使用其他账户重新开始",
622
"noPending": "未找到待验证的账户",
623
"noPendingInfo": "如果您最近创建了账户需要验证,可能需要重新创建账户。如果您已完成验证,可以直接登录。",
624
"createAccount": "创建账户",
625
+
"signIn": "登录",
626
+
"verified": "验证成功!",
627
+
"channelVerified": "您的{channel}已成功验证。",
628
+
"canNowSignIn": "您现在可以登录账户。",
629
+
"continue": "继续",
630
+
"identifierLabel": "邮箱或标识符",
631
+
"identifierPlaceholder": "you@example.com",
632
+
"identifierHelp": "接收验证码的邮箱地址或标识符"
633
},
634
"resetPassword": {
635
"title": "重置密码",
···
641
"sendCode": "发送重置验证码",
642
"sending": "发送中...",
643
"codeSent": "重置验证码已发送!请检查您的首选通知渠道。",
644
+
"enterCode": "输入您收到的验证码和新密码。",
645
"code": "重置验证码",
646
"codePlaceholder": "输入重置验证码",
647
"newPassword": "新密码",
···
700
},
701
"registerPasskey": {
702
"title": "创建通行密钥账户",
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": "您的账户已成功创建!",
713
"handle": "用户名",
714
"handlePlaceholder": "您的用户名",
715
"handleHint": "您的完整用户名将是:@{handle}",
716
+
"handleDotWarning": "可以在创建账户后设置自定义域名。",
717
"email": "邮箱地址",
718
"emailPlaceholder": "you@example.com",
719
"inviteCode": "邀请码",
720
"inviteCodePlaceholder": "输入您的邀请码",
721
"createButton": "创建账户",
722
"creating": "创建中...",
723
+
"continue": "继续",
724
+
"back": "返回",
725
"alreadyHaveAccount": "已有账户?",
726
"signIn": "立即登录",
727
"wantPassword": "想使用密码?",
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
+
}
783
},
784
"trustedDevices": {
785
"title": "受信任设备",
···
812
"verify": "验证",
813
"verifying": "验证中...",
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": "验证"
835
}
836
}
+71
-71
frontend/src/routes/Admin.svelte
+71
-71
frontend/src/routes/Admin.svelte
···
302
{#if auth.session?.isAdmin}
303
<div class="page">
304
<header>
305
-
<a href="#/dashboard" class="back">← Dashboard</a>
306
-
<h1>Admin Panel</h1>
307
</header>
308
{#if loading}
309
-
<p class="loading">Loading...</p>
310
{:else}
311
{#if error}
312
<div class="message error">{error}</div>
313
{/if}
314
<section>
315
-
<h2>Server Configuration</h2>
316
<form class="config-form" onsubmit={saveServerConfig}>
317
<div class="form-group">
318
-
<label for="serverName">Server Name</label>
319
<input
320
type="text"
321
id="serverName"
322
bind:value={serverNameInput}
323
-
placeholder="My PDS"
324
maxlength="100"
325
disabled={serverConfigLoading}
326
/>
327
-
<span class="help-text">Displayed in the browser tab and other places</span>
328
</div>
329
330
<div class="form-group">
331
-
<label for="serverLogo">Server Logo</label>
332
<div class="logo-upload">
333
{#if logoPreview}
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>
337
</div>
338
{:else}
339
<input
···
345
/>
346
{/if}
347
</div>
348
-
<span class="help-text">Used as favicon and shown in the navbar</span>
349
</div>
350
351
-
<h3 class="subsection-title">Theme Colors</h3>
352
-
<p class="theme-hint">Leave blank to use default colors.</p>
353
354
<div class="color-grid">
355
<div class="color-group">
356
-
<label for="primaryColor">Primary (Light Mode)</label>
357
<div class="color-input-row">
358
<input
359
type="color"
···
364
type="text"
365
id="primaryColor"
366
bind:value={primaryColorInput}
367
-
placeholder="#2c00ff (default)"
368
disabled={serverConfigLoading}
369
/>
370
</div>
371
</div>
372
<div class="color-group">
373
-
<label for="primaryColorDark">Primary (Dark Mode)</label>
374
<div class="color-input-row">
375
<input
376
type="color"
···
381
type="text"
382
id="primaryColorDark"
383
bind:value={primaryColorDarkInput}
384
-
placeholder="#7b6bff (default)"
385
disabled={serverConfigLoading}
386
/>
387
</div>
388
</div>
389
<div class="color-group">
390
-
<label for="secondaryColor">Secondary (Light Mode)</label>
391
<div class="color-input-row">
392
<input
393
type="color"
···
398
type="text"
399
id="secondaryColor"
400
bind:value={secondaryColorInput}
401
-
placeholder="#ff2400 (default)"
402
disabled={serverConfigLoading}
403
/>
404
</div>
405
</div>
406
<div class="color-group">
407
-
<label for="secondaryColorDark">Secondary (Dark Mode)</label>
408
<div class="color-input-row">
409
<input
410
type="color"
···
415
type="text"
416
id="secondaryColorDark"
417
bind:value={secondaryColorDarkInput}
418
-
placeholder="#ff6b5b (default)"
419
disabled={serverConfigLoading}
420
/>
421
</div>
···
426
<div class="message error">{serverConfigError}</div>
427
{/if}
428
{#if serverConfigSuccess}
429
-
<div class="message success">Server configuration saved</div>
430
{/if}
431
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
432
-
{serverConfigLoading ? 'Saving...' : 'Save Configuration'}
433
</button>
434
</form>
435
</section>
436
{#if stats}
437
<section>
438
-
<h2>Server Statistics</h2>
439
<div class="stats-grid">
440
<div class="stat-card">
441
<div class="stat-value">{formatNumber(stats.userCount)}</div>
442
-
<div class="stat-label">Users</div>
443
</div>
444
<div class="stat-card">
445
<div class="stat-value">{formatNumber(stats.repoCount)}</div>
446
-
<div class="stat-label">Repositories</div>
447
</div>
448
<div class="stat-card">
449
<div class="stat-value">{formatNumber(stats.recordCount)}</div>
450
-
<div class="stat-label">Records</div>
451
</div>
452
<div class="stat-card">
453
<div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
454
-
<div class="stat-label">Blob Storage</div>
455
</div>
456
</div>
457
-
<button class="refresh-btn" onclick={loadStats}>Refresh Stats</button>
458
</section>
459
{/if}
460
<section>
461
-
<h2>User Management</h2>
462
<form class="search-form" onsubmit={handleSearch}>
463
<input
464
type="text"
465
bind:value={handleSearchQuery}
466
-
placeholder="Search by handle (optional)"
467
disabled={usersLoading}
468
/>
469
<button type="submit" disabled={usersLoading}>
470
-
{usersLoading ? 'Loading...' : 'Search Users'}
471
</button>
472
</form>
473
{#if usersError}
···
476
{#if showUsers}
477
<div class="user-list">
478
{#if users.length === 0}
479
-
<p class="no-results">No users found</p>
480
{:else}
481
<table>
482
<thead>
483
<tr>
484
-
<th>Handle</th>
485
-
<th>Email</th>
486
-
<th>Status</th>
487
-
<th>Created</th>
488
</tr>
489
</thead>
490
<tbody>
···
494
<td class="email">{user.email || '-'}</td>
495
<td>
496
{#if user.deactivatedAt}
497
-
<span class="badge deactivated">Deactivated</span>
498
{:else if user.emailConfirmedAt}
499
-
<span class="badge verified">Verified</span>
500
{:else}
501
-
<span class="badge unverified">Unverified</span>
502
{/if}
503
</td>
504
<td class="date">{formatDate(user.indexedAt)}</td>
···
508
</table>
509
{#if usersCursor}
510
<button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
511
-
{usersLoading ? 'Loading...' : 'Load More'}
512
</button>
513
{/if}
514
{/if}
···
516
{/if}
517
</section>
518
<section>
519
-
<h2>Invite Codes</h2>
520
<div class="section-actions">
521
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
522
-
{invitesLoading ? 'Loading...' : showInvites ? 'Refresh' : 'Load Invite Codes'}
523
</button>
524
</div>
525
{#if invitesError}
···
528
{#if showInvites}
529
<div class="invite-list">
530
{#if invites.length === 0}
531
-
<p class="no-results">No invite codes found</p>
532
{:else}
533
<table>
534
<thead>
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>
542
</tr>
543
</thead>
544
<tbody>
···
549
<td>{invite.uses.length}</td>
550
<td>
551
{#if invite.disabled}
552
-
<span class="badge deactivated">Disabled</span>
553
{:else if invite.available === 0}
554
-
<span class="badge unverified">Exhausted</span>
555
{:else}
556
-
<span class="badge verified">Active</span>
557
{/if}
558
</td>
559
<td class="date">{formatDate(invite.createdAt)}</td>
560
<td>
561
{#if !invite.disabled}
562
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
563
-
Disable
564
</button>
565
{:else}
566
<span class="muted">-</span>
···
572
</table>
573
{#if invitesCursor}
574
<button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
575
-
{invitesLoading ? 'Loading...' : 'Load More'}
576
</button>
577
{/if}
578
{/if}
···
585
<div class="modal-overlay" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
586
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
587
<div class="modal-header">
588
-
<h2>User Details</h2>
589
<button class="close-btn" onclick={closeUserDetail}>×</button>
590
</div>
591
{#if userDetailLoading}
592
-
<p class="loading">Loading...</p>
593
{:else}
594
<div class="modal-body">
595
<dl class="user-details">
596
-
<dt>Handle</dt>
597
<dd>@{selectedUser.handle}</dd>
598
-
<dt>DID</dt>
599
<dd class="mono">{selectedUser.did}</dd>
600
-
<dt>Email</dt>
601
<dd>{selectedUser.email || '-'}</dd>
602
-
<dt>Status</dt>
603
<dd>
604
{#if selectedUser.deactivatedAt}
605
-
<span class="badge deactivated">Deactivated</span>
606
{:else if selectedUser.emailConfirmedAt}
607
-
<span class="badge verified">Verified</span>
608
{:else}
609
-
<span class="badge unverified">Unverified</span>
610
{/if}
611
</dd>
612
-
<dt>Created</dt>
613
<dd>{formatDateTime(selectedUser.indexedAt)}</dd>
614
-
<dt>Invites</dt>
615
<dd>
616
{#if selectedUser.invitesDisabled}
617
-
<span class="badge deactivated">Disabled</span>
618
{:else}
619
-
<span class="badge verified">Enabled</span>
620
{/if}
621
</dd>
622
</dl>
···
626
onclick={toggleUserInvites}
627
disabled={userActionLoading}
628
>
629
-
{selectedUser.invitesDisabled ? 'Enable Invites' : 'Disable Invites'}
630
</button>
631
<button
632
class="action-btn danger"
633
onclick={deleteUser}
634
disabled={userActionLoading}
635
>
636
-
Delete Account
637
</button>
638
</div>
639
</div>
···
642
</div>
643
{/if}
644
{:else if auth.loading}
645
-
<div class="loading">Loading...</div>
646
{/if}
647
<style>
648
.page {
···
302
{#if auth.session?.isAdmin}
303
<div class="page">
304
<header>
305
+
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
306
+
<h1>{$_('admin.title')}</h1>
307
</header>
308
{#if loading}
309
+
<p class="loading">{$_('admin.loading')}</p>
310
{:else}
311
{#if error}
312
<div class="message error">{error}</div>
313
{/if}
314
<section>
315
+
<h2>{$_('admin.serverConfig')}</h2>
316
<form class="config-form" onsubmit={saveServerConfig}>
317
<div class="form-group">
318
+
<label for="serverName">{$_('admin.serverName')}</label>
319
<input
320
type="text"
321
id="serverName"
322
bind:value={serverNameInput}
323
+
placeholder={$_('admin.serverNamePlaceholder')}
324
maxlength="100"
325
disabled={serverConfigLoading}
326
/>
327
+
<span class="help-text">{$_('admin.serverNameHelp')}</span>
328
</div>
329
330
<div class="form-group">
331
+
<label for="serverLogo">{$_('admin.serverLogo')}</label>
332
<div class="logo-upload">
333
{#if logoPreview}
334
<div class="logo-preview">
335
+
<img src={logoPreview} alt={$_('admin.logoPreview')} />
336
+
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>{$_('admin.removeLogo')}</button>
337
</div>
338
{:else}
339
<input
···
345
/>
346
{/if}
347
</div>
348
+
<span class="help-text">{$_('admin.logoHelp')}</span>
349
</div>
350
351
+
<h3 class="subsection-title">{$_('admin.themeColors')}</h3>
352
+
<p class="theme-hint">{$_('admin.themeColorsHint')}</p>
353
354
<div class="color-grid">
355
<div class="color-group">
356
+
<label for="primaryColor">{$_('admin.primaryLight')}</label>
357
<div class="color-input-row">
358
<input
359
type="color"
···
364
type="text"
365
id="primaryColor"
366
bind:value={primaryColorInput}
367
+
placeholder={$_('admin.primaryLightDefault')}
368
disabled={serverConfigLoading}
369
/>
370
</div>
371
</div>
372
<div class="color-group">
373
+
<label for="primaryColorDark">{$_('admin.primaryDark')}</label>
374
<div class="color-input-row">
375
<input
376
type="color"
···
381
type="text"
382
id="primaryColorDark"
383
bind:value={primaryColorDarkInput}
384
+
placeholder={$_('admin.primaryDarkDefault')}
385
disabled={serverConfigLoading}
386
/>
387
</div>
388
</div>
389
<div class="color-group">
390
+
<label for="secondaryColor">{$_('admin.secondaryLight')}</label>
391
<div class="color-input-row">
392
<input
393
type="color"
···
398
type="text"
399
id="secondaryColor"
400
bind:value={secondaryColorInput}
401
+
placeholder={$_('admin.secondaryLightDefault')}
402
disabled={serverConfigLoading}
403
/>
404
</div>
405
</div>
406
<div class="color-group">
407
+
<label for="secondaryColorDark">{$_('admin.secondaryDark')}</label>
408
<div class="color-input-row">
409
<input
410
type="color"
···
415
type="text"
416
id="secondaryColorDark"
417
bind:value={secondaryColorDarkInput}
418
+
placeholder={$_('admin.secondaryDarkDefault')}
419
disabled={serverConfigLoading}
420
/>
421
</div>
···
426
<div class="message error">{serverConfigError}</div>
427
{/if}
428
{#if serverConfigSuccess}
429
+
<div class="message success">{$_('admin.configSaved')}</div>
430
{/if}
431
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
432
+
{serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')}
433
</button>
434
</form>
435
</section>
436
{#if stats}
437
<section>
438
+
<h2>{$_('admin.serverStats')}</h2>
439
<div class="stats-grid">
440
<div class="stat-card">
441
<div class="stat-value">{formatNumber(stats.userCount)}</div>
442
+
<div class="stat-label">{$_('admin.users')}</div>
443
</div>
444
<div class="stat-card">
445
<div class="stat-value">{formatNumber(stats.repoCount)}</div>
446
+
<div class="stat-label">{$_('admin.repos')}</div>
447
</div>
448
<div class="stat-card">
449
<div class="stat-value">{formatNumber(stats.recordCount)}</div>
450
+
<div class="stat-label">{$_('admin.records')}</div>
451
</div>
452
<div class="stat-card">
453
<div class="stat-value">{formatBytes(stats.blobStorageBytes)}</div>
454
+
<div class="stat-label">{$_('admin.blobStorage')}</div>
455
</div>
456
</div>
457
+
<button class="refresh-btn" onclick={loadStats}>{$_('admin.refreshStats')}</button>
458
</section>
459
{/if}
460
<section>
461
+
<h2>{$_('admin.userManagement')}</h2>
462
<form class="search-form" onsubmit={handleSearch}>
463
<input
464
type="text"
465
bind:value={handleSearchQuery}
466
+
placeholder={$_('admin.searchPlaceholder')}
467
disabled={usersLoading}
468
/>
469
<button type="submit" disabled={usersLoading}>
470
+
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
471
</button>
472
</form>
473
{#if usersError}
···
476
{#if showUsers}
477
<div class="user-list">
478
{#if users.length === 0}
479
+
<p class="no-results">{$_('admin.noUsers')}</p>
480
{:else}
481
<table>
482
<thead>
483
<tr>
484
+
<th>{$_('admin.handle')}</th>
485
+
<th>{$_('admin.email')}</th>
486
+
<th>{$_('admin.status')}</th>
487
+
<th>{$_('admin.created')}</th>
488
</tr>
489
</thead>
490
<tbody>
···
494
<td class="email">{user.email || '-'}</td>
495
<td>
496
{#if user.deactivatedAt}
497
+
<span class="badge deactivated">{$_('admin.deactivated')}</span>
498
{:else if user.emailConfirmedAt}
499
+
<span class="badge verified">{$_('admin.verified')}</span>
500
{:else}
501
+
<span class="badge unverified">{$_('admin.unverified')}</span>
502
{/if}
503
</td>
504
<td class="date">{formatDate(user.indexedAt)}</td>
···
508
</table>
509
{#if usersCursor}
510
<button class="load-more" onclick={() => loadUsers(false)} disabled={usersLoading}>
511
+
{usersLoading ? $_('admin.loading') : $_('admin.loadMore')}
512
</button>
513
{/if}
514
{/if}
···
516
{/if}
517
</section>
518
<section>
519
+
<h2>{$_('admin.inviteCodes')}</h2>
520
<div class="section-actions">
521
<button onclick={() => loadInvites(true)} disabled={invitesLoading}>
522
+
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
523
</button>
524
</div>
525
{#if invitesError}
···
528
{#if showInvites}
529
<div class="invite-list">
530
{#if invites.length === 0}
531
+
<p class="no-results">{$_('admin.noInvites')}</p>
532
{:else}
533
<table>
534
<thead>
535
<tr>
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
</tr>
543
</thead>
544
<tbody>
···
549
<td>{invite.uses.length}</td>
550
<td>
551
{#if invite.disabled}
552
+
<span class="badge deactivated">{$_('admin.disabled')}</span>
553
{:else if invite.available === 0}
554
+
<span class="badge unverified">{$_('admin.exhausted')}</span>
555
{:else}
556
+
<span class="badge verified">{$_('admin.active')}</span>
557
{/if}
558
</td>
559
<td class="date">{formatDate(invite.createdAt)}</td>
560
<td>
561
{#if !invite.disabled}
562
<button class="action-btn danger" onclick={() => disableInvite(invite.code)}>
563
+
{$_('admin.disable')}
564
</button>
565
{:else}
566
<span class="muted">-</span>
···
572
</table>
573
{#if invitesCursor}
574
<button class="load-more" onclick={() => loadInvites(false)} disabled={invitesLoading}>
575
+
{invitesLoading ? $_('admin.loading') : $_('admin.loadMore')}
576
</button>
577
{/if}
578
{/if}
···
585
<div class="modal-overlay" onclick={closeUserDetail} onkeydown={(e) => e.key === 'Escape' && closeUserDetail()} role="presentation">
586
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
587
<div class="modal-header">
588
+
<h2>{$_('admin.userDetails')}</h2>
589
<button class="close-btn" onclick={closeUserDetail}>×</button>
590
</div>
591
{#if userDetailLoading}
592
+
<p class="loading">{$_('admin.loading')}</p>
593
{:else}
594
<div class="modal-body">
595
<dl class="user-details">
596
+
<dt>{$_('admin.handle')}</dt>
597
<dd>@{selectedUser.handle}</dd>
598
+
<dt>{$_('admin.did')}</dt>
599
<dd class="mono">{selectedUser.did}</dd>
600
+
<dt>{$_('admin.email')}</dt>
601
<dd>{selectedUser.email || '-'}</dd>
602
+
<dt>{$_('admin.status')}</dt>
603
<dd>
604
{#if selectedUser.deactivatedAt}
605
+
<span class="badge deactivated">{$_('admin.deactivated')}</span>
606
{:else if selectedUser.emailConfirmedAt}
607
+
<span class="badge verified">{$_('admin.verified')}</span>
608
{:else}
609
+
<span class="badge unverified">{$_('admin.unverified')}</span>
610
{/if}
611
</dd>
612
+
<dt>{$_('admin.created')}</dt>
613
<dd>{formatDateTime(selectedUser.indexedAt)}</dd>
614
+
<dt>{$_('admin.invites')}</dt>
615
<dd>
616
{#if selectedUser.invitesDisabled}
617
+
<span class="badge deactivated">{$_('admin.disabled')}</span>
618
{:else}
619
+
<span class="badge verified">{$_('admin.enabled')}</span>
620
{/if}
621
</dd>
622
</dl>
···
626
onclick={toggleUserInvites}
627
disabled={userActionLoading}
628
>
629
+
{selectedUser.invitesDisabled ? $_('admin.enableInvites') : $_('admin.disableInvites')}
630
</button>
631
<button
632
class="action-btn danger"
633
onclick={deleteUser}
634
disabled={userActionLoading}
635
>
636
+
{$_('admin.deleteAccount')}
637
</button>
638
</div>
639
</div>
···
642
</div>
643
{/if}
644
{:else if auth.loading}
645
+
<div class="loading">{$_('admin.loading')}</div>
646
{/if}
647
<style>
648
.page {
+10
-1
frontend/src/routes/Comms.svelte
+10
-1
frontend/src/routes/Comms.svelte
···
93
if (!auth.session || !verificationCode) return
94
verificationError = null
95
verificationSuccess = null
96
try {
97
-
await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode)
98
await refreshSession()
99
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
100
verificationCode = ''
···
93
if (!auth.session || !verificationCode) return
94
verificationError = null
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
+
105
try {
106
+
await api.confirmChannelVerification(auth.session.accessJwt, channel, identifier, verificationCode)
107
await refreshSession()
108
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
109
verificationCode = ''
+10
-4
frontend/src/routes/Register.svelte
+10
-4
frontend/src/routes/Register.svelte
···
33
}
34
})
35
36
async function loadServerInfo() {
37
try {
38
serverInfo = await api.describeServer()
···
140
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
141
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
142
case 'activating': return 'Activating your account...'
143
-
case 'complete': return 'Your account has been created successfully!'
144
default: return ''
145
}
146
}
···
383
/>
384
385
{:else if flow.state.step === 'creating'}
386
-
{#await flow.createPasswordAccount()}
387
-
<p class="loading">{$_('register.creating')}</p>
388
-
{/await}
389
390
{:else if flow.state.step === 'verify'}
391
<VerificationStep {flow} />
···
33
}
34
})
35
36
+
let creatingStarted = false
37
+
$effect(() => {
38
+
if (flow?.state.step === 'creating' && !creatingStarted) {
39
+
creatingStarted = true
40
+
flow.createPasswordAccount()
41
+
}
42
+
})
43
+
44
async function loadServerInfo() {
45
try {
46
serverInfo = await api.describeServer()
···
148
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
149
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
150
case 'activating': return 'Activating your account...'
151
+
case 'redirect-to-dashboard': return 'Your account has been created successfully!'
152
default: return ''
153
}
154
}
···
391
/>
392
393
{:else if flow.state.step === 'creating'}
394
+
<p class="loading">{$_('register.creating')}</p>
395
396
{:else if flow.state.step === 'verify'}
397
<VerificationStep {flow} />
+96
-90
frontend/src/routes/RegisterPasskey.svelte
+96
-90
frontend/src/routes/RegisterPasskey.svelte
···
34
}
35
})
36
37
async function loadServerInfo() {
38
try {
39
serverInfo = await api.describeServer()
···
49
function validateInfoStep(): string | null {
50
if (!flow) return 'Flow not initialized'
51
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.'
54
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
55
-
return 'Invite code is required'
56
}
57
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:'
60
}
61
switch (info.verificationChannel) {
62
case 'email':
63
-
if (!info.email.trim()) return 'Email is required for email verification'
64
break
65
case 'discord':
66
-
if (!info.discordId?.trim()) return 'Discord ID is required for Discord verification'
67
break
68
case 'telegram':
69
-
if (!info.telegramUsername?.trim()) return 'Telegram username is required for Telegram verification'
70
break
71
case 'signal':
72
-
if (!info.signalNumber?.trim()) return 'Phone number is required for Signal verification'
73
break
74
}
75
return null
···
121
}
122
123
if (!window.PublicKeyCredential) {
124
-
flow.setError('Passkeys are not supported in this browser. Please use a different browser or register with a password instead.')
125
return
126
}
127
···
153
})
154
155
if (!credential) {
156
-
flow.setError('Passkey creation was cancelled')
157
flow.setSubmitting(false)
158
return
159
}
···
180
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
181
} catch (err) {
182
if (err instanceof DOMException && err.name === 'NotAllowedError') {
183
-
flow.setError('Passkey creation was cancelled')
184
} else if (err instanceof ApiError) {
185
-
flow.setError(err.message || 'Passkey registration failed')
186
} else if (err instanceof Error) {
187
-
flow.setError(err.message || 'Passkey registration failed')
188
} else {
189
-
flow.setError('Passkey registration failed')
190
}
191
} finally {
192
flow.setSubmitting(false)
···
207
208
function channelLabel(ch: string): string {
209
switch (ch) {
210
-
case 'email': return 'Email'
211
-
case 'discord': return 'Discord'
212
-
case 'telegram': return 'Telegram'
213
-
case 'signal': return 'Signal'
214
default: return ch
215
}
216
}
···
230
function getSubtitle(): string {
231
if (!flow) return ''
232
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!'
243
default: return ''
244
}
245
}
···
259
</div>
260
{/if}
261
262
-
<h1>Create Passkey Account</h1>
263
<p class="subtitle">{getSubtitle()}</p>
264
265
{#if flow?.state.error}
···
267
{/if}
268
269
{#if loadingServerInfo || !flow}
270
-
<p class="loading">Loading...</p>
271
272
{:else if flow.state.step === 'info'}
273
<form onsubmit={handleInfoSubmit}>
274
<div class="field">
275
-
<label for="handle">Handle</label>
276
<input
277
id="handle"
278
type="text"
279
bind:value={flow.info.handle}
280
-
placeholder="yourname"
281
disabled={flow.state.submitting}
282
required
283
/>
284
{#if flow.info.handle.includes('.')}
285
-
<p class="hint warning">Custom domain handles can be set up after account creation.</p>
286
{:else if fullHandle()}
287
-
<p class="hint">Your full handle will be: @{fullHandle()}</p>
288
{/if}
289
</div>
290
291
<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>
294
<div class="field">
295
-
<label for="verification-channel">Verification Method</label>
296
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
297
-
<option value="email">Email</option>
298
<option value="discord" disabled={!isChannelAvailable('discord')}>
299
-
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
300
</option>
301
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
302
-
Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
303
</option>
304
<option value="signal" disabled={!isChannelAvailable('signal')}>
305
-
Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
306
</option>
307
</select>
308
</div>
309
{#if flow.info.verificationChannel === 'email'}
310
<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 />
313
</div>
314
{:else if flow.info.verificationChannel === 'discord'}
315
<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>
319
</div>
320
{:else if flow.info.verificationChannel === 'telegram'}
321
<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 />
324
</div>
325
{:else if flow.info.verificationChannel === 'signal'}
326
<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>
330
</div>
331
{/if}
332
</fieldset>
333
334
<fieldset class="section-fieldset">
335
-
<legend>Identity Type</legend>
336
-
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
337
<div class="radio-group">
338
<label class="radio-label">
339
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
340
<span class="radio-content">
341
-
<strong>did:plc</strong> (Recommended)
342
-
<span class="radio-hint">Portable identity managed by PLC Directory</span>
343
</span>
344
</label>
345
<label class="radio-label">
346
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
347
<span class="radio-content">
348
-
<strong>did:web</strong>
349
-
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
350
</span>
351
</label>
352
<label class="radio-label">
353
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
354
<span class="radio-content">
355
-
<strong>did:web (BYOD)</strong>
356
-
<span class="radio-hint">Bring your own domain</span>
357
</span>
358
</label>
359
</div>
360
{#if flow.info.didType === 'web'}
361
<div class="warning-box">
362
-
<strong>Important: Understand the trade-offs</strong>
363
<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>
368
</ul>
369
</div>
370
{/if}
371
{#if flow.info.didType === 'web-external'}
372
<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>
376
</div>
377
{/if}
378
</fieldset>
379
380
{#if serverInfo?.inviteCodeRequired}
381
<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 />
384
</div>
385
{/if}
386
387
<div class="info-box">
388
-
<strong>Why passkey-only?</strong>
389
-
<p>Passkey accounts are more secure than password-based accounts because they:</p>
390
<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>
394
</ul>
395
</div>
396
397
<button type="submit" disabled={flow.state.submitting}>
398
-
{flow.state.submitting ? 'Creating account...' : 'Continue'}
399
</button>
400
</form>
401
402
<p class="link-text">
403
-
Want a traditional password? <a href="#/register">Register with password</a>
404
</p>
405
406
{:else if flow.state.step === 'key-choice'}
···
415
/>
416
417
{:else if flow.state.step === 'creating'}
418
-
{#await flow.createPasskeyAccount()}
419
-
<p class="loading">Creating your account...</p>
420
-
{/await}
421
422
{:else if flow.state.step === 'passkey'}
423
<div class="step-content">
424
<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>
428
</div>
429
430
<div class="info-box">
431
-
<p>Click the button below to create your passkey. You'll be prompted to use:</p>
432
<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>
436
</ul>
437
</div>
438
439
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
440
-
{flow.state.submitting ? 'Creating Passkey...' : 'Create Passkey'}
441
</button>
442
443
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
444
-
Back
445
</button>
446
</div>
447
···
459
/>
460
461
{:else if flow.state.step === 'redirect-to-dashboard'}
462
-
<p class="loading">Redirecting to dashboard...</p>
463
{/if}
464
</div>
465
···
34
}
35
})
36
37
+
let creatingStarted = false
38
+
$effect(() => {
39
+
if (flow?.state.step === 'creating' && !creatingStarted) {
40
+
creatingStarted = true
41
+
flow.createPasskeyAccount()
42
+
}
43
+
})
44
+
45
async function loadServerInfo() {
46
try {
47
serverInfo = await api.describeServer()
···
57
function validateInfoStep(): string | null {
58
if (!flow) return 'Flow not initialized'
59
const info = flow.info
60
+
if (!info.handle.trim()) return $_('registerPasskey.errors.handleRequired')
61
+
if (info.handle.includes('.')) return $_('registerPasskey.errors.handleNoDots')
62
if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) {
63
+
return $_('registerPasskey.errors.inviteRequired')
64
}
65
if (info.didType === 'web-external') {
66
+
if (!info.externalDid?.trim()) return $_('registerPasskey.errors.externalDidRequired')
67
+
if (!info.externalDid.trim().startsWith('did:web:')) return $_('registerPasskey.errors.externalDidFormat')
68
}
69
switch (info.verificationChannel) {
70
case 'email':
71
+
if (!info.email.trim()) return $_('registerPasskey.errors.emailRequired')
72
break
73
case 'discord':
74
+
if (!info.discordId?.trim()) return $_('registerPasskey.errors.discordRequired')
75
break
76
case 'telegram':
77
+
if (!info.telegramUsername?.trim()) return $_('registerPasskey.errors.telegramRequired')
78
break
79
case 'signal':
80
+
if (!info.signalNumber?.trim()) return $_('registerPasskey.errors.signalRequired')
81
break
82
}
83
return null
···
129
}
130
131
if (!window.PublicKeyCredential) {
132
+
flow.setError($_('registerPasskey.errors.passkeysNotSupported'))
133
return
134
}
135
···
161
})
162
163
if (!credential) {
164
+
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
165
flow.setSubmitting(false)
166
return
167
}
···
188
flow.setPasskeyComplete(result.appPassword, result.appPasswordName)
189
} catch (err) {
190
if (err instanceof DOMException && err.name === 'NotAllowedError') {
191
+
flow.setError($_('registerPasskey.errors.passkeyCancelled'))
192
} else if (err instanceof ApiError) {
193
+
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
194
} else if (err instanceof Error) {
195
+
flow.setError(err.message || $_('registerPasskey.errors.passkeyFailed'))
196
} else {
197
+
flow.setError($_('registerPasskey.errors.passkeyFailed'))
198
}
199
} finally {
200
flow.setSubmitting(false)
···
215
216
function channelLabel(ch: string): string {
217
switch (ch) {
218
+
case 'email': return $_('register.email')
219
+
case 'discord': return $_('register.discord')
220
+
case 'telegram': return $_('register.telegram')
221
+
case 'signal': return $_('register.signal')
222
default: return ch
223
}
224
}
···
238
function getSubtitle(): string {
239
if (!flow) return ''
240
switch (flow.state.step) {
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')
251
default: return ''
252
}
253
}
···
267
</div>
268
{/if}
269
270
+
<h1>{$_('registerPasskey.title')}</h1>
271
<p class="subtitle">{getSubtitle()}</p>
272
273
{#if flow?.state.error}
···
275
{/if}
276
277
{#if loadingServerInfo || !flow}
278
+
<p class="loading">{$_('registerPasskey.loading')}</p>
279
280
{:else if flow.state.step === 'info'}
281
<form onsubmit={handleInfoSubmit}>
282
<div class="field">
283
+
<label for="handle">{$_('registerPasskey.handle')}</label>
284
<input
285
id="handle"
286
type="text"
287
bind:value={flow.info.handle}
288
+
placeholder={$_('registerPasskey.handlePlaceholder')}
289
disabled={flow.state.submitting}
290
required
291
/>
292
{#if flow.info.handle.includes('.')}
293
+
<p class="hint warning">{$_('registerPasskey.handleDotWarning')}</p>
294
{:else if fullHandle()}
295
+
<p class="hint">{$_('registerPasskey.handleHint', { values: { handle: fullHandle() } })}</p>
296
{/if}
297
</div>
298
299
<fieldset class="section-fieldset">
300
+
<legend>{$_('registerPasskey.contactMethod')}</legend>
301
+
<p class="section-hint">{$_('registerPasskey.contactMethodHint')}</p>
302
<div class="field">
303
+
<label for="verification-channel">{$_('registerPasskey.verificationMethod')}</label>
304
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
305
+
<option value="email">{$_('register.email')}</option>
306
<option value="discord" disabled={!isChannelAvailable('discord')}>
307
+
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
308
</option>
309
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
310
+
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
311
</option>
312
<option value="signal" disabled={!isChannelAvailable('signal')}>
313
+
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
314
</option>
315
</select>
316
</div>
317
{#if flow.info.verificationChannel === 'email'}
318
<div class="field">
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 />
321
</div>
322
{:else if flow.info.verificationChannel === 'discord'}
323
<div class="field">
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>
327
</div>
328
{:else if flow.info.verificationChannel === 'telegram'}
329
<div class="field">
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 />
332
</div>
333
{:else if flow.info.verificationChannel === 'signal'}
334
<div class="field">
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>
338
</div>
339
{/if}
340
</fieldset>
341
342
<fieldset class="section-fieldset">
343
+
<legend>{$_('registerPasskey.identityType')}</legend>
344
+
<p class="section-hint">{$_('registerPasskey.identityTypeHint')}</p>
345
<div class="radio-group">
346
<label class="radio-label">
347
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
348
<span class="radio-content">
349
+
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
350
+
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
351
</span>
352
</label>
353
<label class="radio-label">
354
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
355
<span class="radio-content">
356
+
<strong>{$_('registerPasskey.didWeb')}</strong>
357
+
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
358
</span>
359
</label>
360
<label class="radio-label">
361
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
362
<span class="radio-content">
363
+
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
364
+
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
365
</span>
366
</label>
367
</div>
368
{#if flow.info.didType === 'web'}
369
<div class="warning-box">
370
+
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
371
<ul>
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>
376
</ul>
377
</div>
378
{/if}
379
{#if flow.info.didType === 'web-external'}
380
<div class="field">
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>
384
</div>
385
{/if}
386
</fieldset>
387
388
{#if serverInfo?.inviteCodeRequired}
389
<div class="field">
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 />
392
</div>
393
{/if}
394
395
<div class="info-box">
396
+
<strong>{$_('registerPasskey.whyPasskeyOnly')}</strong>
397
+
<p>{$_('registerPasskey.whyPasskeyOnlyDesc')}</p>
398
<ul>
399
+
<li>{$_('registerPasskey.whyPasskeyBullet1')}</li>
400
+
<li>{$_('registerPasskey.whyPasskeyBullet2')}</li>
401
+
<li>{$_('registerPasskey.whyPasskeyBullet3')}</li>
402
</ul>
403
</div>
404
405
<button type="submit" disabled={flow.state.submitting}>
406
+
{flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')}
407
</button>
408
</form>
409
410
<p class="link-text">
411
+
{$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a>
412
</p>
413
414
{:else if flow.state.step === 'key-choice'}
···
423
/>
424
425
{:else if flow.state.step === 'creating'}
426
+
<p class="loading">{$_('registerPasskey.subtitleCreating')}</p>
427
428
{:else if flow.state.step === 'passkey'}
429
<div class="step-content">
430
<div class="field">
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>
434
</div>
435
436
<div class="info-box">
437
+
<p>{$_('registerPasskey.passkeyPrompt')}</p>
438
<ul>
439
+
<li>{$_('registerPasskey.passkeyPromptBullet1')}</li>
440
+
<li>{$_('registerPasskey.passkeyPromptBullet2')}</li>
441
+
<li>{$_('registerPasskey.passkeyPromptBullet3')}</li>
442
</ul>
443
</div>
444
445
<button onclick={handlePasskeyRegistration} disabled={flow.state.submitting} class="passkey-btn">
446
+
{flow.state.submitting ? $_('registerPasskey.creatingPasskey') : $_('registerPasskey.createPasskey')}
447
</button>
448
449
<button type="button" class="secondary" onclick={() => flow?.goBack()} disabled={flow.state.submitting}>
450
+
{$_('registerPasskey.back')}
451
</button>
452
</div>
453
···
465
/>
466
467
{:else if flow.state.step === 'redirect-to-dashboard'}
468
+
<p class="loading">{$_('registerPasskey.redirecting')}</p>
469
{/if}
470
</div>
471
+1
-1
frontend/src/routes/Settings.svelte
+1
-1
frontend/src/routes/Settings.svelte
···
55
const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
56
emailTokenRequired = result.tokenRequired
57
if (emailTokenRequired) {
58
-
showMessage('success', $_('settings.messages.verificationCodeSent'))
59
} else {
60
await api.updateEmail(auth.session.accessJwt, newEmail)
61
await refreshSession()
···
55
const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
56
emailTokenRequired = result.tokenRequired
57
if (emailTokenRequired) {
58
+
showMessage('success', $_('settings.messages.emailCodeSent'))
59
} else {
60
await api.updateEmail(auth.session.accessJwt, newEmail)
61
await refreshSession()
+231
-28
frontend/src/routes/Verify.svelte
+231
-28
frontend/src/routes/Verify.svelte
···
1
<script lang="ts">
2
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
···
11
channel: string
12
}
13
14
let pendingVerification = $state<PendingVerification | null>(null)
15
let verificationCode = $state('')
16
let submitting = $state(false)
17
let resendingCode = $state(false)
18
let error = $state<string | null>(null)
19
let resendMessage = $state<string | null>(null)
20
21
const auth = getAuthState()
22
23
-
$effect(() => {
24
-
if (auth.session) {
25
-
clearPendingVerification()
26
-
navigate('/dashboard')
27
}
28
})
29
30
$effect(() => {
31
-
const stored = localStorage.getItem(STORAGE_KEY)
32
-
if (stored) {
33
-
try {
34
-
pendingVerification = JSON.parse(stored)
35
-
} catch {
36
-
pendingVerification = null
37
-
}
38
}
39
})
40
···
43
pendingVerification = null
44
}
45
46
-
async function handleVerification(e: Event) {
47
e.preventDefault()
48
if (!pendingVerification || !verificationCode.trim()) return
49
···
61
}
62
}
63
64
-
async function handleResendCode() {
65
-
if (!pendingVerification || resendingCode) return
66
67
-
resendingCode = true
68
-
resendMessage = null
69
error = null
70
71
try {
72
-
await resendVerification(pendingVerification.did)
73
-
resendMessage = 'Verification code resent!'
74
} catch (e: any) {
75
-
error = e.message || 'Failed to resend code'
76
} finally {
77
-
resendingCode = false
78
}
79
}
80
···
87
default: return ch
88
}
89
}
90
</script>
91
92
<div class="verify-page">
93
-
{#if error}
94
-
<div class="message error">{error}</div>
95
-
{/if}
96
97
-
{#if pendingVerification}
98
<h1>{$_('verify.title')}</h1>
99
<p class="subtitle">
100
{$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })}
101
</p>
102
<p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p>
103
104
{#if resendMessage}
105
<div class="message success">{resendMessage}</div>
106
{/if}
107
108
-
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
109
<div class="field">
110
<label for="verification-code">{$_('verify.codeLabel')}</label>
111
<input
···
115
placeholder={$_('verify.codePlaceholder')}
116
disabled={submitting}
117
required
118
-
maxlength="6"
119
-
inputmode="numeric"
120
-
autocomplete="one-time-code"
121
/>
122
</div>
123
124
<button type="submit" disabled={submitting || !verificationCode.trim()}>
···
178
gap: var(--space-4);
179
}
180
181
.link-text {
182
text-align: center;
183
margin-top: var(--space-6);
···
222
.btn.secondary:hover {
223
background: var(--accent);
224
color: var(--text-inverse);
225
}
226
</style>
···
1
<script lang="ts">
2
+
import { onMount } from 'svelte'
3
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
import { navigate } from '../lib/router.svelte'
6
import { _ } from '../lib/i18n'
7
···
13
channel: string
14
}
15
16
+
type VerificationMode = 'signup' | 'token'
17
+
18
+
let mode = $state<VerificationMode>('signup')
19
let pendingVerification = $state<PendingVerification | null>(null)
20
let verificationCode = $state('')
21
+
let identifier = $state('')
22
let submitting = $state(false)
23
let resendingCode = $state(false)
24
let error = $state<string | null>(null)
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)
30
31
const auth = getAuthState()
32
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
+
}
74
}
75
})
76
77
$effect(() => {
78
+
if (mode === 'signup' && auth.session) {
79
+
clearPendingVerification()
80
+
navigate('/dashboard')
81
}
82
})
83
···
86
pendingVerification = null
87
}
88
89
+
async function handleSignupVerification(e: Event) {
90
e.preventDefault()
91
if (!pendingVerification || !verificationCode.trim()) return
92
···
104
}
105
}
106
107
+
async function handleTokenVerification() {
108
+
if (!verificationCode.trim() || !identifier.trim()) return
109
110
+
submitting = true
111
error = null
112
113
try {
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
122
} catch (e: any) {
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
+
}
132
} finally {
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
+
}
168
}
169
}
170
···
177
default: return ch
178
}
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
+
}
190
</script>
191
192
<div class="verify-page">
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>
257
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}
271
<h1>{$_('verify.title')}</h1>
272
<p class="subtitle">
273
{$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })}
274
</p>
275
<p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p>
276
277
+
{#if error}
278
+
<div class="message error">{error}</div>
279
+
{/if}
280
+
281
{#if resendMessage}
282
<div class="message success">{resendMessage}</div>
283
{/if}
284
285
+
<form onsubmit={(e) => { e.preventDefault(); handleSignupVerification(e); }}>
286
<div class="field">
287
<label for="verification-code">{$_('verify.codeLabel')}</label>
288
<input
···
292
placeholder={$_('verify.codePlaceholder')}
293
disabled={submitting}
294
required
295
+
autocomplete="off"
296
+
class="token-input"
297
/>
298
+
<p class="field-help">{$_('verify.codeHelp')}</p>
299
</div>
300
301
<button type="submit" disabled={submitting || !verificationCode.trim()}>
···
355
gap: var(--space-4);
356
}
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
+
369
.link-text {
370
text-align: center;
371
margin-top: var(--space-6);
···
410
.btn.secondary:hover {
411
background: var(--accent);
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);
428
}
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
use crate::api::error::ApiError;
2
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
-
use axum::{extract::State, Json};
5
use serde::{Deserialize, Serialize};
6
use tracing::error;
7
···
80
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
81
sqlx::query(
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()"
84
)
85
.bind(key)
86
.bind(value)
···
105
if let Some(server_name) = req.server_name {
106
let trimmed = server_name.trim();
107
if trimmed.is_empty() || trimmed.len() > 100 {
108
-
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
109
}
110
upsert_config(&state.db, "server_name", trimmed).await?;
111
}
···
116
} else if is_valid_hex_color(color) {
117
upsert_config(&state.db, "primary_color", color).await?;
118
} else {
119
-
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
120
}
121
}
122
···
126
} else if is_valid_hex_color(color) {
127
upsert_config(&state.db, "primary_color_dark", color).await?;
128
} else {
129
-
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
130
}
131
}
132
···
136
} else if is_valid_hex_color(color) {
137
upsert_config(&state.db, "secondary_color", color).await?;
138
} else {
139
-
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
140
}
141
}
142
···
146
} else if is_valid_hex_color(color) {
147
upsert_config(&state.db, "secondary_color_dark", color).await?;
148
} else {
149
-
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
150
}
151
}
152
153
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?;
159
160
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
161
(Some(old), true) => Some(old.clone()),
···
163
_ => None,
164
};
165
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
173
{
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
-
}
183
}
184
}
185
···
1
use crate::api::error::ApiError;
2
use crate::auth::BearerAuthAdmin;
3
use crate::state::AppState;
4
+
use axum::{Json, extract::State};
5
use serde::{Deserialize, Serialize};
6
use tracing::error;
7
···
80
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
81
sqlx::query(
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()",
84
)
85
.bind(key)
86
.bind(value)
···
105
if let Some(server_name) = req.server_name {
106
let trimmed = server_name.trim();
107
if trimmed.is_empty() || trimmed.len() > 100 {
108
+
return Err(ApiError::InvalidRequest(
109
+
"Server name must be 1-100 characters".into(),
110
+
));
111
}
112
upsert_config(&state.db, "server_name", trimmed).await?;
113
}
···
118
} else if is_valid_hex_color(color) {
119
upsert_config(&state.db, "primary_color", color).await?;
120
} else {
121
+
return Err(ApiError::InvalidRequest(
122
+
"Invalid primary color format (expected #RRGGBB)".into(),
123
+
));
124
}
125
}
126
···
130
} else if is_valid_hex_color(color) {
131
upsert_config(&state.db, "primary_color_dark", color).await?;
132
} else {
133
+
return Err(ApiError::InvalidRequest(
134
+
"Invalid primary dark color format (expected #RRGGBB)".into(),
135
+
));
136
}
137
}
138
···
142
} else if is_valid_hex_color(color) {
143
upsert_config(&state.db, "secondary_color", color).await?;
144
} else {
145
+
return Err(ApiError::InvalidRequest(
146
+
"Invalid secondary color format (expected #RRGGBB)".into(),
147
+
));
148
}
149
}
150
···
154
} else if is_valid_hex_color(color) {
155
upsert_config(&state.db, "secondary_color_dark", color).await?;
156
} else {
157
+
return Err(ApiError::InvalidRequest(
158
+
"Invalid secondary dark color format (expected #RRGGBB)".into(),
159
+
));
160
}
161
}
162
163
if let Some(ref logo_cid) = req.logo_cid {
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?;
168
169
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
170
(Some(old), true) => Some(old.clone()),
···
172
_ => None,
173
};
174
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
187
{
188
+
error!("Failed to delete old logo blob record: {:?}", e);
189
}
190
}
191
+3
-1
src/api/error.rs
+3
-1
src/api/error.rs
···
94
fn error_name(&self) -> Cow<'static, str> {
95
match self {
96
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
97
-
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"),
98
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
99
Self::UpstreamError { error, .. } => {
100
if let Some(e) = error {
···
94
fn error_name(&self) -> Cow<'static, str> {
95
match self {
96
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
97
+
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => {
98
+
Cow::Borrowed("UpstreamFailure")
99
+
}
100
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
101
Self::UpstreamError { error, .. } => {
102
if let Some(e) = error {
+75
-71
src/api/identity/account.rs
+75
-71
src/api/identity/account.rs
···
132
.map(|d| d.starts_with("did:plc:"))
133
.unwrap_or(false);
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 (
140
StatusCode::FORBIDDEN,
141
Json(json!({
142
"error": "AuthorizationError",
···
144
})),
145
)
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
-
}
153
}
154
}
155
···
348
)
349
.into_response();
350
}
351
-
if !is_did_web_byod {
352
-
if let Err(e) =
353
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
-
}
361
}
362
info!(did = %d, "Creating external did:web account");
363
d.clone()
···
368
info!(did = %d, "Migration with existing did:plc");
369
d.clone()
370
} 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
-
}
382
}
383
d.clone()
384
} else if !d.trim().is_empty() {
···
710
.into_response();
711
}
712
};
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
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
716
.fetch_one(&mut *tx)
717
.await
···
758
)
759
.bind(is_first_user)
760
.bind(deactivated_at)
761
-
.bind(is_migration)
762
.fetch_one(&mut *tx)
763
.await;
764
let user_id = match user_insert {
···
806
}
807
};
808
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
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
829
Ok(enc) => enc,
830
Err(e) => {
···
881
}
882
};
883
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
-
};
895
let commit_cid = match state.block_store.put(&commit_bytes).await {
896
Ok(c) => c,
897
Err(e) => {
···
973
warn!("Failed to create default profile for {}: {}", did, e);
974
}
975
}
976
if !is_migration {
977
-
if let Some(ref recipient) = verification_recipient
978
-
&& let Err(e) = crate::comms::enqueue_signup_verification(
979
&state.db,
980
user_id,
981
verification_channel,
982
recipient,
983
-
&verification_code,
984
None,
985
)
986
.await
987
{
988
-
warn!(
989
-
"Failed to enqueue signup verification notification: {:?}",
990
-
e
991
-
);
992
}
993
}
994
···
132
.map(|d| d.starts_with("did:plc:"))
133
.unwrap_or(false);
134
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
StatusCode::FORBIDDEN,
141
Json(json!({
142
"error": "AuthorizationError",
···
144
})),
145
)
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
}
153
}
154
···
347
)
348
.into_response();
349
}
350
+
if !is_did_web_byod
351
+
&& let Err(e) =
352
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
353
+
{
354
+
return (
355
+
StatusCode::BAD_REQUEST,
356
+
Json(json!({"error": "InvalidDid", "message": e})),
357
+
)
358
+
.into_response();
359
}
360
info!(did = %d, "Creating external did:web account");
361
d.clone()
···
366
info!(did = %d, "Migration with existing did:plc");
367
d.clone()
368
} else if d.starts_with("did:web:") {
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();
383
}
384
d.clone()
385
} else if !d.trim().is_empty() {
···
711
.into_response();
712
}
713
};
714
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
715
.fetch_one(&mut *tx)
716
.await
···
757
)
758
.bind(is_first_user)
759
.bind(deactivated_at)
760
+
.bind(false)
761
.fetch_one(&mut *tx)
762
.await;
763
let user_id = match user_insert {
···
805
}
806
};
807
808
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
809
Ok(enc) => enc,
810
Err(e) => {
···
861
}
862
};
863
let rev = Tid::now(LimitedU32::MIN);
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
+
};
876
let commit_cid = match state.block_store.put(&commit_bytes).await {
877
Ok(c) => c,
878
Err(e) => {
···
954
warn!("Failed to create default profile for {}: {}", did, e);
955
}
956
}
957
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
958
if !is_migration {
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(
968
&state.db,
969
user_id,
970
verification_channel,
971
recipient,
972
+
&formatted_token,
973
None,
974
)
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
994
{
995
+
warn!("Failed to enqueue migration verification email: {:?}", e);
996
}
997
}
998
+33
-59
src/api/notification_prefs.rs
+33
-59
src/api/notification_prefs.rs
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
-
use chrono::{Duration, Utc};
10
-
use rand::Rng;
11
use serde::{Deserialize, Serialize};
12
use serde_json::json;
13
use sqlx::Row;
14
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
24
#[derive(Serialize)]
25
#[serde(rename_all = "camelCase")]
···
228
pub async fn request_channel_verification(
229
db: &sqlx::PgPool,
230
user_id: uuid::Uuid,
231
channel: &str,
232
identifier: &str,
233
handle: Option<&str>,
234
) -> 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))?;
254
255
if channel == "email" {
256
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
257
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))?;
261
} else {
262
sqlx::query!(
263
r#"
···
267
user_id,
268
channel as _,
269
identifier,
270
-
format!("Your verification code is: {}", code),
271
-
json!({"code": code})
272
)
273
.execute(db)
274
.await
275
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
276
}
277
278
-
Ok(code)
279
}
280
281
pub async fn update_notification_prefs(
···
397
if let Err(e) = request_channel_verification(
398
&state.db,
399
user_id,
400
"email",
401
&email_clean,
402
Some(&handle),
···
429
)
430
.into_response();
431
}
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
info!(did = %user.did, "Cleared Discord ID");
439
} else {
440
-
if let Err(e) =
441
-
request_channel_verification(&state.db, user_id, "discord", discord_id, None).await
442
{
443
return (
444
StatusCode::INTERNAL_SERVER_ERROR,
···
467
)
468
.into_response();
469
}
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
info!(did = %user.did, "Cleared Telegram username");
477
} else {
478
-
if let Err(e) =
479
-
request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None)
480
-
.await
481
{
482
return (
483
StatusCode::INTERNAL_SERVER_ERROR,
···
505
)
506
.into_response();
507
}
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
info!(did = %user.did, "Cleared Signal number");
515
} else {
516
if let Err(e) =
517
-
request_channel_verification(&state.db, user_id, "signal", signal, None).await
518
{
519
return (
520
StatusCode::INTERNAL_SERVER_ERROR,
···
6
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
use serde::{Deserialize, Serialize};
10
use serde_json::json;
11
use sqlx::Row;
12
use tracing::info;
13
14
#[derive(Serialize)]
15
#[serde(rename_all = "camelCase")]
···
218
pub async fn request_channel_verification(
219
db: &sqlx::PgPool,
220
user_id: uuid::Uuid,
221
+
did: &str,
222
channel: &str,
223
identifier: &str,
224
handle: Option<&str>,
225
) -> Result<String, String> {
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);
229
230
if channel == "email" {
231
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
232
let handle_str = handle.unwrap_or("user");
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))?;
243
} else {
244
sqlx::query!(
245
r#"
···
249
user_id,
250
channel as _,
251
identifier,
252
+
format!("Your verification code is: {}", formatted_token),
253
+
json!({"code": formatted_token})
254
)
255
.execute(db)
256
.await
257
.map_err(|e| format!("Failed to enqueue notification: {}", e))?;
258
}
259
260
+
Ok(token)
261
}
262
263
pub async fn update_notification_prefs(
···
379
if let Err(e) = request_channel_verification(
380
&state.db,
381
user_id,
382
+
&user.did,
383
"email",
384
&email_clean,
385
Some(&handle),
···
412
)
413
.into_response();
414
}
415
info!(did = %user.did, "Cleared Discord ID");
416
} else {
417
+
if let Err(e) = request_channel_verification(
418
+
&state.db, user_id, &user.did, "discord", discord_id, None,
419
+
)
420
+
.await
421
{
422
return (
423
StatusCode::INTERNAL_SERVER_ERROR,
···
446
)
447
.into_response();
448
}
449
info!(did = %user.did, "Cleared Telegram username");
450
} else {
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
460
{
461
return (
462
StatusCode::INTERNAL_SERVER_ERROR,
···
484
)
485
.into_response();
486
}
487
info!(did = %user.did, "Cleared Signal number");
488
} else {
489
if let Err(e) =
490
+
request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None)
491
+
.await
492
{
493
return (
494
StatusCode::INTERNAL_SERVER_ERROR,
+1
-1
src/api/repo/record/utils.rs
+1
-1
src/api/repo/record/utils.rs
+64
-108
src/api/server/email.rs
+64
-108
src/api/server/email.rs
···
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
-
use chrono::Utc;
10
use serde::Deserialize;
11
use serde_json::json;
12
use tracing::{error, info, warn};
···
66
return e;
67
}
68
69
-
let did = auth_user.did;
70
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
71
.fetch_optional(&state.db)
72
.await
···
117
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
118
&state.db,
119
user_id,
120
"email",
121
&email,
122
Some(&handle),
···
206
}
207
};
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
-
_ => {
218
return (
219
StatusCode::BAD_REQUEST,
220
-
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
221
)
222
.into_response();
223
}
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();
244
}
245
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
let update = sqlx::query!(
260
-
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
261
-
pending_email,
262
user_id
263
)
264
-
.execute(&mut *tx)
265
.await;
266
267
if let Err(e) = update {
···
283
.into_response();
284
}
285
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
info!("Email updated for user {}", user_id);
302
(StatusCode::OK, Json(json!({}))).into_response()
303
}
···
377
return (StatusCode::OK, Json(json!({}))).into_response();
378
}
379
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);
387
388
-
if let Some(ver) = verification {
389
-
let confirmation_token = match &input.token {
390
-
Some(t) => t.trim(),
391
-
None => {
392
return (
393
StatusCode::BAD_REQUEST,
394
-
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
395
)
396
.into_response();
397
}
398
-
};
399
-
400
-
let pending_email = ver.pending_identifier.unwrap_or_default();
401
-
if pending_email.to_lowercase() != new_email {
402
return (
403
StatusCode::BAD_REQUEST,
404
-
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
405
)
406
.into_response();
407
}
408
-
409
-
if ver.code != confirmation_token {
410
return (
411
StatusCode::BAD_REQUEST,
412
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
413
)
414
.into_response();
415
}
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
}
425
426
let exists = sqlx::query!(
···
438
)
439
.into_response();
440
}
441
-
442
-
let mut tx = match state.db.begin().await {
443
-
Ok(tx) => tx,
444
-
Err(_) => return ApiError::InternalError.into_response(),
445
-
};
446
447
let update = sqlx::query!(
448
-
"UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2",
449
new_email,
450
user_id
451
)
452
-
.execute(&mut *tx)
453
.await;
454
455
if let Err(e) = update {
···
469
Json(json!({"error": "InternalError"})),
470
)
471
.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
}
484
485
match sqlx::query!(
···
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
use serde::Deserialize;
10
use serde_json::json;
11
use tracing::{error, info, warn};
···
65
return e;
66
}
67
68
+
let did = auth_user.did.clone();
69
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
70
.fetch_optional(&state.db)
71
.await
···
116
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
117
&state.db,
118
user_id,
119
+
&did,
120
"email",
121
&email,
122
Some(&handle),
···
206
}
207
};
208
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) => {
232
return (
233
StatusCode::BAD_REQUEST,
234
+
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
235
)
236
.into_response();
237
}
238
+
Err(_) => {
239
+
return (
240
+
StatusCode::BAD_REQUEST,
241
+
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
242
+
)
243
+
.into_response();
244
+
}
245
}
246
247
let update = sqlx::query!(
248
+
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
249
+
email,
250
user_id
251
)
252
+
.execute(&state.db)
253
.await;
254
255
if let Err(e) = update {
···
271
.into_response();
272
}
273
274
info!("Email updated for user {}", user_id);
275
(StatusCode::OK, Json(json!({}))).into_response()
276
}
···
350
return (StatusCode::OK, Json(json!({}))).into_response();
351
}
352
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
+
);
369
370
+
match verified {
371
+
Ok(token_data) => {
372
+
if token_data.did != did {
373
return (
374
StatusCode::BAD_REQUEST,
375
+
Json(
376
+
json!({"error": "InvalidToken", "message": "Token does not match account"}),
377
+
),
378
)
379
.into_response();
380
}
381
+
}
382
+
Err(crate::auth::verification_token::VerifyError::Expired) => {
383
return (
384
StatusCode::BAD_REQUEST,
385
+
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
386
)
387
.into_response();
388
}
389
+
Err(_) => {
390
return (
391
StatusCode::BAD_REQUEST,
392
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
393
)
394
.into_response();
395
}
396
}
397
398
let exists = sqlx::query!(
···
410
)
411
.into_response();
412
}
413
414
let update = sqlx::query!(
415
+
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
416
new_email,
417
user_id
418
)
419
+
.execute(&state.db)
420
.await;
421
422
if let Err(e) = update {
···
436
Json(json!({"error": "InternalError"})),
437
)
438
.into_response();
439
}
440
441
match sqlx::query!(
+11
-12
src/api/server/logo.rs
+11
-12
src/api/server/logo.rs
···
9
use tracing::error;
10
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
-
};
24
25
let cid = match logo_cid {
26
Some(c) if !c.is_empty() => c,
···
9
use tracing::error;
10
11
pub async fn get_logo(State(state): State<AppState>) -> Response {
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
+
};
23
24
let cid = match logo_cid {
25
Some(c) if !c.is_empty() => c,
+7
-3
src/api/server/mod.rs
+7
-3
src/api/server/mod.rs
···
13
pub mod signing_key;
14
pub mod totp;
15
pub mod trusted_devices;
16
17
pub use account_status::{
18
activate_account, check_account_status, deactivate_account, delete_account,
···
35
change_password, get_password_status, remove_password, request_password_reset, reset_password,
36
};
37
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,
41
};
42
pub use service_auth::get_service_auth;
43
pub use session::{
···
54
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
55
trust_device, update_trusted_device,
56
};
···
13
pub mod signing_key;
14
pub mod totp;
15
pub mod trusted_devices;
16
+
pub mod verify_email;
17
+
pub mod verify_token;
18
19
pub use account_status::{
20
activate_account, check_account_status, deactivate_account, delete_account,
···
37
change_password, get_password_status, remove_password, request_password_reset, reset_password,
38
};
39
pub use reauth::{
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,
43
};
44
pub use service_auth::get_service_auth;
45
pub use session::{
···
56
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
57
trust_device, update_trusted_device,
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
.await
118
{
119
Ok(claims) => {
120
-
debug!("Service token verified for BYOD did:web: iss={}", claims.iss);
121
Some(claims.iss)
122
}
123
Err(e) => {
···
342
.into_response();
343
}
344
if is_byod_did_web {
345
-
if let Some(ref auth_did) = byod_auth {
346
-
if d != auth_did {
347
-
return (
348
StatusCode::FORBIDDEN,
349
Json(json!({
350
"error": "AuthorizationError",
···
352
})),
353
)
354
.into_response();
355
-
}
356
}
357
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
358
} else {
···
415
};
416
417
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
425
let setup_token = generate_setup_token();
426
let setup_token_hash = match hash(&setup_token, DEFAULT_COST) {
···
591
}
592
};
593
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
-
};
605
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
606
Ok(c) => c,
607
Err(e) => {
···
647
.await;
648
}
649
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
if let Err(e) = tx.commit().await {
670
error!("Error committing transaction: {:?}", e);
671
return (
···
703
}
704
}
705
706
if let Err(e) = crate::comms::enqueue_signup_verification(
707
&state.db,
708
user_id,
709
verification_channel,
710
&verification_recipient,
711
-
&verification_code,
712
None,
713
)
714
.await
···
847
}
848
};
849
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 (
857
StatusCode::BAD_REQUEST,
858
Json(
859
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
860
),
861
)
862
.into_response();
863
-
}
864
-
};
865
866
let security_key = match webauthn.finish_registration(&credential, ®_state) {
867
Ok(sk) => sk,
···
117
.await
118
{
119
Ok(claims) => {
120
+
debug!(
121
+
"Service token verified for BYOD did:web: iss={}",
122
+
claims.iss
123
+
);
124
Some(claims.iss)
125
}
126
Err(e) => {
···
345
.into_response();
346
}
347
if is_byod_did_web {
348
+
if let Some(ref auth_did) = byod_auth
349
+
&& d != auth_did
350
+
{
351
+
return (
352
StatusCode::FORBIDDEN,
353
Json(json!({
354
"error": "AuthorizationError",
···
356
})),
357
)
358
.into_response();
359
}
360
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
361
} else {
···
418
};
419
420
info!(did = %did, handle = %handle, "Created DID for passkey-only account");
421
422
let setup_token = generate_setup_token();
423
let setup_token_hash = match hash(&setup_token, DEFAULT_COST) {
···
588
}
589
};
590
let rev = Tid::now(LimitedU32::MIN);
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
+
};
603
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
604
Ok(c) => c,
605
Err(e) => {
···
645
.await;
646
}
647
648
if let Err(e) = tx.commit().await {
649
error!("Error committing transaction: {:?}", e);
650
return (
···
682
}
683
}
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);
692
if let Err(e) = crate::comms::enqueue_signup_verification(
693
&state.db,
694
user_id,
695
verification_channel,
696
&verification_recipient,
697
+
&formatted_token,
698
None,
699
)
700
.await
···
833
}
834
};
835
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 (
842
StatusCode::BAD_REQUEST,
843
Json(
844
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
845
),
846
)
847
.into_response();
848
+
}
849
+
};
850
851
let security_key = match webauthn.finish_registration(&credential, ®_state) {
852
Ok(sk) => sk,
+7
-1
src/api/server/password.rs
+7
-1
src/api/server/password.rs
+10
-9
src/api/server/reauth.rs
+10
-9
src/api/server/reauth.rs
···
376
{
377
Ok(false) => {
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;
380
return (
381
StatusCode::UNAUTHORIZED,
382
Json(json!({
···
494
did: &str,
495
) -> bool {
496
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
-
}
505
}
506
}
507
}
···
376
{
377
Ok(false) => {
378
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
379
+
let _ =
380
+
crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
381
return (
382
StatusCode::UNAUTHORIZED,
383
Json(json!({
···
495
did: &str,
496
) -> bool {
497
let cache_key = format!("reauth:{}", did);
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;
506
}
507
}
508
}
+20
-16
src/api/server/service_auth.rs
+20
-16
src/api/server/service_auth.rs
···
66
}
67
};
68
69
-
let (token, is_dpop) = if auth_header.len() >= 7 && auth_header[..7].eq_ignore_ascii_case("bearer ") {
70
(auth_header[7..].trim().to_string(), false)
71
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
72
(auth_header[5..].trim().to_string(), true)
···
81
&token,
82
dpop_proof,
83
"GET",
84
-
&format!("/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}",
85
-
params.aud,
86
-
params.lxm.as_deref().unwrap_or("")),
87
-
).await {
88
Ok(result) => crate::auth::AuthenticatedUser {
89
did: result.did,
90
is_oauth: true,
···
100
"error": "use_dpop_nonce",
101
"message": "DPoP nonce required"
102
})),
103
-
).into_response();
104
}
105
Err(e) => {
106
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
···
110
"error": "AuthenticationFailed",
111
"message": format!("{:?}", e)
112
})),
113
-
).into_response();
114
}
115
}
116
} else {
···
136
"SELECT k.key_bytes, k.encryption_version
137
FROM users u
138
JOIN user_keys k ON u.id = k.user_id
139
-
WHERE u.did = $1"
140
)
141
.bind(&auth_user.did)
142
.fetch_optional(&state.db)
···
155
}
156
}
157
Ok(None) => {
158
-
return ApiError::AuthenticationFailedMsg(
159
-
"User has no signing key".into(),
160
-
)
161
-
.into_response();
162
}
163
Err(e) => {
164
error!(error = ?e, "DB error fetching user key");
165
-
return ApiError::AuthenticationFailedMsg(
166
-
"Failed to get signing key".into(),
167
-
)
168
-
.into_response();
169
}
170
}
171
}
···
66
}
67
};
68
69
+
let (token, is_dpop) = if auth_header.len() >= 7
70
+
&& auth_header[..7].eq_ignore_ascii_case("bearer ")
71
+
{
72
(auth_header[7..].trim().to_string(), false)
73
} else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") {
74
(auth_header[5..].trim().to_string(), true)
···
83
&token,
84
dpop_proof,
85
"GET",
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
+
{
94
Ok(result) => crate::auth::AuthenticatedUser {
95
did: result.did,
96
is_oauth: true,
···
106
"error": "use_dpop_nonce",
107
"message": "DPoP nonce required"
108
})),
109
+
)
110
+
.into_response();
111
}
112
Err(e) => {
113
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
···
117
"error": "AuthenticationFailed",
118
"message": format!("{:?}", e)
119
})),
120
+
)
121
+
.into_response();
122
}
123
}
124
} else {
···
144
"SELECT k.key_bytes, k.encryption_version
145
FROM users u
146
JOIN user_keys k ON u.id = k.user_id
147
+
WHERE u.did = $1",
148
)
149
.bind(&auth_user.did)
150
.fetch_optional(&state.db)
···
163
}
164
}
165
Ok(None) => {
166
+
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
167
+
.into_response();
168
}
169
Err(e) => {
170
error!(error = ?e, "DB error fetching user key");
171
+
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
172
+
.into_response();
173
}
174
}
175
}
+51
-73
src/api/server/session.rs
+51
-73
src/api/server/session.rs
···
8
response::{IntoResponse, Response},
9
};
10
use bcrypt::verify;
11
-
use chrono::Utc;
12
use serde::{Deserialize, Serialize};
13
use serde_json::json;
14
use tracing::{error, info, warn};
···
167
let has_totp = row.totp_enabled.unwrap_or(false);
168
let is_legacy_login = has_totp;
169
if has_totp && !row.allow_legacy_login {
170
-
warn!(
171
-
"Legacy login blocked for TOTP-enabled account: {}",
172
-
row.did
173
-
);
174
return (
175
StatusCode::FORBIDDEN,
176
Json(json!({
···
556
r#"SELECT
557
u.id, u.did, u.handle, u.email,
558
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
559
k.key_bytes, k.encryption_version
560
FROM users u
561
JOIN user_keys k ON u.id = k.user_id
···
577
}
578
};
579
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();
598
}
599
-
Err(e) => {
600
-
error!("Database error fetching verification: {:?}", e);
601
-
return ApiError::InternalError.into_response();
602
}
603
};
604
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();
612
}
613
614
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
···
632
{
633
error!("Failed to update verification status: {:?}", e);
634
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
}
647
648
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···
737
if is_verified {
738
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
739
}
740
-
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
741
-
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
742
743
let (channel_str, recipient) = match row.channel {
744
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
···
754
}
755
};
756
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
-
}
776
if let Err(e) = crate::comms::enqueue_signup_verification(
777
&state.db,
778
row.id,
779
channel_str,
780
&recipient,
781
-
&verification_code,
782
None,
783
)
784
.await
···
886
Ok(rows) => {
887
for (id, token_id, created_at, expires_at, client_id) in rows {
888
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);
891
sessions.push(SessionInfo {
892
id: format!("oauth:{}", id),
893
session_type: "oauth".to_string(),
···
1071
.into_response();
1072
}
1073
} 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
1079
{
1080
error!("DB error revoking JWT sessions: {:?}", e);
1081
return (
···
8
response::{IntoResponse, Response},
9
};
10
use bcrypt::verify;
11
use serde::{Deserialize, Serialize};
12
use serde_json::json;
13
use tracing::{error, info, warn};
···
166
let has_totp = row.totp_enabled.unwrap_or(false);
167
let is_legacy_login = has_totp;
168
if has_totp && !row.allow_legacy_login {
169
+
warn!("Legacy login blocked for TOTP-enabled account: {}", row.did);
170
return (
171
StatusCode::FORBIDDEN,
172
Json(json!({
···
552
r#"SELECT
553
u.id, u.did, u.handle, u.email,
554
u.preferred_comms_channel as "channel: crate::comms::CommsChannel",
555
+
u.discord_id, u.telegram_username, u.signal_number,
556
k.key_bytes, k.encryption_version
557
FROM users u
558
JOIN user_keys k ON u.id = k.user_id
···
574
}
575
};
576
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())
581
}
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())
588
}
589
};
590
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
+
}
617
}
618
619
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
···
637
{
638
error!("Failed to update verification status: {:?}", e);
639
return ApiError::InternalError.into_response();
640
}
641
642
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···
731
if is_verified {
732
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
733
}
734
735
let (channel_str, recipient) = match row.channel {
736
crate::comms::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()),
···
746
}
747
};
748
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
+
754
if let Err(e) = crate::comms::enqueue_signup_verification(
755
&state.db,
756
row.id,
757
channel_str,
758
&recipient,
759
+
&formatted_token,
760
None,
761
)
762
.await
···
864
Ok(rows) => {
865
for (id, token_id, created_at, expires_at, client_id) in rows {
866
let client_name = extract_client_name(&client_id);
867
+
let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id);
868
sessions.push(SessionInfo {
869
id: format!("oauth:{}", id),
870
session_type: "oauth".to_string(),
···
1048
.into_response();
1049
}
1050
} else {
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
1057
{
1058
error!("DB error revoking JWT sessions: {:?}", e);
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
return Err(HandleValidationError::TooLong);
65
}
66
67
-
if let Some(first_char) = handle.chars().next() {
68
-
if first_char == '-' || first_char == '_' {
69
-
return Err(HandleValidationError::StartsWithInvalidChar);
70
-
}
71
}
72
73
-
if let Some(last_char) = handle.chars().last() {
74
-
if last_char == '-' || last_char == '_' {
75
-
return Err(HandleValidationError::EndsWithInvalidChar);
76
-
}
77
}
78
79
for c in handle.chars() {
···
64
return Err(HandleValidationError::TooLong);
65
}
66
67
+
if let Some(first_char) = handle.chars().next()
68
+
&& (first_char == '-' || first_char == '_')
69
+
{
70
+
return Err(HandleValidationError::StartsWithInvalidChar);
71
}
72
73
+
if let Some(last_char) = handle.chars().last()
74
+
&& (last_char == '-' || last_char == '_')
75
+
{
76
+
return Err(HandleValidationError::EndsWithInvalidChar);
77
}
78
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
use crate::state::AppState;
3
use axum::{
4
Json,
5
extract::State,
6
-
http::{HeaderMap, StatusCode},
7
response::{IntoResponse, Response},
8
};
9
-
use chrono::Utc;
10
use serde::Deserialize;
11
use serde_json::json;
12
-
use tracing::{error, info};
13
14
#[derive(Deserialize)]
15
#[serde(rename_all = "camelCase")]
16
pub struct ConfirmChannelVerificationInput {
17
pub channel: String,
18
pub code: String,
19
}
20
···
23
headers: HeaderMap,
24
Json(input): Json<ConfirmChannelVerificationInput>,
25
) -> 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!(),
152
};
153
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();
188
}
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
}
···
1
use crate::state::AppState;
2
use axum::{
3
Json,
4
extract::State,
5
+
http::HeaderMap,
6
response::{IntoResponse, Response},
7
};
8
use serde::Deserialize;
9
use serde_json::json;
10
11
#[derive(Deserialize)]
12
#[serde(rename_all = "camelCase")]
13
pub struct ConfirmChannelVerificationInput {
14
pub channel: String,
15
+
pub identifier: String,
16
pub code: String,
17
}
18
···
21
headers: HeaderMap,
22
Json(input): Json<ConfirmChannelVerificationInput>,
23
) -> Response {
24
+
let token_input = crate::api::server::VerifyTokenInput {
25
+
token: input.code,
26
+
identifier: input.identifier,
27
};
28
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(),
32
}
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
pub struct NotificationStrings {
13
pub welcome_subject: &'static str,
14
pub welcome_body: &'static str,
15
-
pub email_verification_subject: &'static str,
16
-
pub email_verification_body: &'static str,
17
pub password_reset_subject: &'static str,
18
pub password_reset_body: &'static str,
19
pub email_update_subject: &'static str,
···
30
pub signup_verification_body: &'static str,
31
pub legacy_login_subject: &'static str,
32
pub legacy_login_body: &'static str,
33
}
34
35
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
···
46
static STRINGS_EN: NotificationStrings = NotificationStrings {
47
welcome_subject: "Welcome to {hostname}",
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
password_reset_subject: "Password Reset - {hostname}",
52
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
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.",
55
account_deletion_subject: "Account Deletion Request - {hostname}",
56
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
plc_operation_subject: "{hostname} - PLC Operation Token",
···
61
passkey_recovery_subject: "Account Recovery - {hostname}",
62
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
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}.",
65
legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}",
66
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}",
67
};
68
69
static STRINGS_ZH: NotificationStrings = NotificationStrings {
70
welcome_subject: "欢迎加入 {hostname}",
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
password_reset_subject: "密码重置 - {hostname}",
75
password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。",
76
email_update_subject: "确认您的新邮箱 - {hostname}",
77
-
email_update_body: "您好 @{handle},\n\n您的邮箱更新确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。",
78
account_deletion_subject: "账户删除请求 - {hostname}",
79
account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。",
80
plc_operation_subject: "{hostname} - PLC 操作令牌",
···
84
passkey_recovery_subject: "账户恢复 - {hostname}",
85
passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。",
86
signup_verification_subject: "验证您的账户 - {hostname}",
87
-
signup_verification_body: "欢迎!您的账户验证码是:{code}\n\n此验证码将在30分钟后过期。\n\n请输入此验证码完成在 {hostname} 上的注册。",
88
legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}",
89
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}",
90
};
91
92
static STRINGS_JA: NotificationStrings = NotificationStrings {
93
welcome_subject: "{hostname} へようこそ",
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
password_reset_subject: "パスワードリセット - {hostname}",
98
password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。",
99
email_update_subject: "新しいメールアドレスの確認 - {hostname}",
100
-
email_update_body: "@{handle} 様\n\nメールアドレス更新の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。",
101
account_deletion_subject: "アカウント削除リクエスト - {hostname}",
102
account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。",
103
plc_operation_subject: "{hostname} - PLC 操作トークン",
···
107
passkey_recovery_subject: "アカウント復旧 - {hostname}",
108
passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。",
109
signup_verification_subject: "アカウント認証 - {hostname}",
110
-
signup_verification_body: "ようこそ!アカウント認証コードは:{code}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} への登録を完了するには、このコードを入力してください。",
111
legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}",
112
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}",
113
};
114
115
static STRINGS_KO: NotificationStrings = NotificationStrings {
116
welcome_subject: "{hostname}에 오신 것을 환영합니다",
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
password_reset_subject: "비밀번호 재설정 - {hostname}",
121
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요청하지 않으셨다면 이 이메일을 무시하세요.",
124
account_deletion_subject: "계정 삭제 요청 - {hostname}",
125
account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.",
126
plc_operation_subject: "{hostname} - PLC 작업 토큰",
···
130
passkey_recovery_subject: "계정 복구 - {hostname}",
131
passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.",
132
signup_verification_subject: "계정 인증 - {hostname}",
133
-
signup_verification_body: "환영합니다! 계정 인증 코드는: {code}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 등록을 완료하려면 이 코드를 입력하세요.",
134
legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}",
135
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} 드림",
136
};
137
138
static STRINGS_SV: NotificationStrings = NotificationStrings {
139
welcome_subject: "Välkommen till {hostname}",
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
password_reset_subject: "Lösenordsåterställning - {hostname}",
144
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
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.",
147
account_deletion_subject: "Begäran om kontoradering - {hostname}",
148
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
plc_operation_subject: "{hostname} - PLC-operationstoken",
···
153
passkey_recovery_subject: "Kontoåterställning - {hostname}",
154
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
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}.",
157
legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}",
158
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}",
159
};
160
161
static STRINGS_FI: NotificationStrings = NotificationStrings {
162
welcome_subject: "Tervetuloa palveluun {hostname}",
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
password_reset_subject: "Salasanan palautus - {hostname}",
167
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.",
170
account_deletion_subject: "Tilin poistopyyntö - {hostname}",
171
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
plc_operation_subject: "{hostname} - PLC-toimintotunniste",
···
176
passkey_recovery_subject: "Tilin palautus - {hostname}",
177
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
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}.",
180
legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}",
181
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}",
182
};
183
184
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
···
12
pub struct NotificationStrings {
13
pub welcome_subject: &'static str,
14
pub welcome_body: &'static str,
15
pub password_reset_subject: &'static str,
16
pub password_reset_body: &'static str,
17
pub email_update_subject: &'static str,
···
28
pub signup_verification_body: &'static str,
29
pub legacy_login_subject: &'static str,
30
pub legacy_login_body: &'static str,
31
+
pub migration_verification_subject: &'static str,
32
+
pub migration_verification_body: &'static str,
33
}
34
35
pub fn get_strings(locale: &str) -> &'static NotificationStrings {
···
46
static STRINGS_EN: NotificationStrings = NotificationStrings {
47
welcome_subject: "Welcome to {hostname}",
48
welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.",
49
password_reset_subject: "Password Reset - {hostname}",
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.",
51
email_update_subject: "Confirm your new email - {hostname}",
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})",
53
account_deletion_subject: "Account Deletion Request - {hostname}",
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.",
55
plc_operation_subject: "{hostname} - PLC Operation Token",
···
59
passkey_recovery_subject: "Account Recovery - {hostname}",
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.",
61
signup_verification_subject: "Verify your account - {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})",
63
legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}",
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
};
68
69
static STRINGS_ZH: NotificationStrings = NotificationStrings {
70
welcome_subject: "欢迎加入 {hostname}",
71
welcome_body: "欢迎加入 {hostname}!\n\n您的用户名是:@{handle}\n\n感谢您的加入。",
72
password_reset_subject: "密码重置 - {hostname}",
73
password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。",
74
email_update_subject: "确认您的新邮箱 - {hostname}",
75
+
email_update_body: "您好 @{handle},\n\n您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。\n\n(或者直接点击链接:{verify_link})",
76
account_deletion_subject: "账户删除请求 - {hostname}",
77
account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。",
78
plc_operation_subject: "{hostname} - PLC 操作令牌",
···
82
passkey_recovery_subject: "账户恢复 - {hostname}",
83
passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。",
84
signup_verification_subject: "验证您的账户 - {hostname}",
85
+
signup_verification_body: "欢迎!您的验证码是:\n{code}\n\n复制上述验证码并在此输入:\n{verify_page}\n\n此验证码将在30分钟后过期。\n\n如果您没有在 {hostname} 上创建账户,请忽略此消息。\n\n(或者直接点击链接:{verify_link})",
86
legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}",
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
};
91
92
static STRINGS_JA: NotificationStrings = NotificationStrings {
93
welcome_subject: "{hostname} へようこそ",
94
welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。",
95
password_reset_subject: "パスワードリセット - {hostname}",
96
password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。",
97
email_update_subject: "新しいメールアドレスの確認 - {hostname}",
98
+
email_update_body: "@{handle} 様\n\n確認コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
99
account_deletion_subject: "アカウント削除リクエスト - {hostname}",
100
account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。",
101
plc_operation_subject: "{hostname} - PLC 操作トークン",
···
105
passkey_recovery_subject: "アカウント復旧 - {hostname}",
106
passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。",
107
signup_verification_subject: "アカウント認証 - {hostname}",
108
+
signup_verification_body: "ようこそ!認証コードは:\n{code}\n\n上記のコードをコピーして、こちらで入力してください:\n{verify_page}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} でアカウントを作成していない場合は、このメールを無視してください。\n\n(自己責任でワンクリック認証:{verify_link})",
109
legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}",
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
};
114
115
static STRINGS_KO: NotificationStrings = NotificationStrings {
116
welcome_subject: "{hostname}에 오신 것을 환영합니다",
117
welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.",
118
password_reset_subject: "비밀번호 재설정 - {hostname}",
119
password_reset_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})",
122
account_deletion_subject: "계정 삭제 요청 - {hostname}",
123
account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.",
124
plc_operation_subject: "{hostname} - PLC 작업 토큰",
···
128
passkey_recovery_subject: "계정 복구 - {hostname}",
129
passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.",
130
signup_verification_subject: "계정 인증 - {hostname}",
131
+
signup_verification_body: "환영합니다! 인증 코드는:\n{code}\n\n위 코드를 복사하여 여기에 입력하세요:\n{verify_page}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 계정을 만들지 않았다면 이 이메일을 무시하세요.\n\n(위험을 감수하고 원클릭 인증: {verify_link})",
132
legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}",
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
};
137
138
static STRINGS_SV: NotificationStrings = NotificationStrings {
139
welcome_subject: "Välkommen till {hostname}",
140
welcome_body: "Välkommen till {hostname}!\n\nDitt användarnamn är: @{handle}\n\nTack för att du gick med.",
141
password_reset_subject: "Lösenordsåterställning - {hostname}",
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.",
143
email_update_subject: "Bekräfta din nya e-post - {hostname}",
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})",
145
account_deletion_subject: "Begäran om kontoradering - {hostname}",
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.",
147
plc_operation_subject: "{hostname} - PLC-operationstoken",
···
151
passkey_recovery_subject: "Kontoåterställning - {hostname}",
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.",
153
signup_verification_subject: "Verifiera ditt konto - {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})",
155
legacy_login_subject: "Säkerhetsvarning: Äldre inloggning upptäckt - {hostname}",
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
};
160
161
static STRINGS_FI: NotificationStrings = NotificationStrings {
162
welcome_subject: "Tervetuloa palveluun {hostname}",
163
welcome_body: "Tervetuloa palveluun {hostname}!\n\nKäyttäjänimesi on: @{handle}\n\nKiitos liittymisestä.",
164
password_reset_subject: "Salasanan palautus - {hostname}",
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.",
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})",
168
account_deletion_subject: "Tilin poistopyyntö - {hostname}",
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.",
170
plc_operation_subject: "{hostname} - PLC-toimintotunniste",
···
174
passkey_recovery_subject: "Tilin palautus - {hostname}",
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.",
176
signup_verification_subject: "Vahvista tilisi - {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})",
178
legacy_login_subject: "Turvallisuushälytys: Vanha kirjautuminen havaittu - {hostname}",
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
};
183
184
pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String {
+1
-1
src/comms/mod.rs
+1
-1
src/comms/mod.rs
···
10
11
pub use service::{
12
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
13
-
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
14
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
15
queue_legacy_login_notification,
16
};
···
10
11
pub use service::{
12
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
13
+
enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery,
14
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
15
queue_legacy_login_notification,
16
};
+81
-34
src/comms/service.rs
+81
-34
src/comms/service.rs
···
313
.await
314
}
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
pub async fn enqueue_password_reset(
345
db: &PgPool,
346
user_id: Uuid,
···
378
) -> Result<Uuid, sqlx::Error> {
379
let prefs = get_user_comms_prefs(db, user_id).await?;
380
let strings = get_strings(&prefs.locale);
381
let body = format_message(
382
strings.email_update_body,
383
-
&[("handle", handle), ("code", code)],
384
);
385
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
386
enqueue_comms(
···
530
_ => CommsChannel::Email,
531
};
532
let strings = get_strings(locale.unwrap_or("en"));
533
let body = format_message(
534
strings.signup_verification_body,
535
-
&[("code", code), ("hostname", &hostname)],
536
);
537
let subject = match comms_channel {
538
-
CommsChannel::Email => {
539
-
Some(format_message(strings.signup_verification_subject, &[("hostname", &hostname)]))
540
-
}
541
_ => None,
542
};
543
enqueue_comms(
···
554
.await
555
}
556
557
pub async fn queue_legacy_login_notification(
558
db: &PgPool,
559
user_id: Uuid,
···
563
) -> Result<Uuid, sqlx::Error> {
564
let prefs = get_user_comms_prefs(db, user_id).await?;
565
let strings = get_strings(&prefs.locale);
566
-
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
567
let body = format_message(
568
strings.legacy_login_body,
569
&[
···
313
.await
314
}
315
316
pub async fn enqueue_password_reset(
317
db: &PgPool,
318
user_id: Uuid,
···
350
) -> Result<Uuid, sqlx::Error> {
351
let prefs = get_user_comms_prefs(db, user_id).await?;
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
+
);
360
let body = format_message(
361
strings.email_update_body,
362
+
&[
363
+
("handle", handle),
364
+
("code", code),
365
+
("verify_page", &verify_page),
366
+
("verify_link", &verify_link),
367
+
],
368
);
369
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
370
enqueue_comms(
···
514
_ => CommsChannel::Email,
515
};
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
+
};
530
let body = format_message(
531
strings.signup_verification_body,
532
+
&[
533
+
("code", code),
534
+
("hostname", &hostname),
535
+
("verify_page", &verify_page),
536
+
("verify_link", &verify_link),
537
+
],
538
);
539
let subject = match comms_channel {
540
+
CommsChannel::Email => Some(format_message(
541
+
strings.signup_verification_subject,
542
+
&[("hostname", &hostname)],
543
+
)),
544
_ => None,
545
};
546
enqueue_comms(
···
557
.await
558
}
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
+
602
pub async fn queue_legacy_login_notification(
603
db: &PgPool,
604
user_id: Uuid,
···
608
) -> Result<Uuid, sqlx::Error> {
609
let prefs = get_user_comms_prefs(db, user_id).await?;
610
let strings = get_strings(&prefs.locale);
611
+
let timestamp = chrono::Utc::now()
612
+
.format("%Y-%m-%d %H:%M:%S UTC")
613
+
.to_string();
614
let body = format_message(
615
strings.legacy_login_body,
616
&[
+1
src/comms/types.rs
+1
src/comms/types.rs
+5
-2
src/config.rs
+5
-2
src/config.rs
+12
src/lib.rs
+12
src/lib.rs
···
296
post(api::server::reserve_signing_key),
297
)
298
.route(
299
"/xrpc/com.atproto.identity.updateHandle",
300
post(api::identity::update_handle),
301
)
···
549
.route(
550
"/xrpc/com.tranquil.account.confirmChannelVerification",
551
post(api::verification::confirm_channel_verification),
552
)
553
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
554
.layer(middleware::from_fn(metrics::metrics_middleware))
···
296
post(api::server::reserve_signing_key),
297
)
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(
307
"/xrpc/com.atproto.identity.updateHandle",
308
post(api::identity::update_handle),
309
)
···
557
.route(
558
"/xrpc/com.tranquil.account.confirmChannelVerification",
559
post(api::verification::confirm_channel_verification),
560
+
)
561
+
.route(
562
+
"/xrpc/com.tranquil.account.verifyToken",
563
+
post(api::server::verify_token),
564
)
565
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
566
.layer(middleware::from_fn(metrics::metrics_middleware))
+2
-1
src/oauth/endpoints/metadata.rs
+2
-1
src/oauth/endpoints/metadata.rs
···
172
"refresh_token".to_string(),
173
],
174
response_types: vec!["code".to_string()],
175
-
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
176
token_endpoint_auth_method: "none".to_string(),
177
application_type: "web".to_string(),
178
dpop_bound_access_tokens: true,
···
172
"refresh_token".to_string(),
173
],
174
response_types: vec!["code".to_string()],
175
+
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"
176
+
.to_string(),
177
token_endpoint_auth_method: "none".to_string(),
178
application_type: "web".to_string(),
179
dpop_bound_access_tokens: true,
+4
-3
src/rate_limit.rs
+4
-3
src/rate_limit.rs
+51
-13
src/validation/mod.rs
+51
-13
src/validation/mod.rs
···
458
459
fn is_common_password(password: &str) -> bool {
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",
474
];
475
476
let lower = password.to_lowercase();
···
458
459
fn is_common_password(password: &str) -> bool {
460
const COMMON_PASSWORDS: &[&str] = &[
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",
512
];
513
514
let lower = password.to_lowercase();
+44
-8
tests/account_notifications.rs
+44
-8
tests/account_notifications.rs
···
92
.await
93
.expect("User not found");
94
95
-
let code: String = sqlx::query_scalar!(
96
-
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'discord'",
97
user_id
98
)
99
.fetch_one(&pool)
100
.await
101
.expect("Verification code not found");
102
103
let input = json!({
104
"channel": "discord",
105
"code": code
106
});
107
let resp = client
···
153
154
let input = json!({
155
"channel": "telegram",
156
-
"code": "000000"
157
});
158
let resp = client
159
.post(format!(
···
165
.send()
166
.await
167
.unwrap();
168
-
assert_eq!(resp.status(), 400);
169
}
170
171
#[tokio::test]
···
176
177
let input = json!({
178
"channel": "signal",
179
-
"code": "123456"
180
});
181
let resp = client
182
.post(format!(
···
188
.send()
189
.await
190
.unwrap();
191
-
assert_eq!(resp.status(), 400);
192
}
193
194
#[tokio::test]
···
226
.await
227
.expect("User not found");
228
229
-
let code: String = sqlx::query_scalar!(
230
-
"SELECT code FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
231
user_id
232
)
233
.fetch_one(&pool)
234
.await
235
.expect("Verification code not found");
236
237
let input = json!({
238
"channel": "email",
239
"code": code
240
});
241
let resp = client
···
92
.await
93
.expect("User not found");
94
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
user_id
98
)
99
.fetch_one(&pool)
100
.await
101
.expect("Verification code not found");
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
+
110
let input = json!({
111
"channel": "discord",
112
+
"identifier": "123456789",
113
"code": code
114
});
115
let resp = client
···
161
162
let input = json!({
163
"channel": "telegram",
164
+
"identifier": "testuser",
165
+
"code": "XXXX-XXXX-XXXX-XXXX"
166
});
167
let resp = client
168
.post(format!(
···
174
.send()
175
.await
176
.unwrap();
177
+
assert!(
178
+
resp.status() == 400 || resp.status() == 422,
179
+
"Expected 400 or 422, got {}",
180
+
resp.status()
181
+
);
182
}
183
184
#[tokio::test]
···
189
190
let input = json!({
191
"channel": "signal",
192
+
"identifier": "123456",
193
+
"code": "XXXX-XXXX-XXXX-XXXX"
194
});
195
let resp = client
196
.post(format!(
···
202
.send()
203
.await
204
.unwrap();
205
+
assert!(
206
+
resp.status() == 400 || resp.status() == 422,
207
+
"Expected 400 or 422, got {}",
208
+
resp.status()
209
+
);
210
}
211
212
#[tokio::test]
···
244
.await
245
.expect("User not found");
246
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",
249
user_id
250
)
251
.fetch_one(&pool)
252
.await
253
.expect("Verification code not found");
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
+
272
let input = json!({
273
"channel": "email",
274
+
"identifier": unique_email,
275
"code": code
276
});
277
let resp = client
+48
-5
tests/common/mod.rs
+48
-5
tests/common/mod.rs
···
297
.connect(&conn_str)
298
.await
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'",
302
did
303
)
304
.fetch_one(&pool)
305
.await
306
.expect("Failed to get verification code");
307
308
let confirm_payload = json!({
309
"did": did,
···
453
if let Some(access_jwt) = body["accessJwt"].as_str() {
454
return (access_jwt.to_string(), did);
455
}
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'",
458
&did
459
)
460
.fetch_one(&pool)
461
.await
462
-
.expect("Failed to get verification code");
463
464
let confirm_payload = json!({
465
"did": did,
···
297
.connect(&conn_str)
298
.await
299
.expect("Failed to connect to test database");
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
did
303
)
304
.fetch_one(&pool)
305
.await
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
+
});
327
328
let confirm_payload = json!({
329
"did": did,
···
473
if let Some(access_jwt) = body["accessJwt"].as_str() {
474
return (access_jwt.to_string(), did);
475
}
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",
478
&did
479
)
480
.fetch_one(&pool)
481
.await
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
+
});
506
507
let confirm_payload = json!({
508
"did": did,
+18
-14
tests/did_web.rs
+18
-14
tests/did_web.rs
···
1
mod common;
2
-
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3
use base64::Engine;
4
use common::*;
5
use k256::ecdsa::{SigningKey, signature::Signer};
6
use reqwest::StatusCode;
···
387
let mock_uri = mock_server.uri();
388
let mock_addr = mock_uri.trim_start_matches("http://");
389
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "");
390
-
let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id);
391
let handle = format!("byod_{}", uuid::Uuid::new_v4());
392
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
398
let temp_key = SigningKey::random(&mut rand::thread_rng());
399
let public_key_multibase = signing_key_to_multibase(&temp_key);
···
443
let body: Value = res.json().await.expect("Response was not JSON");
444
let returned_did = body["did"].as_str().expect("No DID in response");
445
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");
449
450
let res = client
451
.get(format!(
452
"{}/xrpc/com.atproto.server.checkAccountStatus",
453
base_url().await
454
))
455
-
.bearer_auth(access_jwt)
456
.send()
457
.await
458
.expect("Failed to check account status");
···
468
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
469
base_url().await
470
))
471
-
.bearer_auth(access_jwt)
472
.send()
473
.await
474
.expect("Failed to get recommended credentials");
···
491
"{}/xrpc/com.atproto.server.activateAccount",
492
base_url().await
493
))
494
-
.bearer_auth(access_jwt)
495
.send()
496
.await
497
.expect("Failed to activate account");
···
506
"{}/xrpc/com.atproto.server.checkAccountStatus",
507
base_url().await
508
))
509
-
.bearer_auth(access_jwt)
510
.send()
511
.await
512
.expect("Failed to check account status");
···
522
"{}/xrpc/com.atproto.repo.createRecord",
523
base_url().await
524
))
525
-
.bearer_auth(access_jwt)
526
.json(&json!({
527
"repo": did,
528
"collection": "app.bsky.feed.post",
···
1
mod common;
2
use base64::Engine;
3
+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4
use common::*;
5
use k256::ecdsa::{SigningKey, signature::Signer};
6
use reqwest::StatusCode;
···
387
let mock_uri = mock_server.uri();
388
let mock_addr = mock_uri.trim_start_matches("http://");
389
let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "");
390
+
let did = format!(
391
+
"did:web:{}:byod:{}",
392
+
mock_addr.replace(":", "%3A"),
393
+
unique_id
394
+
);
395
let handle = format!("byod_{}", uuid::Uuid::new_v4());
396
let pds_endpoint = base_url().await.replace("http://", "https://");
397
+
let pds_did = format!("did:web:{}", pds_endpoint.trim_start_matches("https://"));
398
399
let temp_key = SigningKey::random(&mut rand::thread_rng());
400
let public_key_multibase = signing_key_to_multibase(&temp_key);
···
444
let body: Value = res.json().await.expect("Response was not JSON");
445
let returned_did = body["did"].as_str().expect("No DID in response");
446
assert_eq!(returned_did, did, "Returned DID should match requested DID");
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;
453
454
let res = client
455
.get(format!(
456
"{}/xrpc/com.atproto.server.checkAccountStatus",
457
base_url().await
458
))
459
+
.bearer_auth(&access_jwt)
460
.send()
461
.await
462
.expect("Failed to check account status");
···
472
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
473
base_url().await
474
))
475
+
.bearer_auth(&access_jwt)
476
.send()
477
.await
478
.expect("Failed to get recommended credentials");
···
495
"{}/xrpc/com.atproto.server.activateAccount",
496
base_url().await
497
))
498
+
.bearer_auth(&access_jwt)
499
.send()
500
.await
501
.expect("Failed to activate account");
···
510
"{}/xrpc/com.atproto.server.checkAccountStatus",
511
base_url().await
512
))
513
+
.bearer_auth(&access_jwt)
514
.send()
515
.await
516
.expect("Failed to check account status");
···
526
"{}/xrpc/com.atproto.repo.createRecord",
527
base_url().await
528
))
529
+
.bearer_auth(&access_jwt)
530
.json(&json!({
531
"repo": did,
532
"collection": "app.bsky.feed.post",
+40
-57
tests/email_update.rs
+40
-57
tests/email_update.rs
···
12
.expect("Failed to connect to test database")
13
}
14
15
async fn create_verified_account(
16
client: &reqwest::Client,
17
base_url: &str,
···
61
let body: Value = res.json().await.expect("Invalid JSON");
62
assert_eq!(body["tokenRequired"], true);
63
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;
77
let res = client
78
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
79
.bearer_auth(&access_jwt)
···
90
.await
91
.expect("User not found");
92
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
}
103
104
#[tokio::test]
···
180
.await
181
.expect("Failed to request email update");
182
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;
191
let res = client
192
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
193
.bearer_auth(&access_jwt)
···
200
.expect("Failed to confirm email");
201
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
202
let body: Value = res.json().await.expect("Invalid JSON");
203
-
assert_eq!(body["message"], "Email does not match pending update");
204
}
205
206
#[tokio::test]
207
-
async fn test_update_email_success_no_token_required() {
208
let client = common::client();
209
let base_url = common::base_url().await;
210
-
let pool = get_pool().await;
211
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
212
let email = format!("{}@example.com", handle);
213
-
let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await;
214
let new_email = format!("direct_{}@example.com", handle);
215
let res = client
216
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
···
219
.send()
220
.await
221
.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));
228
}
229
230
#[tokio::test]
···
299
.await
300
.expect("Failed to request email update");
301
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;
310
let res = client
311
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
312
.bearer_auth(&access_jwt)
···
323
.await
324
.expect("User not found");
325
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
}
335
336
#[tokio::test]
···
387
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
388
let body: Value = res.json().await.expect("Invalid JSON");
389
assert!(
390
-
body["message"].as_str().unwrap().contains("already in use")
391
|| body["error"] == "InvalidRequest"
392
);
393
}
···
12
.expect("Failed to connect to test database")
13
}
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
+
39
async fn create_verified_account(
40
client: &reqwest::Client,
41
base_url: &str,
···
85
let body: Value = res.json().await.expect("Invalid JSON");
86
assert_eq!(body["tokenRequired"], true);
87
88
+
let code = get_email_update_token(&pool, &did).await;
89
let res = client
90
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
91
.bearer_auth(&access_jwt)
···
102
.await
103
.expect("User not found");
104
assert_eq!(user.email, Some(new_email));
105
}
106
107
#[tokio::test]
···
183
.await
184
.expect("Failed to request email update");
185
assert_eq!(res.status(), StatusCode::OK);
186
+
let code = get_email_update_token(&pool, &did).await;
187
let res = client
188
.post(format!("{}/xrpc/com.atproto.server.confirmEmail", base_url))
189
.bearer_auth(&access_jwt)
···
196
.expect("Failed to confirm email");
197
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
198
let body: Value = res.json().await.expect("Invalid JSON");
199
+
assert!(
200
+
body["message"].as_str().unwrap().contains("mismatch") || body["error"] == "InvalidToken"
201
+
);
202
}
203
204
#[tokio::test]
205
+
async fn test_update_email_requires_token() {
206
let client = common::client();
207
let base_url = common::base_url().await;
208
let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4());
209
let email = format!("{}@example.com", handle);
210
+
let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await;
211
let new_email = format!("direct_{}@example.com", handle);
212
let res = client
213
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
···
216
.send()
217
.await
218
.expect("Failed to update 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");
222
}
223
224
#[tokio::test]
···
293
.await
294
.expect("Failed to request email update");
295
assert_eq!(res.status(), StatusCode::OK);
296
+
let code = get_email_update_token(&pool, &did).await;
297
let res = client
298
.post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url))
299
.bearer_auth(&access_jwt)
···
310
.await
311
.expect("User not found");
312
assert_eq!(user.email, Some(new_email));
313
}
314
315
#[tokio::test]
···
366
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
367
let body: Value = res.json().await.expect("Invalid JSON");
368
assert!(
369
+
body["error"] == "TokenRequired"
370
+
|| body["message"]
371
+
.as_str()
372
+
.unwrap_or("")
373
+
.contains("already in use")
374
|| body["error"] == "InvalidRequest"
375
);
376
}
+21
-2
tests/jwt_security.rs
+21
-2
tests/jwt_security.rs
···
688
.connect(&get_db_connection_string().await)
689
.await
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'",
693
did
694
).fetch_one(&pool).await.unwrap();
695
696
let confirm = http_client
697
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
···
688
.connect(&get_db_connection_string().await)
689
.await
690
.unwrap();
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
did
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
+
});
714
715
let confirm = http_client
716
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))