this repo has no description

'Clever' stateless token verification. We could revert later if it sucks.

lewis 71f19252 c69c7749

Changed files
+3020 -1330
.sqlx
frontend
migrations
src
tests
+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
··· 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
··· 40 "two_factor_code", 41 "channel_verification", 42 "passkey_recovery", 43 - "legacy_login_alert" 44 ] 45 } 46 }
··· 40 "two_factor_code", 41 "channel_verification", 42 "passkey_recovery", 43 + "legacy_login_alert", 44 + "migration_verification" 45 ] 46 } 47 }
+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
··· 48 "two_factor_code", 49 "channel_verification", 50 "passkey_recovery", 51 - "legacy_login_alert" 52 ] 53 } 54 }
··· 48 "two_factor_code", 49 "channel_verification", 50 "passkey_recovery", 51 + "legacy_login_alert", 52 + "migration_verification" 53 ] 54 } 55 }
-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
··· 40 "two_factor_code", 41 "channel_verification", 42 "passkey_recovery", 43 - "legacy_login_alert" 44 ] 45 } 46 }
··· 40 "two_factor_code", 41 "channel_verification", 42 "passkey_recovery", 43 + "legacy_login_alert", 44 + "migration_verification" 45 ] 46 } 47 }
-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
··· 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
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
···
··· 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
··· 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
···
··· 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
···
··· 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
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
··· 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
··· 43 "two_factor_code", 44 "channel_verification", 45 "passkey_recovery", 46 - "legacy_login_alert" 47 ] 48 } 49 }
··· 43 "two_factor_code", 44 "channel_verification", 45 "passkey_recovery", 46 + "legacy_login_alert", 47 + "migration_verification" 48 ] 49 } 50 }
+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
··· 15 ...rest 16 }: Props = $props() 17 18 - let inputId = id || `input-${Math.random().toString(36).slice(2, 9)}` 19 </script> 20 21 <div class="field">
··· 15 ...rest 16 }: Props = $props() 17 18 + const fallbackId = `input-${Math.random().toString(36).slice(2, 9)}` 19 + let inputId = $derived(id || fallbackId) 20 </script> 21 22 <div class="field">
-4
frontend/src/components/ui/Section.svelte
··· 33 padding: var(--space-6); 34 } 35 36 - .section + .section { 37 - margin-top: var(--space-6); 38 - } 39 - 40 .section-danger { 41 background: var(--error-bg); 42 border: 1px solid var(--error-border);
··· 33 padding: var(--space-6); 34 } 35 36 .section-danger { 37 background: var(--error-bg); 38 border: 1px solid var(--error-border);
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 302 {#if auth.session?.isAdmin} 303 <div class="page"> 304 <header> 305 - <a href="#/dashboard" class="back">&larr; 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}>&times;</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}>&times;</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
··· 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
··· 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
··· 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
··· 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
··· 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 + ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'migration_verification';
+1
migrations/20251233_remove_channel_verifications.sql
···
··· 1 + DROP TABLE IF EXISTS channel_verifications;
+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
··· 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
··· 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
··· 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
··· 3 use cid::Cid; 4 use jacquard::types::{integer::LimitedU32, string::Tid}; 5 use jacquard_repo::storage::BlockStore; 6 - use k256::ecdsa::{signature::Signer, Signature, SigningKey}; 7 use serde::Serialize; 8 use serde_json::json; 9 use uuid::Uuid;
··· 3 use cid::Cid; 4 use jacquard::types::{integer::LimitedU32, string::Tid}; 5 use jacquard_repo::storage::BlockStore; 6 + use k256::ecdsa::{Signature, SigningKey, signature::Signer}; 7 use serde::Serialize; 8 use serde_json::json; 9 use uuid::Uuid;
+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
··· 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
··· 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
··· 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, &reg_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, &reg_state) { 852 Ok(sk) => sk,
+7 -1
src/api/server/password.rs
··· 471 .await; 472 } 473 474 - if crate::api::server::reauth::check_reauth_required_cached(&state.db, &state.cache, &auth.0.did).await { 475 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 476 } 477
··· 471 .await; 472 } 473 474 + if crate::api::server::reauth::check_reauth_required_cached( 475 + &state.db, 476 + &state.cache, 477 + &auth.0.did, 478 + ) 479 + .await 480 + { 481 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 482 } 483
+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
··· 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
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
··· 12 pub mod service; 13 pub mod token; 14 pub mod totp; 15 pub mod verify; 16 pub mod webauthn; 17
··· 12 pub mod service; 13 pub mod token; 14 pub mod totp; 15 + pub mod verification_token; 16 pub mod verify; 17 pub mod webauthn; 18
+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
··· 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
··· 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
··· 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
··· 34 TwoFactorCode, 35 PasskeyRecovery, 36 LegacyLoginAlert, 37 } 38 39 #[derive(Debug, Clone, FromRow)]
··· 34 TwoFactorCode, 35 PasskeyRecovery, 36 LegacyLoginAlert, 37 + MigrationVerification, 38 } 39 40 #[derive(Debug, Clone, FromRow)]
+5 -2
src/config.rs
··· 114 .expect("HKDF expansion failed"); 115 116 let mut device_cookie_key = [0u8; 32]; 117 - hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key) 118 - .expect("HKDF expansion failed"); 119 120 AuthConfig { 121 jwt_secret,
··· 114 .expect("HKDF expansion failed"); 115 116 let mut device_cookie_key = [0u8; 32]; 117 + hk.expand( 118 + b"tranquil-pds-device-cookie-signing", 119 + &mut device_cookie_key, 120 + ) 121 + .expect("HKDF expansion failed"); 122 123 AuthConfig { 124 jwt_secret,
+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
··· 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
··· 74 email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( 75 NonZeroU32::new(5).unwrap(), 76 ))), 77 - totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60)) 78 - .unwrap() 79 - .allow_burst(NonZeroU32::new(5).unwrap()), 80 )), 81 } 82 }
··· 74 email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( 75 NonZeroU32::new(5).unwrap(), 76 ))), 77 + totp_verify: Arc::new(RateLimiter::keyed( 78 + Quota::with_period(std::time::Duration::from_secs(60)) 79 + .unwrap() 80 + .allow_burst(NonZeroU32::new(5).unwrap()), 81 )), 82 } 83 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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))