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