I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go

Locales in frontend, why not

+4 -4
.sqlx/query-17da8b6f6b46eae067bd8842a369a406699888f689122d2bae8bef13b532bcd2.json .sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 3 + "query": "SELECT\n handle, email, email_verified, is_admin, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 - "name": "deactivated_at", 29 - "type_info": "Timestamptz" 28 + "name": "preferred_locale", 29 + "type_info": "Varchar" 30 30 }, 31 31 { 32 32 "ordinal": 5, ··· 78 78 false 79 79 ] 80 80 }, 81 - "hash": "17da8b6f6b46eae067bd8842a369a406699888f689122d2bae8bef13b532bcd2" 81 + "hash": "7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c" 82 82 }
+23
.sqlx/query-50fef1f4f739c42622f2d23c8057861fea2e0e757c40b4d43bafa1beb156c2f1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET preferred_locale = $1 WHERE did = $2 RETURNING did", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Varchar", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "50fef1f4f739c42622f2d23c8057861fea2e0e757c40b4d43bafa1beb156c2f1" 23 + }
+9 -3
.sqlx/query-8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721.json .sqlx/query-b554241c510dae200a0de3050da30d1236b5f8c876de016eb358bdcfc383671d.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT\n email,\n handle,\n preferred_comms_channel as \"channel: CommsChannel\"\n FROM users\n WHERE id = $1\n ", 3 + "query": "\n SELECT\n email,\n handle,\n preferred_comms_channel as \"channel: CommsChannel\",\n preferred_locale\n FROM users\n WHERE id = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 29 29 } 30 30 } 31 31 } 32 + }, 33 + { 34 + "ordinal": 3, 35 + "name": "preferred_locale", 36 + "type_info": "Varchar" 32 37 } 33 38 ], 34 39 "parameters": { ··· 39 44 "nullable": [ 40 45 true, 41 46 false, 42 - false 47 + false, 48 + true 43 49 ] 44 50 }, 45 - "hash": "8c69c5f98e3ee59b50346094ff39eed73bb602f0b5ab48c11e53c82839a66721" 51 + "hash": "b554241c510dae200a0de3050da30d1236b5f8c876de016eb358bdcfc383671d" 46 52 }
+17 -5
.sqlx/query-de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9.json .sqlx/query-0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT\n handle, email, email_verified, is_admin,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 3 + "query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 + "name": "deactivated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "preferred_locale", 34 + "type_info": "Varchar" 35 + }, 36 + { 37 + "ordinal": 6, 28 38 "name": "preferred_channel: crate::comms::CommsChannel", 29 39 "type_info": { 30 40 "Custom": { ··· 41 51 } 42 52 }, 43 53 { 44 - "ordinal": 5, 54 + "ordinal": 7, 45 55 "name": "discord_verified", 46 56 "type_info": "Bool" 47 57 }, 48 58 { 49 - "ordinal": 6, 59 + "ordinal": 8, 50 60 "name": "telegram_verified", 51 61 "type_info": "Bool" 52 62 }, 53 63 { 54 - "ordinal": 7, 64 + "ordinal": 9, 55 65 "name": "signal_verified", 56 66 "type_info": "Bool" 57 67 } ··· 66 76 true, 67 77 false, 68 78 false, 79 + true, 80 + true, 69 81 false, 70 82 false, 71 83 false, 72 84 false 73 85 ] 74 86 }, 75 - "hash": "de72338f80b4f7b5bc7c9fc44100b6eb9e75f442b5b37a5a1cd761cd3b6950d9" 87 + "hash": "0fe621daeeb56e4be363ce96df73278467cba319b1fbe312d9220253610c4fcd" 76 88 }
+1 -1
README.md
··· 14 14 15 15 This software isn't an afterthought by a company with limited resources. 16 16 17 - It is a superset of the reference PDS, including: multi-channel communication (email, discord, telegram, signal) for verification and alerts. Built-in web UI for account management, OAuth consent, repo browsing, and admin. Granular OAuth scopes with UI support such that users choose exactly what apps can access. 17 + It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 18 19 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20 20
+343 -1
frontend/deno.lock
··· 6 6 "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1", 7 7 "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", 8 8 "npm:jsdom@^25.0.1": "25.0.1", 9 + "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", 9 10 "npm:svelte@5": "5.45.10_acorn@8.15.0", 10 11 "npm:vite@*": "6.4.1_picomatch@4.0.3", 11 12 "npm:vite@6": "6.4.1_picomatch@4.0.3", ··· 68 69 "@csstools/css-tokenizer@3.0.4": { 69 70 "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==" 70 71 }, 72 + "@esbuild/aix-ppc64@0.19.12": { 73 + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", 74 + "os": ["aix"], 75 + "cpu": ["ppc64"] 76 + }, 71 77 "@esbuild/aix-ppc64@0.21.5": { 72 78 "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 73 79 "os": ["aix"], ··· 78 84 "os": ["aix"], 79 85 "cpu": ["ppc64"] 80 86 }, 87 + "@esbuild/android-arm64@0.19.12": { 88 + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", 89 + "os": ["android"], 90 + "cpu": ["arm64"] 91 + }, 81 92 "@esbuild/android-arm64@0.21.5": { 82 93 "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 83 94 "os": ["android"], ··· 88 99 "os": ["android"], 89 100 "cpu": ["arm64"] 90 101 }, 102 + "@esbuild/android-arm@0.19.12": { 103 + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", 104 + "os": ["android"], 105 + "cpu": ["arm"] 106 + }, 91 107 "@esbuild/android-arm@0.21.5": { 92 108 "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 93 109 "os": ["android"], ··· 98 114 "os": ["android"], 99 115 "cpu": ["arm"] 100 116 }, 117 + "@esbuild/android-x64@0.19.12": { 118 + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", 119 + "os": ["android"], 120 + "cpu": ["x64"] 121 + }, 101 122 "@esbuild/android-x64@0.21.5": { 102 123 "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 103 124 "os": ["android"], ··· 108 129 "os": ["android"], 109 130 "cpu": ["x64"] 110 131 }, 132 + "@esbuild/darwin-arm64@0.19.12": { 133 + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", 134 + "os": ["darwin"], 135 + "cpu": ["arm64"] 136 + }, 111 137 "@esbuild/darwin-arm64@0.21.5": { 112 138 "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", 113 139 "os": ["darwin"], ··· 118 144 "os": ["darwin"], 119 145 "cpu": ["arm64"] 120 146 }, 147 + "@esbuild/darwin-x64@0.19.12": { 148 + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", 149 + "os": ["darwin"], 150 + "cpu": ["x64"] 151 + }, 121 152 "@esbuild/darwin-x64@0.21.5": { 122 153 "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 123 154 "os": ["darwin"], ··· 128 159 "os": ["darwin"], 129 160 "cpu": ["x64"] 130 161 }, 162 + "@esbuild/freebsd-arm64@0.19.12": { 163 + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", 164 + "os": ["freebsd"], 165 + "cpu": ["arm64"] 166 + }, 131 167 "@esbuild/freebsd-arm64@0.21.5": { 132 168 "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 133 169 "os": ["freebsd"], ··· 138 174 "os": ["freebsd"], 139 175 "cpu": ["arm64"] 140 176 }, 177 + "@esbuild/freebsd-x64@0.19.12": { 178 + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", 179 + "os": ["freebsd"], 180 + "cpu": ["x64"] 181 + }, 141 182 "@esbuild/freebsd-x64@0.21.5": { 142 183 "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 143 184 "os": ["freebsd"], ··· 148 189 "os": ["freebsd"], 149 190 "cpu": ["x64"] 150 191 }, 192 + "@esbuild/linux-arm64@0.19.12": { 193 + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", 194 + "os": ["linux"], 195 + "cpu": ["arm64"] 196 + }, 151 197 "@esbuild/linux-arm64@0.21.5": { 152 198 "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 153 199 "os": ["linux"], ··· 158 204 "os": ["linux"], 159 205 "cpu": ["arm64"] 160 206 }, 207 + "@esbuild/linux-arm@0.19.12": { 208 + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", 209 + "os": ["linux"], 210 + "cpu": ["arm"] 211 + }, 161 212 "@esbuild/linux-arm@0.21.5": { 162 213 "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 163 214 "os": ["linux"], ··· 168 219 "os": ["linux"], 169 220 "cpu": ["arm"] 170 221 }, 222 + "@esbuild/linux-ia32@0.19.12": { 223 + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", 224 + "os": ["linux"], 225 + "cpu": ["ia32"] 226 + }, 171 227 "@esbuild/linux-ia32@0.21.5": { 172 228 "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 173 229 "os": ["linux"], ··· 178 234 "os": ["linux"], 179 235 "cpu": ["ia32"] 180 236 }, 237 + "@esbuild/linux-loong64@0.19.12": { 238 + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", 239 + "os": ["linux"], 240 + "cpu": ["loong64"] 241 + }, 181 242 "@esbuild/linux-loong64@0.21.5": { 182 243 "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 183 244 "os": ["linux"], ··· 188 249 "os": ["linux"], 189 250 "cpu": ["loong64"] 190 251 }, 252 + "@esbuild/linux-mips64el@0.19.12": { 253 + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", 254 + "os": ["linux"], 255 + "cpu": ["mips64el"] 256 + }, 191 257 "@esbuild/linux-mips64el@0.21.5": { 192 258 "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 193 259 "os": ["linux"], ··· 197 263 "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", 198 264 "os": ["linux"], 199 265 "cpu": ["mips64el"] 266 + }, 267 + "@esbuild/linux-ppc64@0.19.12": { 268 + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", 269 + "os": ["linux"], 270 + "cpu": ["ppc64"] 200 271 }, 201 272 "@esbuild/linux-ppc64@0.21.5": { 202 273 "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", ··· 207 278 "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", 208 279 "os": ["linux"], 209 280 "cpu": ["ppc64"] 281 + }, 282 + "@esbuild/linux-riscv64@0.19.12": { 283 + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", 284 + "os": ["linux"], 285 + "cpu": ["riscv64"] 210 286 }, 211 287 "@esbuild/linux-riscv64@0.21.5": { 212 288 "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", ··· 218 294 "os": ["linux"], 219 295 "cpu": ["riscv64"] 220 296 }, 297 + "@esbuild/linux-s390x@0.19.12": { 298 + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", 299 + "os": ["linux"], 300 + "cpu": ["s390x"] 301 + }, 221 302 "@esbuild/linux-s390x@0.21.5": { 222 303 "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 223 304 "os": ["linux"], ··· 227 308 "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", 228 309 "os": ["linux"], 229 310 "cpu": ["s390x"] 311 + }, 312 + "@esbuild/linux-x64@0.19.12": { 313 + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", 314 + "os": ["linux"], 315 + "cpu": ["x64"] 230 316 }, 231 317 "@esbuild/linux-x64@0.21.5": { 232 318 "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", ··· 243 329 "os": ["netbsd"], 244 330 "cpu": ["arm64"] 245 331 }, 332 + "@esbuild/netbsd-x64@0.19.12": { 333 + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", 334 + "os": ["netbsd"], 335 + "cpu": ["x64"] 336 + }, 246 337 "@esbuild/netbsd-x64@0.21.5": { 247 338 "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 248 339 "os": ["netbsd"], ··· 258 349 "os": ["openbsd"], 259 350 "cpu": ["arm64"] 260 351 }, 352 + "@esbuild/openbsd-x64@0.19.12": { 353 + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", 354 + "os": ["openbsd"], 355 + "cpu": ["x64"] 356 + }, 261 357 "@esbuild/openbsd-x64@0.21.5": { 262 358 "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 263 359 "os": ["openbsd"], ··· 273 369 "os": ["openharmony"], 274 370 "cpu": ["arm64"] 275 371 }, 372 + "@esbuild/sunos-x64@0.19.12": { 373 + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", 374 + "os": ["sunos"], 375 + "cpu": ["x64"] 376 + }, 276 377 "@esbuild/sunos-x64@0.21.5": { 277 378 "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 278 379 "os": ["sunos"], ··· 283 384 "os": ["sunos"], 284 385 "cpu": ["x64"] 285 386 }, 387 + "@esbuild/win32-arm64@0.19.12": { 388 + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", 389 + "os": ["win32"], 390 + "cpu": ["arm64"] 391 + }, 286 392 "@esbuild/win32-arm64@0.21.5": { 287 393 "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 288 394 "os": ["win32"], ··· 293 399 "os": ["win32"], 294 400 "cpu": ["arm64"] 295 401 }, 402 + "@esbuild/win32-ia32@0.19.12": { 403 + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", 404 + "os": ["win32"], 405 + "cpu": ["ia32"] 406 + }, 296 407 "@esbuild/win32-ia32@0.21.5": { 297 408 "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 298 409 "os": ["win32"], ··· 303 414 "os": ["win32"], 304 415 "cpu": ["ia32"] 305 416 }, 417 + "@esbuild/win32-x64@0.19.12": { 418 + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", 419 + "os": ["win32"], 420 + "cpu": ["x64"] 421 + }, 306 422 "@esbuild/win32-x64@0.21.5": { 307 423 "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 308 424 "os": ["win32"], ··· 313 429 "os": ["win32"], 314 430 "cpu": ["x64"] 315 431 }, 432 + "@formatjs/ecma402-abstract@2.3.6": { 433 + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", 434 + "dependencies": [ 435 + "@formatjs/fast-memoize", 436 + "@formatjs/intl-localematcher", 437 + "decimal.js", 438 + "tslib" 439 + ] 440 + }, 441 + "@formatjs/fast-memoize@2.2.7": { 442 + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", 443 + "dependencies": [ 444 + "tslib" 445 + ] 446 + }, 447 + "@formatjs/icu-messageformat-parser@2.11.4": { 448 + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", 449 + "dependencies": [ 450 + "@formatjs/ecma402-abstract", 451 + "@formatjs/icu-skeleton-parser", 452 + "tslib" 453 + ] 454 + }, 455 + "@formatjs/icu-skeleton-parser@1.8.16": { 456 + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", 457 + "dependencies": [ 458 + "@formatjs/ecma402-abstract", 459 + "tslib" 460 + ] 461 + }, 462 + "@formatjs/intl-localematcher@0.6.2": { 463 + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", 464 + "dependencies": [ 465 + "tslib" 466 + ] 467 + }, 316 468 "@jridgewell/gen-mapping@0.3.13": { 317 469 "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", 318 470 "dependencies": [ ··· 540 692 "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", 541 693 "dependencies": [ 542 694 "@vitest/spy", 543 - "estree-walker", 695 + "estree-walker@3.0.3", 544 696 "magic-string", 545 697 "vite@5.4.21" 546 698 ], ··· 637 789 "check-error@2.1.1": { 638 790 "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==" 639 791 }, 792 + "cli-color@2.0.4": { 793 + "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", 794 + "dependencies": [ 795 + "d", 796 + "es5-ext", 797 + "es6-iterator", 798 + "memoizee", 799 + "timers-ext" 800 + ] 801 + }, 640 802 "clsx@2.1.1": { 641 803 "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" 642 804 }, ··· 654 816 "dependencies": [ 655 817 "@asamuzakjp/css-color", 656 818 "rrweb-cssom@0.8.0" 819 + ] 820 + }, 821 + "d@1.0.2": { 822 + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", 823 + "dependencies": [ 824 + "es5-ext", 825 + "type" 657 826 ] 658 827 }, 659 828 "data-urls@5.0.0": { ··· 728 897 "hasown" 729 898 ] 730 899 }, 900 + "es5-ext@0.10.64": { 901 + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", 902 + "dependencies": [ 903 + "es6-iterator", 904 + "es6-symbol", 905 + "esniff", 906 + "next-tick" 907 + ], 908 + "scripts": true 909 + }, 910 + "es6-iterator@2.0.3": { 911 + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", 912 + "dependencies": [ 913 + "d", 914 + "es5-ext", 915 + "es6-symbol" 916 + ] 917 + }, 918 + "es6-symbol@3.1.4": { 919 + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", 920 + "dependencies": [ 921 + "d", 922 + "ext" 923 + ] 924 + }, 925 + "es6-weak-map@2.0.3": { 926 + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", 927 + "dependencies": [ 928 + "d", 929 + "es5-ext", 930 + "es6-iterator", 931 + "es6-symbol" 932 + ] 933 + }, 934 + "esbuild@0.19.12": { 935 + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", 936 + "optionalDependencies": [ 937 + "@esbuild/aix-ppc64@0.19.12", 938 + "@esbuild/android-arm@0.19.12", 939 + "@esbuild/android-arm64@0.19.12", 940 + "@esbuild/android-x64@0.19.12", 941 + "@esbuild/darwin-arm64@0.19.12", 942 + "@esbuild/darwin-x64@0.19.12", 943 + "@esbuild/freebsd-arm64@0.19.12", 944 + "@esbuild/freebsd-x64@0.19.12", 945 + "@esbuild/linux-arm@0.19.12", 946 + "@esbuild/linux-arm64@0.19.12", 947 + "@esbuild/linux-ia32@0.19.12", 948 + "@esbuild/linux-loong64@0.19.12", 949 + "@esbuild/linux-mips64el@0.19.12", 950 + "@esbuild/linux-ppc64@0.19.12", 951 + "@esbuild/linux-riscv64@0.19.12", 952 + "@esbuild/linux-s390x@0.19.12", 953 + "@esbuild/linux-x64@0.19.12", 954 + "@esbuild/netbsd-x64@0.19.12", 955 + "@esbuild/openbsd-x64@0.19.12", 956 + "@esbuild/sunos-x64@0.19.12", 957 + "@esbuild/win32-arm64@0.19.12", 958 + "@esbuild/win32-ia32@0.19.12", 959 + "@esbuild/win32-x64@0.19.12" 960 + ], 961 + "scripts": true, 962 + "bin": true 963 + }, 731 964 "esbuild@0.21.5": { 732 965 "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", 733 966 "optionalDependencies": [ ··· 794 1027 "esm-env@1.2.2": { 795 1028 "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" 796 1029 }, 1030 + "esniff@2.0.1": { 1031 + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", 1032 + "dependencies": [ 1033 + "d", 1034 + "es5-ext", 1035 + "event-emitter", 1036 + "type" 1037 + ] 1038 + }, 797 1039 "esrap@2.2.1": { 798 1040 "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", 799 1041 "dependencies": [ 800 1042 "@jridgewell/sourcemap-codec" 801 1043 ] 1044 + }, 1045 + "estree-walker@2.0.2": { 1046 + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" 802 1047 }, 803 1048 "estree-walker@3.0.3": { 804 1049 "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", ··· 806 1051 "@types/estree" 807 1052 ] 808 1053 }, 1054 + "event-emitter@0.3.5": { 1055 + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", 1056 + "dependencies": [ 1057 + "d", 1058 + "es5-ext" 1059 + ] 1060 + }, 809 1061 "expect-type@1.3.0": { 810 1062 "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==" 811 1063 }, 1064 + "ext@1.7.0": { 1065 + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", 1066 + "dependencies": [ 1067 + "type" 1068 + ] 1069 + }, 812 1070 "fdir@6.5.0_picomatch@4.0.3": { 813 1071 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 814 1072 "dependencies": [ ··· 858 1116 "es-object-atoms" 859 1117 ] 860 1118 }, 1119 + "globalyzer@0.1.0": { 1120 + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==" 1121 + }, 1122 + "globrex@0.1.2": { 1123 + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" 1124 + }, 861 1125 "gopd@1.2.0": { 862 1126 "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 863 1127 }, ··· 905 1169 "indent-string@4.0.0": { 906 1170 "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" 907 1171 }, 1172 + "intl-messageformat@10.7.18": { 1173 + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", 1174 + "dependencies": [ 1175 + "@formatjs/ecma402-abstract", 1176 + "@formatjs/fast-memoize", 1177 + "@formatjs/icu-messageformat-parser", 1178 + "tslib" 1179 + ] 1180 + }, 908 1181 "is-potential-custom-element-name@1.0.1": { 909 1182 "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" 1183 + }, 1184 + "is-promise@2.2.2": { 1185 + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" 910 1186 }, 911 1187 "is-reference@3.0.3": { 912 1188 "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", ··· 955 1231 "lru-cache@10.4.3": { 956 1232 "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" 957 1233 }, 1234 + "lru-queue@0.1.0": { 1235 + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", 1236 + "dependencies": [ 1237 + "es5-ext" 1238 + ] 1239 + }, 958 1240 "lz-string@1.5.0": { 959 1241 "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", 960 1242 "bin": true ··· 968 1250 "math-intrinsics@1.1.0": { 969 1251 "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 970 1252 }, 1253 + "memoizee@0.4.17": { 1254 + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", 1255 + "dependencies": [ 1256 + "d", 1257 + "es5-ext", 1258 + "es6-weak-map", 1259 + "event-emitter", 1260 + "is-promise", 1261 + "lru-queue", 1262 + "next-tick", 1263 + "timers-ext" 1264 + ] 1265 + }, 971 1266 "mime-db@1.52.0": { 972 1267 "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 973 1268 }, ··· 980 1275 "min-indent@1.0.1": { 981 1276 "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" 982 1277 }, 1278 + "mri@1.2.0": { 1279 + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" 1280 + }, 983 1281 "ms@2.1.3": { 984 1282 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 985 1283 }, ··· 987 1285 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 988 1286 "bin": true 989 1287 }, 1288 + "next-tick@1.1.0": { 1289 + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" 1290 + }, 990 1291 "nwsapi@2.2.23": { 991 1292 "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==" 992 1293 }, ··· 1075 1376 "rrweb-cssom@0.8.0": { 1076 1377 "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" 1077 1378 }, 1379 + "sade@1.8.1": { 1380 + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", 1381 + "dependencies": [ 1382 + "mri" 1383 + ] 1384 + }, 1078 1385 "safer-buffer@2.1.2": { 1079 1386 "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1080 1387 }, ··· 1101 1408 "dependencies": [ 1102 1409 "min-indent" 1103 1410 ] 1411 + }, 1412 + "svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": { 1413 + "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", 1414 + "dependencies": [ 1415 + "cli-color", 1416 + "deepmerge", 1417 + "esbuild@0.19.12", 1418 + "estree-walker@2.0.2", 1419 + "intl-messageformat", 1420 + "sade", 1421 + "svelte", 1422 + "tiny-glob" 1423 + ], 1424 + "bin": true 1104 1425 }, 1105 1426 "svelte@5.45.10_acorn@8.15.0": { 1106 1427 "integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==", ··· 1125 1446 "symbol-tree@3.2.4": { 1126 1447 "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" 1127 1448 }, 1449 + "timers-ext@0.1.8": { 1450 + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", 1451 + "dependencies": [ 1452 + "es5-ext", 1453 + "next-tick" 1454 + ] 1455 + }, 1456 + "tiny-glob@0.2.9": { 1457 + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", 1458 + "dependencies": [ 1459 + "globalyzer", 1460 + "globrex" 1461 + ] 1462 + }, 1128 1463 "tinybench@2.9.0": { 1129 1464 "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" 1130 1465 }, ··· 1168 1503 "dependencies": [ 1169 1504 "punycode" 1170 1505 ] 1506 + }, 1507 + "tslib@2.8.1": { 1508 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 1509 + }, 1510 + "type@2.7.3": { 1511 + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" 1171 1512 }, 1172 1513 "vite-node@2.1.9": { 1173 1514 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", ··· 1300 1641 "npm:@testing-library/svelte@^5.2.6", 1301 1642 "npm:@testing-library/user-event@^14.5.2", 1302 1643 "npm:jsdom@^25.0.1", 1644 + "npm:svelte-i18n@^4.0.1", 1303 1645 "npm:svelte@5", 1304 1646 "npm:vite@6", 1305 1647 "npm:vitest@^2.1.8"
+3
frontend/package.json
··· 11 11 "test:run": "vitest run", 12 12 "test:coverage": "vitest run --coverage" 13 13 }, 14 + "dependencies": { 15 + "svelte-i18n": "^4.0.1" 16 + }, 14 17 "devDependencies": { 15 18 "@sveltejs/vite-plugin-svelte": "^5.0.0", 16 19 "@testing-library/jest-dom": "^6.6.3",
+10 -66
frontend/src/App.svelte
··· 1 1 <script lang="ts"> 2 2 import { getCurrentPath } from './lib/router.svelte' 3 3 import { initAuth, getAuthState } from './lib/auth.svelte' 4 + import { initI18n, _ } from './lib/i18n' 5 + import { isLoading as i18nLoading } from 'svelte-i18n' 4 6 import Login from './routes/Login.svelte' 5 7 import Register from './routes/Register.svelte' 6 8 import RegisterPasskey from './routes/RegisterPasskey.svelte' ··· 13 15 import InviteCodes from './routes/InviteCodes.svelte' 14 16 import Settings from './routes/Settings.svelte' 15 17 import Sessions from './routes/Sessions.svelte' 16 - import Notifications from './routes/Notifications.svelte' 18 + import Comms from './routes/Comms.svelte' 17 19 import RepoExplorer from './routes/RepoExplorer.svelte' 18 20 import Admin from './routes/Admin.svelte' 19 21 import OAuthConsent from './routes/OAuthConsent.svelte' ··· 25 27 import OAuthError from './routes/OAuthError.svelte' 26 28 import Security from './routes/Security.svelte' 27 29 import TrustedDevices from './routes/TrustedDevices.svelte' 30 + import Home from './routes/Home.svelte' 31 + 32 + initI18n() 28 33 29 34 const auth = getAuthState() 30 35 ··· 58 63 return Settings 59 64 case '/sessions': 60 65 return Sessions 61 - case '/notifications': 62 - return Notifications 66 + case '/comms': 67 + return Comms 63 68 case '/repo': 64 69 return RepoExplorer 65 70 case '/admin': ··· 83 88 case '/trusted-devices': 84 89 return TrustedDevices 85 90 default: 86 - return auth.session ? Dashboard : Login 91 + return Home 87 92 } 88 93 } 89 94 ··· 92 97 </script> 93 98 94 99 <main> 95 - {#if auth.loading} 100 + {#if auth.loading || $i18nLoading} 96 101 <div class="loading"> 97 102 <p>Loading...</p> 98 103 </div> ··· 102 107 </main> 103 108 104 109 <style> 105 - :global(:root) { 106 - --bg-primary: #fafafa; 107 - --bg-secondary: #f9f9f9; 108 - --bg-card: #ffffff; 109 - --bg-input: #ffffff; 110 - --bg-input-disabled: #f5f5f5; 111 - --text-primary: #333333; 112 - --text-secondary: #666666; 113 - --text-muted: #999999; 114 - --border-color: #dddddd; 115 - --border-color-light: #cccccc; 116 - --accent: #0066cc; 117 - --accent-hover: #0052a3; 118 - --success-bg: #dfd; 119 - --success-border: #8c8; 120 - --success-text: #060; 121 - --error-bg: #fee; 122 - --error-border: #fcc; 123 - --error-text: #c00; 124 - --warning-bg: #ffd; 125 - --warning-text: #660; 126 - } 127 - 128 - @media (prefers-color-scheme: dark) { 129 - :global(:root) { 130 - --bg-primary: #1a1a1a; 131 - --bg-secondary: #242424; 132 - --bg-card: #2a2a2a; 133 - --bg-input: #333333; 134 - --bg-input-disabled: #2a2a2a; 135 - --text-primary: #e0e0e0; 136 - --text-secondary: #a0a0a0; 137 - --text-muted: #707070; 138 - --border-color: #404040; 139 - --border-color-light: #505050; 140 - --accent: #4da6ff; 141 - --accent-hover: #7abbff; 142 - --success-bg: #1a3d1a; 143 - --success-border: #2d5a2d; 144 - --success-text: #7bc67b; 145 - --error-bg: #3d1a1a; 146 - --error-border: #5a2d2d; 147 - --error-text: #ff7b7b; 148 - --warning-bg: #3d3d1a; 149 - --warning-text: #c6c67b; 150 - } 151 - } 152 - 153 - :global(body) { 154 - margin: 0; 155 - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 156 - line-height: 1.5; 157 - color: var(--text-primary); 158 - background: var(--bg-primary); 159 - } 160 - 161 - :global(*) { 162 - box-sizing: border-box; 163 - } 164 - 165 110 main { 166 111 min-height: 100vh; 167 - background: var(--bg-primary); 168 112 } 169 113 170 114 .loading {
+1
frontend/src/components/ReauthModal.svelte
··· 1 1 <script lang="ts"> 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { api, ApiError } from '../lib/api' 4 + import { _ } from '../lib/i18n' 4 5 5 6 interface Props { 6 7 show: boolean
+75
frontend/src/components/ui/Button.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import type { HTMLButtonAttributes } from 'svelte/elements' 4 + 5 + interface Props extends HTMLButtonAttributes { 6 + variant?: 'primary' | 'secondary' | 'tertiary' | 'danger' | 'ghost' 7 + size?: 'sm' | 'md' | 'lg' 8 + loading?: boolean 9 + fullWidth?: boolean 10 + children: Snippet 11 + } 12 + 13 + let { 14 + variant = 'primary', 15 + size = 'md', 16 + loading = false, 17 + fullWidth = false, 18 + disabled, 19 + children, 20 + ...rest 21 + }: Props = $props() 22 + </script> 23 + 24 + <button 25 + class="btn btn-{variant} btn-{size}" 26 + class:full-width={fullWidth} 27 + disabled={disabled || loading} 28 + {...rest} 29 + > 30 + {#if loading} 31 + <span class="spinner"></span> 32 + {/if} 33 + {@render children()} 34 + </button> 35 + 36 + <style> 37 + .btn { 38 + display: inline-flex; 39 + align-items: center; 40 + justify-content: center; 41 + gap: var(--space-2); 42 + } 43 + 44 + .btn-sm { 45 + padding: var(--space-2) var(--space-4); 46 + font-size: var(--text-sm); 47 + } 48 + 49 + .btn-md { 50 + padding: var(--space-4) var(--space-6); 51 + font-size: var(--text-base); 52 + } 53 + 54 + .btn-lg { 55 + padding: var(--space-5) var(--space-7); 56 + font-size: var(--text-lg); 57 + } 58 + 59 + .full-width { 60 + width: 100%; 61 + } 62 + 63 + .spinner { 64 + width: 1em; 65 + height: 1em; 66 + border: 2px solid currentColor; 67 + border-right-color: transparent; 68 + border-radius: 50%; 69 + animation: spin 0.6s linear infinite; 70 + } 71 + 72 + @keyframes spin { 73 + to { transform: rotate(360deg); } 74 + } 75 + </style>
+49
frontend/src/components/ui/Card.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import type { HTMLAttributes } from 'svelte/elements' 4 + 5 + interface Props extends HTMLAttributes<HTMLDivElement> { 6 + variant?: 'default' | 'interactive' | 'danger' 7 + padding?: 'none' | 'sm' | 'md' | 'lg' 8 + children: Snippet 9 + } 10 + 11 + let { 12 + variant = 'default', 13 + padding = 'md', 14 + children, 15 + ...rest 16 + }: Props = $props() 17 + </script> 18 + 19 + <div class="card card-{variant} padding-{padding}" {...rest}> 20 + {@render children()} 21 + </div> 22 + 23 + <style> 24 + .card { 25 + background: var(--bg-card); 26 + border: 1px solid var(--border-color); 27 + border-radius: var(--radius-xl); 28 + } 29 + 30 + .card-interactive { 31 + cursor: pointer; 32 + transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 33 + } 34 + 35 + .card-interactive:hover { 36 + border-color: var(--accent); 37 + box-shadow: 0 2px 8px var(--accent-muted); 38 + } 39 + 40 + .card-danger { 41 + background: var(--error-bg); 42 + border-color: var(--error-border); 43 + } 44 + 45 + .padding-none { padding: 0; } 46 + .padding-sm { padding: var(--space-4); } 47 + .padding-md { padding: var(--space-6); } 48 + .padding-lg { padding: var(--space-7); } 49 + </style>
+42
frontend/src/components/ui/Input.svelte
··· 1 + <script lang="ts"> 2 + import type { HTMLInputAttributes } from 'svelte/elements' 3 + 4 + interface Props extends HTMLInputAttributes { 5 + label?: string 6 + hint?: string 7 + error?: string 8 + } 9 + 10 + let { 11 + label, 12 + hint, 13 + error, 14 + id, 15 + ...rest 16 + }: Props = $props() 17 + 18 + let inputId = id || `input-${Math.random().toString(36).slice(2, 9)}` 19 + </script> 20 + 21 + <div class="field"> 22 + {#if label} 23 + <label for={inputId}>{label}</label> 24 + {/if} 25 + <input id={inputId} class:has-error={!!error} {...rest} /> 26 + {#if error} 27 + <span class="hint error">{error}</span> 28 + {:else if hint} 29 + <span class="hint">{hint}</span> 30 + {/if} 31 + </div> 32 + 33 + <style> 34 + .has-error { 35 + border-color: var(--error-text); 36 + } 37 + 38 + .has-error:focus { 39 + border-color: var(--error-text); 40 + box-shadow: 0 0 0 2px var(--error-bg); 41 + } 42 + </style>
+46
frontend/src/components/ui/Message.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + 4 + interface Props { 5 + variant: 'success' | 'error' | 'warning' | 'info' 6 + children: Snippet 7 + } 8 + 9 + let { variant, children }: Props = $props() 10 + </script> 11 + 12 + <div class="message message-{variant}"> 13 + {@render children()} 14 + </div> 15 + 16 + <style> 17 + .message { 18 + padding: var(--space-4); 19 + border-radius: var(--radius-md); 20 + font-size: var(--text-sm); 21 + } 22 + 23 + .message-success { 24 + background: var(--success-bg); 25 + border: 1px solid var(--success-border); 26 + color: var(--success-text); 27 + } 28 + 29 + .message-error { 30 + background: var(--error-bg); 31 + border: 1px solid var(--error-border); 32 + color: var(--error-text); 33 + } 34 + 35 + .message-warning { 36 + background: var(--warning-bg); 37 + border: 1px solid var(--warning-border); 38 + color: var(--warning-text); 39 + } 40 + 41 + .message-info { 42 + background: var(--accent-muted); 43 + border: 1px solid var(--accent); 44 + color: var(--text-primary); 45 + } 46 + </style>
+82
frontend/src/components/ui/Page.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import { _ } from '../../lib/i18n' 4 + 5 + interface Props { 6 + title: string 7 + size?: 'sm' | 'md' | 'lg' 8 + backHref?: string 9 + backLabel?: string 10 + children: Snippet 11 + actions?: Snippet 12 + } 13 + 14 + let { 15 + title, 16 + size = 'md', 17 + backHref, 18 + backLabel, 19 + children, 20 + actions 21 + }: Props = $props() 22 + </script> 23 + 24 + <div class="page page-{size}"> 25 + <header> 26 + {#if backHref} 27 + <a href={backHref} class="back-link">&larr; {backLabel || $_('common.backToDashboard')}</a> 28 + {/if} 29 + <div class="header-row"> 30 + <h1>{title}</h1> 31 + {#if actions} 32 + <div class="actions"> 33 + {@render actions()} 34 + </div> 35 + {/if} 36 + </div> 37 + </header> 38 + {@render children()} 39 + </div> 40 + 41 + <style> 42 + .page { 43 + margin: 0 auto; 44 + padding: var(--space-7); 45 + } 46 + 47 + .page-sm { max-width: var(--width-sm); } 48 + .page-md { max-width: var(--width-md); } 49 + .page-lg { max-width: var(--width-lg); } 50 + 51 + header { 52 + margin-bottom: var(--space-7); 53 + } 54 + 55 + .back-link { 56 + display: inline-block; 57 + color: var(--text-secondary); 58 + font-size: var(--text-sm); 59 + text-decoration: none; 60 + margin-bottom: var(--space-3); 61 + } 62 + 63 + .back-link:hover { 64 + color: var(--accent); 65 + } 66 + 67 + .header-row { 68 + display: flex; 69 + justify-content: space-between; 70 + align-items: center; 71 + gap: var(--space-4); 72 + } 73 + 74 + h1 { 75 + margin: 0; 76 + } 77 + 78 + .actions { 79 + display: flex; 80 + gap: var(--space-3); 81 + } 82 + </style>
+59
frontend/src/components/ui/Section.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + 4 + interface Props { 5 + title?: string 6 + description?: string 7 + variant?: 'default' | 'danger' 8 + children: Snippet 9 + } 10 + 11 + let { 12 + title, 13 + description, 14 + variant = 'default', 15 + children 16 + }: Props = $props() 17 + </script> 18 + 19 + <section class="section section-{variant}"> 20 + {#if title} 21 + <h2>{title}</h2> 22 + {/if} 23 + {#if description} 24 + <p class="description">{description}</p> 25 + {/if} 26 + {@render children()} 27 + </section> 28 + 29 + <style> 30 + .section { 31 + background: var(--bg-secondary); 32 + border-radius: var(--radius-xl); 33 + padding: var(--space-6); 34 + } 35 + 36 + .section + .section { 37 + margin-top: var(--space-6); 38 + } 39 + 40 + .section-danger { 41 + background: var(--error-bg); 42 + border: 1px solid var(--error-border); 43 + } 44 + 45 + .section-danger h2 { 46 + color: var(--error-text); 47 + } 48 + 49 + h2 { 50 + margin: 0 0 var(--space-3) 0; 51 + font-size: var(--text-lg); 52 + } 53 + 54 + .description { 55 + color: var(--text-secondary); 56 + font-size: var(--text-sm); 57 + margin-bottom: var(--space-5); 58 + } 59 + </style>
+6
frontend/src/components/ui/index.ts
··· 1 + export { default as Button } from './Button.svelte' 2 + export { default as Card } from './Card.svelte' 3 + export { default as Input } from './Input.svelte' 4 + export { default as Message } from './Message.svelte' 5 + export { default as Page } from './Page.svelte' 6 + export { default as Section } from './Section.svelte'
+8
frontend/src/lib/api.ts
··· 343 343 }) 344 344 }, 345 345 346 + async updateLocale(token: string, preferredLocale: string): Promise<{ preferredLocale: string }> { 347 + return xrpc('com.tranquil.account.updateLocale', { 348 + method: 'POST', 349 + token, 350 + body: { preferredLocale }, 351 + }) 352 + }, 353 + 346 354 async listSessions(token: string): Promise<{ 347 355 sessions: Array<{ 348 356 id: string
+28 -2
frontend/src/lib/auth.svelte.ts
··· 1 1 import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api' 2 2 import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth' 3 + import { setLocale, type SupportedLocale } from './i18n' 4 + 5 + function applyLocaleFromSession(sessionInfo: { preferredLocale?: string | null }) { 6 + if (sessionInfo.preferredLocale) { 7 + setLocale(sessionInfo.preferredLocale as SupportedLocale) 8 + } 9 + } 3 10 4 11 const STORAGE_KEY = 'tranquil_pds_session' 5 12 const ACCOUNTS_KEY = 'tranquil_pds_accounts' ··· 104 111 state.session = session 105 112 saveSession(session) 106 113 addOrUpdateSavedAccount(session) 114 + applyLocaleFromSession(sessionInfo) 107 115 state.loading = false 108 116 return 109 117 } catch (e) { ··· 116 124 const stored = loadSession() 117 125 if (stored) { 118 126 try { 119 - const session = await api.getSession(stored.accessJwt) 120 - state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } 127 + const sessionInfo = await api.getSession(stored.accessJwt) 128 + state.session = { ...sessionInfo, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } 121 129 addOrUpdateSavedAccount(state.session) 130 + applyLocaleFromSession(sessionInfo) 122 131 } catch (e) { 123 132 if (e instanceof ApiError && e.status === 401) { 124 133 try { ··· 132 141 state.session = session 133 142 saveSession(session) 134 143 addOrUpdateSavedAccount(session) 144 + applyLocaleFromSession(sessionInfo) 135 145 } catch { 136 146 saveSession(null) 137 147 state.session = null ··· 289 299 290 300 export function getAuthState() { 291 301 return state 302 + } 303 + 304 + export async function refreshSession(): Promise<void> { 305 + if (!state.session) return 306 + try { 307 + const sessionInfo = await api.getSession(state.session.accessJwt) 308 + state.session = { 309 + ...sessionInfo, 310 + accessJwt: state.session.accessJwt, 311 + refreshJwt: state.session.refreshJwt, 312 + } 313 + saveSession(state.session) 314 + addOrUpdateSavedAccount(state.session) 315 + } catch (e) { 316 + console.error('Failed to refresh session:', e) 317 + } 292 318 } 293 319 294 320 export function getToken(): string | null {
+17
frontend/src/lib/date.ts
··· 1 + export function formatDate(dateStr: string): string { 2 + const date = new Date(dateStr) 3 + const year = date.getFullYear() 4 + const month = String(date.getMonth() + 1).padStart(2, '0') 5 + const day = String(date.getDate()).padStart(2, '0') 6 + return `${year}-${month}-${day}` 7 + } 8 + 9 + export function formatDateTime(dateStr: string): string { 10 + const date = new Date(dateStr) 11 + const year = date.getFullYear() 12 + const month = String(date.getMonth() + 1).padStart(2, '0') 13 + const day = String(date.getDate()).padStart(2, '0') 14 + const hours = String(date.getHours()).padStart(2, '0') 15 + const minutes = String(date.getMinutes()).padStart(2, '0') 16 + return `${year}-${month}-${day} ${hours}:${minutes}` 17 + }
+54
frontend/src/lib/i18n.ts
··· 1 + import { register, init, getLocaleFromNavigator, locale, _ } from 'svelte-i18n' 2 + 3 + const LOCALE_STORAGE_KEY = 'tranquil-pds-locale' 4 + 5 + const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko'] as const 6 + export type SupportedLocale = typeof SUPPORTED_LOCALES[number] 7 + 8 + export const localeNames: Record<SupportedLocale, string> = { 9 + en: 'English', 10 + zh: '中文', 11 + ja: '日本語', 12 + ko: '한국어' 13 + } 14 + 15 + register('en', () => import('../locales/en.json')) 16 + register('zh', () => import('../locales/zh.json')) 17 + register('ja', () => import('../locales/ja.json')) 18 + register('ko', () => import('../locales/ko.json')) 19 + 20 + function getInitialLocale(): string { 21 + const stored = localStorage.getItem(LOCALE_STORAGE_KEY) 22 + if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) { 23 + return stored 24 + } 25 + 26 + const browserLocale = getLocaleFromNavigator() 27 + if (browserLocale) { 28 + const lang = browserLocale.split('-')[0] 29 + if (SUPPORTED_LOCALES.includes(lang as SupportedLocale)) { 30 + return lang 31 + } 32 + } 33 + 34 + return 'en' 35 + } 36 + 37 + export function initI18n() { 38 + init({ 39 + fallbackLocale: 'en', 40 + initialLocale: getInitialLocale() 41 + }) 42 + } 43 + 44 + export function setLocale(newLocale: SupportedLocale) { 45 + locale.set(newLocale) 46 + localStorage.setItem(LOCALE_STORAGE_KEY, newLocale) 47 + document.documentElement.lang = newLocale 48 + } 49 + 50 + export function getSupportedLocales(): SupportedLocale[] { 51 + return [...SUPPORTED_LOCALES] 52 + } 53 + 54 + export { locale, _ }
+702
frontend/src/locales/en.json
··· 1 + { 2 + "common": { 3 + "loading": "Loading...", 4 + "error": "Error", 5 + "save": "Save", 6 + "cancel": "Cancel", 7 + "back": "Back", 8 + "done": "Done", 9 + "refresh": "Refresh", 10 + "create": "Create", 11 + "delete": "Delete", 12 + "confirm": "Confirm", 13 + "created": "Created", 14 + "expires": "Expires", 15 + "name": "Name", 16 + "dashboard": "Dashboard", 17 + "backToDashboard": "← Dashboard" 18 + }, 19 + "login": { 20 + "title": "Sign In", 21 + "subtitle": "Sign in to manage your PDS account", 22 + "button": "Sign In", 23 + "redirecting": "Redirecting...", 24 + "chooseAccount": "Choose an account", 25 + "signInToAnother": "Sign in to another account", 26 + "backToSaved": "← Back to saved accounts", 27 + "forgotPassword": "Forgot password?", 28 + "lostPasskey": "Lost passkey?", 29 + "noAccount": "Don't have an account?", 30 + "createAccount": "Create account", 31 + "removeAccount": "Remove from saved accounts" 32 + }, 33 + "verification": { 34 + "title": "Verify Your Account", 35 + "subtitle": "Your account needs verification. Enter the code sent to your verification method.", 36 + "codeLabel": "Verification Code", 37 + "codePlaceholder": "Enter 6-digit code", 38 + "verifyButton": "Verify Account", 39 + "verifying": "Verifying...", 40 + "resendButton": "Resend Code", 41 + "resending": "Resending...", 42 + "resent": "Verification code resent!", 43 + "backToLogin": "Back to Login" 44 + }, 45 + "register": { 46 + "title": "Create Account", 47 + "subtitle": "Create a new account on this PDS", 48 + "handle": "Handle", 49 + "handlePlaceholder": "yourname", 50 + "handleHint": "Your full handle will be: @{handle}", 51 + "handleDotWarning": "Custom domain handles can be set up after account creation in Settings.", 52 + "password": "Password", 53 + "passwordPlaceholder": "At least 8 characters", 54 + "confirmPassword": "Confirm Password", 55 + "confirmPasswordPlaceholder": "Confirm your password", 56 + "identityType": "Identity Type", 57 + "identityHint": "Choose how your decentralized identity will be managed.", 58 + "didPlc": "did:plc", 59 + "didPlcRecommended": "(Recommended)", 60 + "didPlcHint": "Portable identity managed by PLC Directory", 61 + "didWeb": "did:web", 62 + "didWebHint": "Identity hosted on this PDS (read warning below)", 63 + "didWebBYOD": "did:web (BYOD)", 64 + "didWebBYODHint": "Bring your own domain", 65 + "didWebWarningTitle": "Important: Understand the trade-offs", 66 + "didWebWarning1": "Permanent tie to this PDS:", 67 + "didWebWarning1Detail": "Your identity will be {did}. Even if you migrate to another PDS later, this server must continue hosting your DID document.", 68 + "didWebWarning2": "No recovery mechanism:", 69 + "didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.", 70 + "didWebWarning3": "We commit to you:", 71 + "didWebWarning3Detail": "If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.", 72 + "didWebWarning4": "Recommendation:", 73 + "didWebWarning4Detail": "Choose did:plc unless you have a specific reason to prefer did:web.", 74 + "externalDid": "Your did:web", 75 + "externalDidPlaceholder": "did:web:yourdomain.com", 76 + "externalDidHint": "Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS", 77 + "contactMethod": "Contact Method", 78 + "contactMethodHint": "Choose how you'd like to verify your account and receive notifications. You only need one.", 79 + "verificationMethod": "Verification Method", 80 + "email": "Email", 81 + "emailAddress": "Email Address", 82 + "emailPlaceholder": "you@example.com", 83 + "discord": "Discord", 84 + "discordId": "Discord User ID", 85 + "discordIdPlaceholder": "Your Discord user ID", 86 + "discordIdHint": "Your numeric Discord user ID (enable Developer Mode to find it)", 87 + "telegram": "Telegram", 88 + "telegramUsername": "Telegram Username", 89 + "telegramUsernamePlaceholder": "@yourusername", 90 + "signal": "Signal", 91 + "signalNumber": "Signal Phone Number", 92 + "signalNumberPlaceholder": "+1234567890", 93 + "signalNumberHint": "Include country code (e.g., +1 for US)", 94 + "inviteCode": "Invite Code", 95 + "inviteCodePlaceholder": "Enter your invite code", 96 + "inviteCodeRequired": "required", 97 + "createButton": "Create Account", 98 + "creating": "Creating account...", 99 + "alreadyHaveAccount": "Already have an account?", 100 + "signIn": "Sign in", 101 + "wantPasswordless": "Want passwordless security?", 102 + "createPasskeyAccount": "Create a passkey account", 103 + "validation": { 104 + "handleRequired": "Handle is required", 105 + "handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.", 106 + "passwordRequired": "Password is required", 107 + "passwordLength": "Password must be at least 8 characters", 108 + "passwordsMismatch": "Passwords do not match", 109 + "inviteCodeRequired": "Invite code is required", 110 + "externalDidRequired": "External did:web is required", 111 + "externalDidFormat": "External DID must start with did:web:", 112 + "emailRequired": "Email is required for email verification", 113 + "discordIdRequired": "Discord ID is required for Discord verification", 114 + "telegramRequired": "Telegram username is required for Telegram verification", 115 + "signalRequired": "Phone number is required for Signal verification" 116 + } 117 + }, 118 + "dashboard": { 119 + "title": "Dashboard", 120 + "switchAccount": "Switch Account", 121 + "addAnotherAccount": "Add another account", 122 + "signOut": "Sign out @{handle}", 123 + "deactivatedTitle": "Account Deactivated", 124 + "deactivatedMessage": "Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.", 125 + "accountOverview": "Account Overview", 126 + "handle": "Handle", 127 + "did": "DID", 128 + "primaryContact": "Primary Contact", 129 + "admin": "Admin", 130 + "deactivated": "Deactivated", 131 + "verified": "Verified", 132 + "unverified": "Unverified", 133 + "navAppPasswords": "App Passwords", 134 + "navAppPasswordsDesc": "Manage passwords for third-party apps", 135 + "navSessions": "Active Sessions", 136 + "navSessionsDesc": "View and manage your login sessions", 137 + "navInviteCodes": "Invite Codes", 138 + "navInviteCodesDesc": "View and create invite codes", 139 + "navSettings": "Account Settings", 140 + "navSettingsDesc": "Email, password, handle, and more", 141 + "navSecurity": "Security", 142 + "navSecurityDesc": "Two-factor authentication", 143 + "navComms": "Communication Preferences", 144 + "navCommsDesc": "Discord, Telegram, Signal channels", 145 + "navRepo": "Repository Explorer", 146 + "navRepoDesc": "Browse and manage raw AT Protocol records", 147 + "navAdmin": "Admin Panel", 148 + "navAdminDesc": "Server stats and admin operations" 149 + }, 150 + "settings": { 151 + "title": "Account Settings", 152 + "language": "Language", 153 + "languageDescription": "Choose your preferred language", 154 + "changeEmail": "Change Email", 155 + "currentEmail": "Current: {email}", 156 + "newEmail": "New Email", 157 + "newEmailPlaceholder": "new@example.com", 158 + "changeEmailButton": "Change Email", 159 + "requesting": "Requesting...", 160 + "verificationCode": "Verification Code", 161 + "verificationCodePlaceholder": "Enter code from email", 162 + "confirmEmailChange": "Confirm Email Change", 163 + "updating": "Updating...", 164 + "changeHandle": "Change Handle", 165 + "currentHandle": "Current: @{handle}", 166 + "pdsHandle": "PDS Handle", 167 + "customDomain": "Custom Domain", 168 + "customDomainDescription": "Use your own domain as your handle. You need to verify domain ownership first.", 169 + "setupInstructions": "Setup Instructions", 170 + "setupMethodsIntro": "Choose one of these verification methods:", 171 + "dnsMethod": "Option 1: DNS TXT Record (Recommended)", 172 + "dnsMethodDesc": "Add this TXT record to your domain:", 173 + "httpMethod": "Option 2: HTTP Well-Known File", 174 + "httpMethodDesc": "Serve your DID at this URL:", 175 + "httpMethodContent": "The file should contain only:", 176 + "yourDomain": "Your Domain", 177 + "yourDomainPlaceholder": "example.com", 178 + "verifyAndUpdate": "Verify & Update Handle", 179 + "verifying": "Verifying...", 180 + "newHandle": "New Handle", 181 + "newHandlePlaceholder": "yourhandle", 182 + "changeHandleButton": "Change Handle", 183 + "changePassword": "Change Password", 184 + "currentPassword": "Current Password", 185 + "currentPasswordPlaceholder": "Enter current password", 186 + "newPassword": "New Password", 187 + "newPasswordPlaceholder": "At least 8 characters", 188 + "confirmNewPassword": "Confirm New Password", 189 + "confirmNewPasswordPlaceholder": "Confirm new password", 190 + "changePasswordButton": "Change Password", 191 + "changing": "Changing...", 192 + "exportData": "Export Data", 193 + "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 194 + "downloadRepo": "Download Repository", 195 + "exporting": "Exporting...", 196 + "deleteAccount": "Delete Account", 197 + "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 198 + "requestDeletion": "Request Account Deletion", 199 + "confirmationCode": "Confirmation Code (from email)", 200 + "confirmationCodePlaceholder": "Enter confirmation code", 201 + "yourPassword": "Your Password", 202 + "yourPasswordPlaceholder": "Enter your password", 203 + "permanentlyDelete": "Permanently Delete Account", 204 + "deleting": "Deleting...", 205 + "messages": { 206 + "emailCodeSent": "Verification code sent to your current email", 207 + "emailUpdated": "Email updated successfully", 208 + "handleUpdated": "Handle updated successfully", 209 + "passwordChanged": "Password changed successfully", 210 + "passwordsMismatch": "Passwords do not match", 211 + "passwordLength": "Password must be at least 8 characters", 212 + "deletionCodeSent": "Deletion confirmation sent to your email", 213 + "repoExported": "Repository exported successfully", 214 + "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 215 + } 216 + }, 217 + "appPasswords": { 218 + "title": "App Passwords", 219 + "description": "App passwords let you sign in to third-party apps without giving them your main password. Each app password can be revoked individually.", 220 + "createNew": "Create New App Password", 221 + "appNamePlaceholder": "App name (e.g., Graysky, Skeets)", 222 + "created": "App Password Created", 223 + "createdMessage": "Copy this password now. You won't be able to see it again.", 224 + "yourPasswords": "Your App Passwords", 225 + "noPasswords": "No app passwords yet", 226 + "revoke": "Revoke", 227 + "revoking": "Revoking...", 228 + "creating": "Creating...", 229 + "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account." 230 + }, 231 + "sessions": { 232 + "title": "Active Sessions", 233 + "loadingSessions": "Loading sessions...", 234 + "noSessions": "No active sessions found.", 235 + "current": "Current", 236 + "oauth": "OAuth", 237 + "session": "Session", 238 + "signOut": "Sign Out", 239 + "revoke": "Revoke", 240 + "revokeAll": "Revoke All Other Sessions", 241 + "revokeCurrentConfirm": "This will log you out of this session. Continue?", 242 + "revokeConfirm": "Revoke this session?", 243 + "revokeAllConfirm": "This will revoke {count} other session(s). Continue?", 244 + "noOtherSessions": "No other sessions to revoke", 245 + "failedToLoad": "Failed to load sessions", 246 + "failedToRevoke": "Failed to revoke session", 247 + "failedToRevokeAll": "Failed to revoke sessions", 248 + "created": "Created:", 249 + "expires": "Expires:", 250 + "daysAgo": "{count} day(s) ago", 251 + "hoursAgo": "{count} hour(s) ago", 252 + "minutesAgo": "{count} minute(s) ago", 253 + "justNow": "Just now" 254 + }, 255 + "inviteCodes": { 256 + "title": "Invite Codes", 257 + "description": "Invite codes let you invite friends to join. Each code can be used once.", 258 + "createNew": "Create New Invite Code", 259 + "uses": "Uses", 260 + "usesPlaceholder": "Number of uses (1-100)", 261 + "yourCodes": "Your Invite Codes", 262 + "noCodes": "No invite codes yet", 263 + "available": "Available", 264 + "used": "Used by @{handle}", 265 + "disabled": "Disabled", 266 + "usedBy": "Used by", 267 + "creating": "Creating...", 268 + "disableConfirm": "Disable this invite code? It can no longer be used.", 269 + "created": "Invite Code Created", 270 + "copy": "Copy", 271 + "createdOn": "Created {date}" 272 + }, 273 + "security": { 274 + "title": "Security", 275 + "passkeys": "Passkeys", 276 + "passkeysDescription": "Passkeys provide secure, passwordless authentication using your device's built-in security (fingerprint, face, or PIN).", 277 + "addPasskey": "Add Passkey", 278 + "adding": "Adding...", 279 + "noPasskeys": "No passkeys registered", 280 + "passkeyName": "Passkey name", 281 + "passkeyNamePlaceholder": "e.g., MacBook Pro, iPhone", 282 + "register": "Register", 283 + "registering": "Registering...", 284 + "rename": "Rename", 285 + "renaming": "Renaming...", 286 + "deletePasskey": "Delete", 287 + "deletePasskeyConfirm": "Delete passkey \"{name}\"? You won't be able to use it to sign in anymore.", 288 + "totp": "Authenticator App (TOTP)", 289 + "totpDescription": "Use an authenticator app like Google Authenticator, Authy, or 1Password for two-factor authentication.", 290 + "totpEnabled": "TOTP is enabled", 291 + "totpDisabled": "TOTP is not enabled", 292 + "enableTotp": "Enable TOTP", 293 + "disableTotp": "Disable TOTP", 294 + "disabling": "Disabling...", 295 + "totpSetup": "Set up Authenticator App", 296 + "totpSetupInstructions": "Scan this QR code with your authenticator app, then enter the 6-digit code to verify.", 297 + "totpCode": "Verification Code", 298 + "totpCodePlaceholder": "Enter 6-digit code", 299 + "verifyAndEnable": "Verify & Enable", 300 + "backupCodes": "Backup Codes", 301 + "backupCodesDescription": "Use these codes to sign in if you lose access to your authenticator app. Each code can only be used once.", 302 + "regenerateBackupCodes": "Regenerate Backup Codes", 303 + "regenerating": "Regenerating...", 304 + "regenerateConfirm": "Regenerate backup codes? Your current codes will no longer work.", 305 + "legacyLogin": "Legacy Login", 306 + "legacyLoginDescription": "Allow signing in with username/password directly (legacy mode). When disabled, you must use OAuth with MFA.", 307 + "legacyLoginOn": "Legacy login is enabled", 308 + "legacyLoginOff": "Legacy login is disabled", 309 + "legacyLoginWarning": "Warning: Enabling legacy login bypasses MFA for direct password logins. Only enable if needed for app compatibility.", 310 + "totpPasswordWarning": "With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked. To change your password, you have two options:", 311 + "totpPasswordOption1Label": "Change it here:", 312 + "totpPasswordOption1Text": "Use this website's", 313 + "totpPasswordOption1Link": "Settings page", 314 + "totpPasswordOption1Suffix": "where you can verify with your authenticator app.", 315 + "totpPasswordOption2Label": "Verify your session first:", 316 + "totpPasswordOption2Text": "Use the", 317 + "totpPasswordOption2Link": "re-authenticate option", 318 + "totpPasswordOption2Suffix": "to verify your Bluesky session with TOTP, then password changes will work temporarily.", 319 + "legacyAppsTitle": "What are legacy apps?", 320 + "legacyAppsDescription": "Some apps (like the official Bluesky app) use older authentication that only requires your password. When you have MFA enabled, these apps bypass your second factor. Disabling legacy login forces all apps to use OAuth, which properly enforces MFA.", 321 + "password": "Password", 322 + "passwordStatus": "You have a password set", 323 + "noPassword": "No password set (passkey-only account)", 324 + "setPassword": "Set Password", 325 + "removePassword": "Remove Password", 326 + "removePasswordConfirm": "Remove your password? You'll need to use passkeys to sign in.", 327 + "removing": "Removing...", 328 + "loading": "Loading...", 329 + "loadingPasskeys": "Loading passkeys...", 330 + "cancel": "Cancel", 331 + "save": "Save", 332 + "back": "Back", 333 + "next": "Next: Verify Code", 334 + "copyToClipboard": "Copy to Clipboard", 335 + "savedMyCodes": "I've Saved My Codes", 336 + "cantScan": "Can't scan? Enter manually", 337 + "unnamedPasskey": "Unnamed passkey", 338 + "added": "Added", 339 + "lastUsed": "Last used", 340 + "passwordDescription": "Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience.", 341 + "disableTotpWarning": "This will make your account less secure.", 342 + "removePasswordWarning": "This will make your account passkey-only. You'll only be able to sign in using your registered passkeys. If you lose access to all your passkeys, you can recover your account using your notification channel.", 343 + "beforeProceeding": "Before proceeding:", 344 + "beforeProceedingItem1": "Make sure you have at least one reliable passkey registered", 345 + "beforeProceedingItem2": "Consider registering passkeys on multiple devices", 346 + "beforeProceedingItem3": "Ensure your recovery notification channel is up to date", 347 + "addPasskeyFirst": "Add at least one passkey before you can remove your password.", 348 + "passkeyOnlyHint": "You sign in using passkeys only. If you ever lose access to your passkeys, you can recover your account using the \"Lost passkey?\" link on the login page.", 349 + "trustedDevices": "Trusted Devices", 350 + "trustedDevicesDescription": "Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device.", 351 + "manageTrustedDevices": "Manage Trusted Devices", 352 + "appCompatibility": "App Compatibility", 353 + "enterPassword": "Enter your password", 354 + "legacyLoginEnabled": "Legacy app login enabled", 355 + "legacyLoginDisabled": "Legacy app login disabled - only OAuth apps can sign in", 356 + "failedToUpdatePreference": "Failed to update preference", 357 + "passwordRemoved": "Password removed. Your account is now passkey-only.", 358 + "failedToRemovePassword": "Failed to remove password", 359 + "failedToLoadTotpStatus": "Failed to load TOTP status", 360 + "totpEnabledSuccess": "Two-factor authentication enabled successfully", 361 + "totpDisabledSuccess": "Two-factor authentication disabled", 362 + "backupCodesCopied": "Backup codes copied to clipboard", 363 + "failedToLoadPasskeys": "Failed to load passkeys", 364 + "passkeysNotSupported": "Passkeys are not supported in this browser", 365 + "passkeyCreationCancelled": "Passkey creation was cancelled", 366 + "passkeyAddedSuccess": "Passkey added successfully", 367 + "passkeyDeleted": "Passkey deleted", 368 + "passkeyRenamed": "Passkey renamed" 369 + }, 370 + "comms": { 371 + "title": "Communication Preferences", 372 + "description": "Choose how you want to receive important messages like password resets, security alerts, and account updates.", 373 + "preferredChannel": "Preferred Channel", 374 + "preferredChannelDescription": "Select your preferred way to receive messages. You must configure a channel before you can select it.", 375 + "channelConfiguration": "Channel Configuration", 376 + "emailVia": "Receive messages via email", 377 + "discordVia": "Receive messages via Discord DM", 378 + "telegramVia": "Receive messages via Telegram", 379 + "signalVia": "Receive messages via Signal", 380 + "configureToEnable": "Configure below to enable", 381 + "emailManagedInSettings": "Your email is managed in Account Settings", 382 + "discordIdHint": "Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.", 383 + "telegramHint": "Your Telegram username without the @ symbol", 384 + "signalHint": "Your Signal phone number with country code", 385 + "primary": "Primary", 386 + "verified": "Verified", 387 + "notVerified": "Not verified", 388 + "verifyButton": "Verify", 389 + "verifyCodePlaceholder": "Enter verification code", 390 + "submit": "Submit", 391 + "saving": "Saving...", 392 + "savePreferences": "Save Preferences", 393 + "preferencesSaved": "Communication preferences saved", 394 + "verifiedSuccess": "{channel} verified successfully", 395 + "messageHistory": "Message History", 396 + "historyDescription": "View recent messages sent to your account.", 397 + "loadHistory": "Load History", 398 + "hideHistory": "Hide History", 399 + "noMessages": "No messages found.", 400 + "sent": "sent", 401 + "failed": "failed" 402 + }, 403 + "repoExplorer": { 404 + "title": "Repository Explorer", 405 + "description": "Browse and manage your raw AT Protocol records.", 406 + "collections": "Collections", 407 + "noCollections": "No collections found", 408 + "records": "Records", 409 + "noRecords": "No records in this collection", 410 + "recordDetails": "Record Details", 411 + "rkey": "Record Key", 412 + "cid": "CID", 413 + "value": "Value", 414 + "deleteRecord": "Delete Record", 415 + "deleteConfirm": "Delete record {rkey}? This cannot be undone.", 416 + "unknownError": "An unknown error occurred", 417 + "invalidJson": "Invalid JSON", 418 + "collectionRequired": "Collection is required", 419 + "recordCreated": "Record created: {uri}", 420 + "recordUpdated": "Record updated", 421 + "recordDeleted": "Record deleted", 422 + "newRecord": "New Record", 423 + "createRecord": "Create Record", 424 + "filterCollections": "Filter collections...", 425 + "filterRecords": "Filter records...", 426 + "noCollectionsYet": "No collections yet. Create your first record to get started.", 427 + "loadMore": "Load More", 428 + "recordJson": "Record JSON", 429 + "saving": "Saving...", 430 + "updateRecord": "Update Record", 431 + "collectionNsid": "Collection (NSID)", 432 + "recordKeyOptional": "Record Key (optional)", 433 + "autoGenerated": "Auto-generated if empty (TID)", 434 + "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 435 + "creating": "Creating...", 436 + "demoPostText": "Hello from my PDS! This is my first post.", 437 + "demoDisplayName": "Your Display Name", 438 + "demoBio": "A short bio about yourself." 439 + }, 440 + "admin": { 441 + "title": "Admin Panel", 442 + "serverStats": "Server Statistics", 443 + "users": "Users", 444 + "repos": "Repositories", 445 + "records": "Records", 446 + "blobStorage": "Blob Storage", 447 + "refreshStats": "Refresh Stats", 448 + "userManagement": "User Management", 449 + "searchPlaceholder": "Search by handle (optional)", 450 + "searchUsers": "Search Users", 451 + "noUsers": "No users found", 452 + "handle": "Handle", 453 + "email": "Email", 454 + "status": "Status", 455 + "created": "Created", 456 + "loadMore": "Load More", 457 + "inviteCodes": "Invite Codes", 458 + "loadInviteCodes": "Load Invite Codes", 459 + "refresh": "Refresh", 460 + "noInvites": "No invite codes found", 461 + "code": "Code", 462 + "available": "Available", 463 + "uses": "Uses", 464 + "actions": "Actions", 465 + "disable": "Disable", 466 + "disableInviteConfirm": "Disable invite code {code}?", 467 + "active": "Active", 468 + "exhausted": "Exhausted", 469 + "disabled": "Disabled", 470 + "userDetails": "User Details", 471 + "did": "DID", 472 + "invites": "Invites", 473 + "enabled": "Enabled", 474 + "enableInvites": "Enable Invites", 475 + "disableInvites": "Disable Invites", 476 + "deleteAccount": "Delete Account", 477 + "deleteConfirm": "Delete account @{handle}? This cannot be undone.", 478 + "verified": "Verified", 479 + "unverified": "Unverified", 480 + "deactivated": "Deactivated" 481 + }, 482 + "oauth": { 483 + "login": { 484 + "title": "Sign In", 485 + "subtitle": "Sign in to continue to the application", 486 + "signingIn": "Signing in...", 487 + "authenticating": "Authenticating...", 488 + "checkingPasskey": "Checking passkey...", 489 + "signInWithPasskey": "Sign in with passkey", 490 + "passkeyNotSetUp": "Passkey not set up", 491 + "orUsePassword": "or use password", 492 + "password": "Password", 493 + "rememberDevice": "Remember this device", 494 + "passkeyHintChecking": "Checking passkey status...", 495 + "passkeyHintAvailable": "Sign in with your passkey", 496 + "passkeyHintNotAvailable": "No passkeys registered for this account" 497 + }, 498 + "consent": { 499 + "title": "Authorize Application", 500 + "appWantsAccess": "{app} wants to access your account", 501 + "permissions": "This application will be able to:", 502 + "readProfile": "Read your profile information", 503 + "readPosts": "Read your posts and content", 504 + "writePosts": "Create and delete posts on your behalf", 505 + "readNotifications": "Read your notifications", 506 + "fullAccess": "Full access to your account", 507 + "authorize": "Authorize", 508 + "deny": "Deny", 509 + "authorizing": "Authorizing...", 510 + "rememberChoice": "Remember this choice", 511 + "signingInAs": "Signing in as:", 512 + "permissionsRequested": "Permissions Requested", 513 + "required": "Required", 514 + "rememberChoiceLabel": "Remember my choice for this application" 515 + }, 516 + "accounts": { 517 + "title": "Choose Account", 518 + "subtitle": "Select an account to continue", 519 + "useAnother": "Use a different account" 520 + }, 521 + "twoFactor": { 522 + "title": "Two-Factor Authentication", 523 + "subtitle": "Additional verification is required", 524 + "usePasskey": "Use Passkey", 525 + "useTotp": "Use Authenticator App", 526 + "verifying": "Verifying..." 527 + }, 528 + "twoFactorCode": { 529 + "title": "Two-Factor Authentication", 530 + "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 531 + "codeLabel": "Verification Code", 532 + "codePlaceholder": "Enter 6-digit code", 533 + "verify": "Verify", 534 + "verifying": "Verifying...", 535 + "errors": { 536 + "missingRequestUri": "Missing request_uri parameter", 537 + "verificationFailed": "Verification failed", 538 + "connectionFailed": "Failed to connect to server", 539 + "unexpectedResponse": "Unexpected response from server" 540 + } 541 + }, 542 + "totp": { 543 + "title": "Enter Authenticator Code", 544 + "subtitle": "Enter the 6-digit code from your authenticator app", 545 + "codePlaceholder": "Enter 6-digit code", 546 + "verify": "Verify", 547 + "verifying": "Verifying...", 548 + "useBackupCode": "Use backup code instead", 549 + "backupCodePlaceholder": "Enter backup code", 550 + "trustDevice": "Trust this device for 30 days", 551 + "hintBackupCode": "Using backup code", 552 + "hintTotpCode": "Using authenticator code", 553 + "hintDefault": "6 digits for authenticator, 8 characters for backup code" 554 + }, 555 + "passkey": { 556 + "title": "Passkey Verification", 557 + "subtitle": "Use your passkey to verify your identity", 558 + "waiting": "Waiting for passkey...", 559 + "useTotp": "Use authenticator app instead" 560 + }, 561 + "error": { 562 + "title": "Authorization Error", 563 + "genericError": "An error occurred during authorization.", 564 + "tryAgain": "Try Again", 565 + "backToApp": "Back to Application" 566 + } 567 + }, 568 + "verify": { 569 + "title": "Verify Your Account", 570 + "subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.", 571 + "codePlaceholder": "Enter 6-digit code", 572 + "codeLabel": "Verification Code", 573 + "verifyButton": "Verify Account", 574 + "verifying": "Verifying...", 575 + "resendCode": "Resend Code", 576 + "resending": "Resending...", 577 + "codeResent": "Verification code resent!", 578 + "backToLogin": "Back to Login", 579 + "verifyingAccount": "Verifying account: @{handle}", 580 + "startOver": "Start over with a different account", 581 + "noPending": "No pending verification found.", 582 + "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.", 583 + "createAccount": "Create Account", 584 + "signIn": "Sign In" 585 + }, 586 + "resetPassword": { 587 + "title": "Reset Password", 588 + "forgotTitle": "Forgot Password", 589 + "subtitle": "Enter the code you received and choose a new password.", 590 + "forgotSubtitle": "Enter your handle or email and we'll send you a code to reset your password.", 591 + "handleOrEmail": "Handle or Email", 592 + "emailPlaceholder": "handle or you@example.com", 593 + "sendCode": "Send Reset Code", 594 + "sending": "Sending...", 595 + "codeSent": "Password reset code sent! Check your preferred notification channel.", 596 + "enterCode": "Enter the code from your email and your new password.", 597 + "code": "Reset Code", 598 + "codePlaceholder": "Enter reset code", 599 + "newPassword": "New Password", 600 + "newPasswordPlaceholder": "At least 8 characters", 601 + "confirmPassword": "Confirm Password", 602 + "confirmPasswordPlaceholder": "Confirm new password", 603 + "resetButton": "Reset Password", 604 + "resetting": "Resetting...", 605 + "success": "Password reset successfully!", 606 + "backToLogin": "Back to Sign In", 607 + "requestNewCode": "Request New Code", 608 + "passwordsMismatch": "Passwords do not match", 609 + "passwordLength": "Password must be at least 8 characters" 610 + }, 611 + "recoverPasskey": { 612 + "title": "Recover Your Account", 613 + "invalidLinkTitle": "Invalid Recovery Link", 614 + "invalidLinkMessage": "This recovery link is invalid or has been corrupted. Please request a new recovery email.", 615 + "goToLogin": "Go to Login", 616 + "successTitle": "Password Set!", 617 + "successMessage": "Your temporary password has been set. You can now sign in with this password.", 618 + "successNextSteps": "After signing in, we recommend adding a new passkey in your security settings to restore passkey-only authentication.", 619 + "signIn": "Sign In", 620 + "subtitle": "Set a temporary password to regain access to your passkey-only account.", 621 + "newPassword": "New Password", 622 + "newPasswordPlaceholder": "At least 8 characters", 623 + "confirmPassword": "Confirm Password", 624 + "confirmPasswordPlaceholder": "Confirm your password", 625 + "whatHappensNext": "What happens next?", 626 + "whatHappensNextDetail": "After setting this password, you can sign in and add a new passkey in your security settings. Once you have a new passkey, you can optionally remove the temporary password.", 627 + "setPassword": "Set Password", 628 + "settingPassword": "Setting password...", 629 + "validation": { 630 + "passwordRequired": "New password is required", 631 + "passwordLength": "Password must be at least 8 characters", 632 + "passwordsMismatch": "Passwords do not match" 633 + }, 634 + "errors": { 635 + "invalidLink": "Invalid recovery link. Please request a new one.", 636 + "expired": "This recovery link has expired. Please request a new one." 637 + } 638 + }, 639 + "requestPasskeyRecovery": { 640 + "title": "Recover Passkey Account", 641 + "subtitle": "Lost access to your passkey? Enter your handle or email and we'll send you a recovery link.", 642 + "successTitle": "Recovery Link Sent", 643 + "successMessage": "If your account exists and is a passkey-only account, you'll receive a recovery link at your preferred notification channel.", 644 + "successInfo": "The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal depending on your account settings.", 645 + "handleOrEmail": "Handle or Email", 646 + "emailPlaceholder": "handle or you@example.com", 647 + "howItWorks": "How it works", 648 + "howItWorksDetail": "We'll send a secure link to your registered notification channel. Click the link to set a temporary password. Then you can sign in and add a new passkey.", 649 + "sendRecoveryLink": "Send Recovery Link", 650 + "sending": "Sending...", 651 + "backToLogin": "Back to Sign In" 652 + }, 653 + "registerPasskey": { 654 + "title": "Create Passkey Account", 655 + "subtitle": "Create a passwordless account using a passkey.", 656 + "handle": "Handle", 657 + "handlePlaceholder": "yourname", 658 + "handleHint": "Your full handle will be: @{handle}", 659 + "email": "Email Address", 660 + "emailPlaceholder": "you@example.com", 661 + "inviteCode": "Invite Code", 662 + "inviteCodePlaceholder": "Enter your invite code", 663 + "createButton": "Create Account", 664 + "creating": "Creating...", 665 + "alreadyHaveAccount": "Already have an account?", 666 + "signIn": "Sign in", 667 + "wantPassword": "Want to use a password?", 668 + "createPasswordAccount": "Create a password account" 669 + }, 670 + "trustedDevices": { 671 + "title": "Trusted Devices", 672 + "backToSecurity": "← Security Settings", 673 + "description": "Trusted devices can skip two-factor authentication when logging in. Trust is granted for 30 days and automatically extends when you use the device.", 674 + "noDevices": "No trusted devices yet.", 675 + "noDevicesHint": "When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.", 676 + "lastSeen": "Last seen:", 677 + "trustedSince": "Trusted since:", 678 + "trustExpires": "Trust expires:", 679 + "expired": "Expired", 680 + "tomorrow": "Tomorrow", 681 + "inDays": "In {days} days", 682 + "revoke": "Revoke Trust", 683 + "revokeConfirm": "Are you sure you want to revoke trust for this device? You will need to enter your 2FA code next time you log in from this device.", 684 + "deviceRevoked": "Device trust revoked", 685 + "deviceRenamed": "Device renamed", 686 + "deviceNamePlaceholder": "Device name", 687 + "browser": "Browser:", 688 + "unknownDevice": "Unknown device" 689 + }, 690 + "reauth": { 691 + "title": "Re-authentication Required", 692 + "subtitle": "Please verify your identity to continue.", 693 + "usePassword": "Use Password", 694 + "usePasskey": "Use Passkey", 695 + "useTotp": "Use Authenticator", 696 + "passwordPlaceholder": "Enter your password", 697 + "totpPlaceholder": "Enter 6-digit code", 698 + "verify": "Verify", 699 + "verifying": "Verifying...", 700 + "cancel": "Cancel" 701 + } 702 + }
+702
frontend/src/locales/ja.json
··· 1 + { 2 + "common": { 3 + "loading": "読み込み中...", 4 + "error": "エラー", 5 + "save": "保存", 6 + "cancel": "キャンセル", 7 + "back": "戻る", 8 + "done": "完了", 9 + "refresh": "更新", 10 + "create": "作成", 11 + "delete": "削除", 12 + "confirm": "確認", 13 + "created": "作成日時", 14 + "expires": "有効期限", 15 + "name": "名前", 16 + "dashboard": "ダッシュボード", 17 + "backToDashboard": "← ダッシュボード" 18 + }, 19 + "login": { 20 + "title": "サインイン", 21 + "subtitle": "PDS アカウントを管理するにはサインインしてください", 22 + "button": "サインイン", 23 + "redirecting": "リダイレクト中...", 24 + "chooseAccount": "アカウントを選択", 25 + "signInToAnother": "別のアカウントでサインイン", 26 + "backToSaved": "← 保存済みアカウントに戻る", 27 + "forgotPassword": "パスワードをお忘れですか?", 28 + "lostPasskey": "パスキーを紛失しましたか?", 29 + "noAccount": "アカウントをお持ちでないですか?", 30 + "createAccount": "アカウントを作成", 31 + "removeAccount": "保存済みアカウントから削除" 32 + }, 33 + "verification": { 34 + "title": "アカウント確認", 35 + "subtitle": "アカウントの確認が必要です。確認方法に送信されたコードを入力してください。", 36 + "codeLabel": "確認コード", 37 + "codePlaceholder": "6桁のコードを入力", 38 + "verifyButton": "確認する", 39 + "verifying": "確認中...", 40 + "resendButton": "コードを再送信", 41 + "resending": "送信中...", 42 + "resent": "確認コードを再送信しました!", 43 + "backToLogin": "ログインに戻る" 44 + }, 45 + "register": { 46 + "title": "アカウント作成", 47 + "subtitle": "この PDS で新規アカウントを作成", 48 + "handle": "ハンドル", 49 + "handlePlaceholder": "あなたの名前", 50 + "handleHint": "完全なハンドル: @{handle}", 51 + "handleDotWarning": "カスタムドメインハンドルはアカウント作成後に設定で構成できます。", 52 + "password": "パスワード", 53 + "passwordPlaceholder": "8文字以上", 54 + "confirmPassword": "パスワード確認", 55 + "confirmPasswordPlaceholder": "パスワードを再入力", 56 + "identityType": "アイデンティティタイプ", 57 + "identityHint": "分散型アイデンティティの管理方法を選択してください。", 58 + "didPlc": "did:plc", 59 + "didPlcRecommended": "(推奨)", 60 + "didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ", 61 + "didWeb": "did:web", 62 + "didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)", 63 + "didWebBYOD": "did:web (BYOD)", 64 + "didWebBYODHint": "独自ドメインを持ち込む", 65 + "didWebWarningTitle": "重要: トレードオフをご理解ください", 66 + "didWebWarning1": "この PDS への永続的な紐付け:", 67 + "didWebWarning1Detail": "あなたのアイデンティティは {did} になります。後で別の PDS に移行しても、このサーバーは DID ドキュメントをホストし続ける必要があります。", 68 + "didWebWarning2": "復旧手段がありません:", 69 + "didWebWarning2Detail": "did:plc と異なり、did:web にはローテーションキーがありません。この PDS が永久にオフラインになると、アイデンティティは復旧できません。", 70 + "didWebWarning3": "私たちの約束:", 71 + "didWebWarning3Detail": "移行する場合、新しい PDS を指す最小限の DID ドキュメントを引き続き提供します。アイデンティティは機能し続けます。", 72 + "didWebWarning4": "推奨:", 73 + "didWebWarning4Detail": "did:web を希望する特定の理由がない限り、did:plc を選択してください。", 74 + "externalDid": "あなたの did:web", 75 + "externalDidPlaceholder": "did:web:yourdomain.com", 76 + "externalDidHint": "ドメインは /.well-known/did.json でこの PDS を指す有効な DID ドキュメントを提供する必要があります", 77 + "contactMethod": "連絡方法", 78 + "contactMethodHint": "アカウントの確認と通知の受信方法を選択してください。1つだけ必要です。", 79 + "verificationMethod": "確認方法", 80 + "email": "メール", 81 + "emailAddress": "メールアドレス", 82 + "emailPlaceholder": "you@example.com", 83 + "discord": "Discord", 84 + "discordId": "Discord ユーザー ID", 85 + "discordIdPlaceholder": "Discord ユーザー ID", 86 + "discordIdHint": "数値の Discord ユーザー ID(開発者モードを有効にして確認)", 87 + "telegram": "Telegram", 88 + "telegramUsername": "Telegram ユーザー名", 89 + "telegramUsernamePlaceholder": "@yourusername", 90 + "signal": "Signal", 91 + "signalNumber": "Signal 電話番号", 92 + "signalNumberPlaceholder": "+81XXXXXXXXXX", 93 + "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 94 + "inviteCode": "招待コード", 95 + "inviteCodePlaceholder": "招待コードを入力", 96 + "inviteCodeRequired": "必須", 97 + "createButton": "アカウントを作成", 98 + "creating": "作成中...", 99 + "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 100 + "signIn": "サインイン", 101 + "wantPasswordless": "パスワードレスをご希望ですか?", 102 + "createPasskeyAccount": "パスキーアカウントを作成", 103 + "validation": { 104 + "handleRequired": "ハンドルは必須です", 105 + "handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。", 106 + "passwordRequired": "パスワードは必須です", 107 + "passwordLength": "パスワードは8文字以上である必要があります", 108 + "passwordsMismatch": "パスワードが一致しません", 109 + "inviteCodeRequired": "招待コードは必須です", 110 + "externalDidRequired": "外部 did:web は必須です", 111 + "externalDidFormat": "外部 DID は did:web: で始まる必要があります", 112 + "emailRequired": "メール認証にはメールアドレスが必要です", 113 + "discordIdRequired": "Discord 認証には Discord ID が必要です", 114 + "telegramRequired": "Telegram 認証には Telegram ユーザー名が必要です", 115 + "signalRequired": "Signal 認証には電話番号が必要です" 116 + } 117 + }, 118 + "dashboard": { 119 + "title": "ダッシュボード", 120 + "switchAccount": "アカウント切替", 121 + "addAnotherAccount": "別のアカウントを追加", 122 + "signOut": "@{handle} からサインアウト", 123 + "deactivatedTitle": "アカウント無効化", 124 + "deactivatedMessage": "アカウントは現在無効化されています。これは通常、アカウント移行中に発生します。アカウントが再有効化されるまで、一部の機能が制限される場合があります。", 125 + "accountOverview": "アカウント概要", 126 + "handle": "ハンドル", 127 + "did": "DID", 128 + "primaryContact": "主要連絡先", 129 + "admin": "管理者", 130 + "deactivated": "無効化", 131 + "verified": "認証済み", 132 + "unverified": "未認証", 133 + "navAppPasswords": "アプリパスワード", 134 + "navAppPasswordsDesc": "サードパーティアプリのパスワードを管理", 135 + "navSessions": "アクティブセッション", 136 + "navSessionsDesc": "ログインセッションを表示・管理", 137 + "navInviteCodes": "招待コード", 138 + "navInviteCodesDesc": "招待コードを表示・作成", 139 + "navSettings": "アカウント設定", 140 + "navSettingsDesc": "メール、パスワード、ハンドルなど", 141 + "navSecurity": "セキュリティ", 142 + "navSecurityDesc": "二要素認証", 143 + "navComms": "連絡設定", 144 + "navCommsDesc": "Discord、Telegram、Signal チャンネル", 145 + "navRepo": "リポジトリエクスプローラー", 146 + "navRepoDesc": "AT Protocol レコードを閲覧・管理", 147 + "navAdmin": "管理パネル", 148 + "navAdminDesc": "サーバー統計と管理操作" 149 + }, 150 + "settings": { 151 + "title": "アカウント設定", 152 + "language": "言語", 153 + "languageDescription": "お好みの言語を選択", 154 + "changeEmail": "メール変更", 155 + "currentEmail": "現在: {email}", 156 + "newEmail": "新しいメール", 157 + "newEmailPlaceholder": "new@example.com", 158 + "changeEmailButton": "メールを変更", 159 + "requesting": "リクエスト中...", 160 + "verificationCode": "確認コード", 161 + "verificationCodePlaceholder": "メールから受け取ったコードを入力", 162 + "confirmEmailChange": "メール変更を確認", 163 + "updating": "更新中...", 164 + "changeHandle": "ハンドル変更", 165 + "currentHandle": "現在: @{handle}", 166 + "pdsHandle": "PDS ハンドル", 167 + "customDomain": "カスタムドメイン", 168 + "customDomainDescription": "独自のドメインをハンドルとして使用します。まずドメインの所有権を確認する必要があります。", 169 + "setupInstructions": "設定手順", 170 + "setupMethodsIntro": "以下の確認方法のいずれかを選択してください:", 171 + "dnsMethod": "方法 1: DNS TXT レコード(推奨)", 172 + "dnsMethodDesc": "ドメインにこの TXT レコードを追加:", 173 + "httpMethod": "方法 2: HTTP Well-Known ファイル", 174 + "httpMethodDesc": "この URL で DID を提供:", 175 + "httpMethodContent": "ファイルには以下の内容のみを含める:", 176 + "yourDomain": "ドメイン", 177 + "yourDomainPlaceholder": "example.com", 178 + "verifyAndUpdate": "確認してハンドルを更新", 179 + "verifying": "確認中...", 180 + "newHandle": "新しいハンドル", 181 + "newHandlePlaceholder": "yourhandle", 182 + "changeHandleButton": "ハンドルを変更", 183 + "changePassword": "パスワード変更", 184 + "currentPassword": "現在のパスワード", 185 + "currentPasswordPlaceholder": "現在のパスワードを入力", 186 + "newPassword": "新しいパスワード", 187 + "newPasswordPlaceholder": "8文字以上", 188 + "confirmNewPassword": "新しいパスワードの確認", 189 + "confirmNewPasswordPlaceholder": "新しいパスワードを再入力", 190 + "changePasswordButton": "パスワードを変更", 191 + "changing": "変更中...", 192 + "exportData": "データエクスポート", 193 + "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 194 + "downloadRepo": "リポジトリをダウンロード", 195 + "exporting": "エクスポート中...", 196 + "deleteAccount": "アカウント削除", 197 + "deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。", 198 + "requestDeletion": "アカウント削除をリクエスト", 199 + "confirmationCode": "確認コード(メールから)", 200 + "confirmationCodePlaceholder": "確認コードを入力", 201 + "yourPassword": "パスワード", 202 + "yourPasswordPlaceholder": "パスワードを入力", 203 + "permanentlyDelete": "アカウントを完全に削除", 204 + "deleting": "削除中...", 205 + "messages": { 206 + "emailCodeSent": "現在のメールに確認コードを送信しました", 207 + "emailUpdated": "メールを更新しました", 208 + "handleUpdated": "ハンドルを更新しました", 209 + "passwordChanged": "パスワードを変更しました", 210 + "passwordsMismatch": "パスワードが一致しません", 211 + "passwordLength": "パスワードは8文字以上である必要があります", 212 + "deletionCodeSent": "削除確認をメールに送信しました", 213 + "repoExported": "リポジトリをエクスポートしました", 214 + "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 215 + } 216 + }, 217 + "appPasswords": { 218 + "title": "アプリパスワード", 219 + "description": "アプリパスワードを使用すると、メインパスワードを提供せずにサードパーティアプリにサインインできます。各アプリパスワードは個別に取り消すことができます。", 220 + "createNew": "新しいアプリパスワードを作成", 221 + "appNamePlaceholder": "アプリ名(例: Graysky、Skeets)", 222 + "created": "アプリパスワードを作成しました", 223 + "createdMessage": "このパスワードを今すぐコピーしてください。再度表示することはできません。", 224 + "yourPasswords": "アプリパスワード一覧", 225 + "noPasswords": "アプリパスワードはまだありません", 226 + "revoke": "取り消す", 227 + "revoking": "取り消し中...", 228 + "creating": "作成中...", 229 + "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。" 230 + }, 231 + "sessions": { 232 + "title": "アクティブセッション", 233 + "loadingSessions": "セッションを読み込み中...", 234 + "noSessions": "アクティブなセッションが見つかりません。", 235 + "current": "現在", 236 + "oauth": "OAuth", 237 + "session": "セッション", 238 + "signOut": "サインアウト", 239 + "revoke": "取り消す", 240 + "revokeAll": "他のすべてのセッションを取り消す", 241 + "revokeCurrentConfirm": "このセッションからサインアウトされます。続行しますか?", 242 + "revokeConfirm": "このセッションを取り消しますか?", 243 + "revokeAllConfirm": "他の {count} 件のセッションを取り消します。続行しますか?", 244 + "noOtherSessions": "取り消す他のセッションはありません", 245 + "failedToLoad": "セッションの読み込みに失敗しました", 246 + "failedToRevoke": "セッションの取り消しに失敗しました", 247 + "failedToRevokeAll": "セッションの取り消しに失敗しました", 248 + "created": "作成日時:", 249 + "expires": "有効期限:", 250 + "daysAgo": "{count}日前", 251 + "hoursAgo": "{count}時間前", 252 + "minutesAgo": "{count}分前", 253 + "justNow": "たった今" 254 + }, 255 + "inviteCodes": { 256 + "title": "招待コード", 257 + "description": "招待コードで友人をこの PDS に招待できます。各コードは1回のみ使用可能です。", 258 + "createNew": "新しい招待コードを作成", 259 + "uses": "使用回数", 260 + "usesPlaceholder": "使用回数(1-100)", 261 + "yourCodes": "招待コード一覧", 262 + "noCodes": "招待コードはまだありません", 263 + "available": "利用可能", 264 + "used": "@{handle} が使用済み", 265 + "disabled": "無効", 266 + "usedBy": "使用者", 267 + "creating": "作成中...", 268 + "disableConfirm": "この招待コードを無効にしますか?使用できなくなります。", 269 + "created": "招待コードを作成しました", 270 + "copy": "コピー", 271 + "createdOn": "{date} に作成" 272 + }, 273 + "security": { 274 + "title": "セキュリティ", 275 + "passkeys": "パスキー", 276 + "passkeysDescription": "パスキーは、デバイスの内蔵セキュリティ(指紋、顔、または PIN)を使用して、安全なパスワードレス認証を提供します。", 277 + "addPasskey": "パスキーを追加", 278 + "adding": "追加中...", 279 + "noPasskeys": "登録されたパスキーはありません", 280 + "passkeyName": "パスキー名", 281 + "passkeyNamePlaceholder": "例: MacBook Pro、iPhone", 282 + "register": "登録", 283 + "registering": "登録中...", 284 + "rename": "名前変更", 285 + "renaming": "名前変更中...", 286 + "deletePasskey": "削除", 287 + "deletePasskeyConfirm": "パスキー「{name}」を削除しますか?サインインに使用できなくなります。", 288 + "totp": "認証アプリ (TOTP)", 289 + "totpDescription": "Google Authenticator、Authy、1Password などの認証アプリを二要素認証に使用します。", 290 + "totpEnabled": "TOTP は有効です", 291 + "totpDisabled": "TOTP は無効です", 292 + "enableTotp": "TOTP を有効化", 293 + "disableTotp": "TOTP を無効化", 294 + "disabling": "無効化中...", 295 + "totpSetup": "認証アプリの設定", 296 + "totpSetupInstructions": "認証アプリでこの QR コードをスキャンし、6桁のコードを入力して確認してください。", 297 + "totpCode": "確認コード", 298 + "totpCodePlaceholder": "6桁のコードを入力", 299 + "verifyAndEnable": "確認して有効化", 300 + "backupCodes": "バックアップコード", 301 + "backupCodesDescription": "認証アプリにアクセスできなくなった場合、これらのコードを使用してサインインします。各コードは1回のみ使用可能です。", 302 + "regenerateBackupCodes": "バックアップコードを再生成", 303 + "regenerating": "再生成中...", 304 + "regenerateConfirm": "バックアップコードを再生成しますか?現在のコードは使用できなくなります。", 305 + "legacyLogin": "レガシーログイン", 306 + "legacyLoginDescription": "ユーザー名/パスワードでの直接ログイン(レガシーモード)を許可します。無効にすると、MFA 付きの OAuth を使用する必要があります。", 307 + "legacyLoginOn": "レガシーログインは有効です", 308 + "legacyLoginOff": "レガシーログインは無効です", 309 + "legacyLoginWarning": "警告: レガシーログインを有効にすると、直接パスワードログインの MFA がバイパスされます。アプリの互換性が必要な場合にのみ有効にしてください。", 310 + "totpPasswordWarning": "TOTP が有効な場合、Bluesky アプリ(または他のレガシーアプリ)からパスワードを変更することはできません。パスワードを変更するには、2つの方法があります:", 311 + "totpPasswordOption1Label": "ここで変更する:", 312 + "totpPasswordOption1Text": "このウェブサイトの", 313 + "totpPasswordOption1Link": "設定ページ", 314 + "totpPasswordOption1Suffix": "を使用して、認証アプリで確認できます。", 315 + "totpPasswordOption2Label": "まずセッションを確認する:", 316 + "totpPasswordOption2Text": "", 317 + "totpPasswordOption2Link": "再認証オプション", 318 + "totpPasswordOption2Suffix": "を使用して Bluesky セッションを TOTP で確認すると、一時的にパスワード変更が可能になります。", 319 + "legacyAppsTitle": "レガシーアプリとは?", 320 + "legacyAppsDescription": "一部のアプリ(公式 Bluesky アプリなど)は、パスワードのみを必要とする古い認証を使用します。MFA を有効にしている場合、これらのアプリは二要素認証をバイパスします。レガシーログインを無効にすると、すべてのアプリが OAuth を使用するよう強制され、MFA が適切に適用されます。", 321 + "password": "パスワード", 322 + "passwordStatus": "パスワードが設定されています", 323 + "noPassword": "パスワードは設定されていません(パスキーのみのアカウント)", 324 + "setPassword": "パスワードを設定", 325 + "removePassword": "パスワードを削除", 326 + "removePasswordConfirm": "パスワードを削除しますか?サインインにパスキーが必要になります。", 327 + "removing": "削除中...", 328 + "loading": "読み込み中...", 329 + "loadingPasskeys": "パスキーを読み込み中...", 330 + "cancel": "キャンセル", 331 + "save": "保存", 332 + "back": "戻る", 333 + "next": "次へ: コードを確認", 334 + "copyToClipboard": "クリップボードにコピー", 335 + "savedMyCodes": "コードを保存しました", 336 + "cantScan": "スキャンできませんか?手動で入力", 337 + "unnamedPasskey": "名前のないパスキー", 338 + "added": "追加日", 339 + "lastUsed": "最終使用日", 340 + "passwordDescription": "アカウントパスワードを管理します。パスキーを設定している場合、完全にパスワードレスな体験のためにパスワードを削除することもできます。", 341 + "disableTotpWarning": "これによりアカウントのセキュリティが低下します。", 342 + "removePasswordWarning": "これによりアカウントはパスキーのみになります。登録済みのパスキーでのみサインインできます。すべてのパスキーにアクセスできなくなった場合、通知チャンネルを使用してアカウントを復旧できます。", 343 + "beforeProceeding": "続行する前に:", 344 + "beforeProceedingItem1": "少なくとも1つの信頼できるパスキーが登録されていることを確認", 345 + "beforeProceedingItem2": "複数のデバイスにパスキーを登録することを検討", 346 + "beforeProceedingItem3": "復旧用の通知チャンネルが最新であることを確認", 347 + "addPasskeyFirst": "パスワードを削除する前に、少なくとも1つのパスキーを追加してください。", 348 + "passkeyOnlyHint": "パスキーのみでサインインしています。パスキーにアクセスできなくなった場合、ログインページの「パスキーを紛失しましたか?」リンクからアカウントを復旧できます。", 349 + "trustedDevices": "信頼済みデバイス", 350 + "trustedDevicesDescription": "サインイン時に二要素認証をスキップできるデバイスを管理します。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 351 + "manageTrustedDevices": "信頼済みデバイスを管理", 352 + "appCompatibility": "アプリ互換性", 353 + "enterPassword": "パスワードを入力", 354 + "legacyLoginEnabled": "レガシーアプリログインが有効", 355 + "legacyLoginDisabled": "レガシーアプリログインが無効 - OAuth アプリのみサインイン可能", 356 + "failedToUpdatePreference": "設定の更新に失敗しました", 357 + "passwordRemoved": "パスワードが削除されました。アカウントはパスキーのみになりました。", 358 + "failedToRemovePassword": "パスワードの削除に失敗しました", 359 + "failedToLoadTotpStatus": "TOTP ステータスの読み込みに失敗しました", 360 + "totpEnabledSuccess": "二要素認証が正常に有効化されました", 361 + "totpDisabledSuccess": "二要素認証が無効化されました", 362 + "backupCodesCopied": "バックアップコードをクリップボードにコピーしました", 363 + "failedToLoadPasskeys": "パスキーの読み込みに失敗しました", 364 + "passkeysNotSupported": "このブラウザではパスキーがサポートされていません", 365 + "passkeyCreationCancelled": "パスキーの作成がキャンセルされました", 366 + "passkeyAddedSuccess": "パスキーが追加されました", 367 + "passkeyDeleted": "パスキーが削除されました", 368 + "passkeyRenamed": "パスキーの名前が変更されました" 369 + }, 370 + "comms": { 371 + "title": "連絡設定", 372 + "description": "パスワードリセット、セキュリティアラート、アカウント更新などの重要なメッセージの受信方法を選択してください。", 373 + "preferredChannel": "優先チャンネル", 374 + "preferredChannelDescription": "メッセージの優先受信方法を選択してください。選択する前にチャンネルを設定する必要があります。", 375 + "channelConfiguration": "チャンネル設定", 376 + "emailVia": "メールでメッセージを受信", 377 + "discordVia": "Discord DM でメッセージを受信", 378 + "telegramVia": "Telegram でメッセージを受信", 379 + "signalVia": "Signal でメッセージを受信", 380 + "configureToEnable": "有効にするには下記で設定", 381 + "emailManagedInSettings": "メールはアカウント設定で管理されています", 382 + "discordIdHint": "Discord ユーザー ID(ユーザー名ではありません)。Discord で開発者モードを有効にしてコピーしてください。", 383 + "telegramHint": "@ 記号なしの Telegram ユーザー名", 384 + "signalHint": "国番号付きの Signal 電話番号", 385 + "primary": "優先", 386 + "verified": "確認済み", 387 + "notVerified": "未確認", 388 + "verifyButton": "確認", 389 + "verifyCodePlaceholder": "確認コードを入力", 390 + "submit": "送信", 391 + "saving": "保存中...", 392 + "savePreferences": "設定を保存", 393 + "preferencesSaved": "連絡設定を保存しました", 394 + "verifiedSuccess": "{channel} を確認しました", 395 + "messageHistory": "メッセージ履歴", 396 + "historyDescription": "アカウントに送信された最近のメッセージを表示します。", 397 + "loadHistory": "履歴を読み込む", 398 + "hideHistory": "履歴を隠す", 399 + "noMessages": "メッセージが見つかりません。", 400 + "sent": "送信済み", 401 + "failed": "失敗" 402 + }, 403 + "repoExplorer": { 404 + "title": "リポジトリエクスプローラー", 405 + "description": "AT Protocol レコードを閲覧・管理します。", 406 + "collections": "コレクション", 407 + "noCollections": "コレクションが見つかりません", 408 + "records": "レコード", 409 + "noRecords": "このコレクションにレコードはありません", 410 + "recordDetails": "レコード詳細", 411 + "rkey": "レコードキー", 412 + "cid": "CID", 413 + "value": "値", 414 + "deleteRecord": "レコードを削除", 415 + "deleteConfirm": "レコード {rkey} を削除しますか?この操作は取り消せません。", 416 + "unknownError": "不明なエラーが発生しました", 417 + "invalidJson": "無効な JSON", 418 + "collectionRequired": "コレクションは必須です", 419 + "recordCreated": "レコードを作成しました: {uri}", 420 + "recordUpdated": "レコードを更新しました", 421 + "recordDeleted": "レコードを削除しました", 422 + "newRecord": "新規レコード", 423 + "createRecord": "レコードを作成", 424 + "filterCollections": "コレクションを検索...", 425 + "filterRecords": "レコードを検索...", 426 + "noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。", 427 + "loadMore": "さらに読み込む", 428 + "recordJson": "レコード JSON", 429 + "saving": "保存中...", 430 + "updateRecord": "レコードを更新", 431 + "collectionNsid": "コレクション (NSID)", 432 + "recordKeyOptional": "レコードキー(任意)", 433 + "autoGenerated": "空白で自動生成 (TID)", 434 + "autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます", 435 + "creating": "作成中...", 436 + "demoPostText": "こんにちは、私の PDS からの初投稿です!", 437 + "demoDisplayName": "表示名", 438 + "demoBio": "自己紹介を書いてください。" 439 + }, 440 + "admin": { 441 + "title": "管理パネル", 442 + "serverStats": "サーバー統計", 443 + "users": "ユーザー", 444 + "repos": "リポジトリ", 445 + "records": "レコード", 446 + "blobStorage": "Blob ストレージ", 447 + "refreshStats": "統計を更新", 448 + "userManagement": "ユーザー管理", 449 + "searchPlaceholder": "ハンドルで検索(任意)", 450 + "searchUsers": "ユーザーを検索", 451 + "noUsers": "ユーザーが見つかりません", 452 + "handle": "ハンドル", 453 + "email": "メール", 454 + "status": "ステータス", 455 + "created": "作成日時", 456 + "loadMore": "さらに読み込む", 457 + "inviteCodes": "招待コード", 458 + "loadInviteCodes": "招待コードを読み込む", 459 + "refresh": "更新", 460 + "noInvites": "招待コードが見つかりません", 461 + "code": "コード", 462 + "available": "利用可能", 463 + "uses": "使用回数", 464 + "actions": "アクション", 465 + "disable": "無効化", 466 + "disableInviteConfirm": "招待コード {code} を無効にしますか?", 467 + "active": "アクティブ", 468 + "exhausted": "使用済み", 469 + "disabled": "無効", 470 + "userDetails": "ユーザー詳細", 471 + "did": "DID", 472 + "invites": "招待", 473 + "enabled": "有効", 474 + "enableInvites": "招待を有効化", 475 + "disableInvites": "招待を無効化", 476 + "deleteAccount": "アカウント削除", 477 + "deleteConfirm": "アカウント @{handle} を削除しますか?この操作は取り消せません。", 478 + "verified": "確認済み", 479 + "unverified": "未確認", 480 + "deactivated": "無効化" 481 + }, 482 + "oauth": { 483 + "login": { 484 + "title": "サインイン", 485 + "subtitle": "アプリを続行するにはサインインしてください", 486 + "signingIn": "サインイン中...", 487 + "authenticating": "認証中...", 488 + "checkingPasskey": "パスキーを確認中...", 489 + "signInWithPasskey": "パスキーでサインイン", 490 + "passkeyNotSetUp": "パスキーは設定されていません", 491 + "orUsePassword": "またはパスワードを使用", 492 + "password": "パスワード", 493 + "rememberDevice": "このデバイスを記憶する", 494 + "passkeyHintChecking": "パスキーの状態を確認中...", 495 + "passkeyHintAvailable": "パスキーでサインイン", 496 + "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません" 497 + }, 498 + "consent": { 499 + "title": "アプリを承認", 500 + "appWantsAccess": "{app} があなたのアカウントにアクセスしようとしています", 501 + "permissions": "このアプリは以下のことができるようになります:", 502 + "readProfile": "プロフィール情報を読み取る", 503 + "readPosts": "投稿とコンテンツを読み取る", 504 + "writePosts": "あなたに代わって投稿を作成・削除する", 505 + "readNotifications": "通知を読み取る", 506 + "fullAccess": "アカウントへのフルアクセス", 507 + "authorize": "承認", 508 + "deny": "拒否", 509 + "authorizing": "承認中...", 510 + "rememberChoice": "この選択を記憶", 511 + "signingInAs": "サインイン中のアカウント:", 512 + "permissionsRequested": "リクエストされた権限", 513 + "required": "必須", 514 + "rememberChoiceLabel": "このアプリに対する選択を記憶する" 515 + }, 516 + "accounts": { 517 + "title": "アカウントを選択", 518 + "subtitle": "続行するアカウントを選択", 519 + "useAnother": "別のアカウントを使用" 520 + }, 521 + "twoFactor": { 522 + "title": "二要素認証", 523 + "subtitle": "追加の確認が必要です", 524 + "usePasskey": "パスキーを使用", 525 + "useTotp": "認証アプリを使用", 526 + "verifying": "確認中..." 527 + }, 528 + "twoFactorCode": { 529 + "title": "二要素認証", 530 + "subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。", 531 + "codeLabel": "確認コード", 532 + "codePlaceholder": "6桁のコードを入力", 533 + "verify": "確認", 534 + "verifying": "確認中...", 535 + "errors": { 536 + "missingRequestUri": "request_uri パラメータがありません", 537 + "verificationFailed": "確認に失敗しました", 538 + "connectionFailed": "サーバーへの接続に失敗しました", 539 + "unexpectedResponse": "サーバーからの予期しない応答" 540 + } 541 + }, 542 + "totp": { 543 + "title": "認証コードを入力", 544 + "subtitle": "認証アプリの6桁のコードを入力", 545 + "codePlaceholder": "6桁のコードを入力", 546 + "verify": "確認", 547 + "verifying": "確認中...", 548 + "useBackupCode": "バックアップコードを使用", 549 + "backupCodePlaceholder": "バックアップコードを入力", 550 + "trustDevice": "このデバイスを30日間信頼する", 551 + "hintBackupCode": "バックアップコードを使用中", 552 + "hintTotpCode": "認証コードを使用中", 553 + "hintDefault": "認証アプリは6桁、バックアップコードは8文字" 554 + }, 555 + "passkey": { 556 + "title": "パスキー確認", 557 + "subtitle": "パスキーで本人確認を行います", 558 + "waiting": "パスキーを待機中...", 559 + "useTotp": "認証アプリを使用" 560 + }, 561 + "error": { 562 + "title": "承認エラー", 563 + "genericError": "承認中にエラーが発生しました。", 564 + "tryAgain": "再試行", 565 + "backToApp": "アプリに戻る" 566 + } 567 + }, 568 + "verify": { 569 + "title": "アカウント確認", 570 + "subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。", 571 + "codePlaceholder": "6桁のコードを入力", 572 + "codeLabel": "確認コード", 573 + "verifyButton": "アカウントを確認", 574 + "verifying": "確認中...", 575 + "resendCode": "コードを再送信", 576 + "resending": "送信中...", 577 + "codeResent": "確認コードを再送信しました!", 578 + "backToLogin": "ログインに戻る", 579 + "verifyingAccount": "確認中のアカウント: @{handle}", 580 + "startOver": "別のアカウントでやり直す", 581 + "noPending": "保留中の確認が見つかりません。", 582 + "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 583 + "createAccount": "アカウントを作成", 584 + "signIn": "サインイン" 585 + }, 586 + "resetPassword": { 587 + "title": "パスワードリセット", 588 + "forgotTitle": "パスワードをお忘れですか", 589 + "subtitle": "受け取ったコードを入力して、新しいパスワードを選択してください。", 590 + "forgotSubtitle": "ハンドルまたはメールアドレスを入力すると、パスワードリセットコードを送信します。", 591 + "handleOrEmail": "ハンドルまたはメール", 592 + "emailPlaceholder": "ハンドルまたは you@example.com", 593 + "sendCode": "リセットコードを送信", 594 + "sending": "送信中...", 595 + "codeSent": "パスワードリセットコードを送信しました!優先通知チャンネルを確認してください。", 596 + "enterCode": "メールからのコードと新しいパスワードを入力してください。", 597 + "code": "リセットコード", 598 + "codePlaceholder": "リセットコードを入力", 599 + "newPassword": "新しいパスワード", 600 + "newPasswordPlaceholder": "8文字以上", 601 + "confirmPassword": "パスワード確認", 602 + "confirmPasswordPlaceholder": "新しいパスワードを再入力", 603 + "resetButton": "パスワードをリセット", 604 + "resetting": "リセット中...", 605 + "success": "パスワードをリセットしました!", 606 + "backToLogin": "サインインに戻る", 607 + "requestNewCode": "新しいコードをリクエスト", 608 + "passwordsMismatch": "パスワードが一致しません", 609 + "passwordLength": "パスワードは8文字以上である必要があります" 610 + }, 611 + "recoverPasskey": { 612 + "title": "アカウントを復旧", 613 + "invalidLinkTitle": "無効な復旧リンク", 614 + "invalidLinkMessage": "この復旧リンクは無効または破損しています。新しい復旧メールをリクエストしてください。", 615 + "goToLogin": "ログインへ", 616 + "successTitle": "パスワードを設定しました!", 617 + "successMessage": "一時パスワードを設定しました。このパスワードでサインインできます。", 618 + "successNextSteps": "サインイン後、セキュリティ設定で新しいパスキーを追加して、パスキーのみの認証を復元することをお勧めします。", 619 + "signIn": "サインイン", 620 + "subtitle": "パスキーのみのアカウントへのアクセスを回復するために一時パスワードを設定します。", 621 + "newPassword": "新しいパスワード", 622 + "newPasswordPlaceholder": "8文字以上", 623 + "confirmPassword": "パスワード確認", 624 + "confirmPasswordPlaceholder": "パスワードを再入力", 625 + "whatHappensNext": "次のステップ", 626 + "whatHappensNextDetail": "このパスワードを設定後、サインインしてセキュリティ設定で新しいパスキーを追加できます。新しいパスキーを追加したら、一時パスワードを削除することもできます。", 627 + "setPassword": "パスワードを設定", 628 + "settingPassword": "パスワードを設定中...", 629 + "validation": { 630 + "passwordRequired": "新しいパスワードは必須です", 631 + "passwordLength": "パスワードは8文字以上である必要があります", 632 + "passwordsMismatch": "パスワードが一致しません" 633 + }, 634 + "errors": { 635 + "invalidLink": "無効な復旧リンクです。新しいリンクをリクエストしてください。", 636 + "expired": "この復旧リンクは期限切れです。新しいリンクをリクエストしてください。" 637 + } 638 + }, 639 + "requestPasskeyRecovery": { 640 + "title": "パスキーアカウントを復旧", 641 + "subtitle": "パスキーにアクセスできなくなりましたか?ハンドルまたはメールを入力すると、復旧リンクを送信します。", 642 + "successTitle": "復旧リンクを送信しました", 643 + "successMessage": "アカウントが存在し、パスキーのみのアカウントの場合、優先通知チャンネルに復旧リンクが届きます。", 644 + "successInfo": "リンクは1時間で期限切れになります。アカウント設定に応じて、メール、Discord、Telegram、または Signal を確認してください。", 645 + "handleOrEmail": "ハンドルまたはメール", 646 + "emailPlaceholder": "ハンドルまたは you@example.com", 647 + "howItWorks": "仕組み", 648 + "howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。", 649 + "sendRecoveryLink": "復旧リンクを送信", 650 + "sending": "送信中...", 651 + "backToLogin": "サインインに戻る" 652 + }, 653 + "registerPasskey": { 654 + "title": "パスキーアカウントを作成", 655 + "subtitle": "パスキーを使用してパスワードレスアカウントを作成します。", 656 + "handle": "ハンドル", 657 + "handlePlaceholder": "あなたの名前", 658 + "handleHint": "完全なハンドル: @{handle}", 659 + "email": "メールアドレス", 660 + "emailPlaceholder": "you@example.com", 661 + "inviteCode": "招待コード", 662 + "inviteCodePlaceholder": "招待コードを入力", 663 + "createButton": "アカウントを作成", 664 + "creating": "作成中...", 665 + "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 666 + "signIn": "サインイン", 667 + "wantPassword": "パスワードを使用しますか?", 668 + "createPasswordAccount": "パスワードアカウントを作成" 669 + }, 670 + "trustedDevices": { 671 + "title": "信頼済みデバイス", 672 + "backToSecurity": "← セキュリティ設定", 673 + "description": "信頼済みデバイスはログイン時に二要素認証をスキップできます。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 674 + "noDevices": "信頼済みデバイスはまだありません。", 675 + "noDevicesHint": "二要素認証を有効にしてログインする際に、デバイスを30日間信頼することを選択できます。", 676 + "lastSeen": "最終使用:", 677 + "trustedSince": "信頼開始:", 678 + "trustExpires": "信頼期限:", 679 + "expired": "期限切れ", 680 + "tomorrow": "明日", 681 + "inDays": "あと{days}日", 682 + "revoke": "信頼を取り消す", 683 + "revokeConfirm": "このデバイスへの信頼を取り消しますか?次回このデバイスからログインする際に2FAコードの入力が必要になります。", 684 + "deviceRevoked": "デバイスの信頼を取り消しました", 685 + "deviceRenamed": "デバイス名を変更しました", 686 + "deviceNamePlaceholder": "デバイス名", 687 + "browser": "ブラウザ:", 688 + "unknownDevice": "不明なデバイス" 689 + }, 690 + "reauth": { 691 + "title": "再認証が必要です", 692 + "subtitle": "続行するには本人確認を行ってください。", 693 + "usePassword": "パスワードを使用", 694 + "usePasskey": "パスキーを使用", 695 + "useTotp": "認証アプリを使用", 696 + "passwordPlaceholder": "パスワードを入力", 697 + "totpPlaceholder": "6桁のコードを入力", 698 + "verify": "確認", 699 + "verifying": "確認中...", 700 + "cancel": "キャンセル" 701 + } 702 + }
+702
frontend/src/locales/ko.json
··· 1 + { 2 + "common": { 3 + "loading": "로딩 중...", 4 + "error": "오류", 5 + "save": "저장", 6 + "cancel": "취소", 7 + "back": "뒤로", 8 + "done": "완료", 9 + "refresh": "새로고침", 10 + "create": "생성", 11 + "delete": "삭제", 12 + "confirm": "확인", 13 + "created": "생성일", 14 + "expires": "만료일", 15 + "name": "이름", 16 + "dashboard": "대시보드", 17 + "backToDashboard": "← 대시보드" 18 + }, 19 + "login": { 20 + "title": "로그인", 21 + "subtitle": "PDS 계정을 관리하려면 로그인하세요", 22 + "button": "로그인", 23 + "redirecting": "리디렉션 중...", 24 + "chooseAccount": "계정 선택", 25 + "signInToAnother": "다른 계정으로 로그인", 26 + "backToSaved": "← 저장된 계정으로 돌아가기", 27 + "forgotPassword": "비밀번호를 잊으셨나요?", 28 + "lostPasskey": "패스키를 분실하셨나요?", 29 + "noAccount": "계정이 없으신가요?", 30 + "createAccount": "계정 만들기", 31 + "removeAccount": "저장된 계정에서 삭제" 32 + }, 33 + "verification": { 34 + "title": "계정 인증", 35 + "subtitle": "계정 인증이 필요합니다. 인증 방법으로 전송된 코드를 입력하세요.", 36 + "codeLabel": "인증 코드", 37 + "codePlaceholder": "6자리 코드 입력", 38 + "verifyButton": "계정 인증", 39 + "verifying": "인증 중...", 40 + "resendButton": "코드 다시 보내기", 41 + "resending": "전송 중...", 42 + "resent": "인증 코드를 다시 보냈습니다!", 43 + "backToLogin": "로그인으로 돌아가기" 44 + }, 45 + "register": { 46 + "title": "계정 만들기", 47 + "subtitle": "이 PDS에 새 계정을 만듭니다", 48 + "handle": "핸들", 49 + "handlePlaceholder": "사용자 이름", 50 + "handleHint": "전체 핸들: @{handle}", 51 + "handleDotWarning": "사용자 정의 도메인 핸들은 계정 생성 후 설정에서 구성할 수 있습니다.", 52 + "password": "비밀번호", 53 + "passwordPlaceholder": "8자 이상", 54 + "confirmPassword": "비밀번호 확인", 55 + "confirmPasswordPlaceholder": "비밀번호 재입력", 56 + "identityType": "ID 유형", 57 + "identityHint": "분산 ID를 관리하는 방법을 선택하세요.", 58 + "didPlc": "did:plc", 59 + "didPlcRecommended": "(권장)", 60 + "didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID", 61 + "didWeb": "did:web", 62 + "didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)", 63 + "didWebBYOD": "did:web (BYOD)", 64 + "didWebBYODHint": "자체 도메인 사용", 65 + "didWebWarningTitle": "중요: 장단점을 이해하세요", 66 + "didWebWarning1": "이 PDS에 영구 연결:", 67 + "didWebWarning1Detail": "ID는 {did}가 됩니다. 나중에 다른 PDS로 마이그레이션하더라도 이 서버는 계속 DID 문서를 호스팅해야 합니다.", 68 + "didWebWarning2": "복구 메커니즘 없음:", 69 + "didWebWarning2Detail": "did:plc와 달리 did:web에는 순환 키가 없습니다. 이 PDS가 영구적으로 오프라인이 되면 ID를 복구할 수 없습니다.", 70 + "didWebWarning3": "우리의 약속:", 71 + "didWebWarning3Detail": "마이그레이션하면 새 PDS를 가리키는 최소한의 DID 문서를 계속 제공합니다. ID는 계속 작동합니다.", 72 + "didWebWarning4": "권장:", 73 + "didWebWarning4Detail": "did:web을 선호하는 특별한 이유가 없다면 did:plc를 선택하세요.", 74 + "externalDid": "귀하의 did:web", 75 + "externalDidPlaceholder": "did:web:yourdomain.com", 76 + "externalDidHint": "도메인은 /.well-known/did.json에서 이 PDS를 가리키는 유효한 DID 문서를 제공해야 합니다", 77 + "contactMethod": "연락 방법", 78 + "contactMethodHint": "계정 인증 및 알림 수신 방법을 선택하세요. 하나만 필요합니다.", 79 + "verificationMethod": "인증 방법", 80 + "email": "이메일", 81 + "emailAddress": "이메일 주소", 82 + "emailPlaceholder": "you@example.com", 83 + "discord": "Discord", 84 + "discordId": "Discord 사용자 ID", 85 + "discordIdPlaceholder": "Discord 사용자 ID", 86 + "discordIdHint": "숫자 Discord 사용자 ID (개발자 모드를 활성화하여 찾기)", 87 + "telegram": "Telegram", 88 + "telegramUsername": "Telegram 사용자 이름", 89 + "telegramUsernamePlaceholder": "@yourusername", 90 + "signal": "Signal", 91 + "signalNumber": "Signal 전화번호", 92 + "signalNumberPlaceholder": "+821012345678", 93 + "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 94 + "inviteCode": "초대 코드", 95 + "inviteCodePlaceholder": "초대 코드 입력", 96 + "inviteCodeRequired": "필수", 97 + "createButton": "계정 만들기", 98 + "creating": "계정 생성 중...", 99 + "alreadyHaveAccount": "이미 계정이 있으신가요?", 100 + "signIn": "로그인", 101 + "wantPasswordless": "비밀번호 없는 보안을 원하시나요?", 102 + "createPasskeyAccount": "패스키 계정 만들기", 103 + "validation": { 104 + "handleRequired": "핸들은 필수입니다", 105 + "handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.", 106 + "passwordRequired": "비밀번호는 필수입니다", 107 + "passwordLength": "비밀번호는 8자 이상이어야 합니다", 108 + "passwordsMismatch": "비밀번호가 일치하지 않습니다", 109 + "inviteCodeRequired": "초대 코드는 필수입니다", 110 + "externalDidRequired": "외부 did:web은 필수입니다", 111 + "externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다", 112 + "emailRequired": "이메일 인증에는 이메일이 필요합니다", 113 + "discordIdRequired": "Discord 인증에는 Discord ID가 필요합니다", 114 + "telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다", 115 + "signalRequired": "Signal 인증에는 전화번호가 필요합니다" 116 + } 117 + }, 118 + "dashboard": { 119 + "title": "대시보드", 120 + "switchAccount": "계정 전환", 121 + "addAnotherAccount": "다른 계정 추가", 122 + "signOut": "@{handle} 로그아웃", 123 + "deactivatedTitle": "계정 비활성화됨", 124 + "deactivatedMessage": "계정이 현재 비활성화되어 있습니다. 이는 일반적으로 계정 마이그레이션 중에 발생합니다. 계정이 다시 활성화될 때까지 일부 기능이 제한될 수 있습니다.", 125 + "accountOverview": "계정 개요", 126 + "handle": "핸들", 127 + "did": "DID", 128 + "primaryContact": "주요 연락처", 129 + "admin": "관리자", 130 + "deactivated": "비활성화됨", 131 + "verified": "인증됨", 132 + "unverified": "미인증", 133 + "navAppPasswords": "앱 비밀번호", 134 + "navAppPasswordsDesc": "타사 앱의 비밀번호 관리", 135 + "navSessions": "활성 세션", 136 + "navSessionsDesc": "로그인 세션 보기 및 관리", 137 + "navInviteCodes": "초대 코드", 138 + "navInviteCodesDesc": "초대 코드 보기 및 생성", 139 + "navSettings": "계정 설정", 140 + "navSettingsDesc": "이메일, 비밀번호, 핸들 등", 141 + "navSecurity": "보안", 142 + "navSecurityDesc": "2단계 인증", 143 + "navComms": "통신 설정", 144 + "navCommsDesc": "Discord, Telegram, Signal 채널", 145 + "navRepo": "저장소 탐색기", 146 + "navRepoDesc": "AT Protocol 레코드 탐색 및 관리", 147 + "navAdmin": "관리 패널", 148 + "navAdminDesc": "서버 통계 및 관리 작업" 149 + }, 150 + "settings": { 151 + "title": "계정 설정", 152 + "language": "언어", 153 + "languageDescription": "선호하는 언어를 선택하세요", 154 + "changeEmail": "이메일 변경", 155 + "currentEmail": "현재: {email}", 156 + "newEmail": "새 이메일", 157 + "newEmailPlaceholder": "new@example.com", 158 + "changeEmailButton": "이메일 변경", 159 + "requesting": "요청 중...", 160 + "verificationCode": "인증 코드", 161 + "verificationCodePlaceholder": "이메일의 코드 입력", 162 + "confirmEmailChange": "이메일 변경 확인", 163 + "updating": "업데이트 중...", 164 + "changeHandle": "핸들 변경", 165 + "currentHandle": "현재: @{handle}", 166 + "pdsHandle": "PDS 핸들", 167 + "customDomain": "사용자 정의 도메인", 168 + "customDomainDescription": "자체 도메인을 핸들로 사용합니다. 먼저 도메인 소유권을 확인해야 합니다.", 169 + "setupInstructions": "설정 지침", 170 + "setupMethodsIntro": "다음 인증 방법 중 하나를 선택하세요:", 171 + "dnsMethod": "방법 1: DNS TXT 레코드 (권장)", 172 + "dnsMethodDesc": "도메인에 이 TXT 레코드 추가:", 173 + "httpMethod": "방법 2: HTTP Well-Known 파일", 174 + "httpMethodDesc": "이 URL에서 DID 제공:", 175 + "httpMethodContent": "파일에는 다음만 포함:", 176 + "yourDomain": "도메인", 177 + "yourDomainPlaceholder": "example.com", 178 + "verifyAndUpdate": "확인 후 핸들 업데이트", 179 + "verifying": "확인 중...", 180 + "newHandle": "새 핸들", 181 + "newHandlePlaceholder": "yourhandle", 182 + "changeHandleButton": "핸들 변경", 183 + "changePassword": "비밀번호 변경", 184 + "currentPassword": "현재 비밀번호", 185 + "currentPasswordPlaceholder": "현재 비밀번호 입력", 186 + "newPassword": "새 비밀번호", 187 + "newPasswordPlaceholder": "8자 이상", 188 + "confirmNewPassword": "새 비밀번호 확인", 189 + "confirmNewPasswordPlaceholder": "새 비밀번호 재입력", 190 + "changePasswordButton": "비밀번호 변경", 191 + "changing": "변경 중...", 192 + "exportData": "데이터 내보내기", 193 + "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 194 + "downloadRepo": "저장소 다운로드", 195 + "exporting": "내보내기 중...", 196 + "deleteAccount": "계정 삭제", 197 + "deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.", 198 + "requestDeletion": "계정 삭제 요청", 199 + "confirmationCode": "확인 코드 (이메일에서)", 200 + "confirmationCodePlaceholder": "확인 코드 입력", 201 + "yourPassword": "비밀번호", 202 + "yourPasswordPlaceholder": "비밀번호 입력", 203 + "permanentlyDelete": "계정 영구 삭제", 204 + "deleting": "삭제 중...", 205 + "messages": { 206 + "emailCodeSent": "현재 이메일로 인증 코드를 보냈습니다", 207 + "emailUpdated": "이메일이 업데이트되었습니다", 208 + "handleUpdated": "핸들이 업데이트되었습니다", 209 + "passwordChanged": "비밀번호가 변경되었습니다", 210 + "passwordsMismatch": "비밀번호가 일치하지 않습니다", 211 + "passwordLength": "비밀번호는 8자 이상이어야 합니다", 212 + "deletionCodeSent": "이메일로 삭제 확인을 보냈습니다", 213 + "repoExported": "저장소를 내보냈습니다", 214 + "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 215 + } 216 + }, 217 + "appPasswords": { 218 + "title": "앱 비밀번호", 219 + "description": "앱 비밀번호를 사용하면 기본 비밀번호를 제공하지 않고 타사 앱에 로그인할 수 있습니다. 각 앱 비밀번호는 개별적으로 취소할 수 있습니다.", 220 + "createNew": "새 앱 비밀번호 만들기", 221 + "appNamePlaceholder": "앱 이름 (예: Graysky, Skeets)", 222 + "created": "앱 비밀번호가 생성되었습니다", 223 + "createdMessage": "지금 이 비밀번호를 복사하세요. 다시 볼 수 없습니다.", 224 + "yourPasswords": "앱 비밀번호 목록", 225 + "noPasswords": "앱 비밀번호가 아직 없습니다", 226 + "revoke": "취소", 227 + "revoking": "취소 중...", 228 + "creating": "생성 중...", 229 + "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다." 230 + }, 231 + "sessions": { 232 + "title": "활성 세션", 233 + "loadingSessions": "세션 로딩 중...", 234 + "noSessions": "활성 세션이 없습니다.", 235 + "current": "현재", 236 + "oauth": "OAuth", 237 + "session": "세션", 238 + "signOut": "로그아웃", 239 + "revoke": "취소", 240 + "revokeAll": "다른 모든 세션 취소", 241 + "revokeCurrentConfirm": "이 세션에서 로그아웃됩니다. 계속하시겠습니까?", 242 + "revokeConfirm": "이 세션을 취소하시겠습니까?", 243 + "revokeAllConfirm": "{count}개의 다른 세션을 취소합니다. 계속하시겠습니까?", 244 + "noOtherSessions": "취소할 다른 세션이 없습니다", 245 + "failedToLoad": "세션 로딩에 실패했습니다", 246 + "failedToRevoke": "세션 취소에 실패했습니다", 247 + "failedToRevokeAll": "세션 취소에 실패했습니다", 248 + "created": "생성일:", 249 + "expires": "만료일:", 250 + "daysAgo": "{count}일 전", 251 + "hoursAgo": "{count}시간 전", 252 + "minutesAgo": "{count}분 전", 253 + "justNow": "방금" 254 + }, 255 + "inviteCodes": { 256 + "title": "초대 코드", 257 + "description": "초대 코드로 친구를 이 PDS에 초대할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.", 258 + "createNew": "새 초대 코드 만들기", 259 + "uses": "사용 횟수", 260 + "usesPlaceholder": "사용 횟수 (1-100)", 261 + "yourCodes": "초대 코드 목록", 262 + "noCodes": "초대 코드가 아직 없습니다", 263 + "available": "사용 가능", 264 + "used": "@{handle}이(가) 사용함", 265 + "disabled": "비활성화됨", 266 + "usedBy": "사용자", 267 + "creating": "생성 중...", 268 + "disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.", 269 + "created": "초대 코드가 생성되었습니다", 270 + "copy": "복사", 271 + "createdOn": "{date}에 생성됨" 272 + }, 273 + "security": { 274 + "title": "보안", 275 + "passkeys": "패스키", 276 + "passkeysDescription": "패스키는 기기의 내장 보안(지문, 얼굴 또는 PIN)을 사용하여 안전한 비밀번호 없는 인증을 제공합니다.", 277 + "addPasskey": "패스키 추가", 278 + "adding": "추가 중...", 279 + "noPasskeys": "등록된 패스키가 없습니다", 280 + "passkeyName": "패스키 이름", 281 + "passkeyNamePlaceholder": "예: MacBook Pro, iPhone", 282 + "register": "등록", 283 + "registering": "등록 중...", 284 + "rename": "이름 변경", 285 + "renaming": "이름 변경 중...", 286 + "deletePasskey": "삭제", 287 + "deletePasskeyConfirm": "패스키 \"{name}\"을(를) 삭제하시겠습니까? 더 이상 로그인에 사용할 수 없습니다.", 288 + "totp": "인증 앱 (TOTP)", 289 + "totpDescription": "Google Authenticator, Authy 또는 1Password와 같은 인증 앱을 2단계 인증에 사용합니다.", 290 + "totpEnabled": "TOTP가 활성화되었습니다", 291 + "totpDisabled": "TOTP가 비활성화되었습니다", 292 + "enableTotp": "TOTP 활성화", 293 + "disableTotp": "TOTP 비활성화", 294 + "disabling": "비활성화 중...", 295 + "totpSetup": "인증 앱 설정", 296 + "totpSetupInstructions": "인증 앱으로 이 QR 코드를 스캔한 다음 6자리 코드를 입력하여 확인합니다.", 297 + "totpCode": "인증 코드", 298 + "totpCodePlaceholder": "6자리 코드 입력", 299 + "verifyAndEnable": "확인 후 활성화", 300 + "backupCodes": "백업 코드", 301 + "backupCodesDescription": "인증 앱에 액세스할 수 없는 경우 이 코드를 사용하여 로그인합니다. 각 코드는 한 번만 사용할 수 있습니다.", 302 + "regenerateBackupCodes": "백업 코드 재생성", 303 + "regenerating": "재생성 중...", 304 + "regenerateConfirm": "백업 코드를 재생성하시겠습니까? 현재 코드는 더 이상 작동하지 않습니다.", 305 + "legacyLogin": "레거시 로그인", 306 + "legacyLoginDescription": "사용자 이름/비밀번호로 직접 로그인(레거시 모드)을 허용합니다. 비활성화하면 MFA가 있는 OAuth를 사용해야 합니다.", 307 + "legacyLoginOn": "레거시 로그인이 활성화되었습니다", 308 + "legacyLoginOff": "레거시 로그인이 비활성화되었습니다", 309 + "legacyLoginWarning": "경고: 레거시 로그인을 활성화하면 직접 비밀번호 로그인에 대한 MFA가 우회됩니다. 앱 호환성이 필요한 경우에만 활성화하세요.", 310 + "totpPasswordWarning": "TOTP가 활성화되면 Bluesky 앱(또는 기타 레거시 앱)에서 비밀번호를 변경할 수 없습니다. 비밀번호를 변경하려면 두 가지 방법이 있습니다:", 311 + "totpPasswordOption1Label": "여기에서 변경:", 312 + "totpPasswordOption1Text": "이 웹사이트의", 313 + "totpPasswordOption1Link": "설정 페이지", 314 + "totpPasswordOption1Suffix": "에서 인증 앱으로 확인할 수 있습니다.", 315 + "totpPasswordOption2Label": "먼저 세션 확인:", 316 + "totpPasswordOption2Text": "", 317 + "totpPasswordOption2Link": "재인증 옵션", 318 + "totpPasswordOption2Suffix": "을 사용하여 TOTP로 Bluesky 세션을 확인하면 일시적으로 비밀번호 변경이 가능합니다.", 319 + "legacyAppsTitle": "레거시 앱이란?", 320 + "legacyAppsDescription": "일부 앱(공식 Bluesky 앱 등)은 비밀번호만 필요한 이전 인증을 사용합니다. MFA가 활성화되어 있으면 이러한 앱은 두 번째 인증 요소를 우회합니다. 레거시 로그인을 비활성화하면 모든 앱이 OAuth를 사용하도록 강제되어 MFA가 적절히 적용됩니다.", 321 + "password": "비밀번호", 322 + "passwordStatus": "비밀번호가 설정되었습니다", 323 + "noPassword": "비밀번호가 설정되지 않음 (패스키 전용 계정)", 324 + "setPassword": "비밀번호 설정", 325 + "removePassword": "비밀번호 제거", 326 + "removePasswordConfirm": "비밀번호를 제거하시겠습니까? 로그인에 패스키가 필요합니다.", 327 + "removing": "제거 중...", 328 + "loading": "로딩 중...", 329 + "loadingPasskeys": "패스키 로딩 중...", 330 + "cancel": "취소", 331 + "save": "저장", 332 + "back": "뒤로", 333 + "next": "다음: 코드 확인", 334 + "copyToClipboard": "클립보드에 복사", 335 + "savedMyCodes": "코드를 저장했습니다", 336 + "cantScan": "스캔할 수 없나요? 수동 입력", 337 + "unnamedPasskey": "이름 없는 패스키", 338 + "added": "추가됨", 339 + "lastUsed": "마지막 사용", 340 + "passwordDescription": "계정 비밀번호를 관리합니다. 패스키를 설정한 경우 완전한 비밀번호 없는 경험을 위해 비밀번호를 제거할 수 있습니다.", 341 + "disableTotpWarning": "이렇게 하면 계정 보안이 약해집니다.", 342 + "removePasswordWarning": "이렇게 하면 계정이 패스키 전용이 됩니다. 등록된 패스키로만 로그인할 수 있습니다. 모든 패스키에 액세스할 수 없게 되면 알림 채널을 사용하여 계정을 복구할 수 있습니다.", 343 + "beforeProceeding": "계속하기 전에:", 344 + "beforeProceedingItem1": "최소 하나의 신뢰할 수 있는 패스키가 등록되어 있는지 확인", 345 + "beforeProceedingItem2": "여러 기기에 패스키 등록을 고려", 346 + "beforeProceedingItem3": "복구 알림 채널이 최신인지 확인", 347 + "addPasskeyFirst": "비밀번호를 제거하려면 먼저 최소 하나의 패스키를 추가하세요.", 348 + "passkeyOnlyHint": "패스키로만 로그인합니다. 패스키에 액세스할 수 없게 되면 로그인 페이지의 '패스키를 분실하셨나요?' 링크를 사용하여 계정을 복구할 수 있습니다.", 349 + "trustedDevices": "신뢰할 수 있는 기기", 350 + "trustedDevicesDescription": "로그인 시 2단계 인증을 건너뛸 수 있는 기기를 관리합니다. 신뢰는 30일간 유효하며 기기를 사용하면 자동으로 연장됩니다.", 351 + "manageTrustedDevices": "신뢰할 수 있는 기기 관리", 352 + "appCompatibility": "앱 호환성", 353 + "enterPassword": "비밀번호를 입력하세요", 354 + "legacyLoginEnabled": "레거시 앱 로그인 활성화됨", 355 + "legacyLoginDisabled": "레거시 앱 로그인 비활성화됨 - OAuth 앱만 로그인 가능", 356 + "failedToUpdatePreference": "설정 업데이트에 실패했습니다", 357 + "passwordRemoved": "비밀번호가 제거되었습니다. 이제 계정은 패스키 전용입니다.", 358 + "failedToRemovePassword": "비밀번호 제거에 실패했습니다", 359 + "failedToLoadTotpStatus": "TOTP 상태 로딩에 실패했습니다", 360 + "totpEnabledSuccess": "2단계 인증이 활성화되었습니다", 361 + "totpDisabledSuccess": "2단계 인증이 비활성화되었습니다", 362 + "backupCodesCopied": "백업 코드가 클립보드에 복사되었습니다", 363 + "failedToLoadPasskeys": "패스키 로딩에 실패했습니다", 364 + "passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다", 365 + "passkeyCreationCancelled": "패스키 생성이 취소되었습니다", 366 + "passkeyAddedSuccess": "패스키가 추가되었습니다", 367 + "passkeyDeleted": "패스키가 삭제되었습니다", 368 + "passkeyRenamed": "패스키 이름이 변경되었습니다" 369 + }, 370 + "comms": { 371 + "title": "통신 설정", 372 + "description": "비밀번호 재설정, 보안 알림, 계정 업데이트 등 중요한 메시지를 받는 방법을 선택하세요.", 373 + "preferredChannel": "선호 채널", 374 + "preferredChannelDescription": "메시지 수신 방법을 선택하세요. 선택하기 전에 채널을 설정해야 합니다.", 375 + "channelConfiguration": "채널 설정", 376 + "emailVia": "이메일로 메시지 받기", 377 + "discordVia": "Discord DM으로 메시지 받기", 378 + "telegramVia": "Telegram으로 메시지 받기", 379 + "signalVia": "Signal로 메시지 받기", 380 + "configureToEnable": "활성화하려면 아래에서 설정", 381 + "emailManagedInSettings": "이메일은 계정 설정에서 관리됩니다", 382 + "discordIdHint": "Discord 사용자 ID (사용자 이름 아님). Discord에서 개발자 모드를 활성화하여 복사하세요.", 383 + "telegramHint": "@ 기호 없이 Telegram 사용자 이름", 384 + "signalHint": "국가 코드가 포함된 Signal 전화번호", 385 + "primary": "기본", 386 + "verified": "인증됨", 387 + "notVerified": "미인증", 388 + "verifyButton": "인증", 389 + "verifyCodePlaceholder": "인증 코드 입력", 390 + "submit": "제출", 391 + "saving": "저장 중...", 392 + "savePreferences": "설정 저장", 393 + "preferencesSaved": "통신 설정이 저장되었습니다", 394 + "verifiedSuccess": "{channel} 인증 완료", 395 + "messageHistory": "메시지 기록", 396 + "historyDescription": "계정에 전송된 최근 메시지를 확인합니다.", 397 + "loadHistory": "기록 불러오기", 398 + "hideHistory": "기록 숨기기", 399 + "noMessages": "메시지가 없습니다.", 400 + "sent": "전송됨", 401 + "failed": "실패" 402 + }, 403 + "repoExplorer": { 404 + "title": "저장소 탐색기", 405 + "description": "AT Protocol 레코드를 탐색하고 관리합니다.", 406 + "collections": "컬렉션", 407 + "noCollections": "컬렉션을 찾을 수 없습니다", 408 + "records": "레코드", 409 + "noRecords": "이 컬렉션에 레코드가 없습니다", 410 + "recordDetails": "레코드 세부 정보", 411 + "rkey": "레코드 키", 412 + "cid": "CID", 413 + "value": "값", 414 + "deleteRecord": "레코드 삭제", 415 + "deleteConfirm": "레코드 {rkey}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 416 + "unknownError": "알 수 없는 오류가 발생했습니다", 417 + "invalidJson": "잘못된 JSON", 418 + "collectionRequired": "컬렉션은 필수입니다", 419 + "recordCreated": "레코드 생성됨: {uri}", 420 + "recordUpdated": "레코드가 업데이트되었습니다", 421 + "recordDeleted": "레코드가 삭제되었습니다", 422 + "newRecord": "새 레코드", 423 + "createRecord": "레코드 생성", 424 + "filterCollections": "컬렉션 검색...", 425 + "filterRecords": "레코드 검색...", 426 + "noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.", 427 + "loadMore": "더 불러오기", 428 + "recordJson": "레코드 JSON", 429 + "saving": "저장 중...", 430 + "updateRecord": "레코드 업데이트", 431 + "collectionNsid": "컬렉션 (NSID)", 432 + "recordKeyOptional": "레코드 키 (선택사항)", 433 + "autoGenerated": "비워두면 자동 생성 (TID)", 434 + "autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다", 435 + "creating": "생성 중...", 436 + "demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!", 437 + "demoDisplayName": "표시 이름", 438 + "demoBio": "간단한 자기소개를 작성하세요." 439 + }, 440 + "admin": { 441 + "title": "관리 패널", 442 + "serverStats": "서버 통계", 443 + "users": "사용자", 444 + "repos": "저장소", 445 + "records": "레코드", 446 + "blobStorage": "Blob 저장소", 447 + "refreshStats": "통계 새로고침", 448 + "userManagement": "사용자 관리", 449 + "searchPlaceholder": "핸들로 검색 (선택사항)", 450 + "searchUsers": "사용자 검색", 451 + "noUsers": "사용자를 찾을 수 없습니다", 452 + "handle": "핸들", 453 + "email": "이메일", 454 + "status": "상태", 455 + "created": "생성일", 456 + "loadMore": "더 불러오기", 457 + "inviteCodes": "초대 코드", 458 + "loadInviteCodes": "초대 코드 불러오기", 459 + "refresh": "새로고침", 460 + "noInvites": "초대 코드가 없습니다", 461 + "code": "코드", 462 + "available": "사용 가능", 463 + "uses": "사용 횟수", 464 + "actions": "작업", 465 + "disable": "비활성화", 466 + "disableInviteConfirm": "초대 코드 {code}을(를) 비활성화하시겠습니까?", 467 + "active": "활성", 468 + "exhausted": "소진됨", 469 + "disabled": "비활성화됨", 470 + "userDetails": "사용자 세부 정보", 471 + "did": "DID", 472 + "invites": "초대", 473 + "enabled": "활성화됨", 474 + "enableInvites": "초대 활성화", 475 + "disableInvites": "초대 비활성화", 476 + "deleteAccount": "계정 삭제", 477 + "deleteConfirm": "계정 @{handle}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 478 + "verified": "인증됨", 479 + "unverified": "미인증", 480 + "deactivated": "비활성화됨" 481 + }, 482 + "oauth": { 483 + "login": { 484 + "title": "로그인", 485 + "subtitle": "앱을 계속하려면 로그인하세요", 486 + "signingIn": "로그인 중...", 487 + "authenticating": "인증 중...", 488 + "checkingPasskey": "패스키 확인 중...", 489 + "signInWithPasskey": "패스키로 로그인", 490 + "passkeyNotSetUp": "패스키가 설정되지 않음", 491 + "orUsePassword": "또는 비밀번호 사용", 492 + "password": "비밀번호", 493 + "rememberDevice": "이 기기 기억하기", 494 + "passkeyHintChecking": "패스키 상태 확인 중...", 495 + "passkeyHintAvailable": "패스키로 로그인", 496 + "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다" 497 + }, 498 + "consent": { 499 + "title": "앱 승인", 500 + "appWantsAccess": "{app}이(가) 계정에 액세스하려고 합니다", 501 + "permissions": "이 앱은 다음을 수행할 수 있습니다:", 502 + "readProfile": "프로필 정보 읽기", 503 + "readPosts": "게시물 및 콘텐츠 읽기", 504 + "writePosts": "대신 게시물 작성 및 삭제", 505 + "readNotifications": "알림 읽기", 506 + "fullAccess": "계정에 대한 전체 액세스", 507 + "authorize": "승인", 508 + "deny": "거부", 509 + "authorizing": "승인 중...", 510 + "rememberChoice": "이 선택 기억", 511 + "signingInAs": "로그인 계정:", 512 + "permissionsRequested": "요청된 권한", 513 + "required": "필수", 514 + "rememberChoiceLabel": "이 앱에 대한 선택 기억하기" 515 + }, 516 + "accounts": { 517 + "title": "계정 선택", 518 + "subtitle": "계속할 계정 선택", 519 + "useAnother": "다른 계정 사용" 520 + }, 521 + "twoFactor": { 522 + "title": "2단계 인증", 523 + "subtitle": "추가 확인이 필요합니다", 524 + "usePasskey": "패스키 사용", 525 + "useTotp": "인증 앱 사용", 526 + "verifying": "확인 중..." 527 + }, 528 + "twoFactorCode": { 529 + "title": "2단계 인증", 530 + "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.", 531 + "codeLabel": "인증 코드", 532 + "codePlaceholder": "6자리 코드 입력", 533 + "verify": "확인", 534 + "verifying": "확인 중...", 535 + "errors": { 536 + "missingRequestUri": "request_uri 매개변수가 없습니다", 537 + "verificationFailed": "인증에 실패했습니다", 538 + "connectionFailed": "서버에 연결하지 못했습니다", 539 + "unexpectedResponse": "서버로부터 예기치 않은 응답" 540 + } 541 + }, 542 + "totp": { 543 + "title": "인증 코드 입력", 544 + "subtitle": "인증 앱의 6자리 코드를 입력하세요", 545 + "codePlaceholder": "6자리 코드 입력", 546 + "verify": "확인", 547 + "verifying": "확인 중...", 548 + "useBackupCode": "백업 코드 사용", 549 + "backupCodePlaceholder": "백업 코드 입력", 550 + "trustDevice": "이 기기를 30일간 신뢰", 551 + "hintBackupCode": "백업 코드 사용 중", 552 + "hintTotpCode": "인증 코드 사용 중", 553 + "hintDefault": "인증 앱은 6자리, 백업 코드는 8자" 554 + }, 555 + "passkey": { 556 + "title": "패스키 확인", 557 + "subtitle": "패스키를 사용하여 본인 확인", 558 + "waiting": "패스키 대기 중...", 559 + "useTotp": "인증 앱 사용" 560 + }, 561 + "error": { 562 + "title": "승인 오류", 563 + "genericError": "승인 중 오류가 발생했습니다.", 564 + "tryAgain": "다시 시도", 565 + "backToApp": "앱으로 돌아가기" 566 + } 567 + }, 568 + "verify": { 569 + "title": "계정 인증", 570 + "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.", 571 + "codePlaceholder": "6자리 코드 입력", 572 + "codeLabel": "인증 코드", 573 + "verifyButton": "계정 인증", 574 + "verifying": "인증 중...", 575 + "resendCode": "코드 다시 보내기", 576 + "resending": "전송 중...", 577 + "codeResent": "인증 코드를 다시 보냈습니다!", 578 + "backToLogin": "로그인으로 돌아가기", 579 + "verifyingAccount": "인증 중인 계정: @{handle}", 580 + "startOver": "다른 계정으로 다시 시작", 581 + "noPending": "보류 중인 인증이 없습니다.", 582 + "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 583 + "createAccount": "계정 만들기", 584 + "signIn": "로그인" 585 + }, 586 + "resetPassword": { 587 + "title": "비밀번호 재설정", 588 + "forgotTitle": "비밀번호를 잊으셨나요", 589 + "subtitle": "받은 코드를 입력하고 새 비밀번호를 선택하세요.", 590 + "forgotSubtitle": "핸들 또는 이메일을 입력하면 비밀번호 재설정 코드를 보내드립니다.", 591 + "handleOrEmail": "핸들 또는 이메일", 592 + "emailPlaceholder": "핸들 또는 you@example.com", 593 + "sendCode": "재설정 코드 보내기", 594 + "sending": "전송 중...", 595 + "codeSent": "비밀번호 재설정 코드를 보냈습니다! 선호하는 알림 채널을 확인하세요.", 596 + "enterCode": "이메일의 코드와 새 비밀번호를 입력하세요.", 597 + "code": "재설정 코드", 598 + "codePlaceholder": "재설정 코드 입력", 599 + "newPassword": "새 비밀번호", 600 + "newPasswordPlaceholder": "8자 이상", 601 + "confirmPassword": "비밀번호 확인", 602 + "confirmPasswordPlaceholder": "새 비밀번호 재입력", 603 + "resetButton": "비밀번호 재설정", 604 + "resetting": "재설정 중...", 605 + "success": "비밀번호가 재설정되었습니다!", 606 + "backToLogin": "로그인으로 돌아가기", 607 + "requestNewCode": "새 코드 요청", 608 + "passwordsMismatch": "비밀번호가 일치하지 않습니다", 609 + "passwordLength": "비밀번호는 8자 이상이어야 합니다" 610 + }, 611 + "recoverPasskey": { 612 + "title": "계정 복구", 613 + "invalidLinkTitle": "잘못된 복구 링크", 614 + "invalidLinkMessage": "이 복구 링크가 잘못되었거나 손상되었습니다. 새 복구 이메일을 요청하세요.", 615 + "goToLogin": "로그인으로 이동", 616 + "successTitle": "비밀번호가 설정되었습니다!", 617 + "successMessage": "임시 비밀번호가 설정되었습니다. 이 비밀번호로 로그인할 수 있습니다.", 618 + "successNextSteps": "로그인 후 보안 설정에서 새 패스키를 추가하여 패스키 전용 인증을 복원하는 것이 좋습니다.", 619 + "signIn": "로그인", 620 + "subtitle": "패스키 전용 계정에 대한 액세스를 복구하기 위해 임시 비밀번호를 설정합니다.", 621 + "newPassword": "새 비밀번호", 622 + "newPasswordPlaceholder": "8자 이상", 623 + "confirmPassword": "비밀번호 확인", 624 + "confirmPasswordPlaceholder": "비밀번호 재입력", 625 + "whatHappensNext": "다음 단계", 626 + "whatHappensNextDetail": "이 비밀번호를 설정한 후 로그인하여 보안 설정에서 새 패스키를 추가할 수 있습니다. 새 패스키를 추가한 후 임시 비밀번호를 제거할 수 있습니다.", 627 + "setPassword": "비밀번호 설정", 628 + "settingPassword": "비밀번호 설정 중...", 629 + "validation": { 630 + "passwordRequired": "새 비밀번호는 필수입니다", 631 + "passwordLength": "비밀번호는 8자 이상이어야 합니다", 632 + "passwordsMismatch": "비밀번호가 일치하지 않습니다" 633 + }, 634 + "errors": { 635 + "invalidLink": "잘못된 복구 링크입니다. 새 링크를 요청하세요.", 636 + "expired": "이 복구 링크가 만료되었습니다. 새 링크를 요청하세요." 637 + } 638 + }, 639 + "requestPasskeyRecovery": { 640 + "title": "패스키 계정 복구", 641 + "subtitle": "패스키에 액세스할 수 없나요? 핸들 또는 이메일을 입력하면 복구 링크를 보내드립니다.", 642 + "successTitle": "복구 링크 전송됨", 643 + "successMessage": "계정이 존재하고 패스키 전용 계정인 경우 선호하는 알림 채널로 복구 링크를 받게 됩니다.", 644 + "successInfo": "링크는 1시간 후 만료됩니다. 계정 설정에 따라 이메일, Discord, Telegram 또는 Signal을 확인하세요.", 645 + "handleOrEmail": "핸들 또는 이메일", 646 + "emailPlaceholder": "핸들 또는 you@example.com", 647 + "howItWorks": "작동 방식", 648 + "howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.", 649 + "sendRecoveryLink": "복구 링크 보내기", 650 + "sending": "전송 중...", 651 + "backToLogin": "로그인으로 돌아가기" 652 + }, 653 + "registerPasskey": { 654 + "title": "패스키 계정 만들기", 655 + "subtitle": "패스키를 사용하여 비밀번호 없는 계정을 만듭니다.", 656 + "handle": "핸들", 657 + "handlePlaceholder": "사용자 이름", 658 + "handleHint": "전체 핸들: @{handle}", 659 + "email": "이메일 주소", 660 + "emailPlaceholder": "you@example.com", 661 + "inviteCode": "초대 코드", 662 + "inviteCodePlaceholder": "초대 코드 입력", 663 + "createButton": "계정 만들기", 664 + "creating": "생성 중...", 665 + "alreadyHaveAccount": "이미 계정이 있으신가요?", 666 + "signIn": "로그인", 667 + "wantPassword": "비밀번호를 사용하시겠습니까?", 668 + "createPasswordAccount": "비밀번호 계정 만들기" 669 + }, 670 + "trustedDevices": { 671 + "title": "신뢰할 수 있는 기기", 672 + "backToSecurity": "← 보안 설정", 673 + "description": "신뢰할 수 있는 기기는 로그인 시 2단계 인증을 건너뛸 수 있습니다. 신뢰는 30일간 유효하며 기기를 사용할 때 자동으로 연장됩니다.", 674 + "noDevices": "신뢰할 수 있는 기기가 아직 없습니다.", 675 + "noDevicesHint": "2단계 인증이 활성화된 상태로 로그인할 때 기기를 30일간 신뢰하도록 선택할 수 있습니다.", 676 + "lastSeen": "마지막 접속:", 677 + "trustedSince": "신뢰 시작:", 678 + "trustExpires": "신뢰 만료:", 679 + "expired": "만료됨", 680 + "tomorrow": "내일", 681 + "inDays": "{days}일 후", 682 + "revoke": "신뢰 취소", 683 + "revokeConfirm": "이 기기에 대한 신뢰를 취소하시겠습니까? 다음에 이 기기에서 로그인할 때 2FA 코드를 입력해야 합니다.", 684 + "deviceRevoked": "기기 신뢰가 취소되었습니다", 685 + "deviceRenamed": "기기 이름이 변경되었습니다", 686 + "deviceNamePlaceholder": "기기 이름", 687 + "browser": "브라우저:", 688 + "unknownDevice": "알 수 없는 기기" 689 + }, 690 + "reauth": { 691 + "title": "재인증 필요", 692 + "subtitle": "계속하려면 본인 확인을 해주세요.", 693 + "usePassword": "비밀번호 사용", 694 + "usePasskey": "패스키 사용", 695 + "useTotp": "인증 앱 사용", 696 + "passwordPlaceholder": "비밀번호 입력", 697 + "totpPlaceholder": "6자리 코드 입력", 698 + "verify": "확인", 699 + "verifying": "확인 중...", 700 + "cancel": "취소" 701 + } 702 + }
+702
frontend/src/locales/zh.json
··· 1 + { 2 + "common": { 3 + "loading": "加载中...", 4 + "error": "错误", 5 + "save": "保存", 6 + "cancel": "取消", 7 + "back": "返回", 8 + "done": "完成", 9 + "refresh": "刷新", 10 + "create": "创建", 11 + "delete": "删除", 12 + "confirm": "确认", 13 + "created": "创建时间", 14 + "expires": "过期时间", 15 + "name": "名称", 16 + "dashboard": "控制台", 17 + "backToDashboard": "← 返回控制台" 18 + }, 19 + "login": { 20 + "title": "登录", 21 + "subtitle": "登录以管理您的 PDS 账户", 22 + "button": "登录", 23 + "redirecting": "跳转中...", 24 + "chooseAccount": "选择账户", 25 + "signInToAnother": "登录其他账户", 26 + "backToSaved": "← 返回已保存账户", 27 + "forgotPassword": "忘记密码?", 28 + "lostPasskey": "丢失通行密钥?", 29 + "noAccount": "还没有账户?", 30 + "createAccount": "立即注册", 31 + "removeAccount": "从已保存账户中移除" 32 + }, 33 + "verification": { 34 + "title": "验证账户", 35 + "subtitle": "您的账户需要验证。请输入发送到您验证方式的验证码。", 36 + "codeLabel": "验证码", 37 + "codePlaceholder": "输入6位验证码", 38 + "verifyButton": "验证账户", 39 + "verifying": "验证中...", 40 + "resendButton": "重新发送验证码", 41 + "resending": "发送中...", 42 + "resent": "验证码已重新发送!", 43 + "backToLogin": "返回登录" 44 + }, 45 + "register": { 46 + "title": "创建账户", 47 + "subtitle": "在此 PDS 上创建新账户", 48 + "handle": "用户名", 49 + "handlePlaceholder": "您的用户名", 50 + "handleHint": "您的完整用户名将是:@{handle}", 51 + "handleDotWarning": "自定义域名可以在创建账户后在设置中配置。", 52 + "password": "密码", 53 + "passwordPlaceholder": "至少8位字符", 54 + "confirmPassword": "确认密码", 55 + "confirmPasswordPlaceholder": "再次输入密码", 56 + "identityType": "身份类型", 57 + "identityHint": "选择如何管理您的去中心化身份。", 58 + "didPlc": "did:plc", 59 + "didPlcRecommended": "(推荐)", 60 + "didPlcHint": "由 PLC 目录管理的可迁移身份", 61 + "didWeb": "did:web", 62 + "didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)", 63 + "didWebBYOD": "did:web(自带域名)", 64 + "didWebBYODHint": "使用您自己的域名", 65 + "didWebWarningTitle": "重要提示:了解利弊", 66 + "didWebWarning1": "永久绑定此 PDS:", 67 + "didWebWarning1Detail": "您的身份将是 {did}。即使您以后迁移到另一个 PDS,此服务器也必须继续托管您的 DID 文档。", 68 + "didWebWarning2": "无法恢复:", 69 + "didWebWarning2Detail": "与 did:plc 不同,did:web 没有密钥轮换机制。如果此 PDS 永久下线,您的身份将无法恢复。", 70 + "didWebWarning3": "我们的承诺:", 71 + "didWebWarning3Detail": "如果您迁移到其他 PDS,我们将继续提供指向您新 PDS 的最小 DID 文档。您的身份将保持可用。", 72 + "didWebWarning4": "建议:", 73 + "didWebWarning4Detail": "除非您有特定原因需要 did:web,否则请选择 did:plc。", 74 + "externalDid": "您的 did:web", 75 + "externalDidPlaceholder": "did:web:yourdomain.com", 76 + "externalDidHint": "您的域名必须在 /.well-known/did.json 提供指向此 PDS 的有效 DID 文档", 77 + "contactMethod": "联系方式", 78 + "contactMethodHint": "选择您希望如何验证账户和接收通知。您只需选择一种。", 79 + "verificationMethod": "验证方式", 80 + "email": "电子邮件", 81 + "emailAddress": "电子邮件地址", 82 + "emailPlaceholder": "you@example.com", 83 + "discord": "Discord", 84 + "discordId": "Discord 用户 ID", 85 + "discordIdPlaceholder": "您的 Discord 用户 ID", 86 + "discordIdHint": "您的 Discord 数字用户 ID(开启开发者模式后可以复制)", 87 + "telegram": "Telegram", 88 + "telegramUsername": "Telegram 用户名", 89 + "telegramUsernamePlaceholder": "@yourusername", 90 + "signal": "Signal", 91 + "signalNumber": "Signal 电话号码", 92 + "signalNumberPlaceholder": "+1234567890", 93 + "signalNumberHint": "包含国家代码(例如中国为 +86)", 94 + "inviteCode": "邀请码", 95 + "inviteCodePlaceholder": "输入您的邀请码", 96 + "inviteCodeRequired": "必填", 97 + "createButton": "创建账户", 98 + "creating": "正在创建...", 99 + "alreadyHaveAccount": "已有账户?", 100 + "signIn": "立即登录", 101 + "wantPasswordless": "想要无密码登录?", 102 + "createPasskeyAccount": "创建通行密钥账户", 103 + "validation": { 104 + "handleRequired": "请输入用户名", 105 + "handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。", 106 + "passwordRequired": "请输入密码", 107 + "passwordLength": "密码至少需要8位字符", 108 + "passwordsMismatch": "两次输入的密码不一致", 109 + "inviteCodeRequired": "请输入邀请码", 110 + "externalDidRequired": "请输入您的 did:web", 111 + "externalDidFormat": "DID 必须以 did:web: 开头", 112 + "emailRequired": "使用邮箱验证需要填写邮箱地址", 113 + "discordIdRequired": "使用 Discord 验证需要填写 Discord ID", 114 + "telegramRequired": "使用 Telegram 验证需要填写用户名", 115 + "signalRequired": "使用 Signal 验证需要填写电话号码" 116 + } 117 + }, 118 + "dashboard": { 119 + "title": "控制台", 120 + "switchAccount": "切换账户", 121 + "addAnotherAccount": "添加其他账户", 122 + "signOut": "退出 @{handle}", 123 + "deactivatedTitle": "账户已停用", 124 + "deactivatedMessage": "您的账户目前已停用。这通常发生在账户迁移期间。在账户重新激活之前,部分功能可能受限。", 125 + "accountOverview": "账户概览", 126 + "handle": "用户名", 127 + "did": "DID", 128 + "primaryContact": "主要联系方式", 129 + "admin": "管理员", 130 + "deactivated": "已停用", 131 + "verified": "已验证", 132 + "unverified": "未验证", 133 + "navAppPasswords": "应用专用密码", 134 + "navAppPasswordsDesc": "管理第三方应用的专用密码", 135 + "navSessions": "登录会话", 136 + "navSessionsDesc": "查看和管理您的登录会话", 137 + "navInviteCodes": "邀请码", 138 + "navInviteCodesDesc": "查看和创建邀请码", 139 + "navSettings": "账户设置", 140 + "navSettingsDesc": "邮箱、密码、用户名等", 141 + "navSecurity": "安全设置", 142 + "navSecurityDesc": "双重身份验证", 143 + "navComms": "通讯偏好", 144 + "navCommsDesc": "Discord、Telegram、Signal 渠道设置", 145 + "navRepo": "数据浏览器", 146 + "navRepoDesc": "浏览和管理原始 AT Protocol 记录", 147 + "navAdmin": "管理后台", 148 + "navAdminDesc": "服务器统计和管理操作" 149 + }, 150 + "settings": { 151 + "title": "账户设置", 152 + "language": "语言", 153 + "languageDescription": "选择您的首选语言", 154 + "changeEmail": "更改邮箱", 155 + "currentEmail": "当前:{email}", 156 + "newEmail": "新邮箱", 157 + "newEmailPlaceholder": "new@example.com", 158 + "changeEmailButton": "更改邮箱", 159 + "requesting": "请求中...", 160 + "verificationCode": "验证码", 161 + "verificationCodePlaceholder": "输入邮件中的验证码", 162 + "confirmEmailChange": "确认更改邮箱", 163 + "updating": "更新中...", 164 + "changeHandle": "更改用户名", 165 + "currentHandle": "当前:@{handle}", 166 + "pdsHandle": "PDS 用户名", 167 + "customDomain": "自定义域名", 168 + "customDomainDescription": "使用您自己的域名作为用户名。需要先验证域名所有权。", 169 + "setupInstructions": "设置说明", 170 + "setupMethodsIntro": "选择以下验证方式之一:", 171 + "dnsMethod": "方式一:DNS TXT 记录(推荐)", 172 + "dnsMethodDesc": "在您的域名中添加此 TXT 记录:", 173 + "httpMethod": "方式二:HTTP Well-Known 文件", 174 + "httpMethodDesc": "在此 URL 提供您的 DID:", 175 + "httpMethodContent": "文件内容应为:", 176 + "yourDomain": "您的域名", 177 + "yourDomainPlaceholder": "example.com", 178 + "verifyAndUpdate": "验证并更新用户名", 179 + "verifying": "验证中...", 180 + "newHandle": "新用户名", 181 + "newHandlePlaceholder": "yourhandle", 182 + "changeHandleButton": "更改用户名", 183 + "changePassword": "更改密码", 184 + "currentPassword": "当前密码", 185 + "currentPasswordPlaceholder": "输入当前密码", 186 + "newPassword": "新密码", 187 + "newPasswordPlaceholder": "至少8位字符", 188 + "confirmNewPassword": "确认新密码", 189 + "confirmNewPasswordPlaceholder": "再次输入新密码", 190 + "changePasswordButton": "更改密码", 191 + "changing": "更改中...", 192 + "exportData": "导出数据", 193 + "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 194 + "downloadRepo": "下载数据", 195 + "exporting": "导出中...", 196 + "deleteAccount": "删除账户", 197 + "deleteWarning": "此操作不可逆。您的所有数据将被永久删除。", 198 + "requestDeletion": "请求删除账户", 199 + "confirmationCode": "确认码(来自邮件)", 200 + "confirmationCodePlaceholder": "输入确认码", 201 + "yourPassword": "您的密码", 202 + "yourPasswordPlaceholder": "输入您的密码", 203 + "permanentlyDelete": "永久删除账户", 204 + "deleting": "删除中...", 205 + "messages": { 206 + "emailCodeSent": "验证码已发送到您当前的邮箱", 207 + "emailUpdated": "邮箱更新成功", 208 + "handleUpdated": "用户名更新成功", 209 + "passwordChanged": "密码更改成功", 210 + "passwordsMismatch": "两次输入的密码不一致", 211 + "passwordLength": "密码至少需要8位字符", 212 + "deletionCodeSent": "删除确认码已发送到您的邮箱", 213 + "repoExported": "数据导出成功", 214 + "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 215 + } 216 + }, 217 + "appPasswords": { 218 + "title": "应用专用密码", 219 + "description": "应用专用密码可让您登录第三方应用而无需提供主密码。每个密码都可以单独撤销。", 220 + "createNew": "创建新密码", 221 + "appNamePlaceholder": "应用名称(如 Graysky、Skeets)", 222 + "created": "应用专用密码已创建", 223 + "createdMessage": "请立即复制此密码,您将无法再次查看。", 224 + "yourPasswords": "您的应用专用密码", 225 + "noPasswords": "暂无应用专用密码", 226 + "revoke": "撤销", 227 + "revoking": "撤销中...", 228 + "creating": "创建中...", 229 + "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。" 230 + }, 231 + "sessions": { 232 + "title": "登录会话", 233 + "loadingSessions": "加载会话中...", 234 + "noSessions": "没有活跃的登录会话", 235 + "current": "当前", 236 + "oauth": "OAuth", 237 + "session": "会话", 238 + "signOut": "退出", 239 + "revoke": "撤销", 240 + "revokeAll": "撤销所有其他会话", 241 + "revokeCurrentConfirm": "这将使您退出当前会话,确定继续?", 242 + "revokeConfirm": "确定撤销此会话?", 243 + "revokeAllConfirm": "这将撤销 {count} 个其他会话,确定继续?", 244 + "noOtherSessions": "没有其他可撤销的会话", 245 + "failedToLoad": "加载会话失败", 246 + "failedToRevoke": "撤销会话失败", 247 + "failedToRevokeAll": "撤销会话失败", 248 + "created": "创建时间:", 249 + "expires": "过期时间:", 250 + "daysAgo": "{count} 天前", 251 + "hoursAgo": "{count} 小时前", 252 + "minutesAgo": "{count} 分钟前", 253 + "justNow": "刚刚" 254 + }, 255 + "inviteCodes": { 256 + "title": "邀请码", 257 + "description": "邀请码可让您邀请朋友加入。每个邀请码只能使用一次。", 258 + "createNew": "创建新邀请码", 259 + "uses": "使用次数", 260 + "usesPlaceholder": "使用次数(1-100)", 261 + "yourCodes": "您的邀请码", 262 + "noCodes": "暂无邀请码", 263 + "available": "可用", 264 + "used": "已被 @{handle} 使用", 265 + "disabled": "已禁用", 266 + "usedBy": "使用者", 267 + "creating": "创建中...", 268 + "disableConfirm": "禁用此邀请码?它将无法再被使用。", 269 + "created": "邀请码已创建", 270 + "copy": "复制", 271 + "createdOn": "创建于 {date}" 272 + }, 273 + "security": { 274 + "title": "安全设置", 275 + "passkeys": "通行密钥", 276 + "passkeysDescription": "通行密钥使用您设备的安全功能(指纹、面容或 PIN)提供安全的无密码登录。", 277 + "addPasskey": "添加通行密钥", 278 + "adding": "添加中...", 279 + "noPasskeys": "未注册通行密钥", 280 + "passkeyName": "通行密钥名称", 281 + "passkeyNamePlaceholder": "如 MacBook Pro、iPhone", 282 + "register": "注册", 283 + "registering": "注册中...", 284 + "rename": "重命名", 285 + "renaming": "重命名中...", 286 + "deletePasskey": "删除", 287 + "deletePasskeyConfirm": "删除通行密钥「{name}」?您将无法再使用它登录。", 288 + "totp": "身份验证器(TOTP)", 289 + "totpDescription": "使用 Google Authenticator、Authy 或 1Password 等应用进行双重身份验证。", 290 + "totpEnabled": "已启用身份验证器", 291 + "totpDisabled": "未启用身份验证器", 292 + "enableTotp": "启用身份验证器", 293 + "disableTotp": "禁用身份验证器", 294 + "disabling": "禁用中...", 295 + "totpSetup": "设置身份验证器", 296 + "totpSetupInstructions": "使用身份验证器应用扫描此二维码,然后输入6位验证码完成验证。", 297 + "totpCode": "验证码", 298 + "totpCodePlaceholder": "输入6位验证码", 299 + "verifyAndEnable": "验证并启用", 300 + "backupCodes": "备用验证码", 301 + "backupCodesDescription": "如果无法使用身份验证器,可以使用这些备用码登录。每个验证码只能使用一次。", 302 + "regenerateBackupCodes": "重新生成备用码", 303 + "regenerating": "生成中...", 304 + "regenerateConfirm": "重新生成备用码?当前的验证码将失效。", 305 + "legacyLogin": "传统登录", 306 + "legacyLoginDescription": "允许使用用户名/密码直接登录(传统模式)。禁用后必须使用 OAuth + 双重验证。", 307 + "legacyLoginOn": "传统登录已启用", 308 + "legacyLoginOff": "传统登录已禁用", 309 + "legacyLoginWarning": "警告:启用传统登录会绕过双重身份验证。仅在需要兼容旧版应用时启用。", 310 + "totpPasswordWarning": "启用 TOTP 后,将无法从 Bluesky 应用(或其他旧版应用)更改密码。要更改密码,您有两个选择:", 311 + "totpPasswordOption1Label": "在这里更改:", 312 + "totpPasswordOption1Text": "使用本网站的", 313 + "totpPasswordOption1Link": "设置页面", 314 + "totpPasswordOption1Suffix": ",您可以使用身份验证器应用进行验证。", 315 + "totpPasswordOption2Label": "先验证您的会话:", 316 + "totpPasswordOption2Text": "使用", 317 + "totpPasswordOption2Link": "重新验证选项", 318 + "totpPasswordOption2Suffix": "用 TOTP 验证您的 Bluesky 会话,然后密码更改将暂时有效。", 319 + "legacyAppsTitle": "什么是旧版应用?", 320 + "legacyAppsDescription": "某些应用(如官方 Bluesky 应用)使用仅需密码的旧版身份验证。启用双重验证后,这些应用会绕过您的第二重验证。禁用传统登录会强制所有应用使用 OAuth,从而正确执行双重验证。", 321 + "password": "密码", 322 + "passwordStatus": "已设置密码", 323 + "noPassword": "未设置密码(仅通行密钥账户)", 324 + "setPassword": "设置密码", 325 + "removePassword": "移除密码", 326 + "removePasswordConfirm": "移除密码后需要使用通行密钥登录,确定继续?", 327 + "removing": "移除中...", 328 + "loading": "加载中...", 329 + "loadingPasskeys": "加载通行密钥中...", 330 + "cancel": "取消", 331 + "save": "保存", 332 + "back": "返回", 333 + "next": "下一步:验证代码", 334 + "copyToClipboard": "复制到剪贴板", 335 + "savedMyCodes": "我已保存备用码", 336 + "cantScan": "无法扫描?手动输入", 337 + "unnamedPasskey": "未命名的通行密钥", 338 + "added": "添加于", 339 + "lastUsed": "上次使用", 340 + "passwordDescription": "管理您的账户密码。如果您已设置通行密钥,可以选择移除密码以获得完全无密码的体验。", 341 + "disableTotpWarning": "这将降低您的账户安全性。", 342 + "removePasswordWarning": "这将使您的账户变为仅通行密钥模式。您只能使用已注册的通行密钥登录。如果您丢失了所有通行密钥,可以通过通知渠道恢复账户。", 343 + "beforeProceeding": "继续之前:", 344 + "beforeProceedingItem1": "确保您至少注册了一个可靠的通行密钥", 345 + "beforeProceedingItem2": "考虑在多个设备上注册通行密钥", 346 + "beforeProceedingItem3": "确保您的恢复通知渠道是最新的", 347 + "addPasskeyFirst": "请先添加至少一个通行密钥才能移除密码。", 348 + "passkeyOnlyHint": "您使用通行密钥登录。如果您丢失了通行密钥,可以使用登录页面上的「丢失通行密钥?」链接恢复账户。", 349 + "trustedDevices": "受信任设备", 350 + "trustedDevicesDescription": "管理可以跳过双重身份验证的设备。信任有效期为30天,使用设备时自动延长。", 351 + "manageTrustedDevices": "管理受信任设备", 352 + "appCompatibility": "应用兼容性", 353 + "enterPassword": "输入您的密码", 354 + "legacyLoginEnabled": "已启用传统应用登录", 355 + "legacyLoginDisabled": "已禁用传统应用登录 - 仅 OAuth 应用可登录", 356 + "failedToUpdatePreference": "更新偏好设置失败", 357 + "passwordRemoved": "密码已移除。您的账户现在仅支持通行密钥。", 358 + "failedToRemovePassword": "移除密码失败", 359 + "failedToLoadTotpStatus": "加载 TOTP 状态失败", 360 + "totpEnabledSuccess": "双重身份验证已成功启用", 361 + "totpDisabledSuccess": "双重身份验证已禁用", 362 + "backupCodesCopied": "备用码已复制到剪贴板", 363 + "failedToLoadPasskeys": "加载通行密钥失败", 364 + "passkeysNotSupported": "此浏览器不支持通行密钥", 365 + "passkeyCreationCancelled": "通行密钥创建已取消", 366 + "passkeyAddedSuccess": "通行密钥添加成功", 367 + "passkeyDeleted": "通行密钥已删除", 368 + "passkeyRenamed": "通行密钥已重命名" 369 + }, 370 + "comms": { 371 + "title": "通讯偏好", 372 + "description": "选择您希望如何接收重要消息,如密码重置、安全提醒和账户更新。", 373 + "preferredChannel": "首选渠道", 374 + "preferredChannelDescription": "选择您首选的消息接收方式。必须先配置好渠道才能选择。", 375 + "channelConfiguration": "渠道配置", 376 + "emailVia": "通过邮件接收消息", 377 + "discordVia": "通过 Discord 私信接收消息", 378 + "telegramVia": "通过 Telegram 接收消息", 379 + "signalVia": "通过 Signal 接收消息", 380 + "configureToEnable": "请先在下方配置", 381 + "emailManagedInSettings": "邮箱在账户设置中管理", 382 + "discordIdHint": "您的 Discord 数字用户 ID(非用户名)。在 Discord 中开启开发者模式即可复制。", 383 + "telegramHint": "您的 Telegram 用户名,不含 @ 符号", 384 + "signalHint": "您的 Signal 电话号码,需包含国家代码", 385 + "primary": "主要", 386 + "verified": "已验证", 387 + "notVerified": "未验证", 388 + "verifyButton": "验证", 389 + "verifyCodePlaceholder": "输入验证码", 390 + "submit": "提交", 391 + "saving": "保存中...", 392 + "savePreferences": "保存偏好设置", 393 + "preferencesSaved": "通讯偏好已保存", 394 + "verifiedSuccess": "{channel} 验证成功", 395 + "messageHistory": "消息历史", 396 + "historyDescription": "查看发送到您账户的最近消息。", 397 + "loadHistory": "加载历史", 398 + "hideHistory": "隐藏历史", 399 + "noMessages": "暂无消息记录", 400 + "sent": "已发送", 401 + "failed": "发送失败" 402 + }, 403 + "repoExplorer": { 404 + "title": "数据浏览器", 405 + "description": "浏览和管理您的原始 AT Protocol 记录。", 406 + "collections": "集合", 407 + "noCollections": "暂无集合", 408 + "records": "记录", 409 + "noRecords": "此集合中暂无记录", 410 + "recordDetails": "记录详情", 411 + "rkey": "记录键", 412 + "cid": "CID", 413 + "value": "值", 414 + "deleteRecord": "删除记录", 415 + "deleteConfirm": "删除记录 {rkey}?此操作无法撤销。", 416 + "unknownError": "发生未知错误", 417 + "invalidJson": "无效的 JSON", 418 + "collectionRequired": "集合是必填项", 419 + "recordCreated": "记录已创建:{uri}", 420 + "recordUpdated": "记录已更新", 421 + "recordDeleted": "记录已删除", 422 + "newRecord": "新建记录", 423 + "createRecord": "创建记录", 424 + "filterCollections": "筛选集合...", 425 + "filterRecords": "筛选记录...", 426 + "noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。", 427 + "loadMore": "加载更多", 428 + "recordJson": "记录 JSON", 429 + "saving": "保存中...", 430 + "updateRecord": "更新记录", 431 + "collectionNsid": "集合 (NSID)", 432 + "recordKeyOptional": "记录键(可选)", 433 + "autoGenerated": "留空自动生成 (TID)", 434 + "autoGeneratedHint": "留空将自动生成基于 TID 的键", 435 + "creating": "创建中...", 436 + "demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。", 437 + "demoDisplayName": "你的显示名称", 438 + "demoBio": "写一段简短的自我介绍。" 439 + }, 440 + "admin": { 441 + "title": "管理后台", 442 + "serverStats": "服务器统计", 443 + "users": "用户", 444 + "repos": "仓库", 445 + "records": "记录", 446 + "blobStorage": "文件存储", 447 + "refreshStats": "刷新统计", 448 + "userManagement": "用户管理", 449 + "searchPlaceholder": "按用户名搜索(可选)", 450 + "searchUsers": "搜索用户", 451 + "noUsers": "未找到用户", 452 + "handle": "用户名", 453 + "email": "邮箱", 454 + "status": "状态", 455 + "created": "创建时间", 456 + "loadMore": "加载更多", 457 + "inviteCodes": "邀请码", 458 + "loadInviteCodes": "加载邀请码", 459 + "refresh": "刷新", 460 + "noInvites": "暂无邀请码", 461 + "code": "邀请码", 462 + "available": "可用", 463 + "uses": "使用次数", 464 + "actions": "操作", 465 + "disable": "禁用", 466 + "disableInviteConfirm": "禁用邀请码 {code}?", 467 + "active": "活跃", 468 + "exhausted": "已用完", 469 + "disabled": "已禁用", 470 + "userDetails": "用户详情", 471 + "did": "DID", 472 + "invites": "邀请", 473 + "enabled": "已启用", 474 + "enableInvites": "启用邀请", 475 + "disableInvites": "禁用邀请", 476 + "deleteAccount": "删除账户", 477 + "deleteConfirm": "删除账户 @{handle}?此操作无法撤销。", 478 + "verified": "已验证", 479 + "unverified": "未验证", 480 + "deactivated": "已停用" 481 + }, 482 + "oauth": { 483 + "login": { 484 + "title": "登录", 485 + "subtitle": "登录以继续使用应用", 486 + "signingIn": "登录中...", 487 + "authenticating": "验证中...", 488 + "checkingPasskey": "检查通行密钥...", 489 + "signInWithPasskey": "使用通行密钥登录", 490 + "passkeyNotSetUp": "未设置通行密钥", 491 + "orUsePassword": "或使用密码", 492 + "password": "密码", 493 + "rememberDevice": "记住此设备", 494 + "passkeyHintChecking": "正在检查通行密钥状态...", 495 + "passkeyHintAvailable": "使用您的通行密钥登录", 496 + "passkeyHintNotAvailable": "此账户未注册通行密钥" 497 + }, 498 + "consent": { 499 + "title": "授权应用", 500 + "appWantsAccess": "{app} 想要访问您的账户", 501 + "permissions": "此应用将能够:", 502 + "readProfile": "读取您的个人资料", 503 + "readPosts": "读取您的帖子和内容", 504 + "writePosts": "代表您发布和删除帖子", 505 + "readNotifications": "读取您的通知", 506 + "fullAccess": "完全访问您的账户", 507 + "authorize": "授权", 508 + "deny": "拒绝", 509 + "authorizing": "授权中...", 510 + "rememberChoice": "记住此选择", 511 + "signingInAs": "登录账户:", 512 + "permissionsRequested": "请求的权限", 513 + "required": "必需", 514 + "rememberChoiceLabel": "记住对此应用的授权选择" 515 + }, 516 + "accounts": { 517 + "title": "选择账户", 518 + "subtitle": "选择一个账户继续", 519 + "useAnother": "使用其他账户" 520 + }, 521 + "twoFactor": { 522 + "title": "双重身份验证", 523 + "subtitle": "需要额外验证", 524 + "usePasskey": "使用通行密钥", 525 + "useTotp": "使用身份验证器", 526 + "verifying": "验证中..." 527 + }, 528 + "twoFactorCode": { 529 + "title": "双重身份验证", 530 + "subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。", 531 + "codeLabel": "验证码", 532 + "codePlaceholder": "输入6位验证码", 533 + "verify": "验证", 534 + "verifying": "验证中...", 535 + "errors": { 536 + "missingRequestUri": "缺少 request_uri 参数", 537 + "verificationFailed": "验证失败", 538 + "connectionFailed": "无法连接到服务器", 539 + "unexpectedResponse": "服务器返回意外响应" 540 + } 541 + }, 542 + "totp": { 543 + "title": "输入验证码", 544 + "subtitle": "请输入身份验证器应用中的6位验证码", 545 + "codePlaceholder": "输入6位验证码", 546 + "verify": "验证", 547 + "verifying": "验证中...", 548 + "useBackupCode": "使用备用验证码", 549 + "backupCodePlaceholder": "输入备用验证码", 550 + "trustDevice": "信任此设备30天", 551 + "hintBackupCode": "正在使用备用验证码", 552 + "hintTotpCode": "正在使用身份验证器验证码", 553 + "hintDefault": "身份验证器为6位数字,备用码为8位字符" 554 + }, 555 + "passkey": { 556 + "title": "通行密钥验证", 557 + "subtitle": "使用您的通行密钥验证身份", 558 + "waiting": "等待通行密钥...", 559 + "useTotp": "改用身份验证器" 560 + }, 561 + "error": { 562 + "title": "授权错误", 563 + "genericError": "授权过程中发生错误。", 564 + "tryAgain": "重试", 565 + "backToApp": "返回应用" 566 + } 567 + }, 568 + "verify": { 569 + "title": "验证账户", 570 + "subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。", 571 + "codePlaceholder": "输入6位验证码", 572 + "codeLabel": "验证码", 573 + "verifyButton": "验证账户", 574 + "verifying": "验证中...", 575 + "resendCode": "重新发送验证码", 576 + "resending": "发送中...", 577 + "codeResent": "验证码已重新发送!", 578 + "backToLogin": "返回登录", 579 + "verifyingAccount": "正在验证账户:@{handle}", 580 + "startOver": "使用其他账户重新开始", 581 + "noPending": "未找到待验证的账户", 582 + "noPendingInfo": "如果您最近创建了账户需要验证,可能需要重新创建账户。如果您已完成验证,可以直接登录。", 583 + "createAccount": "创建账户", 584 + "signIn": "登录" 585 + }, 586 + "resetPassword": { 587 + "title": "重置密码", 588 + "forgotTitle": "忘记密码", 589 + "subtitle": "输入您收到的验证码和新密码。", 590 + "forgotSubtitle": "输入您的用户名或邮箱,我们将发送重置密码的验证码。", 591 + "handleOrEmail": "用户名或邮箱", 592 + "emailPlaceholder": "用户名或 you@example.com", 593 + "sendCode": "发送重置验证码", 594 + "sending": "发送中...", 595 + "codeSent": "重置验证码已发送!请检查您的首选通知渠道。", 596 + "enterCode": "输入邮件中的验证码和新密码。", 597 + "code": "重置验证码", 598 + "codePlaceholder": "输入重置验证码", 599 + "newPassword": "新密码", 600 + "newPasswordPlaceholder": "至少8位字符", 601 + "confirmPassword": "确认密码", 602 + "confirmPasswordPlaceholder": "再次输入新密码", 603 + "resetButton": "重置密码", 604 + "resetting": "重置中...", 605 + "success": "密码重置成功!", 606 + "backToLogin": "返回登录", 607 + "requestNewCode": "重新获取验证码", 608 + "passwordsMismatch": "两次输入的密码不一致", 609 + "passwordLength": "密码至少需要8位字符" 610 + }, 611 + "recoverPasskey": { 612 + "title": "恢复账户", 613 + "invalidLinkTitle": "无效的恢复链接", 614 + "invalidLinkMessage": "此恢复链接无效或已损坏。请重新申请恢复邮件。", 615 + "goToLogin": "前往登录", 616 + "successTitle": "密码设置成功!", 617 + "successMessage": "您的临时密码已设置成功。您现在可以使用此密码登录。", 618 + "successNextSteps": "登录后,建议您在安全设置中添加新的通行密钥以恢复无密码登录。", 619 + "signIn": "登录", 620 + "subtitle": "设置临时密码以恢复您的通行密钥账户访问权限。", 621 + "newPassword": "新密码", 622 + "newPasswordPlaceholder": "至少8位字符", 623 + "confirmPassword": "确认密码", 624 + "confirmPasswordPlaceholder": "再次输入密码", 625 + "whatHappensNext": "接下来会发生什么?", 626 + "whatHappensNextDetail": "设置密码后,您可以登录并在安全设置中添加新的通行密钥。添加通行密钥后,您可以选择移除临时密码。", 627 + "setPassword": "设置密码", 628 + "settingPassword": "设置中...", 629 + "validation": { 630 + "passwordRequired": "请输入新密码", 631 + "passwordLength": "密码至少需要8位字符", 632 + "passwordsMismatch": "两次输入的密码不一致" 633 + }, 634 + "errors": { 635 + "invalidLink": "恢复链接无效,请重新申请。", 636 + "expired": "恢复链接已过期,请重新申请。" 637 + } 638 + }, 639 + "requestPasskeyRecovery": { 640 + "title": "恢复通行密钥账户", 641 + "subtitle": "丢失了通行密钥?输入您的用户名或邮箱,我们将发送恢复链接。", 642 + "successTitle": "恢复链接已发送", 643 + "successMessage": "如果账户存在且为通行密钥账户,您将在首选通知渠道收到恢复链接。", 644 + "successInfo": "链接将在1小时后过期。请根据您的账户设置检查邮箱、Discord、Telegram 或 Signal。", 645 + "handleOrEmail": "用户名或邮箱", 646 + "emailPlaceholder": "用户名或 you@example.com", 647 + "howItWorks": "如何恢复", 648 + "howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。", 649 + "sendRecoveryLink": "发送恢复链接", 650 + "sending": "发送中...", 651 + "backToLogin": "返回登录" 652 + }, 653 + "registerPasskey": { 654 + "title": "创建通行密钥账户", 655 + "subtitle": "使用通行密钥创建无密码账户。", 656 + "handle": "用户名", 657 + "handlePlaceholder": "您的用户名", 658 + "handleHint": "您的完整用户名将是:@{handle}", 659 + "email": "邮箱地址", 660 + "emailPlaceholder": "you@example.com", 661 + "inviteCode": "邀请码", 662 + "inviteCodePlaceholder": "输入您的邀请码", 663 + "createButton": "创建账户", 664 + "creating": "创建中...", 665 + "alreadyHaveAccount": "已有账户?", 666 + "signIn": "立即登录", 667 + "wantPassword": "想使用密码?", 668 + "createPasswordAccount": "创建密码账户" 669 + }, 670 + "trustedDevices": { 671 + "title": "受信任设备", 672 + "backToSecurity": "← 安全设置", 673 + "description": "受信任设备可以跳过双重身份验证。信任有效期为30天,使用设备时自动延长。", 674 + "noDevices": "暂无受信任设备", 675 + "noDevicesHint": "开启双重身份验证后登录时,可以选择信任设备30天。", 676 + "lastSeen": "最后使用:", 677 + "trustedSince": "信任时间:", 678 + "trustExpires": "信任过期:", 679 + "expired": "已过期", 680 + "tomorrow": "明天", 681 + "inDays": "{days}天后", 682 + "revoke": "撤销信任", 683 + "revokeConfirm": "确定撤销对此设备的信任?下次从此设备登录时需要输入双重验证码。", 684 + "deviceRevoked": "设备信任已撤销", 685 + "deviceRenamed": "设备已重命名", 686 + "deviceNamePlaceholder": "设备名称", 687 + "browser": "浏览器:", 688 + "unknownDevice": "未知设备" 689 + }, 690 + "reauth": { 691 + "title": "需要重新验证", 692 + "subtitle": "请验证您的身份以继续。", 693 + "usePassword": "使用密码", 694 + "usePasskey": "使用通行密钥", 695 + "useTotp": "使用身份验证器", 696 + "passwordPlaceholder": "输入您的密码", 697 + "totpPlaceholder": "输入6位验证码", 698 + "verify": "验证", 699 + "verifying": "验证中...", 700 + "cancel": "取消" 701 + } 702 + }
+1
frontend/src/main.ts
··· 1 + import './styles/base.css' 1 2 import App from './App.svelte' 2 3 import { mount } from 'svelte' 3 4
+145 -77
frontend/src/routes/Admin.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import { _ } from '../lib/i18n' 6 + import { formatDate, formatDateTime } from '../lib/date' 5 7 const auth = getAuthState() 6 8 let loading = $state(true) 7 9 let error = $state<string | null>(null) ··· 123 125 } 124 126 async function disableInvite(code: string) { 125 127 if (!auth.session) return 126 - if (!confirm(`Disable invite code ${code}?`)) return 128 + if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return 127 129 try { 128 130 await api.disableInviteCodes(auth.session.accessJwt, [code]) 129 131 invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv) ··· 164 166 } 165 167 async function deleteUser() { 166 168 if (!auth.session || !selectedUser) return 167 - if (!confirm(`Delete account @${selectedUser.handle}? This cannot be undone.`)) return 169 + if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return 168 170 userActionLoading = true 169 171 try { 170 172 await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did) ··· 267 269 <span class="badge unverified">Unverified</span> 268 270 {/if} 269 271 </td> 270 - <td class="date">{new Date(user.indexedAt).toLocaleDateString()}</td> 272 + <td class="date">{formatDate(user.indexedAt)}</td> 271 273 </tr> 272 274 {/each} 273 275 </tbody> ··· 322 324 <span class="badge verified">Active</span> 323 325 {/if} 324 326 </td> 325 - <td class="date">{new Date(invite.createdAt).toLocaleDateString()}</td> 327 + <td class="date">{formatDate(invite.createdAt)}</td> 326 328 <td> 327 329 {#if !invite.disabled} 328 330 <button class="action-btn danger" onclick={() => disableInvite(invite.code)}> ··· 376 378 {/if} 377 379 </dd> 378 380 <dt>Created</dt> 379 - <dd>{new Date(selectedUser.indexedAt).toLocaleString()}</dd> 381 + <dd>{formatDateTime(selectedUser.indexedAt)}</dd> 380 382 <dt>Invites</dt> 381 383 <dd> 382 384 {#if selectedUser.invitesDisabled} ··· 412 414 {/if} 413 415 <style> 414 416 .page { 415 - max-width: 800px; 417 + max-width: var(--width-lg); 416 418 margin: 0 auto; 417 - padding: 2rem; 419 + padding: var(--space-7); 418 420 } 421 + 419 422 header { 420 - margin-bottom: 2rem; 423 + margin-bottom: var(--space-7); 421 424 } 425 + 422 426 .back { 423 427 color: var(--text-secondary); 424 428 text-decoration: none; 425 - font-size: 0.875rem; 429 + font-size: var(--text-sm); 426 430 } 431 + 427 432 .back:hover { 428 433 color: var(--accent); 429 434 } 435 + 430 436 h1 { 431 - margin: 0.5rem 0 0 0; 437 + margin: var(--space-2) 0 0 0; 432 438 } 439 + 433 440 .loading { 434 441 text-align: center; 435 442 color: var(--text-secondary); 436 - padding: 2rem; 443 + padding: var(--space-7); 437 444 } 445 + 438 446 .message { 439 - padding: 0.75rem; 440 - border-radius: 4px; 441 - margin-bottom: 1rem; 447 + padding: var(--space-3); 448 + border-radius: var(--radius-md); 449 + margin-bottom: var(--space-4); 442 450 } 451 + 443 452 .message.error { 444 453 background: var(--error-bg); 445 454 border: 1px solid var(--error-border); 446 455 color: var(--error-text); 447 456 } 457 + 448 458 section { 449 459 background: var(--bg-secondary); 450 - padding: 1.5rem; 451 - border-radius: 8px; 452 - margin-bottom: 1.5rem; 460 + padding: var(--space-6); 461 + border-radius: var(--radius-xl); 462 + margin-bottom: var(--space-6); 453 463 } 464 + 454 465 section h2 { 455 - margin: 0 0 1rem 0; 456 - font-size: 1.25rem; 466 + margin: 0 0 var(--space-4) 0; 467 + font-size: var(--text-lg); 457 468 } 469 + 458 470 .stats-grid { 459 471 display: grid; 460 472 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 461 - gap: 1rem; 462 - margin-bottom: 1rem; 473 + gap: var(--space-4); 474 + margin-bottom: var(--space-4); 463 475 } 476 + 464 477 .stat-card { 465 478 background: var(--bg-card); 466 479 border: 1px solid var(--border-color); 467 - border-radius: 8px; 468 - padding: 1rem; 480 + border-radius: var(--radius-xl); 481 + padding: var(--space-4); 469 482 text-align: center; 470 483 } 484 + 471 485 .stat-value { 472 - font-size: 1.5rem; 473 - font-weight: 600; 486 + font-size: var(--text-xl); 487 + font-weight: var(--font-semibold); 474 488 color: var(--accent); 475 489 } 490 + 476 491 .stat-label { 477 - font-size: 0.875rem; 492 + font-size: var(--text-sm); 478 493 color: var(--text-secondary); 479 - margin-top: 0.25rem; 494 + margin-top: var(--space-1); 480 495 } 496 + 481 497 .refresh-btn { 482 - padding: 0.5rem 1rem; 498 + padding: var(--space-2) var(--space-4); 483 499 background: transparent; 484 500 border: 1px solid var(--border-color); 485 - border-radius: 4px; 501 + border-radius: var(--radius-md); 486 502 cursor: pointer; 487 503 color: var(--text-primary); 488 504 } 505 + 489 506 .refresh-btn:hover { 490 507 background: var(--bg-card); 491 508 border-color: var(--accent); 492 509 } 510 + 493 511 .search-form { 494 512 display: flex; 495 - gap: 0.5rem; 496 - margin-bottom: 1rem; 513 + gap: var(--space-2); 514 + margin-bottom: var(--space-4); 497 515 } 516 + 498 517 .search-form input { 499 518 flex: 1; 500 - padding: 0.5rem 0.75rem; 519 + padding: var(--space-2) var(--space-3); 501 520 border: 1px solid var(--border-color); 502 - border-radius: 4px; 503 - font-size: 0.875rem; 521 + border-radius: var(--radius-md); 522 + font-size: var(--text-sm); 504 523 background: var(--bg-input); 505 524 color: var(--text-primary); 506 525 } 526 + 507 527 .search-form input:focus { 508 528 outline: none; 509 529 border-color: var(--accent); 510 530 } 531 + 511 532 .search-form button { 512 - padding: 0.5rem 1rem; 533 + padding: var(--space-2) var(--space-4); 513 534 background: var(--accent); 514 - color: white; 535 + color: var(--text-inverse); 515 536 border: none; 516 - border-radius: 4px; 537 + border-radius: var(--radius-md); 517 538 cursor: pointer; 518 - font-size: 0.875rem; 539 + font-size: var(--text-sm); 519 540 } 541 + 520 542 .search-form button:hover:not(:disabled) { 521 543 background: var(--accent-hover); 522 544 } 545 + 523 546 .search-form button:disabled { 524 547 opacity: 0.6; 525 548 cursor: not-allowed; 526 549 } 550 + 527 551 .user-list { 528 - margin-top: 1rem; 552 + margin-top: var(--space-4); 529 553 } 554 + 530 555 .no-results { 531 556 color: var(--text-secondary); 532 557 text-align: center; 533 - padding: 1rem; 558 + padding: var(--space-4); 534 559 } 560 + 535 561 table { 536 562 width: 100%; 537 563 border-collapse: collapse; 538 - font-size: 0.875rem; 564 + font-size: var(--text-sm); 539 565 } 566 + 540 567 th, td { 541 - padding: 0.75rem 0.5rem; 568 + padding: var(--space-3) var(--space-2); 542 569 text-align: left; 543 570 border-bottom: 1px solid var(--border-color); 544 571 } 572 + 545 573 th { 546 - font-weight: 600; 574 + font-weight: var(--font-semibold); 547 575 color: var(--text-secondary); 548 - font-size: 0.75rem; 576 + font-size: var(--text-xs); 549 577 text-transform: uppercase; 550 578 letter-spacing: 0.05em; 551 579 } 580 + 552 581 .handle { 553 - font-weight: 500; 582 + font-weight: var(--font-medium); 554 583 } 584 + 555 585 .email { 556 586 color: var(--text-secondary); 557 587 } 588 + 558 589 .date { 559 590 color: var(--text-secondary); 560 - font-size: 0.75rem; 591 + font-size: var(--text-xs); 561 592 } 593 + 562 594 .badge { 563 595 display: inline-block; 564 - padding: 0.125rem 0.5rem; 565 - border-radius: 4px; 566 - font-size: 0.75rem; 596 + padding: 2px var(--space-2); 597 + border-radius: var(--radius-md); 598 + font-size: var(--text-xs); 567 599 } 600 + 568 601 .badge.verified { 569 602 background: var(--success-bg); 570 603 color: var(--success-text); 571 604 } 605 + 572 606 .badge.unverified { 573 607 background: var(--warning-bg); 574 608 color: var(--warning-text); 575 609 } 610 + 576 611 .badge.deactivated { 577 612 background: var(--error-bg); 578 613 color: var(--error-text); 579 614 } 615 + 580 616 .load-more { 581 617 display: block; 582 618 width: 100%; 583 - padding: 0.75rem; 584 - margin-top: 1rem; 619 + padding: var(--space-3); 620 + margin-top: var(--space-4); 585 621 background: transparent; 586 622 border: 1px solid var(--border-color); 587 - border-radius: 4px; 623 + border-radius: var(--radius-md); 588 624 cursor: pointer; 589 625 color: var(--text-primary); 590 - font-size: 0.875rem; 626 + font-size: var(--text-sm); 591 627 } 628 + 592 629 .load-more:hover:not(:disabled) { 593 630 background: var(--bg-card); 594 631 border-color: var(--accent); 595 632 } 633 + 596 634 .load-more:disabled { 597 635 opacity: 0.6; 598 636 cursor: not-allowed; 599 637 } 638 + 600 639 .section-actions { 601 - margin-bottom: 1rem; 640 + margin-bottom: var(--space-4); 602 641 } 642 + 603 643 .section-actions button { 604 - padding: 0.5rem 1rem; 644 + padding: var(--space-2) var(--space-4); 605 645 background: var(--accent); 606 - color: white; 646 + color: var(--text-inverse); 607 647 border: none; 608 - border-radius: 4px; 648 + border-radius: var(--radius-md); 609 649 cursor: pointer; 610 - font-size: 0.875rem; 650 + font-size: var(--text-sm); 611 651 } 652 + 612 653 .section-actions button:hover:not(:disabled) { 613 654 background: var(--accent-hover); 614 655 } 656 + 615 657 .section-actions button:disabled { 616 658 opacity: 0.6; 617 659 cursor: not-allowed; 618 660 } 661 + 619 662 .invite-list { 620 - margin-top: 1rem; 663 + margin-top: var(--space-4); 621 664 } 665 + 622 666 .code { 623 667 font-family: monospace; 624 - font-size: 0.75rem; 668 + font-size: var(--text-xs); 625 669 } 670 + 626 671 .disabled-row { 627 672 opacity: 0.5; 628 673 } 674 + 629 675 .action-btn { 630 - padding: 0.25rem 0.5rem; 631 - font-size: 0.75rem; 676 + padding: var(--space-1) var(--space-2); 677 + font-size: var(--text-xs); 632 678 border: none; 633 - border-radius: 4px; 679 + border-radius: var(--radius-md); 634 680 cursor: pointer; 635 681 } 682 + 636 683 .action-btn.danger { 637 684 background: var(--error-text); 638 - color: white; 685 + color: var(--text-inverse); 639 686 } 687 + 640 688 .action-btn.danger:hover { 641 689 background: #900; 642 690 } 691 + 643 692 .muted { 644 693 color: var(--text-muted); 645 694 } 695 + 646 696 .clickable { 647 697 cursor: pointer; 648 698 } 699 + 649 700 .clickable:hover { 650 701 background: var(--bg-card); 651 702 } 703 + 652 704 .modal-overlay { 653 705 position: fixed; 654 706 top: 0; ··· 661 713 justify-content: center; 662 714 z-index: 1000; 663 715 } 716 + 664 717 .modal { 665 718 background: var(--bg-card); 666 - border-radius: 8px; 719 + border-radius: var(--radius-xl); 667 720 max-width: 500px; 668 721 width: 90%; 669 722 max-height: 90vh; 670 723 overflow-y: auto; 671 724 } 725 + 672 726 .modal-header { 673 727 display: flex; 674 728 justify-content: space-between; 675 729 align-items: center; 676 - padding: 1rem 1.5rem; 730 + padding: var(--space-4) var(--space-6); 677 731 border-bottom: 1px solid var(--border-color); 678 732 } 733 + 679 734 .modal-header h2 { 680 735 margin: 0; 681 - font-size: 1.25rem; 736 + font-size: var(--text-lg); 682 737 } 738 + 683 739 .close-btn { 684 740 background: none; 685 741 border: none; 686 - font-size: 1.5rem; 742 + font-size: var(--text-xl); 687 743 cursor: pointer; 688 744 color: var(--text-secondary); 689 745 padding: 0; 690 746 line-height: 1; 691 747 } 748 + 692 749 .close-btn:hover { 693 750 color: var(--text-primary); 694 751 } 752 + 695 753 .modal-body { 696 - padding: 1.5rem; 754 + padding: var(--space-6); 697 755 } 756 + 698 757 .user-details { 699 758 display: grid; 700 759 grid-template-columns: auto 1fr; 701 - gap: 0.5rem 1rem; 702 - margin: 0 0 1.5rem 0; 760 + gap: var(--space-2) var(--space-4); 761 + margin: 0 0 var(--space-6) 0; 703 762 } 763 + 704 764 .user-details dt { 705 - font-weight: 500; 765 + font-weight: var(--font-medium); 706 766 color: var(--text-secondary); 707 767 } 768 + 708 769 .user-details dd { 709 770 margin: 0; 710 771 } 772 + 711 773 .mono { 712 774 font-family: monospace; 713 - font-size: 0.75rem; 775 + font-size: var(--text-xs); 714 776 word-break: break-all; 715 777 } 778 + 716 779 .modal-actions { 717 780 display: flex; 718 - gap: 0.5rem; 781 + gap: var(--space-2); 719 782 flex-wrap: wrap; 720 783 } 784 + 721 785 .modal-actions .action-btn { 722 - padding: 0.5rem 1rem; 786 + padding: var(--space-2) var(--space-4); 723 787 border: 1px solid var(--border-color); 724 - border-radius: 4px; 788 + border-radius: var(--radius-md); 725 789 background: transparent; 726 790 cursor: pointer; 727 - font-size: 0.875rem; 791 + font-size: var(--text-sm); 728 792 color: var(--text-primary); 729 793 } 794 + 730 795 .modal-actions .action-btn:hover:not(:disabled) { 731 796 background: var(--bg-secondary); 732 797 } 798 + 733 799 .modal-actions .action-btn:disabled { 734 800 opacity: 0.6; 735 801 cursor: not-allowed; 736 802 } 803 + 737 804 .modal-actions .action-btn.danger { 738 805 border-color: var(--error-text); 739 806 color: var(--error-text); 740 807 } 808 + 741 809 .modal-actions .action-btn.danger:hover:not(:disabled) { 742 810 background: var(--error-bg); 743 811 }
+75 -75
frontend/src/routes/AppPasswords.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, type AppPassword, ApiError } from '../lib/api' 5 + import { _ } from '../lib/i18n' 6 + import { formatDate } from '../lib/date' 5 7 const auth = getAuthState() 6 8 let passwords = $state<AppPassword[]>([]) 7 9 let loading = $state(true) ··· 51 53 } 52 54 async function handleRevoke(name: string) { 53 55 if (!auth.session) return 54 - if (!confirm(`Revoke app password "${name}"? Apps using this password will no longer be able to access your account.`)) { 56 + if (!confirm($_('appPasswords.revokeConfirm', { values: { name } }))) { 55 57 return 56 58 } 57 59 revoking = name ··· 71 73 </script> 72 74 <div class="page"> 73 75 <header> 74 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 75 - <h1>App Passwords</h1> 76 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 77 + <h1>{$_('appPasswords.title')}</h1> 76 78 </header> 77 79 <p class="description"> 78 - App passwords let you sign in to third-party apps without giving them your main password. 79 - Each app password can be revoked individually. 80 + {$_('appPasswords.description')} 80 81 </p> 81 82 {#if error} 82 83 <div class="error">{error}</div> 83 84 {/if} 84 85 {#if createdPassword} 85 86 <div class="created-password"> 86 - <h3>App Password Created</h3> 87 - <p>Copy this password now. You won't be able to see it again.</p> 87 + <h3>{$_('appPasswords.created')}</h3> 88 + <p>{$_('appPasswords.createdMessage')}</p> 88 89 <div class="password-display"> 89 90 <code>{createdPassword.password}</code> 90 91 </div> 91 - <p class="password-name">Name: {createdPassword.name}</p> 92 - <button onclick={dismissCreated}>Done</button> 92 + <p class="password-name">{$_('common.name')}: {createdPassword.name}</p> 93 + <button onclick={dismissCreated}>{$_('common.done')}</button> 93 94 </div> 94 95 {/if} 95 96 <section class="create-section"> 96 - <h2>Create New App Password</h2> 97 + <h2>{$_('appPasswords.createNew')}</h2> 97 98 <form onsubmit={handleCreate}> 98 99 <input 99 100 type="text" 100 101 bind:value={newPasswordName} 101 - placeholder="App name (e.g., Graysky, Skeets)" 102 + placeholder={$_('appPasswords.appNamePlaceholder')} 102 103 disabled={creating} 103 104 required 104 105 /> 105 106 <button type="submit" disabled={creating || !newPasswordName.trim()}> 106 - {creating ? 'Creating...' : 'Create'} 107 + {creating ? $_('appPasswords.creating') : $_('common.create')} 107 108 </button> 108 109 </form> 109 110 </section> 110 111 <section class="list-section"> 111 - <h2>Your App Passwords</h2> 112 + <h2>{$_('appPasswords.yourPasswords')}</h2> 112 113 {#if loading} 113 - <p class="empty">Loading...</p> 114 + <p class="empty">{$_('common.loading')}</p> 114 115 {:else if passwords.length === 0} 115 - <p class="empty">No app passwords yet</p> 116 + <p class="empty">{$_('appPasswords.noPasswords')}</p> 116 117 {:else} 117 118 <ul class="password-list"> 118 119 {#each passwords as pw} 119 120 <li> 120 121 <div class="password-info"> 121 122 <span class="name">{pw.name}</span> 122 - <span class="date">Created {new Date(pw.createdAt).toLocaleDateString()}</span> 123 + <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span> 123 124 </div> 124 125 <button 125 126 class="revoke" 126 127 onclick={() => handleRevoke(pw.name)} 127 128 disabled={revoking === pw.name} 128 129 > 129 - {revoking === pw.name ? 'Revoking...' : 'Revoke'} 130 + {revoking === pw.name ? $_('appPasswords.revoking') : $_('appPasswords.revoke')} 130 131 </button> 131 132 </li> 132 133 {/each} ··· 136 137 </div> 137 138 <style> 138 139 .page { 139 - max-width: 600px; 140 + max-width: var(--width-md); 140 141 margin: 0 auto; 141 - padding: 2rem; 142 + padding: var(--space-7); 142 143 } 144 + 143 145 header { 144 - margin-bottom: 1rem; 146 + margin-bottom: var(--space-4); 145 147 } 148 + 146 149 .back { 147 150 color: var(--text-secondary); 148 151 text-decoration: none; 149 - font-size: 0.875rem; 152 + font-size: var(--text-sm); 150 153 } 154 + 151 155 .back:hover { 152 156 color: var(--accent); 153 157 } 158 + 154 159 h1 { 155 - margin: 0.5rem 0 0 0; 160 + margin: var(--space-2) 0 0 0; 156 161 } 162 + 157 163 .description { 158 164 color: var(--text-secondary); 159 - margin-bottom: 2rem; 165 + margin-bottom: var(--space-7); 160 166 } 167 + 161 168 .error { 162 - padding: 0.75rem; 169 + padding: var(--space-3); 163 170 background: var(--error-bg); 164 171 border: 1px solid var(--error-border); 165 - border-radius: 4px; 172 + border-radius: var(--radius-md); 166 173 color: var(--error-text); 167 - margin-bottom: 1rem; 174 + margin-bottom: var(--space-4); 168 175 } 176 + 169 177 .created-password { 170 - padding: 1.5rem; 178 + padding: var(--space-6); 171 179 background: var(--success-bg); 172 180 border: 1px solid var(--success-border); 173 - border-radius: 8px; 174 - margin-bottom: 2rem; 181 + border-radius: var(--radius-xl); 182 + margin-bottom: var(--space-7); 175 183 } 184 + 176 185 .created-password h3 { 177 - margin: 0 0 0.5rem 0; 186 + margin: 0 0 var(--space-2) 0; 178 187 color: var(--success-text); 179 188 } 189 + 180 190 .password-display { 181 191 background: var(--bg-card); 182 - padding: 1rem; 183 - border-radius: 4px; 184 - margin: 1rem 0; 192 + padding: var(--space-4); 193 + border-radius: var(--radius-md); 194 + margin: var(--space-4) 0; 185 195 } 196 + 186 197 .password-display code { 187 - font-size: 1.25rem; 188 - font-family: monospace; 198 + font-size: var(--text-xl); 199 + font-family: ui-monospace, monospace; 189 200 word-break: break-all; 190 201 } 202 + 191 203 .password-name { 192 204 color: var(--text-secondary); 193 - font-size: 0.875rem; 194 - margin-bottom: 1rem; 205 + font-size: var(--text-sm); 206 + margin-bottom: var(--space-4); 195 207 } 208 + 196 209 section { 197 - margin-bottom: 2rem; 210 + margin-bottom: var(--space-7); 198 211 } 212 + 199 213 section h2 { 200 - font-size: 1.125rem; 201 - margin: 0 0 1rem 0; 214 + font-size: var(--text-lg); 215 + margin: 0 0 var(--space-4) 0; 202 216 } 217 + 203 218 .create-section form { 204 219 display: flex; 205 - gap: 0.5rem; 220 + gap: var(--space-2); 206 221 } 222 + 207 223 .create-section input { 208 224 flex: 1; 209 - padding: 0.75rem; 210 - border: 1px solid var(--border-color-light); 211 - border-radius: 4px; 212 - font-size: 1rem; 213 - background: var(--bg-input); 214 - color: var(--text-primary); 215 225 } 216 - .create-section input:focus { 217 - outline: none; 218 - border-color: var(--accent); 219 - } 220 - .create-section button { 221 - padding: 0.75rem 1.5rem; 222 - background: var(--accent); 223 - color: white; 224 - border: none; 225 - border-radius: 4px; 226 - cursor: pointer; 227 - } 228 - .create-section button:hover:not(:disabled) { 229 - background: var(--accent-hover); 230 - } 231 - .create-section button:disabled { 232 - opacity: 0.6; 233 - cursor: not-allowed; 234 - } 226 + 235 227 .password-list { 236 228 list-style: none; 237 229 padding: 0; 238 230 margin: 0; 239 231 } 232 + 240 233 .password-list li { 241 234 display: flex; 242 235 justify-content: space-between; 243 236 align-items: center; 244 - padding: 1rem; 237 + padding: var(--space-4); 245 238 border: 1px solid var(--border-color); 246 - border-radius: 4px; 247 - margin-bottom: 0.5rem; 239 + border-radius: var(--radius-md); 240 + margin-bottom: var(--space-2); 248 241 background: var(--bg-card); 249 242 } 243 + 250 244 .password-info { 251 245 display: flex; 252 246 flex-direction: column; 253 - gap: 0.25rem; 247 + gap: var(--space-1); 254 248 } 249 + 255 250 .name { 256 - font-weight: 500; 251 + font-weight: var(--font-medium); 257 252 } 253 + 258 254 .date { 259 - font-size: 0.875rem; 255 + font-size: var(--text-sm); 260 256 color: var(--text-secondary); 261 257 } 258 + 262 259 .revoke { 263 - padding: 0.5rem 1rem; 260 + padding: var(--space-2) var(--space-4); 264 261 background: transparent; 265 262 border: 1px solid var(--error-text); 266 - border-radius: 4px; 263 + border-radius: var(--radius-md); 267 264 color: var(--error-text); 268 265 cursor: pointer; 269 266 } 267 + 270 268 .revoke:hover:not(:disabled) { 271 269 background: var(--error-bg); 272 270 } 271 + 273 272 .revoke:disabled { 274 273 opacity: 0.6; 275 274 cursor: not-allowed; 276 275 } 276 + 277 277 .empty { 278 278 color: var(--text-secondary); 279 279 text-align: center; 280 - padding: 2rem; 280 + padding: var(--space-7); 281 281 } 282 282 </style>
+140 -97
frontend/src/routes/Dashboard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getAuthState, logout, switchAccount } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 5 + 4 6 const auth = getAuthState() 5 7 let dropdownOpen = $state(false) 6 8 let switching = $state(false) 9 + 7 10 $effect(() => { 8 11 if (!auth.loading && !auth.session) { 9 12 navigate('/login') 10 13 } 11 14 }) 15 + 12 16 async function handleLogout() { 13 17 await logout() 14 18 navigate('/login') 15 19 } 20 + 16 21 async function handleSwitchAccount(did: string) { 17 22 switching = true 18 23 dropdownOpen = false ··· 24 29 switching = false 25 30 } 26 31 } 32 + 27 33 function toggleDropdown() { 28 34 dropdownOpen = !dropdownOpen 29 35 } 36 + 30 37 function closeDropdown(e: MouseEvent) { 31 38 const target = e.target as HTMLElement 32 39 if (!target.closest('.account-dropdown')) { 33 40 dropdownOpen = false 34 41 } 35 42 } 43 + 36 44 $effect(() => { 37 45 if (dropdownOpen) { 38 46 document.addEventListener('click', closeDropdown) 39 47 return () => document.removeEventListener('click', closeDropdown) 40 48 } 41 49 }) 50 + 42 51 let otherAccounts = $derived( 43 52 auth.savedAccounts.filter(a => a.did !== auth.session?.did) 44 53 ) 45 54 </script> 55 + 46 56 {#if auth.session} 47 57 <div class="dashboard"> 48 58 <header> 49 - <h1>Dashboard</h1> 59 + <h1>{$_('dashboard.title')}</h1> 50 60 <div class="account-dropdown"> 51 61 <button class="account-trigger" onclick={toggleDropdown} disabled={switching}> 52 62 <span class="account-handle">@{auth.session.handle}</span> ··· 56 66 <div class="dropdown-menu"> 57 67 {#if otherAccounts.length > 0} 58 68 <div class="dropdown-section"> 59 - <span class="dropdown-label">Switch Account</span> 69 + <span class="dropdown-label">{$_('dashboard.switchAccount')}</span> 60 70 {#each otherAccounts as account} 61 - <button 62 - type="button" 63 - class="dropdown-item" 64 - onclick={() => handleSwitchAccount(account.did)} 65 - > 71 + <button type="button" class="dropdown-item" onclick={() => handleSwitchAccount(account.did)}> 66 72 @{account.handle} 67 73 </button> 68 74 {/each} 69 75 </div> 70 76 <div class="dropdown-divider"></div> 71 77 {/if} 72 - <button 73 - type="button" 74 - class="dropdown-item" 75 - onclick={() => { dropdownOpen = false; navigate('/login') }} 76 - > 77 - Add another account 78 + <button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate('/login') }}> 79 + {$_('dashboard.addAnotherAccount')} 78 80 </button> 79 81 <div class="dropdown-divider"></div> 80 82 <button type="button" class="dropdown-item logout-item" onclick={handleLogout}> 81 - Sign out @{auth.session.handle} 83 + {$_('dashboard.signOut', { values: { handle: auth.session.handle } })} 82 84 </button> 83 85 </div> 84 86 {/if} 85 87 </div> 86 88 </header> 89 + 87 90 {#if auth.session.status === 'deactivated' || auth.session.active === false} 88 91 <div class="deactivated-banner"> 89 - <strong>Account Deactivated</strong> 90 - <p>Your account is currently deactivated. This typically happens during account migration. Some features may be limited until your account is reactivated.</p> 92 + <strong>{$_('dashboard.deactivatedTitle')}</strong> 93 + <p>{$_('dashboard.deactivatedMessage')}</p> 91 94 </div> 92 95 {/if} 96 + 93 97 <section class="account-overview"> 94 - <h2>Account Overview</h2> 98 + <h2>{$_('dashboard.accountOverview')}</h2> 95 99 <dl> 96 - <dt>Handle</dt> 100 + <dt>{$_('dashboard.handle')}</dt> 97 101 <dd> 98 102 @{auth.session.handle} 99 103 {#if auth.session.isAdmin} 100 - <span class="badge admin">Admin</span> 104 + <span class="badge admin">{$_('dashboard.admin')}</span> 101 105 {/if} 102 106 {#if auth.session.status === 'deactivated' || auth.session.active === false} 103 - <span class="badge deactivated">Deactivated</span> 107 + <span class="badge deactivated">{$_('dashboard.deactivated')}</span> 104 108 {/if} 105 109 </dd> 106 - <dt>DID</dt> 110 + <dt>{$_('dashboard.did')}</dt> 107 111 <dd class="mono">{auth.session.did}</dd> 108 112 {#if auth.session.preferredChannel} 109 - <dt>Primary Contact</dt> 113 + <dt>{$_('dashboard.primaryContact')}</dt> 110 114 <dd> 111 115 {#if auth.session.preferredChannel === 'email'} 112 - {auth.session.email || 'Email'} 116 + {auth.session.email || $_('register.email')} 113 117 {:else if auth.session.preferredChannel === 'discord'} 114 - Discord 118 + {$_('register.discord')} 115 119 {:else if auth.session.preferredChannel === 'telegram'} 116 - Telegram 120 + {$_('register.telegram')} 117 121 {:else if auth.session.preferredChannel === 'signal'} 118 - Signal 122 + {$_('register.signal')} 119 123 {:else} 120 124 {auth.session.preferredChannel} 121 125 {/if} 122 126 {#if auth.session.preferredChannelVerified} 123 - <span class="badge success">Verified</span> 127 + <span class="badge success">{$_('dashboard.verified')}</span> 124 128 {:else} 125 - <span class="badge warning">Unverified</span> 129 + <span class="badge warning">{$_('dashboard.unverified')}</span> 126 130 {/if} 127 131 </dd> 128 132 {:else if auth.session.email} 129 - <dt>Email</dt> 133 + <dt>{$_('register.email')}</dt> 130 134 <dd> 131 135 {auth.session.email} 132 136 {#if auth.session.emailConfirmed} 133 - <span class="badge success">Verified</span> 137 + <span class="badge success">{$_('dashboard.verified')}</span> 134 138 {:else} 135 - <span class="badge warning">Unverified</span> 139 + <span class="badge warning">{$_('dashboard.unverified')}</span> 136 140 {/if} 137 141 </dd> 138 142 {/if} 139 143 </dl> 140 144 </section> 145 + 141 146 <nav class="nav-grid"> 142 147 <a href="#/app-passwords" class="nav-card"> 143 - <h3>App Passwords</h3> 144 - <p>Manage passwords for third-party apps</p> 148 + <h3>{$_('dashboard.navAppPasswords')}</h3> 149 + <p>{$_('dashboard.navAppPasswordsDesc')}</p> 145 150 </a> 146 151 <a href="#/sessions" class="nav-card"> 147 - <h3>Active Sessions</h3> 148 - <p>View and manage your login sessions</p> 152 + <h3>{$_('dashboard.navSessions')}</h3> 153 + <p>{$_('dashboard.navSessionsDesc')}</p> 149 154 </a> 150 155 <a href="#/invite-codes" class="nav-card"> 151 - <h3>Invite Codes</h3> 152 - <p>View and create invite codes</p> 156 + <h3>{$_('dashboard.navInviteCodes')}</h3> 157 + <p>{$_('dashboard.navInviteCodesDesc')}</p> 153 158 </a> 154 159 <a href="#/settings" class="nav-card"> 155 - <h3>Account Settings</h3> 156 - <p>Email, password, handle, and more</p> 160 + <h3>{$_('dashboard.navSettings')}</h3> 161 + <p>{$_('dashboard.navSettingsDesc')}</p> 157 162 </a> 158 163 <a href="#/security" class="nav-card"> 159 - <h3>Security</h3> 160 - <p>Two-factor authentication</p> 164 + <h3>{$_('dashboard.navSecurity')}</h3> 165 + <p>{$_('dashboard.navSecurityDesc')}</p> 161 166 </a> 162 - <a href="#/notifications" class="nav-card"> 163 - <h3>Notification Preferences</h3> 164 - <p>Discord, Telegram, Signal channels</p> 167 + <a href="#/comms" class="nav-card"> 168 + <h3>{$_('dashboard.navComms')}</h3> 169 + <p>{$_('dashboard.navCommsDesc')}</p> 165 170 </a> 166 171 <a href="#/repo" class="nav-card"> 167 - <h3>Repository Explorer</h3> 168 - <p>Browse and manage raw AT Protocol records</p> 172 + <h3>{$_('dashboard.navRepo')}</h3> 173 + <p>{$_('dashboard.navRepoDesc')}</p> 169 174 </a> 170 175 {#if auth.session.isAdmin} 171 176 <a href="#/admin" class="nav-card admin-card"> 172 - <h3>Admin Panel</h3> 173 - <p>Server stats and admin operations</p> 177 + <h3>{$_('dashboard.navAdmin')}</h3> 178 + <p>{$_('dashboard.navAdminDesc')}</p> 174 179 </a> 175 180 {/if} 176 181 </nav> 177 182 </div> 178 183 {:else if auth.loading} 179 - <div class="loading">Loading...</div> 184 + <div class="loading">{$_('common.loading')}</div> 180 185 {/if} 186 + 181 187 <style> 182 188 .dashboard { 183 - max-width: 800px; 189 + max-width: var(--width-lg); 184 190 margin: 0 auto; 185 - padding: 2rem; 191 + padding: var(--space-7); 186 192 } 193 + 187 194 header { 188 195 display: flex; 189 196 justify-content: space-between; 190 197 align-items: center; 191 - margin-bottom: 2rem; 198 + margin-bottom: var(--space-7); 192 199 } 200 + 193 201 header h1 { 194 202 margin: 0; 195 203 } 204 + 196 205 .account-dropdown { 197 206 position: relative; 198 207 } 208 + 199 209 .account-trigger { 200 210 display: flex; 201 211 align-items: center; 202 - gap: 0.5rem; 203 - padding: 0.5rem 1rem; 212 + gap: var(--space-3); 213 + padding: var(--space-3) var(--space-5); 204 214 background: transparent; 205 - border: 1px solid var(--border-color-light); 206 - border-radius: 4px; 215 + border: 1px solid var(--border-color); 216 + border-radius: var(--radius-md); 207 217 cursor: pointer; 208 218 color: var(--text-primary); 209 219 } 220 + 210 221 .account-trigger:hover:not(:disabled) { 211 222 background: var(--bg-secondary); 212 223 } 224 + 213 225 .account-trigger:disabled { 214 226 opacity: 0.6; 215 227 cursor: not-allowed; 216 228 } 229 + 217 230 .account-trigger .account-handle { 218 - font-weight: 500; 231 + font-weight: var(--font-medium); 219 232 } 233 + 220 234 .dropdown-arrow { 221 235 font-size: 0.625rem; 222 236 color: var(--text-secondary); 223 237 } 238 + 224 239 .dropdown-menu { 225 240 position: absolute; 226 241 top: 100%; 227 242 right: 0; 228 - margin-top: 0.25rem; 243 + margin-top: var(--space-2); 229 244 min-width: 200px; 230 245 background: var(--bg-card); 231 246 border: 1px solid var(--border-color); 232 - border-radius: 8px; 233 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 247 + border-radius: var(--radius-xl); 248 + box-shadow: var(--shadow-lg); 234 249 z-index: 100; 235 250 overflow: hidden; 236 251 } 252 + 237 253 .dropdown-section { 238 - padding: 0.5rem 0; 254 + padding: var(--space-3) 0; 239 255 } 256 + 240 257 .dropdown-label { 241 258 display: block; 242 - padding: 0.25rem 1rem; 243 - font-size: 0.75rem; 259 + padding: var(--space-2) var(--space-5); 260 + font-size: var(--text-xs); 244 261 color: var(--text-muted); 245 262 text-transform: uppercase; 246 263 letter-spacing: 0.05em; 247 264 } 265 + 248 266 .dropdown-item { 249 267 display: block; 250 268 width: 100%; 251 - padding: 0.75rem 1rem; 269 + padding: var(--space-4) var(--space-5); 252 270 background: transparent; 253 271 border: none; 254 272 text-align: left; 255 273 cursor: pointer; 256 274 color: var(--text-primary); 257 - font-size: 0.875rem; 275 + font-size: var(--text-sm); 258 276 } 277 + 259 278 .dropdown-item:hover { 260 279 background: var(--bg-secondary); 261 280 } 281 + 262 282 .dropdown-item.logout-item { 263 283 color: var(--error-text); 264 284 } 285 + 265 286 .dropdown-divider { 266 287 height: 1px; 267 288 background: var(--border-color); 268 289 margin: 0; 269 290 } 291 + 270 292 section { 271 293 background: var(--bg-secondary); 272 - padding: 1.5rem; 273 - border-radius: 8px; 274 - margin-bottom: 2rem; 294 + padding: var(--space-6); 295 + border-radius: var(--radius-xl); 296 + margin-bottom: var(--space-7); 275 297 } 298 + 276 299 section h2 { 277 - margin: 0 0 1rem 0; 278 - font-size: 1.25rem; 300 + margin: 0 0 var(--space-4) 0; 301 + font-size: var(--text-xl); 279 302 } 303 + 280 304 dl { 281 305 display: grid; 282 306 grid-template-columns: auto 1fr; 283 - gap: 0.5rem 1rem; 307 + gap: var(--space-3) var(--space-5); 284 308 margin: 0; 285 309 } 310 + 286 311 dt { 287 - font-weight: 500; 312 + font-weight: var(--font-medium); 288 313 color: var(--text-secondary); 289 314 } 315 + 290 316 dd { 291 317 margin: 0; 292 318 } 319 + 293 320 .mono { 294 - font-family: monospace; 295 - font-size: 0.875rem; 321 + font-family: ui-monospace, monospace; 322 + font-size: var(--text-sm); 296 323 word-break: break-all; 297 324 } 325 + 298 326 .badge { 299 327 display: inline-block; 300 - padding: 0.125rem 0.5rem; 301 - border-radius: 4px; 302 - font-size: 0.75rem; 303 - margin-left: 0.5rem; 328 + padding: var(--space-1) var(--space-3); 329 + border-radius: var(--radius-md); 330 + font-size: var(--text-xs); 331 + margin-left: var(--space-3); 304 332 } 333 + 305 334 .badge.success { 306 335 background: var(--success-bg); 307 336 color: var(--success-text); 308 337 } 338 + 309 339 .badge.warning { 310 340 background: var(--warning-bg); 311 341 color: var(--warning-text); 312 342 } 343 + 313 344 .badge.admin { 314 345 background: var(--accent); 315 - color: white; 346 + color: var(--text-inverse); 316 347 } 348 + 317 349 .badge.deactivated { 318 350 background: var(--warning-bg); 319 351 color: var(--warning-text); 320 - border: 1px solid #d4a03c; 352 + border: 1px solid var(--warning-border); 321 353 } 354 + 322 355 .nav-grid { 323 356 display: grid; 324 357 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 325 - gap: 1rem; 358 + gap: var(--space-4); 326 359 } 360 + 327 361 .nav-card { 328 362 display: block; 329 - padding: 1.5rem; 363 + padding: var(--space-6); 330 364 background: var(--bg-card); 331 365 border: 1px solid var(--border-color); 332 - border-radius: 8px; 366 + border-radius: var(--radius-xl); 333 367 text-decoration: none; 334 368 color: inherit; 335 - transition: border-color 0.15s, box-shadow 0.15s; 369 + transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 336 370 } 371 + 337 372 .nav-card:hover { 338 373 border-color: var(--accent); 339 - box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); 374 + box-shadow: 0 2px 8px var(--accent-muted); 340 375 } 376 + 341 377 .nav-card h3 { 342 - margin: 0 0 0.5rem 0; 378 + margin: 0 0 var(--space-3) 0; 343 379 color: var(--accent); 344 380 } 381 + 345 382 .nav-card p { 346 383 margin: 0; 347 384 color: var(--text-secondary); 348 - font-size: 0.875rem; 385 + font-size: var(--text-sm); 349 386 } 387 + 350 388 .nav-card.admin-card { 351 389 border-color: var(--accent); 352 - background: linear-gradient(135deg, var(--bg-card) 0%, rgba(77, 166, 255, 0.05) 100%); 390 + background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 353 391 } 392 + 354 393 .nav-card.admin-card:hover { 355 - box-shadow: 0 2px 12px rgba(77, 166, 255, 0.25); 394 + box-shadow: 0 2px 12px var(--accent-muted); 356 395 } 396 + 357 397 .loading { 358 398 text-align: center; 359 - padding: 4rem; 399 + padding: var(--space-9); 360 400 color: var(--text-secondary); 361 401 } 402 + 362 403 .deactivated-banner { 363 404 background: var(--warning-bg); 364 - border: 1px solid #d4a03c; 365 - border-radius: 8px; 366 - padding: 1rem 1.5rem; 367 - margin-bottom: 2rem; 405 + border: 1px solid var(--warning-border); 406 + border-radius: var(--radius-xl); 407 + padding: var(--space-5) var(--space-6); 408 + margin-bottom: var(--space-7); 368 409 } 410 + 369 411 .deactivated-banner strong { 370 412 color: var(--warning-text); 371 - font-size: 1rem; 413 + font-size: var(--text-base); 372 414 } 415 + 373 416 .deactivated-banner p { 374 - margin: 0.5rem 0 0 0; 417 + margin: var(--space-3) 0 0 0; 375 418 color: var(--warning-text); 376 - font-size: 0.875rem; 419 + font-size: var(--text-sm); 377 420 } 378 421 </style>
+146
frontend/src/routes/Home.svelte
··· 1 + <script lang="ts"> 2 + import { _ } from '../lib/i18n' 3 + import { getAuthState } from '../lib/auth.svelte' 4 + const auth = getAuthState() 5 + </script> 6 + <div class="home"> 7 + <header class="hero"> 8 + <h1>Tranquil PDS</h1> 9 + <p class="tagline">A Personal Data Server for the AT Protocol</p> 10 + </header> 11 + <section> 12 + <h2>What is a PDS?</h2> 13 + <p> 14 + Bluesky runs on a federated protocol called AT Protocol. Your account lives on a PDS, 15 + a server that stores your posts, profile, follows, and cryptographic keys. Bluesky hosts 16 + one for you at bsky.social, but you can run your own. Self-hosting means you control your 17 + data; you're not dependent on any company's servers, and your account + data is actually yours. 18 + </p> 19 + </section> 20 + <section> 21 + <h2>What's different about Tranquil?</h2> 22 + <p> 23 + This software isn't an afterthought by a company with limited resources. 24 + It is a superset of the reference PDS, including: 25 + </p> 26 + <ul> 27 + <li>Passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices)</li> 28 + <li>did:web support (PDS-hosted subdomains or bring-your-own)</li> 29 + <li>Multi-channel notifications (email, discord, telegram, signal)</li> 30 + <li>Granular OAuth scopes with a consent UI</li> 31 + <li>Built-in web UI for account management, repo browsing, and admin</li> 32 + </ul> 33 + <p> 34 + Full compatibility with Bluesky's reference PDS: same endpoints, same behavior, 35 + same client compatibility. Everything works. 36 + </p> 37 + </section> 38 + <div class="cta"> 39 + {#if auth.session} 40 + <a href="#/dashboard" class="btn">@{auth.session.handle}</a> 41 + {:else} 42 + <a href="#/login" class="btn">{$_('login.button')}</a> 43 + <a href="#/register" class="btn secondary">{$_('login.createAccount')}</a> 44 + {/if} 45 + </div> 46 + <footer> 47 + <a href="https://tangled.org/lewis.moe/bspds-sandbox" target="_blank" rel="noopener">Source code</a> 48 + </footer> 49 + </div> 50 + <style> 51 + .home { 52 + max-width: var(--width-md); 53 + margin: 0 auto; 54 + padding: var(--space-7); 55 + } 56 + 57 + .hero { 58 + text-align: center; 59 + margin-bottom: var(--space-8); 60 + padding-top: var(--space-7); 61 + } 62 + 63 + .hero h1 { 64 + font-size: var(--text-4xl); 65 + margin-bottom: var(--space-3); 66 + } 67 + 68 + .tagline { 69 + color: var(--text-secondary); 70 + font-size: var(--text-xl); 71 + } 72 + 73 + section { 74 + margin-bottom: var(--space-7); 75 + } 76 + 77 + h2 { 78 + margin-bottom: var(--space-4); 79 + } 80 + 81 + p { 82 + color: var(--text-secondary); 83 + margin-bottom: var(--space-4); 84 + } 85 + 86 + ul { 87 + color: var(--text-secondary); 88 + margin: 0 0 var(--space-4) 0; 89 + padding-left: var(--space-6); 90 + line-height: var(--leading-relaxed); 91 + } 92 + 93 + li { 94 + margin-bottom: var(--space-2); 95 + } 96 + 97 + .cta { 98 + display: flex; 99 + gap: var(--space-4); 100 + justify-content: center; 101 + margin: var(--space-8) 0; 102 + } 103 + 104 + .btn { 105 + display: inline-block; 106 + padding: var(--space-4) var(--space-7); 107 + border-radius: var(--radius-md); 108 + font-size: var(--text-base); 109 + font-weight: var(--font-medium); 110 + text-decoration: none; 111 + transition: background var(--transition-normal), border-color var(--transition-normal); 112 + background: var(--accent); 113 + color: var(--text-inverse); 114 + } 115 + 116 + .btn:hover { 117 + background: var(--accent-hover); 118 + text-decoration: none; 119 + } 120 + 121 + .btn.secondary { 122 + background: transparent; 123 + color: var(--accent); 124 + border: 1px solid var(--accent); 125 + } 126 + 127 + .btn.secondary:hover { 128 + background: var(--accent); 129 + color: var(--text-inverse); 130 + } 131 + 132 + footer { 133 + text-align: center; 134 + padding-top: var(--space-7); 135 + border-top: 1px solid var(--border-color); 136 + } 137 + 138 + footer a { 139 + color: var(--text-muted); 140 + font-size: var(--text-sm); 141 + } 142 + 143 + footer a:hover { 144 + color: var(--accent); 145 + } 146 + </style>
+88 -73
frontend/src/routes/InviteCodes.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, type InviteCode, ApiError } from '../lib/api' 5 + import { _ } from '../lib/i18n' 6 + import { formatDate } from '../lib/date' 5 7 const auth = getAuthState() 6 8 let codes = $state<InviteCode[]>([]) 7 9 let loading = $state(true) ··· 54 56 </script> 55 57 <div class="page"> 56 58 <header> 57 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 58 - <h1>Invite Codes</h1> 59 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 60 + <h1>{$_('inviteCodes.title')}</h1> 59 61 </header> 60 62 <p class="description"> 61 - Invite codes let you invite friends to join. Each code can be used once. 63 + {$_('inviteCodes.description')} 62 64 </p> 63 65 {#if error} 64 66 <div class="error">{error}</div> 65 67 {/if} 66 68 {#if createdCode} 67 69 <div class="created-code"> 68 - <h3>Invite Code Created</h3> 70 + <h3>{$_('inviteCodes.created')}</h3> 69 71 <div class="code-display"> 70 72 <code>{createdCode}</code> 71 - <button class="copy" onclick={() => copyCode(createdCode!)}>Copy</button> 73 + <button class="copy" onclick={() => copyCode(createdCode!)}>{$_('inviteCodes.copy')}</button> 72 74 </div> 73 - <button onclick={dismissCreated}>Done</button> 75 + <button onclick={dismissCreated}>{$_('common.done')}</button> 74 76 </div> 75 77 {/if} 76 78 <section class="create-section"> 77 79 <button onclick={handleCreate} disabled={creating}> 78 - {creating ? 'Creating...' : 'Create New Invite Code'} 80 + {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 79 81 </button> 80 82 </section> 81 83 <section class="list-section"> 82 - <h2>Your Invite Codes</h2> 84 + <h2>{$_('inviteCodes.yourCodes')}</h2> 83 85 {#if loading} 84 - <p class="empty">Loading...</p> 86 + <p class="empty">{$_('common.loading')}</p> 85 87 {:else if codes.length === 0} 86 - <p class="empty">No invite codes yet</p> 88 + <p class="empty">{$_('inviteCodes.noCodes')}</p> 87 89 {:else} 88 90 <ul class="code-list"> 89 91 {#each codes as code} 90 92 <li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}> 91 93 <div class="code-main"> 92 94 <code>{code.code}</code> 93 - <button class="copy-small" onclick={() => copyCode(code.code)} title="Copy"> 94 - Copy 95 + <button class="copy-small" onclick={() => copyCode(code.code)} title={$_('inviteCodes.copy')}> 96 + {$_('inviteCodes.copy')} 95 97 </button> 96 98 </div> 97 99 <div class="code-meta"> 98 - <span class="date">Created {new Date(code.createdAt).toLocaleDateString()}</span> 100 + <span class="date">{$_('inviteCodes.createdOn', { values: { date: formatDate(code.createdAt) } })}</span> 99 101 {#if code.disabled} 100 - <span class="status disabled">Disabled</span> 102 + <span class="status disabled">{$_('inviteCodes.disabled')}</span> 101 103 {:else if code.uses.length > 0} 102 - <span class="status used">Used by @{code.uses[0].usedBy.split(':').pop()}</span> 104 + <span class="status used">{$_('inviteCodes.used', { values: { handle: code.uses[0].usedBy.split(':').pop() } })}</span> 103 105 {:else} 104 - <span class="status available">Available</span> 106 + <span class="status available">{$_('inviteCodes.available')}</span> 105 107 {/if} 106 108 </div> 107 109 </li> ··· 112 114 </div> 113 115 <style> 114 116 .page { 115 - max-width: 600px; 117 + max-width: var(--width-md); 116 118 margin: 0 auto; 117 - padding: 2rem; 119 + padding: var(--space-7); 118 120 } 121 + 119 122 header { 120 - margin-bottom: 1rem; 123 + margin-bottom: var(--space-4); 121 124 } 125 + 122 126 .back { 123 127 color: var(--text-secondary); 124 128 text-decoration: none; 125 - font-size: 0.875rem; 129 + font-size: var(--text-sm); 126 130 } 131 + 127 132 .back:hover { 128 133 color: var(--accent); 129 134 } 135 + 130 136 h1 { 131 - margin: 0.5rem 0 0 0; 137 + margin: var(--space-2) 0 0 0; 132 138 } 139 + 133 140 .description { 134 141 color: var(--text-secondary); 135 - margin-bottom: 2rem; 142 + margin-bottom: var(--space-7); 136 143 } 144 + 137 145 .error { 138 - padding: 0.75rem; 146 + padding: var(--space-3); 139 147 background: var(--error-bg); 140 148 border: 1px solid var(--error-border); 141 - border-radius: 4px; 149 + border-radius: var(--radius-md); 142 150 color: var(--error-text); 143 - margin-bottom: 1rem; 151 + margin-bottom: var(--space-4); 144 152 } 153 + 145 154 .created-code { 146 - padding: 1.5rem; 155 + padding: var(--space-6); 147 156 background: var(--success-bg); 148 157 border: 1px solid var(--success-border); 149 - border-radius: 8px; 150 - margin-bottom: 2rem; 158 + border-radius: var(--radius-xl); 159 + margin-bottom: var(--space-7); 151 160 } 161 + 152 162 .created-code h3 { 153 - margin: 0 0 1rem 0; 163 + margin: 0 0 var(--space-4) 0; 154 164 color: var(--success-text); 155 165 } 166 + 156 167 .code-display { 157 168 display: flex; 158 169 align-items: center; 159 - gap: 1rem; 170 + gap: var(--space-4); 160 171 background: var(--bg-card); 161 - padding: 1rem; 162 - border-radius: 4px; 163 - margin-bottom: 1rem; 172 + padding: var(--space-4); 173 + border-radius: var(--radius-md); 174 + margin-bottom: var(--space-4); 164 175 } 176 + 165 177 .code-display code { 166 - font-size: 1.125rem; 167 - font-family: monospace; 178 + font-size: var(--text-lg); 179 + font-family: ui-monospace, monospace; 168 180 flex: 1; 169 181 } 182 + 170 183 .copy { 171 - padding: 0.5rem 1rem; 184 + padding: var(--space-2) var(--space-4); 172 185 background: var(--accent); 173 - color: white; 186 + color: var(--text-inverse); 174 187 border: none; 175 - border-radius: 4px; 188 + border-radius: var(--radius-md); 176 189 cursor: pointer; 177 190 } 191 + 178 192 .copy:hover { 179 193 background: var(--accent-hover); 180 194 } 195 + 181 196 .create-section { 182 - margin-bottom: 2rem; 183 - } 184 - .create-section button { 185 - padding: 0.75rem 1.5rem; 186 - background: var(--accent); 187 - color: white; 188 - border: none; 189 - border-radius: 4px; 190 - cursor: pointer; 191 - font-size: 1rem; 192 - } 193 - .create-section button:hover:not(:disabled) { 194 - background: var(--accent-hover); 195 - } 196 - .create-section button:disabled { 197 - opacity: 0.6; 198 - cursor: not-allowed; 197 + margin-bottom: var(--space-7); 199 198 } 199 + 200 200 section h2 { 201 - font-size: 1.125rem; 202 - margin: 0 0 1rem 0; 201 + font-size: var(--text-lg); 202 + margin: 0 0 var(--space-4) 0; 203 203 } 204 + 204 205 .code-list { 205 206 list-style: none; 206 207 padding: 0; 207 208 margin: 0; 208 209 } 210 + 209 211 .code-list li { 210 - padding: 1rem; 212 + padding: var(--space-4); 211 213 border: 1px solid var(--border-color); 212 - border-radius: 4px; 213 - margin-bottom: 0.5rem; 214 + border-radius: var(--radius-md); 215 + margin-bottom: var(--space-2); 214 216 background: var(--bg-card); 215 217 } 218 + 216 219 .code-list li.disabled { 217 220 opacity: 0.6; 218 221 } 222 + 219 223 .code-list li.used { 220 224 background: var(--bg-secondary); 221 225 } 226 + 222 227 .code-main { 223 228 display: flex; 224 229 align-items: center; 225 - gap: 0.5rem; 226 - margin-bottom: 0.5rem; 230 + gap: var(--space-2); 231 + margin-bottom: var(--space-2); 227 232 } 233 + 228 234 .code-main code { 229 - font-family: monospace; 230 - font-size: 0.9rem; 235 + font-family: ui-monospace, monospace; 236 + font-size: var(--text-sm); 231 237 } 238 + 232 239 .copy-small { 233 - padding: 0.25rem 0.5rem; 240 + padding: var(--space-1) var(--space-2); 234 241 background: var(--bg-secondary); 235 242 border: 1px solid var(--border-color); 236 - border-radius: 4px; 237 - font-size: 0.75rem; 243 + border-radius: var(--radius-md); 244 + font-size: var(--text-xs); 238 245 cursor: pointer; 239 246 color: var(--text-primary); 240 247 } 248 + 241 249 .copy-small:hover { 242 250 background: var(--bg-input-disabled); 243 251 } 252 + 244 253 .code-meta { 245 254 display: flex; 246 - gap: 1rem; 247 - font-size: 0.875rem; 255 + gap: var(--space-4); 256 + font-size: var(--text-sm); 248 257 } 258 + 249 259 .date { 250 260 color: var(--text-secondary); 251 261 } 262 + 252 263 .status { 253 - padding: 0.125rem 0.5rem; 254 - border-radius: 4px; 255 - font-size: 0.75rem; 264 + padding: var(--space-1) var(--space-2); 265 + border-radius: var(--radius-md); 266 + font-size: var(--text-xs); 256 267 } 268 + 257 269 .status.available { 258 270 background: var(--success-bg); 259 271 color: var(--success-text); 260 272 } 273 + 261 274 .status.used { 262 275 background: var(--bg-secondary); 263 276 color: var(--text-secondary); 264 277 } 278 + 265 279 .status.disabled { 266 280 background: var(--error-bg); 267 281 color: var(--error-text); 268 282 } 283 + 269 284 .empty { 270 285 color: var(--text-secondary); 271 286 text-align: center; 272 - padding: 2rem; 287 + padding: var(--space-7); 273 288 } 274 289 </style>
+116 -137
frontend/src/routes/Login.svelte
··· 1 1 <script lang="ts"> 2 2 import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 5 + 4 6 let submitting = $state(false) 5 7 let pendingVerification = $state<{ did: string } | null>(null) 6 8 let verificationCode = $state('') ··· 8 10 let resendMessage = $state<string | null>(null) 9 11 let showNewLogin = $state(false) 10 12 const auth = getAuthState() 13 + 11 14 async function handleSwitchAccount(did: string) { 12 15 submitting = true 13 16 try { ··· 17 20 submitting = false 18 21 } 19 22 } 23 + 20 24 function handleForgetAccount(did: string, e: Event) { 21 25 e.stopPropagation() 22 26 forgetAccount(did) 23 27 } 28 + 24 29 async function handleOAuthLogin() { 25 30 submitting = true 26 31 try { ··· 29 34 submitting = false 30 35 } 31 36 } 37 + 32 38 async function handleVerification(e: Event) { 33 39 e.preventDefault() 34 40 if (!pendingVerification || !verificationCode.trim()) return ··· 40 46 submitting = false 41 47 } 42 48 } 49 + 43 50 async function handleResendCode() { 44 51 if (!pendingVerification || resendingCode) return 45 52 resendingCode = true 46 53 resendMessage = null 47 54 try { 48 55 await resendVerification(pendingVerification.did) 49 - resendMessage = 'Verification code resent!' 56 + resendMessage = $_('verification.resent') 50 57 } catch { 51 58 resendMessage = null 52 59 } finally { 53 60 resendingCode = false 54 61 } 55 62 } 63 + 56 64 function backToLogin() { 57 65 pendingVerification = null 58 66 verificationCode = '' 59 67 resendMessage = null 60 68 } 61 69 </script> 62 - <div class="login-container"> 70 + 71 + <div class="login-page"> 63 72 {#if auth.error} 64 - <div class="error">{auth.error}</div> 73 + <div class="message error">{auth.error}</div> 65 74 {/if} 75 + 66 76 {#if pendingVerification} 67 - <h1>Verify Your Account</h1> 68 - <p class="subtitle"> 69 - Your account needs verification. Enter the code sent to your verification method. 70 - </p> 77 + <h1>{$_('verification.title')}</h1> 78 + <p class="subtitle">{$_('verification.subtitle')}</p> 79 + 71 80 {#if resendMessage} 72 - <div class="success">{resendMessage}</div> 81 + <div class="message success">{resendMessage}</div> 73 82 {/if} 83 + 74 84 <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}> 75 85 <div class="field"> 76 - <label for="verification-code">Verification Code</label> 86 + <label for="verification-code">{$_('verification.codeLabel')}</label> 77 87 <input 78 88 id="verification-code" 79 89 type="text" 80 90 bind:value={verificationCode} 81 - placeholder="Enter 6-digit code" 91 + placeholder={$_('verification.codePlaceholder')} 82 92 disabled={submitting} 83 93 required 84 94 maxlength="6" ··· 86 96 autocomplete="one-time-code" 87 97 /> 88 98 </div> 89 - <button type="submit" disabled={submitting || !verificationCode.trim()}> 90 - {submitting ? 'Verifying...' : 'Verify Account'} 91 - </button> 92 - <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 93 - {resendingCode ? 'Resending...' : 'Resend Code'} 94 - </button> 95 - <button type="button" class="tertiary" onclick={backToLogin}> 96 - Back to Login 97 - </button> 99 + <div class="actions"> 100 + <button type="submit" disabled={submitting || !verificationCode.trim()}> 101 + {submitting ? $_('verification.verifying') : $_('verification.verifyButton')} 102 + </button> 103 + <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 104 + {resendingCode ? $_('verification.resending') : $_('verification.resendButton')} 105 + </button> 106 + <button type="button" class="tertiary" onclick={backToLogin}> 107 + {$_('verification.backToLogin')} 108 + </button> 109 + </div> 98 110 </form> 111 + 99 112 {:else if auth.savedAccounts.length > 0 && !showNewLogin} 100 - <h1>Sign In</h1> 101 - <p class="subtitle">Choose an account</p> 113 + <h1>{$_('login.title')}</h1> 114 + <p class="subtitle">{$_('login.chooseAccount')}</p> 115 + 102 116 <div class="saved-accounts"> 103 117 {#each auth.savedAccounts as account} 104 118 <div ··· 117 131 type="button" 118 132 class="forget-btn" 119 133 onclick={(e) => handleForgetAccount(account.did, e)} 120 - title="Remove from saved accounts" 134 + title={$_('login.removeAccount')} 121 135 > 122 - × 136 + &times; 123 137 </button> 124 138 </div> 125 139 {/each} 126 140 </div> 127 - <button type="button" class="secondary add-account" onclick={() => showNewLogin = true}> 128 - Sign in to another account 141 + 142 + <button type="button" class="secondary full-width" onclick={() => showNewLogin = true}> 143 + {$_('login.signInToAnother')} 129 144 </button> 130 - <p class="register-link"> 131 - Don't have an account? <a href="#/register">Create one</a> 145 + 146 + <p class="link-text"> 147 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAcount')}</a> 132 148 </p> 149 + 133 150 {:else} 134 - <h1>Sign In</h1> 135 - <p class="subtitle">Sign in to manage your PDS account</p> 151 + <h1>{$_('login.title')}</h1> 152 + <p class="subtitle">{$_('login.subtitle')}</p> 153 + 136 154 {#if auth.savedAccounts.length > 0} 137 155 <button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}> 138 - ← Back to saved accounts 156 + {$_('login.backToSaved')} 139 157 </button> 140 158 {/if} 159 + 141 160 <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}> 142 - {submitting ? 'Redirecting...' : 'Sign In'} 161 + {submitting ? $_('login.redirecting') : $_('login.button')} 143 162 </button> 144 - <p class="forgot-link"> 145 - <a href="#/reset-password">Forgot password?</a> &middot; <a href="#/request-passkey-recovery">Lost passkey?</a> 163 + 164 + <p class="forgot-links"> 165 + <a href="#/reset-password">{$_('login.forgotPassword')}</a> 166 + <span class="separator">&middot;</span> 167 + <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 146 168 </p> 147 - <p class="register-link"> 148 - Don't have an account? <a href="#/register">Create one</a> 169 + 170 + <p class="link-text"> 171 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 149 172 </p> 150 173 {/if} 151 174 </div> 175 + 152 176 <style> 153 - .login-container { 154 - max-width: 400px; 155 - margin: 4rem auto; 156 - padding: 2rem; 177 + .login-page { 178 + max-width: var(--width-sm); 179 + margin: var(--space-9) auto; 180 + padding: var(--space-7); 157 181 } 182 + 158 183 h1 { 159 - margin: 0 0 0.5rem 0; 184 + margin: 0 0 var(--space-3) 0; 160 185 } 186 + 161 187 .subtitle { 162 188 color: var(--text-secondary); 163 - margin: 0 0 2rem 0; 189 + margin: 0 0 var(--space-7) 0; 164 190 } 191 + 165 192 form { 166 193 display: flex; 167 194 flex-direction: column; 168 - gap: 1rem; 195 + gap: var(--space-4); 169 196 } 170 - .field { 197 + 198 + .actions { 171 199 display: flex; 172 200 flex-direction: column; 173 - gap: 0.25rem; 201 + gap: var(--space-3); 202 + margin-top: var(--space-3); 174 203 } 175 - label { 176 - font-size: 0.875rem; 177 - font-weight: 500; 178 - } 179 - input { 180 - padding: 0.75rem; 181 - border: 1px solid var(--border-color-light); 182 - border-radius: 4px; 183 - font-size: 1rem; 184 - background: var(--bg-input); 185 - color: var(--text-primary); 186 - } 187 - input:focus { 188 - outline: none; 189 - border-color: var(--accent); 190 - } 191 - button { 192 - padding: 0.75rem; 193 - background: var(--accent); 194 - color: white; 195 - border: none; 196 - border-radius: 4px; 197 - font-size: 1rem; 198 - cursor: pointer; 199 - margin-top: 0.5rem; 200 - } 201 - button:hover:not(:disabled) { 202 - background: var(--accent-hover); 203 - } 204 - button:disabled { 205 - opacity: 0.6; 206 - cursor: not-allowed; 207 - } 208 - button.secondary { 209 - background: transparent; 210 - color: var(--accent); 211 - border: 1px solid var(--accent); 212 - } 213 - button.secondary:hover:not(:disabled) { 214 - background: var(--accent); 215 - color: white; 216 - } 217 - button.tertiary { 218 - background: transparent; 219 - color: var(--text-secondary); 220 - border: none; 221 - } 222 - button.tertiary:hover:not(:disabled) { 223 - color: var(--text-primary); 224 - } 204 + 225 205 .oauth-btn { 226 206 width: 100%; 227 - padding: 1rem; 228 - font-size: 1.125rem; 229 - font-weight: 500; 207 + padding: var(--space-5); 208 + font-size: var(--text-lg); 230 209 } 231 - .error { 232 - padding: 0.75rem; 233 - background: var(--error-bg); 234 - border: 1px solid var(--error-border); 235 - border-radius: 4px; 236 - color: var(--error-text); 237 - } 238 - .success { 239 - padding: 0.75rem; 240 - background: var(--success-bg); 241 - border: 1px solid var(--success-border); 242 - border-radius: 4px; 243 - color: var(--success-text); 244 - } 245 - .forgot-link { 210 + 211 + .forgot-links { 246 212 text-align: center; 247 - margin-top: 1rem; 248 - margin-bottom: 0; 213 + margin-top: var(--space-5); 249 214 color: var(--text-secondary); 250 215 } 251 - .forgot-link a { 216 + 217 + .forgot-links a { 252 218 color: var(--accent); 253 219 } 254 - .register-link { 220 + 221 + .separator { 222 + margin: 0 var(--space-2); 223 + } 224 + 225 + .link-text { 255 226 text-align: center; 256 - margin-top: 0.5rem; 227 + margin-top: var(--space-4); 257 228 color: var(--text-secondary); 258 229 } 259 - .register-link a { 230 + 231 + .link-text a { 260 232 color: var(--accent); 261 233 } 234 + 262 235 .saved-accounts { 263 236 display: flex; 264 237 flex-direction: column; 265 - gap: 0.5rem; 266 - margin-bottom: 1rem; 238 + gap: var(--space-3); 239 + margin-bottom: var(--space-5); 267 240 } 241 + 268 242 .account-item { 269 243 display: flex; 270 244 align-items: center; 271 245 justify-content: space-between; 272 - padding: 1rem; 246 + padding: var(--space-5); 273 247 background: var(--bg-card); 274 248 border: 1px solid var(--border-color); 275 - border-radius: 8px; 249 + border-radius: var(--radius-xl); 276 250 cursor: pointer; 277 - text-align: left; 278 - width: 100%; 279 - transition: border-color 0.15s, box-shadow 0.15s; 251 + transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 280 252 } 253 + 281 254 .account-item:hover:not(.disabled) { 282 255 border-color: var(--accent); 283 - box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); 256 + box-shadow: var(--shadow-md); 284 257 } 258 + 285 259 .account-item.disabled { 286 260 opacity: 0.6; 287 261 cursor: not-allowed; 288 262 } 263 + 289 264 .account-info { 290 265 display: flex; 291 266 flex-direction: column; 292 - gap: 0.25rem; 267 + gap: var(--space-1); 293 268 } 269 + 294 270 .account-handle { 295 - font-weight: 500; 271 + font-weight: var(--font-medium); 296 272 color: var(--text-primary); 297 273 } 274 + 298 275 .account-did { 299 - font-size: 0.75rem; 276 + font-size: var(--text-xs); 300 277 color: var(--text-muted); 301 - font-family: monospace; 278 + font-family: ui-monospace, monospace; 302 279 overflow: hidden; 303 280 text-overflow: ellipsis; 304 281 max-width: 250px; 305 282 } 283 + 306 284 .forget-btn { 307 - padding: 0.25rem 0.5rem; 285 + padding: var(--space-2) var(--space-3); 308 286 background: transparent; 309 287 border: none; 310 288 color: var(--text-muted); 311 289 cursor: pointer; 312 - font-size: 1.25rem; 290 + font-size: var(--text-xl); 313 291 line-height: 1; 314 - border-radius: 4px; 315 - margin: 0; 292 + border-radius: var(--radius-md); 316 293 } 294 + 317 295 .forget-btn:hover { 318 296 background: var(--error-bg); 319 297 color: var(--error-text); 320 298 } 321 - .add-account { 299 + 300 + .full-width { 322 301 width: 100%; 323 - margin-bottom: 1rem; 324 302 } 303 + 325 304 .back-btn { 326 - margin-bottom: 1rem; 305 + margin-bottom: var(--space-5); 327 306 padding: 0; 328 307 } 329 308 </style>
+238 -208
frontend/src/routes/Notifications.svelte frontend/src/routes/Comms.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState } from '../lib/auth.svelte' 2 + import { getAuthState, refreshSession } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import { _ } from '../lib/i18n' 6 + import { formatDateTime } from '../lib/date' 5 7 const auth = getAuthState() 6 8 let loading = $state(true) 7 9 let saving = $state(false) ··· 21 23 let verificationSuccess = $state<string | null>(null) 22 24 let historyLoading = $state(false) 23 25 let historyError = $state<string | null>(null) 24 - let notifications = $state<Array<{ 26 + let messages = $state<Array<{ 25 27 createdAt: string 26 28 channel: string 27 29 notificationType: string ··· 73 75 telegramUsername: telegramUsername || undefined, 74 76 signalNumber: signalNumber || undefined, 75 77 }) 76 - success = 'Notification preferences saved' 78 + await refreshSession() 79 + success = $_('comms.preferencesSaved') 77 80 await loadPrefs() 78 81 } catch (e) { 79 82 error = e instanceof ApiError ? e.message : 'Failed to save preferences' ··· 87 90 verificationSuccess = null 88 91 try { 89 92 await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode) 90 - verificationSuccess = `${channel} verified successfully` 93 + await refreshSession() 94 + verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } }) 91 95 verificationCode = '' 92 96 verifyingChannel = null 93 97 await loadPrefs() ··· 101 105 historyError = null 102 106 try { 103 107 const result = await api.getNotificationHistory(auth.session.accessJwt) 104 - notifications = result.notifications 108 + messages = result.notifications 105 109 showHistory = true 106 110 } catch (e) { 107 111 historyError = e instanceof ApiError ? e.message : 'Failed to load notification history' ··· 110 114 } 111 115 } 112 116 function formatDate(dateStr: string): string { 113 - return new Date(dateStr).toLocaleString() 117 + return formatDateTime(dateStr) 114 118 } 115 - const channels = [ 116 - { id: 'email', name: 'Email', description: 'Receive notifications via email' }, 117 - { id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' }, 118 - { id: 'telegram', name: 'Telegram', description: 'Receive notifications via Telegram' }, 119 - { id: 'signal', name: 'Signal', description: 'Receive notifications via Signal' }, 120 - ] 119 + const channels = ['email', 'discord', 'telegram', 'signal'] 120 + function getChannelName(id: string): string { 121 + switch (id) { 122 + case 'email': return $_('register.email') 123 + case 'discord': return $_('register.discord') 124 + case 'telegram': return $_('register.telegram') 125 + case 'signal': return $_('register.signal') 126 + default: return id 127 + } 128 + } 129 + function getChannelDescription(id: string): string { 130 + switch (id) { 131 + case 'email': return $_('comms.emailVia') 132 + case 'discord': return $_('comms.discordVia') 133 + case 'telegram': return $_('comms.telegramVia') 134 + case 'signal': return $_('comms.signalVia') 135 + default: return '' 136 + } 137 + } 121 138 function canSelectChannel(channelId: string): boolean { 122 139 if (channelId === 'email') return true 123 140 if (channelId === 'discord') return !!discordId ··· 134 151 </script> 135 152 <div class="page"> 136 153 <header> 137 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 138 - <h1>Notification Preferences</h1> 154 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 155 + <h1>{$_('comms.title')}</h1> 139 156 </header> 140 157 <p class="description"> 141 - Choose how you want to receive important notifications like password resets, 142 - security alerts, and account updates. 158 + {$_('comms.description')} 143 159 </p> 144 160 {#if loading} 145 - <p class="loading">Loading...</p> 161 + <p class="loading">{$_('common.loading')}</p> 146 162 {:else} 147 163 {#if error} 148 164 <div class="message error">{error}</div> ··· 152 168 {/if} 153 169 <form onsubmit={handleSave}> 154 170 <section> 155 - <h2>Preferred Channel</h2> 171 + <h2>{$_('comms.preferredChannel')}</h2> 156 172 <p class="section-description"> 157 - Select your preferred way to receive notifications. You must configure a channel before you can select it. 173 + {$_('comms.preferredChannelDescription')} 158 174 </p> 159 175 <div class="channel-options"> 160 - {#each channels as channel} 161 - <label class="channel-option" class:disabled={!canSelectChannel(channel.id)}> 176 + {#each channels as channelId} 177 + <label class="channel-option" class:disabled={!canSelectChannel(channelId)}> 162 178 <input 163 179 type="radio" 164 180 name="preferredChannel" 165 - value={channel.id} 181 + value={channelId} 166 182 bind:group={preferredChannel} 167 - disabled={!canSelectChannel(channel.id) || saving} 183 + disabled={!canSelectChannel(channelId) || saving} 168 184 /> 169 185 <div class="channel-info"> 170 - <span class="channel-name">{channel.name}</span> 171 - <span class="channel-description">{channel.description}</span> 172 - {#if channel.id !== 'email' && !canSelectChannel(channel.id)} 173 - <span class="channel-hint">Configure below to enable</span> 186 + <span class="channel-name">{getChannelName(channelId)}</span> 187 + <span class="channel-description">{getChannelDescription(channelId)}</span> 188 + {#if channelId !== 'email' && !canSelectChannel(channelId)} 189 + <span class="channel-hint">{$_('comms.configureToEnable')}</span> 174 190 {/if} 175 191 </div> 176 192 </label> ··· 178 194 </div> 179 195 </section> 180 196 <section> 181 - <h2>Channel Configuration</h2> 197 + <h2>{$_('comms.channelConfiguration')}</h2> 182 198 <div class="channel-config"> 183 199 <div class="config-item"> 184 - <label for="email">Email</label> 200 + <label for="email">{$_('register.email')}</label> 185 201 <div class="config-input"> 186 202 <input 187 203 id="email" ··· 190 206 disabled 191 207 class="readonly" 192 208 /> 193 - <span class="status verified">Primary</span> 209 + <span class="status verified">{$_('comms.primary')}</span> 194 210 </div> 195 - <p class="config-hint">Your email is managed in Account Settings</p> 211 + <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 196 212 </div> 197 213 <div class="config-item"> 198 - <label for="discord">Discord User ID</label> 214 + <label for="discord">{$_('register.discordId')}</label> 199 215 <div class="config-input"> 200 216 <input 201 217 id="discord" 202 218 type="text" 203 219 bind:value={discordId} 204 - placeholder="e.g., 123456789012345678" 220 + placeholder={$_('register.discordIdPlaceholder')} 205 221 disabled={saving} 206 222 /> 207 223 {#if discordId} 208 224 {#if discordVerified} 209 - <span class="status verified">Verified</span> 225 + <span class="status verified">{$_('comms.verified')}</span> 210 226 {:else} 211 - <span class="status unverified">Not verified</span> 212 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>Verify</button> 227 + <span class="status unverified">{$_('comms.notVerified')}</span> 228 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 213 229 {/if} 214 230 {/if} 215 231 </div> 216 - <p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p> 232 + <p class="config-hint">{$_('comms.discordIdHint')}</p> 217 233 {#if verifyingChannel === 'discord'} 218 234 <div class="verify-form"> 219 235 <input 220 236 type="text" 221 237 bind:value={verificationCode} 222 - placeholder="Enter verification code" 238 + placeholder={$_('comms.verifyCodePlaceholder')} 223 239 maxlength="6" 224 240 /> 225 - <button type="button" onclick={() => handleVerify('discord')}>Submit</button> 226 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button> 241 + <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 242 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 227 243 </div> 228 244 {/if} 229 245 </div> 230 246 <div class="config-item"> 231 - <label for="telegram">Telegram Username</label> 247 + <label for="telegram">{$_('register.telegramUsername')}</label> 232 248 <div class="config-input"> 233 249 <input 234 250 id="telegram" 235 251 type="text" 236 252 bind:value={telegramUsername} 237 - placeholder="e.g., username" 253 + placeholder={$_('register.telegramUsernamePlaceholder')} 238 254 disabled={saving} 239 255 /> 240 256 {#if telegramUsername} 241 257 {#if telegramVerified} 242 - <span class="status verified">Verified</span> 258 + <span class="status verified">{$_('comms.verified')}</span> 243 259 {:else} 244 - <span class="status unverified">Not verified</span> 245 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>Verify</button> 260 + <span class="status unverified">{$_('comms.notVerified')}</span> 261 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button> 246 262 {/if} 247 263 {/if} 248 264 </div> 249 - <p class="config-hint">Your Telegram username without the @ symbol</p> 265 + <p class="config-hint">{$_('comms.telegramHint')}</p> 250 266 {#if verifyingChannel === 'telegram'} 251 267 <div class="verify-form"> 252 268 <input 253 269 type="text" 254 270 bind:value={verificationCode} 255 - placeholder="Enter verification code" 271 + placeholder={$_('comms.verifyCodePlaceholder')} 256 272 maxlength="6" 257 273 /> 258 - <button type="button" onclick={() => handleVerify('telegram')}>Submit</button> 259 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button> 274 + <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button> 275 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 260 276 </div> 261 277 {/if} 262 278 </div> 263 279 <div class="config-item"> 264 - <label for="signal">Signal Phone Number</label> 280 + <label for="signal">{$_('register.signalNumber')}</label> 265 281 <div class="config-input"> 266 282 <input 267 283 id="signal" 268 284 type="tel" 269 285 bind:value={signalNumber} 270 - placeholder="e.g., +1234567890" 286 + placeholder={$_('register.signalNumberPlaceholder')} 271 287 disabled={saving} 272 288 /> 273 289 {#if signalNumber} 274 290 {#if signalVerified} 275 - <span class="status verified">Verified</span> 291 + <span class="status verified">{$_('comms.verified')}</span> 276 292 {:else} 277 - <span class="status unverified">Not verified</span> 278 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>Verify</button> 293 + <span class="status unverified">{$_('comms.notVerified')}</span> 294 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 279 295 {/if} 280 296 {/if} 281 297 </div> 282 - <p class="config-hint">Your Signal phone number with country code</p> 298 + <p class="config-hint">{$_('comms.signalHint')}</p> 283 299 {#if verifyingChannel === 'signal'} 284 300 <div class="verify-form"> 285 301 <input 286 302 type="text" 287 303 bind:value={verificationCode} 288 - placeholder="Enter verification code" 304 + placeholder={$_('comms.verifyCodePlaceholder')} 289 305 maxlength="6" 290 306 /> 291 - <button type="button" onclick={() => handleVerify('signal')}>Submit</button> 292 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button> 307 + <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button> 308 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 293 309 </div> 294 310 {/if} 295 311 </div> ··· 303 319 </section> 304 320 <div class="actions"> 305 321 <button type="submit" disabled={saving}> 306 - {saving ? 'Saving...' : 'Save Preferences'} 322 + {saving ? $_('comms.saving') : $_('comms.savePreferences')} 307 323 </button> 308 324 </div> 309 325 </form> 310 326 <section class="history-section"> 311 - <h2>Notification History</h2> 312 - <p class="section-description">View recent notifications sent to your account.</p> 327 + <h2>{$_('comms.messageHistory')}</h2> 328 + <p class="section-description">{$_('comms.historyDescription')}</p> 313 329 {#if !showHistory} 314 330 <button class="load-history" onclick={loadHistory} disabled={historyLoading}> 315 - {historyLoading ? 'Loading...' : 'Load History'} 331 + {historyLoading ? $_('common.loading') : $_('comms.loadHistory')} 316 332 </button> 317 333 {:else} 318 - <button class="load-history" onclick={() => showHistory = false}>Hide History</button> 334 + <button class="load-history" onclick={() => showHistory = false}>{$_('comms.hideHistory')}</button> 319 335 {#if historyError} 320 336 <div class="message error">{historyError}</div> 321 - {:else if notifications.length === 0} 322 - <p class="no-notifications">No notifications found.</p> 337 + {:else if messages.length === 0} 338 + <p class="no-messages">{$_('comms.noMessages')}</p> 323 339 {:else} 324 - <div class="notification-list"> 325 - {#each notifications as notification} 326 - <div class="notification-item"> 327 - <div class="notification-header"> 328 - <span class="notification-type">{notification.notificationType}</span> 329 - <span class="notification-channel">{notification.channel}</span> 330 - <span class="notification-status" class:sent={notification.status === 'sent'} class:failed={notification.status === 'failed'}>{notification.status}</span> 340 + <div class="message-list"> 341 + {#each messages as msg} 342 + <div class="message-item"> 343 + <div class="message-header"> 344 + <span class="message-type">{msg.notificationType}</span> 345 + <span class="message-channel">{msg.channel}</span> 346 + <span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span> 331 347 </div> 332 - {#if notification.subject} 333 - <div class="notification-subject">{notification.subject}</div> 348 + {#if msg.subject} 349 + <div class="message-subject">{msg.subject}</div> 334 350 {/if} 335 - <div class="notification-body">{notification.body}</div> 336 - <div class="notification-date">{formatDate(notification.createdAt)}</div> 351 + <div class="message-body">{msg.body}</div> 352 + <div class="message-date">{formatDate(msg.createdAt)}</div> 337 353 </div> 338 354 {/each} 339 355 </div> ··· 344 360 </div> 345 361 <style> 346 362 .page { 347 - max-width: 600px; 363 + max-width: var(--width-md); 348 364 margin: 0 auto; 349 - padding: 2rem; 365 + padding: var(--space-7); 350 366 } 367 + 351 368 header { 352 - margin-bottom: 1rem; 369 + margin-bottom: var(--space-4); 353 370 } 371 + 354 372 .back { 355 373 color: var(--text-secondary); 356 374 text-decoration: none; 357 - font-size: 0.875rem; 375 + font-size: var(--text-sm); 358 376 } 377 + 359 378 .back:hover { 360 379 color: var(--accent); 361 380 } 381 + 362 382 h1 { 363 - margin: 0.5rem 0 0 0; 383 + margin: var(--space-2) 0 0 0; 364 384 } 385 + 365 386 .description { 366 387 color: var(--text-secondary); 367 - margin-bottom: 2rem; 388 + margin-bottom: var(--space-7); 368 389 } 390 + 369 391 .loading { 370 392 text-align: center; 371 393 color: var(--text-secondary); 372 - padding: 2rem; 373 - } 374 - .message { 375 - padding: 0.75rem; 376 - border-radius: 4px; 377 - margin-bottom: 1rem; 394 + padding: var(--space-7); 378 395 } 379 - .message.error { 380 - background: var(--error-bg); 381 - border: 1px solid var(--error-border); 382 - color: var(--error-text); 383 - } 384 - .message.success { 385 - background: var(--success-bg); 386 - border: 1px solid var(--success-border); 387 - color: var(--success-text); 388 - } 396 + 389 397 section { 390 398 background: var(--bg-secondary); 391 - padding: 1.5rem; 392 - border-radius: 8px; 393 - margin-bottom: 1.5rem; 399 + padding: var(--space-6); 400 + border-radius: var(--radius-xl); 401 + margin-bottom: var(--space-6); 394 402 } 403 + 395 404 section h2 { 396 - margin: 0 0 0.5rem 0; 397 - font-size: 1.125rem; 405 + margin: 0 0 var(--space-2) 0; 406 + font-size: var(--text-lg); 398 407 } 408 + 399 409 .section-description { 400 410 color: var(--text-secondary); 401 - font-size: 0.875rem; 402 - margin: 0 0 1rem 0; 411 + font-size: var(--text-sm); 412 + margin: 0 0 var(--space-4) 0; 403 413 } 414 + 404 415 .channel-options { 405 416 display: flex; 406 417 flex-direction: column; 407 - gap: 0.5rem; 418 + gap: var(--space-2); 408 419 } 420 + 409 421 .channel-option { 410 422 display: flex; 411 423 align-items: flex-start; 412 - gap: 0.75rem; 413 - padding: 0.75rem; 424 + gap: var(--space-3); 425 + padding: var(--space-3); 414 426 background: var(--bg-card); 415 427 border: 1px solid var(--border-color); 416 - border-radius: 4px; 428 + border-radius: var(--radius-md); 417 429 cursor: pointer; 418 - transition: border-color 0.15s; 430 + transition: border-color var(--transition-fast); 419 431 } 432 + 420 433 .channel-option:hover:not(.disabled) { 421 434 border-color: var(--accent); 422 435 } 436 + 423 437 .channel-option.disabled { 424 438 opacity: 0.6; 425 439 cursor: not-allowed; 426 440 } 427 - .channel-option input { 428 - margin-top: 0.25rem; 441 + 442 + .channel-option input[type="radio"] { 443 + flex-shrink: 0; 444 + width: 16px; 445 + height: 16px; 446 + margin-top: 2px; 429 447 } 448 + 430 449 .channel-info { 450 + flex: 1; 451 + min-width: 0; 431 452 display: flex; 432 453 flex-direction: column; 433 - gap: 0.125rem; 454 + gap: 2px; 434 455 } 456 + 435 457 .channel-name { 436 - font-weight: 500; 458 + font-weight: var(--font-medium); 437 459 } 460 + 438 461 .channel-description { 439 - font-size: 0.875rem; 462 + font-size: var(--text-sm); 440 463 color: var(--text-secondary); 441 464 } 465 + 442 466 .channel-hint { 443 - font-size: 0.75rem; 467 + font-size: var(--text-xs); 444 468 color: var(--text-muted); 445 469 font-style: italic; 446 470 } 471 + 447 472 .channel-config { 448 473 display: flex; 449 474 flex-direction: column; 450 - gap: 1.25rem; 475 + gap: var(--space-5); 451 476 } 477 + 452 478 .config-item { 453 479 display: flex; 454 480 flex-direction: column; 455 - gap: 0.25rem; 481 + gap: var(--space-1); 456 482 } 483 + 457 484 .config-item label { 458 - font-size: 0.875rem; 459 - font-weight: 500; 485 + font-size: var(--text-sm); 486 + font-weight: var(--font-medium); 460 487 } 488 + 461 489 .config-input { 462 490 display: flex; 463 491 align-items: center; 464 - gap: 0.5rem; 492 + gap: var(--space-2); 465 493 } 494 + 466 495 .config-input input { 467 496 flex: 1; 468 - padding: 0.75rem; 469 - border: 1px solid var(--border-color-light); 470 - border-radius: 4px; 471 - font-size: 1rem; 472 - background: var(--bg-input); 473 - color: var(--text-primary); 474 497 } 475 - .config-input input:focus { 476 - outline: none; 477 - border-color: var(--accent); 478 - } 498 + 479 499 .config-input input.readonly { 480 500 background: var(--bg-input-disabled); 481 501 color: var(--text-secondary); 482 502 } 503 + 483 504 .status { 484 - padding: 0.25rem 0.5rem; 485 - border-radius: 4px; 486 - font-size: 0.75rem; 505 + padding: var(--space-1) var(--space-2); 506 + border-radius: var(--radius-md); 507 + font-size: var(--text-xs); 487 508 white-space: nowrap; 488 509 } 510 + 489 511 .status.verified { 490 512 background: var(--success-bg); 491 513 color: var(--success-text); 492 514 } 515 + 493 516 .status.unverified { 494 517 background: var(--warning-bg); 495 518 color: var(--warning-text); 496 519 } 520 + 497 521 .config-hint { 498 - font-size: 0.75rem; 522 + font-size: var(--text-xs); 499 523 color: var(--text-secondary); 500 524 margin: 0; 501 525 } 526 + 502 527 .actions { 503 528 display: flex; 504 529 justify-content: flex-end; 505 530 } 506 - .actions button { 507 - padding: 0.75rem 2rem; 508 - background: var(--accent); 509 - color: white; 510 - border: none; 511 - border-radius: 4px; 512 - font-size: 1rem; 513 - cursor: pointer; 514 - } 515 - .actions button:hover:not(:disabled) { 516 - background: var(--accent-hover); 517 - } 518 - .actions button:disabled { 519 - opacity: 0.6; 520 - cursor: not-allowed; 521 - } 531 + 522 532 .verify-btn { 523 - padding: 0.25rem 0.5rem; 533 + padding: var(--space-1) var(--space-2); 524 534 background: var(--accent); 525 - color: white; 535 + color: var(--text-inverse); 526 536 border: none; 527 - border-radius: 4px; 528 - font-size: 0.75rem; 537 + border-radius: var(--radius-md); 538 + font-size: var(--text-xs); 529 539 cursor: pointer; 530 540 } 541 + 531 542 .verify-btn:hover { 532 543 background: var(--accent-hover); 533 544 } 545 + 534 546 .verify-form { 535 547 display: flex; 536 - gap: 0.5rem; 537 - margin-top: 0.5rem; 548 + gap: var(--space-2); 549 + margin-top: var(--space-2); 538 550 align-items: center; 539 551 } 552 + 540 553 .verify-form input { 541 - padding: 0.5rem; 542 - border: 1px solid var(--border-color-light); 543 - border-radius: 4px; 544 - font-size: 0.875rem; 554 + padding: var(--space-2); 555 + font-size: var(--text-sm); 545 556 width: 150px; 546 - background: var(--bg-input); 547 - color: var(--text-primary); 548 557 } 558 + 549 559 .verify-form button { 550 - padding: 0.5rem 0.75rem; 560 + padding: var(--space-2) var(--space-3); 551 561 background: var(--accent); 552 - color: white; 562 + color: var(--text-inverse); 553 563 border: none; 554 - border-radius: 4px; 555 - font-size: 0.875rem; 564 + border-radius: var(--radius-md); 565 + font-size: var(--text-sm); 556 566 cursor: pointer; 557 567 } 568 + 558 569 .verify-form button:hover { 559 570 background: var(--accent-hover); 560 571 } 572 + 561 573 .verify-form button.cancel { 562 574 background: transparent; 563 575 border: 1px solid var(--border-color); 564 576 color: var(--text-secondary); 565 577 } 578 + 566 579 .verify-form button.cancel:hover { 567 580 background: var(--bg-secondary); 568 581 } 582 + 569 583 .history-section { 570 584 background: var(--bg-secondary); 571 - padding: 1.5rem; 572 - border-radius: 8px; 573 - margin-top: 1.5rem; 585 + padding: var(--space-6); 586 + border-radius: var(--radius-xl); 587 + margin-top: var(--space-6); 574 588 } 589 + 575 590 .history-section h2 { 576 - margin: 0 0 0.5rem 0; 577 - font-size: 1.125rem; 591 + margin: 0 0 var(--space-2) 0; 592 + font-size: var(--text-lg); 578 593 } 594 + 579 595 .load-history { 580 - padding: 0.5rem 1rem; 596 + padding: var(--space-2) var(--space-4); 581 597 background: transparent; 582 598 border: 1px solid var(--border-color); 583 - border-radius: 4px; 599 + border-radius: var(--radius-md); 584 600 cursor: pointer; 585 601 color: var(--text-primary); 586 - margin-top: 0.5rem; 602 + margin-top: var(--space-2); 587 603 } 604 + 588 605 .load-history:hover:not(:disabled) { 589 606 background: var(--bg-card); 590 607 border-color: var(--accent); 591 608 } 609 + 592 610 .load-history:disabled { 593 611 opacity: 0.6; 594 612 cursor: not-allowed; 595 613 } 596 - .no-notifications { 614 + 615 + .no-messages { 597 616 color: var(--text-secondary); 598 617 font-style: italic; 599 - margin-top: 1rem; 618 + margin-top: var(--space-4); 600 619 } 601 - .notification-list { 620 + 621 + .message-list { 602 622 display: flex; 603 623 flex-direction: column; 604 - gap: 0.75rem; 605 - margin-top: 1rem; 624 + gap: var(--space-3); 625 + margin-top: var(--space-4); 606 626 } 607 - .notification-item { 627 + 628 + .message-item { 608 629 background: var(--bg-card); 609 630 border: 1px solid var(--border-color); 610 - border-radius: 4px; 611 - padding: 0.75rem; 631 + border-radius: var(--radius-md); 632 + padding: var(--space-3); 612 633 } 613 - .notification-header { 634 + 635 + .message-header { 614 636 display: flex; 615 - gap: 0.5rem; 616 - margin-bottom: 0.5rem; 637 + gap: var(--space-2); 638 + margin-bottom: var(--space-2); 617 639 flex-wrap: wrap; 618 640 align-items: center; 619 641 } 620 - .notification-type { 621 - font-weight: 500; 622 - font-size: 0.875rem; 642 + 643 + .message-type { 644 + font-weight: var(--font-medium); 645 + font-size: var(--text-sm); 623 646 } 624 - .notification-channel { 625 - font-size: 0.75rem; 626 - padding: 0.125rem 0.375rem; 647 + 648 + .message-channel { 649 + font-size: var(--text-xs); 650 + padding: var(--space-1) var(--space-2); 627 651 background: var(--bg-secondary); 628 - border-radius: 4px; 652 + border-radius: var(--radius-md); 629 653 color: var(--text-secondary); 630 654 } 631 - .notification-status { 632 - font-size: 0.75rem; 633 - padding: 0.125rem 0.375rem; 634 - border-radius: 4px; 655 + 656 + .message-status { 657 + font-size: var(--text-xs); 658 + padding: var(--space-1) var(--space-2); 659 + border-radius: var(--radius-md); 635 660 margin-left: auto; 636 661 } 637 - .notification-status.sent { 662 + 663 + .message-status.sent { 638 664 background: var(--success-bg); 639 665 color: var(--success-text); 640 666 } 641 - .notification-status.failed { 667 + 668 + .message-status.failed { 642 669 background: var(--error-bg); 643 670 color: var(--error-text); 644 671 } 645 - .notification-subject { 646 - font-weight: 500; 647 - font-size: 0.875rem; 648 - margin-bottom: 0.25rem; 672 + 673 + .message-subject { 674 + font-weight: var(--font-medium); 675 + font-size: var(--text-sm); 676 + margin-bottom: var(--space-1); 649 677 } 650 - .notification-body { 651 - font-size: 0.875rem; 678 + 679 + .message-body { 680 + font-size: var(--text-sm); 652 681 color: var(--text-secondary); 653 682 white-space: pre-wrap; 654 683 word-break: break-word; 655 684 } 656 - .notification-date { 657 - font-size: 0.75rem; 685 + 686 + .message-date { 687 + font-size: var(--text-xs); 658 688 color: var(--text-muted); 659 - margin-top: 0.5rem; 689 + margin-top: var(--space-2); 660 690 } 661 691 </style>
+34 -34
frontend/src/routes/OAuth2FA.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 3 4 4 5 let code = $state('') 5 6 let submitting = $state(false) ··· 19 20 e.preventDefault() 20 21 const requestUri = getRequestUri() 21 22 if (!requestUri) { 22 - error = 'Missing request_uri parameter' 23 + error = $_('oauth.twoFactorCode.errors.missingRequestUri') 23 24 return 24 25 } 25 26 ··· 42 43 const data = await response.json() 43 44 44 45 if (!response.ok) { 45 - error = data.error_description || data.error || 'Verification failed' 46 + error = data.error_description || data.error || $_('oauth.twoFactorCode.errors.verificationFailed') 46 47 submitting = false 47 48 return 48 49 } ··· 52 53 return 53 54 } 54 55 55 - error = 'Unexpected response from server' 56 + error = $_('oauth.twoFactorCode.errors.unexpectedResponse') 56 57 submitting = false 57 58 } catch { 58 - error = 'Failed to connect to server' 59 + error = $_('oauth.twoFactorCode.errors.connectionFailed') 59 60 submitting = false 60 61 } 61 62 } ··· 73 74 </script> 74 75 75 76 <div class="oauth-2fa-container"> 76 - <h1>Two-Factor Authentication</h1> 77 + <h1>{$_('oauth.twoFactorCode.title')}</h1> 77 78 <p class="subtitle"> 78 - A verification code has been sent to your {channel}. 79 - Enter the code below to continue. 79 + {$_('oauth.twoFactorCode.subtitle', { values: { channel } })} 80 80 </p> 81 81 82 82 {#if error} ··· 85 85 86 86 <form onsubmit={handleSubmit}> 87 87 <div class="field"> 88 - <label for="code">Verification Code</label> 88 + <label for="code">{$_('oauth.twoFactorCode.codeLabel')}</label> 89 89 <input 90 90 id="code" 91 91 type="text" 92 92 bind:value={code} 93 - placeholder="Enter 6-digit code" 93 + placeholder={$_('oauth.twoFactorCode.codePlaceholder')} 94 94 disabled={submitting} 95 95 required 96 96 maxlength="6" ··· 102 102 103 103 <div class="actions"> 104 104 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 105 - Cancel 105 + {$_('common.cancel')} 106 106 </button> 107 107 <button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}> 108 - {submitting ? 'Verifying...' : 'Verify'} 108 + {submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')} 109 109 </button> 110 110 </div> 111 111 </form> ··· 113 113 114 114 <style> 115 115 .oauth-2fa-container { 116 - max-width: 400px; 117 - margin: 4rem auto; 118 - padding: 2rem; 116 + max-width: var(--width-sm); 117 + margin: var(--space-9) auto; 118 + padding: var(--space-7); 119 119 } 120 120 121 121 h1 { 122 - margin: 0 0 0.5rem 0; 122 + margin: 0 0 var(--space-2) 0; 123 123 } 124 124 125 125 .subtitle { 126 126 color: var(--text-secondary); 127 - margin: 0 0 2rem 0; 127 + margin: 0 0 var(--space-7) 0; 128 128 } 129 129 130 130 form { 131 131 display: flex; 132 132 flex-direction: column; 133 - gap: 1rem; 133 + gap: var(--space-4); 134 134 } 135 135 136 136 .field { 137 137 display: flex; 138 138 flex-direction: column; 139 - gap: 0.25rem; 139 + gap: var(--space-1); 140 140 } 141 141 142 142 label { 143 - font-size: 0.875rem; 144 - font-weight: 500; 143 + font-size: var(--text-sm); 144 + font-weight: var(--font-medium); 145 145 } 146 146 147 147 input { 148 - padding: 0.75rem; 149 - border: 1px solid var(--border-color-light); 150 - border-radius: 4px; 151 - font-size: 1.5rem; 148 + padding: var(--space-3); 149 + border: 1px solid var(--border-color); 150 + border-radius: var(--radius-md); 151 + font-size: var(--text-xl); 152 152 letter-spacing: 0.5em; 153 153 text-align: center; 154 154 background: var(--bg-input); ··· 161 161 } 162 162 163 163 .error { 164 - padding: 0.75rem; 164 + padding: var(--space-3); 165 165 background: var(--error-bg); 166 166 border: 1px solid var(--error-border); 167 - border-radius: 4px; 167 + border-radius: var(--radius-md); 168 168 color: var(--error-text); 169 - margin-bottom: 1rem; 169 + margin-bottom: var(--space-4); 170 170 } 171 171 172 172 .actions { 173 173 display: flex; 174 - gap: 1rem; 175 - margin-top: 0.5rem; 174 + gap: var(--space-4); 175 + margin-top: var(--space-2); 176 176 } 177 177 178 178 .actions button { 179 179 flex: 1; 180 - padding: 0.75rem; 180 + padding: var(--space-3); 181 181 border: none; 182 - border-radius: 4px; 183 - font-size: 1rem; 182 + border-radius: var(--radius-md); 183 + font-size: var(--text-base); 184 184 cursor: pointer; 185 - transition: background-color 0.15s; 185 + transition: background-color var(--transition-fast); 186 186 } 187 187 188 188 .actions button:disabled { ··· 204 204 205 205 .submit-btn { 206 206 background: var(--accent); 207 - color: white; 207 + color: var(--text-inverse); 208 208 } 209 209 210 210 .submit-btn:hover:not(:disabled) {
+29 -28
frontend/src/routes/OAuthAccounts.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 3 4 4 5 interface AccountInfo { 5 6 did: string ··· 113 114 <div class="oauth-accounts-container"> 114 115 {#if loading} 115 116 <div class="loading"> 116 - <p>Loading accounts...</p> 117 + <p>{$_('common.loading')}</p> 117 118 </div> 118 119 {:else if error} 119 120 <div class="error-container"> 120 121 <h1>Error</h1> 121 122 <div class="error">{error}</div> 122 123 <button type="button" onclick={handleDifferentAccount}> 123 - Sign in with different account 124 + {$_('oauth.accounts.useAnother')} 124 125 </button> 125 126 </div> 126 127 {:else} 127 - <h1>Choose an Account</h1> 128 - <p class="subtitle">Select an account to continue</p> 128 + <h1>{$_('oauth.accounts.title')}</h1> 129 + <p class="subtitle">{$_('oauth.accounts.subtitle')}</p> 129 130 130 131 <div class="accounts-list"> 131 132 {#each accounts as account} ··· 144 145 </div> 145 146 146 147 <button type="button" class="secondary different-account" onclick={handleDifferentAccount}> 147 - Sign in to different account 148 + {$_('oauth.accounts.useAnother')} 148 149 </button> 149 150 {/if} 150 151 </div> 151 152 152 153 <style> 153 154 .oauth-accounts-container { 154 - max-width: 400px; 155 - margin: 4rem auto; 156 - padding: 2rem; 155 + max-width: var(--width-sm); 156 + margin: var(--space-9) auto; 157 + padding: var(--space-7); 157 158 } 158 159 159 160 h1 { 160 - margin: 0 0 0.5rem 0; 161 + margin: 0 0 var(--space-2) 0; 161 162 } 162 163 163 164 .subtitle { 164 165 color: var(--text-secondary); 165 - margin: 0 0 2rem 0; 166 + margin: 0 0 var(--space-7) 0; 166 167 } 167 168 168 169 .loading { ··· 178 179 } 179 180 180 181 .error { 181 - padding: 0.75rem; 182 + padding: var(--space-3); 182 183 background: var(--error-bg); 183 184 border: 1px solid var(--error-border); 184 - border-radius: 4px; 185 + border-radius: var(--radius-md); 185 186 color: var(--error-text); 186 - margin-bottom: 1rem; 187 + margin-bottom: var(--space-4); 187 188 } 188 189 189 190 .accounts-list { 190 191 display: flex; 191 192 flex-direction: column; 192 - gap: 0.5rem; 193 - margin-bottom: 1rem; 193 + gap: var(--space-2); 194 + margin-bottom: var(--space-4); 194 195 } 195 196 196 197 .account-item { 197 198 display: flex; 198 199 align-items: center; 199 - padding: 1rem; 200 + padding: var(--space-4); 200 201 background: var(--bg-card); 201 202 border: 1px solid var(--border-color); 202 - border-radius: 8px; 203 + border-radius: var(--radius-xl); 203 204 cursor: pointer; 204 205 text-align: left; 205 206 width: 100%; 206 - transition: border-color 0.15s, box-shadow 0.15s; 207 + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); 207 208 } 208 209 209 210 .account-item:hover:not(.disabled) { 210 211 border-color: var(--accent); 211 - box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15); 212 + box-shadow: var(--shadow-sm); 212 213 } 213 214 214 215 .account-item.disabled { ··· 219 220 .account-info { 220 221 display: flex; 221 222 flex-direction: column; 222 - gap: 0.25rem; 223 + gap: var(--space-1); 223 224 } 224 225 225 226 .account-handle { 226 - font-weight: 500; 227 + font-weight: var(--font-medium); 227 228 color: var(--text-primary); 228 229 } 229 230 230 231 .account-email { 231 - font-size: 0.875rem; 232 + font-size: var(--text-sm); 232 233 color: var(--text-secondary); 233 234 } 234 235 235 236 button { 236 - padding: 0.75rem; 237 + padding: var(--space-3); 237 238 background: var(--accent); 238 - color: white; 239 + color: var(--text-inverse); 239 240 border: none; 240 - border-radius: 4px; 241 - font-size: 1rem; 241 + border-radius: var(--radius-md); 242 + font-size: var(--text-base); 242 243 cursor: pointer; 243 244 } 244 245 ··· 260 261 261 262 button.secondary:hover:not(:disabled) { 262 263 background: var(--accent); 263 - color: white; 264 + color: var(--text-inverse); 264 265 } 265 266 266 267 .different-account { 267 - margin-top: 1rem; 268 + margin-top: var(--space-4); 268 269 } 269 270 </style>
+65 -64
frontend/src/routes/OAuthConsent.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 3 4 4 5 interface ScopeInfo { 5 6 scope: string ··· 36 37 async function fetchConsentData() { 37 38 const requestUri = getRequestUri() 38 39 if (!requestUri) { 39 - error = 'Missing request_uri parameter' 40 + error = $_('oauth.error.genericError') 40 41 loading = false 41 42 return 42 43 } ··· 45 46 const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) 46 47 if (!response.ok) { 47 48 const data = await response.json() 48 - error = data.error_description || data.error || 'Failed to load consent data' 49 + error = data.error_description || data.error || $_('oauth.error.genericError') 49 50 loading = false 50 51 return 51 52 } ··· 66 67 await submitConsent() 67 68 } 68 69 } catch { 69 - error = 'Failed to connect to server' 70 + error = $_('oauth.error.genericError') 70 71 } finally { 71 72 loading = false 72 73 } ··· 93 94 94 95 if (!response.ok) { 95 96 const data = await response.json() 96 - error = data.error_description || data.error || 'Authorization failed' 97 + error = data.error_description || data.error || $_('oauth.error.genericError') 97 98 submitting = false 98 99 return 99 100 } ··· 103 104 window.location.href = data.redirect_uri 104 105 } 105 106 } catch { 106 - error = 'Failed to complete authorization' 107 + error = $_('oauth.error.genericError') 107 108 submitting = false 108 109 } 109 110 } ··· 123 124 window.location.href = response.url 124 125 } 125 126 } catch { 126 - error = 'Failed to deny authorization' 127 + error = $_('oauth.error.genericError') 127 128 submitting = false 128 129 } 129 130 } ··· 155 156 <div class="consent-container"> 156 157 {#if loading} 157 158 <div class="loading"> 158 - <p>Loading...</p> 159 + <p>{$_('common.loading')}</p> 159 160 </div> 160 161 {:else if error} 161 162 <div class="error-container"> 162 - <h1>Authorization Error</h1> 163 + <h1>{$_('oauth.error.title')}</h1> 163 164 <div class="error">{error}</div> 164 165 <button type="button" onclick={() => navigate('/login')}> 165 - Return to Login 166 + {$_('verify.backToLogin')} 166 167 </button> 167 168 </div> 168 169 {:else if consentData} ··· 170 171 {#if consentData.logo_uri} 171 172 <img src={consentData.logo_uri} alt="" class="client-logo" /> 172 173 {/if} 173 - <h1>{consentData.client_name || 'Application'}</h1> 174 - <p class="subtitle">wants to access your account</p> 174 + <h1>{consentData.client_name || $_('oauth.consent.title')}</h1> 175 + <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p> 175 176 {#if consentData.client_uri} 176 177 <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link"> 177 178 {consentData.client_uri} ··· 180 181 </div> 181 182 182 183 <div class="account-info"> 183 - <span class="label">Signing in as:</span> 184 + <span class="label">{$_('oauth.consent.signingInAs')}</span> 184 185 <span class="did">{consentData.did}</span> 185 186 </div> 186 187 187 188 <div class="scopes-section"> 188 - <h2>Permissions Requested</h2> 189 + <h2>{$_('oauth.consent.permissionsRequested')}</h2> 189 190 {#each Object.entries(scopeGroups) as [category, scopes]} 190 191 <div class="scope-group"> 191 192 <h3 class="category-title">{category}</h3> ··· 201 202 <span class="scope-name">{scope.display_name}</span> 202 203 <span class="scope-description">{scope.description}</span> 203 204 {#if scope.required} 204 - <span class="required-badge">Required</span> 205 + <span class="required-badge">{$_('oauth.consent.required')}</span> 205 206 {/if} 206 207 </div> 207 208 </label> ··· 212 213 213 214 <label class="remember-choice"> 214 215 <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} /> 215 - <span>Remember my choice for this application</span> 216 + <span>{$_('oauth.consent.rememberChoiceLabel')}</span> 216 217 </label> 217 218 218 219 <div class="actions"> 219 220 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}> 220 - Deny 221 + {$_('oauth.consent.deny')} 221 222 </button> 222 223 <button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}> 223 - {submitting ? 'Authorizing...' : 'Authorize'} 224 + {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')} 224 225 </button> 225 226 </div> 226 227 {/if} ··· 229 230 <style> 230 231 .consent-container { 231 232 max-width: 480px; 232 - margin: 2rem auto; 233 - padding: 2rem; 233 + margin: var(--space-7) auto; 234 + padding: var(--space-7); 234 235 } 235 236 236 237 .loading { ··· 246 247 } 247 248 248 249 .error { 249 - padding: 0.75rem; 250 + padding: var(--space-3); 250 251 background: var(--error-bg); 251 252 border: 1px solid var(--error-border); 252 - border-radius: 4px; 253 + border-radius: var(--radius-md); 253 254 color: var(--error-text); 254 - margin-bottom: 1rem; 255 + margin-bottom: var(--space-4); 255 256 } 256 257 257 258 .client-info { 258 259 text-align: center; 259 - margin-bottom: 1.5rem; 260 + margin-bottom: var(--space-6); 260 261 } 261 262 262 263 .client-logo { 263 264 width: 64px; 264 265 height: 64px; 265 - border-radius: 12px; 266 - margin-bottom: 1rem; 266 + border-radius: var(--radius-xl); 267 + margin-bottom: var(--space-4); 267 268 } 268 269 269 270 .client-info h1 { 270 - margin: 0 0 0.25rem 0; 271 - font-size: 1.5rem; 271 + margin: 0 0 var(--space-1) 0; 272 + font-size: var(--text-xl); 272 273 } 273 274 274 275 .subtitle { ··· 278 279 279 280 .client-link { 280 281 display: inline-block; 281 - margin-top: 0.5rem; 282 - font-size: 0.875rem; 282 + margin-top: var(--space-2); 283 + font-size: var(--text-sm); 283 284 color: var(--accent); 284 285 text-decoration: none; 285 286 } ··· 291 292 .account-info { 292 293 display: flex; 293 294 flex-direction: column; 294 - gap: 0.25rem; 295 - padding: 1rem; 295 + gap: var(--space-1); 296 + padding: var(--space-4); 296 297 background: var(--bg-secondary); 297 - border-radius: 8px; 298 - margin-bottom: 1.5rem; 298 + border-radius: var(--radius-xl); 299 + margin-bottom: var(--space-6); 299 300 } 300 301 301 302 .account-info .label { 302 - font-size: 0.75rem; 303 + font-size: var(--text-xs); 303 304 color: var(--text-muted); 304 305 text-transform: uppercase; 305 306 letter-spacing: 0.05em; ··· 307 308 308 309 .account-info .did { 309 310 font-family: monospace; 310 - font-size: 0.875rem; 311 + font-size: var(--text-sm); 311 312 color: var(--text-primary); 312 313 word-break: break-all; 313 314 } 314 315 315 316 .scopes-section { 316 - margin-bottom: 1.5rem; 317 + margin-bottom: var(--space-6); 317 318 } 318 319 319 320 .scopes-section h2 { 320 - font-size: 1rem; 321 - margin: 0 0 1rem 0; 321 + font-size: var(--text-base); 322 + margin: 0 0 var(--space-4) 0; 322 323 color: var(--text-secondary); 323 324 } 324 325 325 326 .scope-group { 326 - margin-bottom: 1rem; 327 + margin-bottom: var(--space-4); 327 328 } 328 329 329 330 .category-title { 330 - font-size: 0.875rem; 331 - font-weight: 600; 331 + font-size: var(--text-sm); 332 + font-weight: var(--font-semibold); 332 333 color: var(--text-primary); 333 - margin: 0 0 0.5rem 0; 334 - padding-bottom: 0.25rem; 334 + margin: 0 0 var(--space-2) 0; 335 + padding-bottom: var(--space-1); 335 336 border-bottom: 1px solid var(--border-color); 336 337 } 337 338 338 339 .scope-item { 339 340 display: flex; 340 - gap: 0.75rem; 341 - padding: 0.75rem; 341 + gap: var(--space-3); 342 + padding: var(--space-3); 342 343 background: var(--bg-card); 343 344 border: 1px solid var(--border-color); 344 - border-radius: 6px; 345 - margin-bottom: 0.5rem; 345 + border-radius: var(--radius-lg); 346 + margin-bottom: var(--space-2); 346 347 cursor: pointer; 347 - transition: border-color 0.15s; 348 + transition: border-color var(--transition-fast); 348 349 } 349 350 350 351 .scope-item:hover:not(.required) { ··· 366 367 flex: 1; 367 368 display: flex; 368 369 flex-direction: column; 369 - gap: 0.125rem; 370 + gap: 2px; 370 371 } 371 372 372 373 .scope-name { 373 - font-weight: 500; 374 + font-weight: var(--font-medium); 374 375 color: var(--text-primary); 375 376 } 376 377 377 378 .scope-description { 378 - font-size: 0.875rem; 379 + font-size: var(--text-sm); 379 380 color: var(--text-secondary); 380 381 } 381 382 382 383 .required-badge { 383 384 display: inline-block; 384 385 font-size: 0.625rem; 385 - padding: 0.125rem 0.375rem; 386 + padding: 2px var(--space-2); 386 387 background: var(--warning-bg); 387 388 color: var(--warning-text); 388 - border-radius: 3px; 389 + border-radius: var(--radius-sm); 389 390 text-transform: uppercase; 390 391 letter-spacing: 0.05em; 391 - margin-top: 0.25rem; 392 + margin-top: var(--space-1); 392 393 width: fit-content; 393 394 } 394 395 395 396 .remember-choice { 396 397 display: flex; 397 398 align-items: center; 398 - gap: 0.5rem; 399 - margin-bottom: 1.5rem; 399 + gap: var(--space-2); 400 + margin-bottom: var(--space-6); 400 401 cursor: pointer; 401 402 color: var(--text-secondary); 402 - font-size: 0.875rem; 403 + font-size: var(--text-sm); 403 404 } 404 405 405 406 .remember-choice input { ··· 409 410 410 411 .actions { 411 412 display: flex; 412 - gap: 1rem; 413 + gap: var(--space-4); 413 414 } 414 415 415 416 .actions button { 416 417 flex: 1; 417 - padding: 0.875rem; 418 + padding: var(--space-3); 418 419 border: none; 419 - border-radius: 6px; 420 - font-size: 1rem; 421 - font-weight: 500; 420 + border-radius: var(--radius-lg); 421 + font-size: var(--text-base); 422 + font-weight: var(--font-medium); 422 423 cursor: pointer; 423 - transition: background-color 0.15s; 424 + transition: background-color var(--transition-fast); 424 425 } 425 426 426 427 .actions button:disabled { ··· 442 443 443 444 .approve-btn { 444 445 background: var(--accent); 445 - color: white; 446 + color: var(--text-inverse); 446 447 } 447 448 448 449 .approve-btn:hover:not(:disabled) {
+18 -16
frontend/src/routes/OAuthError.svelte
··· 1 1 <script lang="ts"> 2 + import { _ } from '../lib/i18n' 3 + 2 4 function getError(): string { 3 5 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 4 6 return params.get('error') || 'Unknown error' ··· 18 20 </script> 19 21 20 22 <div class="oauth-error-container"> 21 - <h1>Authorization Error</h1> 23 + <h1>{$_('oauth.error.title')}</h1> 22 24 23 25 <div class="error-box"> 24 26 <div class="error-code">{error}</div> ··· 28 30 </div> 29 31 30 32 <button type="button" onclick={handleBack}> 31 - Go Back 33 + {$_('oauth.error.tryAgain')} 32 34 </button> 33 35 </div> 34 36 35 37 <style> 36 38 .oauth-error-container { 37 - max-width: 400px; 38 - margin: 4rem auto; 39 - padding: 2rem; 39 + max-width: var(--width-sm); 40 + margin: var(--space-9) auto; 41 + padding: var(--space-7); 40 42 text-align: center; 41 43 } 42 44 43 45 h1 { 44 - margin: 0 0 1.5rem 0; 46 + margin: 0 0 var(--space-6) 0; 45 47 color: var(--error-text); 46 48 } 47 49 48 50 .error-box { 49 - padding: 1.5rem; 51 + padding: var(--space-6); 50 52 background: var(--error-bg); 51 53 border: 1px solid var(--error-border); 52 - border-radius: 8px; 53 - margin-bottom: 1.5rem; 54 + border-radius: var(--radius-xl); 55 + margin-bottom: var(--space-6); 54 56 } 55 57 56 58 .error-code { 57 59 font-family: monospace; 58 - font-size: 1rem; 60 + font-size: var(--text-base); 59 61 color: var(--error-text); 60 - margin-bottom: 0.5rem; 62 + margin-bottom: var(--space-2); 61 63 } 62 64 63 65 .error-description { 64 66 color: var(--text-secondary); 65 - font-size: 0.875rem; 67 + font-size: var(--text-sm); 66 68 } 67 69 68 70 button { 69 - padding: 0.75rem 1.5rem; 71 + padding: var(--space-3) var(--space-6); 70 72 background: var(--accent); 71 - color: white; 73 + color: var(--text-inverse); 72 74 border: none; 73 - border-radius: 4px; 74 - font-size: 1rem; 75 + border-radius: var(--radius-md); 76 + font-size: var(--text-base); 75 77 cursor: pointer; 76 78 } 77 79
+62 -61
frontend/src/routes/OAuthLogin.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 3 4 4 5 let username = $state('') 5 6 let password = $state('') ··· 97 98 async function handlePasskeyLogin() { 98 99 const requestUri = getRequestUri() 99 100 if (!requestUri || !username) { 100 - error = 'Missing required parameters' 101 + error = $_('common.error') 101 102 return 102 103 } 103 104 ··· 131 132 }) as PublicKeyCredential | null 132 133 133 134 if (!credential) { 134 - error = 'Passkey authentication was cancelled' 135 + error = $_('common.error') 135 136 submitting = false 136 137 return 137 138 } ··· 184 185 return 185 186 } 186 187 187 - error = 'Unexpected response from server' 188 + error = $_('common.error') 188 189 submitting = false 189 190 } catch (e) { 190 191 console.error('Passkey login error:', e) 191 192 if (e instanceof DOMException && e.name === 'NotAllowedError') { 192 - error = 'Passkey authentication was cancelled' 193 + error = $_('common.error') 193 194 } else { 194 - error = `Failed to authenticate with passkey: ${e instanceof Error ? e.message : String(e)}` 195 + error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}` 195 196 } 196 197 submitting = false 197 198 } ··· 232 233 e.preventDefault() 233 234 const requestUri = getRequestUri() 234 235 if (!requestUri) { 235 - error = 'Missing request_uri parameter' 236 + error = $_('common.error') 236 237 return 237 238 } 238 239 ··· 277 278 return 278 279 } 279 280 280 - error = 'Unexpected response from server' 281 + error = $_('common.error') 281 282 submitting = false 282 283 } catch { 283 - error = 'Failed to connect to server' 284 + error = $_('common.error') 284 285 submitting = false 285 286 } 286 287 } ··· 314 315 </script> 315 316 316 317 <div class="oauth-login-container"> 317 - <h1>Sign In</h1> 318 + <h1>{$_('oauth.login.title')}</h1> 318 319 <p class="subtitle"> 319 320 {#if clientName} 320 - Sign in to continue to <strong>{clientName}</strong> 321 + {$_('oauth.login.subtitle')} <strong>{clientName}</strong> 321 322 {:else} 322 - Sign in to continue to the application 323 + {$_('oauth.login.subtitle')} 323 324 {/if} 324 325 </p> 325 326 ··· 329 330 330 331 <form onsubmit={handleSubmit}> 331 332 <div class="field"> 332 - <label for="username">Handle or Email</label> 333 + <label for="username">{$_('register.handle')}</label> 333 334 <input 334 335 id="username" 335 336 type="text" 336 337 bind:value={username} 337 - placeholder="you@example.com or handle" 338 + placeholder={$_('register.emailPlaceholder')} 338 339 disabled={submitting} 339 340 required 340 341 autocomplete="username" ··· 348 349 class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 349 350 onclick={handlePasskeyLogin} 350 351 disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 351 - title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 + title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')} 352 353 > 353 354 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 355 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> ··· 357 358 </svg> 358 359 <span class="passkey-text"> 359 360 {#if submitting} 360 - Authenticating... 361 + {$_('oauth.login.authenticating')} 361 362 {:else if checkingSecurityStatus || !securityStatusChecked} 362 - Checking passkey... 363 + {$_('oauth.login.checkingPasskey')} 363 364 {:else if hasPasskeys} 364 - Sign in with passkey 365 + {$_('oauth.login.signInWithPasskey')} 365 366 {:else} 366 - Passkey not set up 367 + {$_('oauth.login.passkeyNotSetUp')} 367 368 {/if} 368 369 </span> 369 370 </button> 370 371 371 372 <div class="auth-divider"> 372 - <span>or use password</span> 373 + <span>{$_('oauth.login.orUsePassword')}</span> 373 374 </div> 374 375 {/if} 375 376 376 377 <div class="field"> 377 - <label for="password">Password</label> 378 + <label for="password">{$_('oauth.login.password')}</label> 378 379 <input 379 380 id="password" 380 381 type="password" ··· 387 388 388 389 <label class="remember-device"> 389 390 <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 390 - <span>Remember this device</span> 391 + <span>{$_('oauth.login.rememberDevice')}</span> 391 392 </label> 392 393 393 394 <div class="actions"> 394 395 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 395 - Cancel 396 + {$_('common.cancel')} 396 397 </button> 397 398 <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 398 - {submitting ? 'Signing in...' : 'Sign In'} 399 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 399 400 </button> 400 401 </div> 401 402 </form> 402 403 403 404 <p class="help-links"> 404 - <a href="#/reset-password">Forgot password?</a> &middot; <a href="#/request-passkey-recovery">Lost passkey?</a> 405 + <a href="#/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 405 406 </p> 406 407 </div> 407 408 408 409 <style> 409 410 .help-links { 410 411 text-align: center; 411 - margin-top: 1rem; 412 - font-size: 0.875rem; 412 + margin-top: var(--space-4); 413 + font-size: var(--text-sm); 413 414 } 414 415 415 416 .help-links a { ··· 422 423 } 423 424 424 425 .oauth-login-container { 425 - max-width: 400px; 426 - margin: 4rem auto; 427 - padding: 2rem; 426 + max-width: var(--width-sm); 427 + margin: var(--space-9) auto; 428 + padding: var(--space-7); 428 429 } 429 430 430 431 h1 { 431 - margin: 0 0 0.5rem 0; 432 + margin: 0 0 var(--space-2) 0; 432 433 } 433 434 434 435 .subtitle { 435 436 color: var(--text-secondary); 436 - margin: 0 0 2rem 0; 437 + margin: 0 0 var(--space-7) 0; 437 438 } 438 439 439 440 form { 440 441 display: flex; 441 442 flex-direction: column; 442 - gap: 1rem; 443 + gap: var(--space-4); 443 444 } 444 445 445 446 .field { 446 447 display: flex; 447 448 flex-direction: column; 448 - gap: 0.25rem; 449 + gap: var(--space-1); 449 450 } 450 451 451 452 label { 452 - font-size: 0.875rem; 453 - font-weight: 500; 453 + font-size: var(--text-sm); 454 + font-weight: var(--font-medium); 454 455 } 455 456 456 457 input[type="text"], 457 458 input[type="password"] { 458 - padding: 0.75rem; 459 - border: 1px solid var(--border-color-light); 460 - border-radius: 4px; 461 - font-size: 1rem; 459 + padding: var(--space-3); 460 + border: 1px solid var(--border-color); 461 + border-radius: var(--radius-md); 462 + font-size: var(--text-base); 462 463 background: var(--bg-input); 463 464 color: var(--text-primary); 464 465 } ··· 471 472 .remember-device { 472 473 display: flex; 473 474 align-items: center; 474 - gap: 0.5rem; 475 + gap: var(--space-2); 475 476 cursor: pointer; 476 477 color: var(--text-secondary); 477 - font-size: 0.875rem; 478 + font-size: var(--text-sm); 478 479 } 479 480 480 481 .remember-device input { ··· 483 484 } 484 485 485 486 .error { 486 - padding: 0.75rem; 487 + padding: var(--space-3); 487 488 background: var(--error-bg); 488 489 border: 1px solid var(--error-border); 489 - border-radius: 4px; 490 + border-radius: var(--radius-md); 490 491 color: var(--error-text); 491 - margin-bottom: 1rem; 492 + margin-bottom: var(--space-4); 492 493 } 493 494 494 495 .actions { 495 496 display: flex; 496 - gap: 1rem; 497 - margin-top: 0.5rem; 497 + gap: var(--space-4); 498 + margin-top: var(--space-2); 498 499 } 499 500 500 501 .actions button { 501 502 flex: 1; 502 - padding: 0.75rem; 503 + padding: var(--space-3); 503 504 border: none; 504 - border-radius: 4px; 505 - font-size: 1rem; 505 + border-radius: var(--radius-md); 506 + font-size: var(--text-base); 506 507 cursor: pointer; 507 - transition: background-color 0.15s; 508 + transition: background-color var(--transition-fast); 508 509 } 509 510 510 511 .actions button:disabled { ··· 526 527 527 528 .submit-btn { 528 529 background: var(--accent); 529 - color: white; 530 + color: var(--text-inverse); 530 531 } 531 532 532 533 .submit-btn:hover:not(:disabled) { ··· 536 537 .auth-divider { 537 538 display: flex; 538 539 align-items: center; 539 - gap: 1rem; 540 - margin: 0.5rem 0; 540 + gap: var(--space-4); 541 + margin: var(--space-2) 0; 541 542 } 542 543 543 544 .auth-divider::before, ··· 545 546 content: ''; 546 547 flex: 1; 547 548 height: 1px; 548 - background: var(--border-color-light); 549 + background: var(--border-color); 549 550 } 550 551 551 552 .auth-divider span { 552 553 color: var(--text-secondary); 553 - font-size: 0.875rem; 554 + font-size: var(--text-sm); 554 555 } 555 556 556 557 .passkey-btn { 557 558 display: flex; 558 559 align-items: center; 559 560 justify-content: center; 560 - gap: 0.5rem; 561 + gap: var(--space-2); 561 562 width: 100%; 562 - padding: 0.75rem; 563 + padding: var(--space-3); 563 564 background: var(--accent); 564 - color: white; 565 + color: var(--text-inverse); 565 566 border: 1px solid var(--accent); 566 - border-radius: 4px; 567 - font-size: 1rem; 567 + border-radius: var(--radius-md); 568 + font-size: var(--text-base); 568 569 cursor: pointer; 569 - transition: background-color 0.15s, border-color 0.15s, opacity 0.15s; 570 + transition: background-color var(--transition-fast), border-color var(--transition-fast), opacity var(--transition-fast); 570 571 } 571 572 572 573 .passkey-btn:hover:not(:disabled) {
+16 -17
frontend/src/routes/OAuthPasskey.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 3 4 4 5 let loading = $state(false) 5 6 let error = $state<string | null>(null) ··· 9 10 const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 10 11 return params.get('request_uri') 11 12 } 13 + 14 + const t = $_ 12 15 13 16 function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 14 17 const bytes = new Uint8Array(buffer) ··· 44 47 async function startPasskeyAuth() { 45 48 const requestUri = getRequestUri() 46 49 if (!requestUri) { 47 - error = 'Missing request_uri parameter' 50 + error = t('common.error') 48 51 return 49 52 } 50 53 51 54 if (!window.PublicKeyCredential) { 52 - error = 'Passkeys are not supported in this browser' 55 + error = t('common.error') 53 56 return 54 57 } 55 58 ··· 66 69 67 70 if (!startResponse.ok) { 68 71 const data = await startResponse.json() 69 - error = data.error_description || data.error || 'Failed to start passkey authentication' 72 + error = data.error_description || data.error || t('common.error') 70 73 loading = false 71 74 return 72 75 } ··· 79 82 }) 80 83 81 84 if (!credential) { 82 - error = 'Passkey authentication was cancelled' 85 + error = t('common.error') 83 86 loading = false 84 87 return 85 88 } ··· 113 116 const finishData = await finishResponse.json() 114 117 115 118 if (!finishResponse.ok) { 116 - error = finishData.error_description || finishData.error || 'Passkey verification failed' 119 + error = finishData.error_description || finishData.error || t('common.error') 117 120 loading = false 118 121 return 119 122 } ··· 123 126 return 124 127 } 125 128 126 - error = 'Unexpected response from server' 129 + error = t('common.error') 127 130 loading = false 128 131 } catch (e) { 129 132 if (e instanceof DOMException && e.name === 'NotAllowedError') { 130 - error = 'Passkey authentication was cancelled' 133 + error = t('common.error') 131 134 } else { 132 - error = 'Failed to authenticate with passkey' 135 + error = t('common.error') 133 136 } 134 137 loading = false 135 138 } ··· 153 156 </script> 154 157 155 158 <div class="oauth-passkey-container"> 156 - <h1>Sign In with Passkey</h1> 159 + <h1>{t('oauth.passkey.title')}</h1> 157 160 <p class="subtitle"> 158 - Your account uses a passkey for authentication. Use your fingerprint, face, or security key to sign in. 161 + {t('oauth.passkey.subtitle')} 159 162 </p> 160 163 161 164 {#if error} ··· 166 169 {#if loading} 167 170 <div class="loading-indicator"> 168 171 <div class="spinner"></div> 169 - <p>Waiting for passkey...</p> 172 + <p>{t('oauth.passkey.waiting')}</p> 170 173 </div> 171 174 {:else} 172 175 <button type="button" class="passkey-btn" onclick={startPasskeyAuth} disabled={loading}> 173 - Use Passkey 176 + {t('oauth.passkey.title')} 174 177 </button> 175 178 {/if} 176 179 </div> 177 180 178 181 <div class="actions"> 179 182 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={loading}> 180 - Cancel 183 + {t('common.cancel')} 181 184 </button> 182 185 </div> 183 - 184 - <p class="help-text"> 185 - If you've lost access to your passkey, you can recover your account using email. 186 - </p> 187 186 </div> 188 187 189 188 <style>
+43 -42
frontend/src/routes/OAuthTotp.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 3 4 4 5 let code = $state('') 5 6 let trustDevice = $state(false) ··· 15 16 e.preventDefault() 16 17 const requestUri = getRequestUri() 17 18 if (!requestUri) { 18 - error = 'Missing request_uri parameter' 19 + error = $_('common.error') 19 20 return 20 21 } 21 22 ··· 39 40 const data = await response.json() 40 41 41 42 if (!response.ok) { 42 - error = data.error_description || data.error || 'Verification failed' 43 + error = data.error_description || data.error || $_('common.error') 43 44 submitting = false 44 45 return 45 46 } ··· 49 50 return 50 51 } 51 52 52 - error = 'Unexpected response from server' 53 + error = $_('common.error') 53 54 submitting = false 54 55 } catch { 55 - error = 'Failed to connect to server' 56 + error = $_('common.error') 56 57 submitting = false 57 58 } 58 59 } ··· 72 73 </script> 73 74 74 75 <div class="oauth-totp-container"> 75 - <h1>Two-Factor Authentication</h1> 76 + <h1>{$_('oauth.totp.title')}</h1> 76 77 <p class="subtitle"> 77 - Enter the 6-digit code from your authenticator app, or use a backup code. 78 + {$_('oauth.totp.subtitle')} 78 79 </p> 79 80 80 81 {#if error} ··· 83 84 84 85 <form onsubmit={handleSubmit}> 85 86 <div class="field"> 86 - <label for="code">Verification Code</label> 87 + <label for="code">{$_('oauth.totp.codePlaceholder')}</label> 87 88 <input 88 89 id="code" 89 90 type="text" 90 91 bind:value={code} 91 - placeholder="Enter code" 92 + placeholder={isBackupCode ? $_('oauth.totp.backupCodePlaceholder') : $_('oauth.totp.codePlaceholder')} 92 93 disabled={submitting} 93 94 required 94 95 maxlength="8" ··· 97 98 /> 98 99 <p class="hint"> 99 100 {#if isBackupCode} 100 - Using backup code 101 + {$_('oauth.totp.hintBackupCode')} 101 102 {:else if isTotpCode} 102 - Using authenticator code 103 + {$_('oauth.totp.hintTotpCode')} 103 104 {:else} 104 - 6 digits for authenticator, 8 characters for backup code 105 + {$_('oauth.totp.hintDefault')} 105 106 {/if} 106 107 </p> 107 108 </div> ··· 112 113 bind:checked={trustDevice} 113 114 disabled={submitting} 114 115 /> 115 - <span>Trust this device for 30 days</span> 116 + <span>{$_('oauth.totp.trustDevice')}</span> 116 117 </label> 117 118 118 119 <div class="actions"> 119 120 <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 120 - Cancel 121 + {$_('common.cancel')} 121 122 </button> 122 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 123 - {submitting ? 'Verifying...' : 'Verify'} 124 + {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')} 124 125 </button> 125 126 </div> 126 127 </form> ··· 128 129 129 130 <style> 130 131 .oauth-totp-container { 131 - max-width: 400px; 132 - margin: 4rem auto; 133 - padding: 2rem; 132 + max-width: var(--width-sm); 133 + margin: var(--space-9) auto; 134 + padding: var(--space-7); 134 135 } 135 136 136 137 h1 { 137 - margin: 0 0 0.5rem 0; 138 + margin: 0 0 var(--space-2) 0; 138 139 } 139 140 140 141 .subtitle { 141 142 color: var(--text-secondary); 142 - margin: 0 0 2rem 0; 143 + margin: 0 0 var(--space-7) 0; 143 144 } 144 145 145 146 form { 146 147 display: flex; 147 148 flex-direction: column; 148 - gap: 1rem; 149 + gap: var(--space-4); 149 150 } 150 151 151 152 .field { 152 153 display: flex; 153 154 flex-direction: column; 154 - gap: 0.25rem; 155 + gap: var(--space-1); 155 156 } 156 157 157 158 label { 158 - font-size: 0.875rem; 159 - font-weight: 500; 159 + font-size: var(--text-sm); 160 + font-weight: var(--font-medium); 160 161 } 161 162 162 163 input { 163 - padding: 0.75rem; 164 - border: 1px solid var(--border-color-light); 165 - border-radius: 4px; 166 - font-size: 1.5rem; 164 + padding: var(--space-3); 165 + border: 1px solid var(--border-color); 166 + border-radius: var(--radius-md); 167 + font-size: var(--text-xl); 167 168 letter-spacing: 0.25em; 168 169 text-align: center; 169 170 background: var(--bg-input); ··· 177 178 } 178 179 179 180 .hint { 180 - font-size: 0.75rem; 181 + font-size: var(--text-xs); 181 182 color: var(--text-muted); 182 - margin: 0.25rem 0 0 0; 183 + margin: var(--space-1) 0 0 0; 183 184 text-align: center; 184 185 } 185 186 186 187 .error { 187 - padding: 0.75rem; 188 + padding: var(--space-3); 188 189 background: var(--error-bg); 189 190 border: 1px solid var(--error-border); 190 - border-radius: 4px; 191 + border-radius: var(--radius-md); 191 192 color: var(--error-text); 192 - margin-bottom: 1rem; 193 + margin-bottom: var(--space-4); 193 194 } 194 195 195 196 .actions { 196 197 display: flex; 197 - gap: 1rem; 198 - margin-top: 0.5rem; 198 + gap: var(--space-4); 199 + margin-top: var(--space-2); 199 200 } 200 201 201 202 .actions button { 202 203 flex: 1; 203 - padding: 0.75rem; 204 + padding: var(--space-3); 204 205 border: none; 205 - border-radius: 4px; 206 - font-size: 1rem; 206 + border-radius: var(--radius-md); 207 + font-size: var(--text-base); 207 208 cursor: pointer; 208 - transition: background-color 0.15s; 209 + transition: background-color var(--transition-fast); 209 210 } 210 211 211 212 .actions button:disabled { ··· 227 228 228 229 .submit-btn { 229 230 background: var(--accent); 230 - color: white; 231 + color: var(--text-inverse); 231 232 } 232 233 233 234 .submit-btn:hover:not(:disabled) { ··· 237 238 .trust-device-label { 238 239 display: flex; 239 240 align-items: center; 240 - gap: 0.5rem; 241 + gap: var(--space-2); 241 242 cursor: pointer; 242 - font-size: 0.875rem; 243 + font-size: var(--text-sm); 243 244 color: var(--text-secondary); 244 - margin-top: 0.5rem; 245 + margin-top: var(--space-2); 245 246 } 246 247 247 248 .trust-device-label input[type="checkbox"] {
+45 -110
frontend/src/routes/RecoverPasskey.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 3 import { api, ApiError } from '../lib/api' 4 + import { _ } from '../lib/i18n' 4 5 5 6 let newPassword = $state('') 6 7 let confirmPassword = $state('') ··· 19 20 let { did, token } = getUrlParams() 20 21 21 22 function validateForm(): string | null { 22 - if (!newPassword) return 'New password is required' 23 - if (newPassword.length < 8) return 'Password must be at least 8 characters' 24 - if (newPassword !== confirmPassword) return 'Passwords do not match' 23 + if (!newPassword) return $_('recoverPasskey.validation.passwordRequired') 24 + if (newPassword.length < 8) return $_('recoverPasskey.validation.passwordLength') 25 + if (newPassword !== confirmPassword) return $_('recoverPasskey.validation.passwordsMismatch') 25 26 return null 26 27 } 27 28 ··· 29 30 e.preventDefault() 30 31 31 32 if (!did || !token) { 32 - error = 'Invalid recovery link. Please request a new one.' 33 + error = $_('recoverPasskey.errors.invalidLink') 33 34 return 34 35 } 35 36 ··· 48 49 } catch (err) { 49 50 if (err instanceof ApiError) { 50 51 if (err.error === 'RecoveryLinkExpired') { 51 - error = 'This recovery link has expired. Please request a new one.' 52 + error = $_('recoverPasskey.errors.expired') 52 53 } else if (err.error === 'InvalidRecoveryLink') { 53 - error = 'Invalid recovery link. Please request a new one.' 54 + error = $_('recoverPasskey.errors.invalidLink') 54 55 } else { 55 - error = err.message || 'Recovery failed' 56 + error = err.message || $_('common.error') 56 57 } 57 58 } else if (err instanceof Error) { 58 - error = err.message || 'Recovery failed' 59 + error = err.message || $_('common.error') 59 60 } else { 60 - error = 'Recovery failed' 61 + error = $_('common.error') 61 62 } 62 63 } finally { 63 64 submitting = false ··· 73 74 } 74 75 </script> 75 76 76 - <div class="recover-container"> 77 + <div class="recover-page"> 77 78 {#if !did || !token} 78 - <h1>Invalid Recovery Link</h1> 79 - <p class="error-message"> 80 - This recovery link is invalid or has been corrupted. Please request a new recovery email. 81 - </p> 82 - <button onclick={requestNewLink}>Go to Login</button> 79 + <h1>{$_('recoverPasskey.invalidLinkTitle')}</h1> 80 + <p class="error-message">{$_('recoverPasskey.invalidLinkMessage')}</p> 81 + <button onclick={requestNewLink}>{$_('recoverPasskey.goToLogin')}</button> 83 82 {:else if success} 84 83 <div class="success-content"> 85 84 <div class="success-icon">&#x2714;</div> 86 - <h1>Password Set!</h1> 87 - <p class="success-message"> 88 - Your temporary password has been set. You can now sign in with this password. 89 - </p> 90 - <p class="next-steps"> 91 - After signing in, we recommend adding a new passkey in your security settings 92 - to restore passkey-only authentication. 93 - </p> 94 - <button onclick={goToLogin}>Sign In</button> 85 + <h1>{$_('recoverPasskey.successTitle')}</h1> 86 + <p class="success-message">{$_('recoverPasskey.successMessage')}</p> 87 + <p class="next-steps">{$_('recoverPasskey.successNextSteps')}</p> 88 + <button onclick={goToLogin}>{$_('recoverPasskey.signIn')}</button> 95 89 </div> 96 90 {:else} 97 - <h1>Recover Your Account</h1> 98 - <p class="subtitle"> 99 - Set a temporary password to regain access to your passkey-only account. 100 - </p> 91 + <h1>{$_('recoverPasskey.title')}</h1> 92 + <p class="subtitle">{$_('recoverPasskey.subtitle')}</p> 101 93 102 94 {#if error} 103 - <div class="error">{error}</div> 95 + <div class="message error">{error}</div> 104 96 {/if} 105 97 106 98 <form onsubmit={handleSubmit}> 107 99 <div class="field"> 108 - <label for="new-password">New Password</label> 100 + <label for="new-password">{$_('recoverPasskey.newPassword')}</label> 109 101 <input 110 102 id="new-password" 111 103 type="password" 112 104 bind:value={newPassword} 113 - placeholder="At least 8 characters" 105 + placeholder={$_('recoverPasskey.newPasswordPlaceholder')} 114 106 disabled={submitting} 115 107 required 116 108 minlength="8" ··· 118 110 </div> 119 111 120 112 <div class="field"> 121 - <label for="confirm-password">Confirm Password</label> 113 + <label for="confirm-password">{$_('recoverPasskey.confirmPassword')}</label> 122 114 <input 123 115 id="confirm-password" 124 116 type="password" 125 117 bind:value={confirmPassword} 126 - placeholder="Confirm your password" 118 + placeholder={$_('recoverPasskey.confirmPasswordPlaceholder')} 127 119 disabled={submitting} 128 120 required 129 121 /> 130 122 </div> 131 123 132 124 <div class="info-box"> 133 - <strong>What happens next?</strong> 134 - <p> 135 - After setting this password, you can sign in and add a new passkey in your security settings. 136 - Once you have a new passkey, you can optionally remove the temporary password. 137 - </p> 125 + <strong>{$_('recoverPasskey.whatHappensNext')}</strong> 126 + <p>{$_('recoverPasskey.whatHappensNextDetail')}</p> 138 127 </div> 139 128 140 129 <button type="submit" disabled={submitting}> 141 - {submitting ? 'Setting password...' : 'Set Password'} 130 + {submitting ? $_('recoverPasskey.settingPassword') : $_('recoverPasskey.setPassword')} 142 131 </button> 143 132 </form> 144 133 {/if} 145 134 </div> 146 135 147 136 <style> 148 - .recover-container { 149 - max-width: 400px; 150 - margin: 4rem auto; 151 - padding: 2rem; 137 + .recover-page { 138 + max-width: var(--width-sm); 139 + margin: var(--space-9) auto; 140 + padding: var(--space-7); 152 141 } 153 142 154 143 h1 { 155 - margin: 0 0 0.5rem 0; 144 + margin: 0 0 var(--space-3) 0; 156 145 } 157 146 158 147 .subtitle { 159 148 color: var(--text-secondary); 160 - margin: 0 0 2rem 0; 149 + margin: 0 0 var(--space-7) 0; 161 150 } 162 151 163 152 form { 164 153 display: flex; 165 154 flex-direction: column; 166 - gap: 1rem; 167 - } 168 - 169 - .field { 170 - display: flex; 171 - flex-direction: column; 172 - gap: 0.25rem; 173 - } 174 - 175 - label { 176 - font-size: 0.875rem; 177 - font-weight: 500; 178 - } 179 - 180 - input { 181 - padding: 0.75rem; 182 - border: 1px solid var(--border-color-light); 183 - border-radius: 4px; 184 - font-size: 1rem; 185 - background: var(--bg-input); 186 - color: var(--text-primary); 187 - } 188 - 189 - input:focus { 190 - outline: none; 191 - border-color: var(--accent); 155 + gap: var(--space-4); 192 156 } 193 157 194 158 .info-box { 195 159 background: var(--bg-secondary); 196 160 border: 1px solid var(--border-color); 197 - border-radius: 6px; 198 - padding: 1rem; 199 - font-size: 0.875rem; 161 + border-radius: var(--radius-lg); 162 + padding: var(--space-5); 163 + font-size: var(--text-sm); 200 164 } 201 165 202 166 .info-box strong { 203 167 display: block; 204 - margin-bottom: 0.5rem; 168 + margin-bottom: var(--space-3); 205 169 } 206 170 207 171 .info-box p { ··· 209 173 color: var(--text-secondary); 210 174 } 211 175 212 - button { 213 - padding: 0.75rem; 214 - background: var(--accent); 215 - color: white; 216 - border: none; 217 - border-radius: 4px; 218 - font-size: 1rem; 219 - cursor: pointer; 220 - margin-top: 0.5rem; 221 - } 222 - 223 - button:hover:not(:disabled) { 224 - background: var(--accent-hover); 225 - } 226 - 227 - button:disabled { 228 - opacity: 0.6; 229 - cursor: not-allowed; 230 - } 231 - 232 - .error { 233 - padding: 0.75rem; 234 - background: var(--error-bg); 235 - border: 1px solid var(--error-border); 236 - border-radius: 4px; 237 - color: var(--error-text); 238 - margin-bottom: 1rem; 239 - } 240 - 241 176 .error-message { 242 177 color: var(--text-secondary); 243 - margin-bottom: 1.5rem; 178 + margin-bottom: var(--space-6); 244 179 } 245 180 246 181 .success-content { ··· 248 183 } 249 184 250 185 .success-icon { 251 - font-size: 4rem; 186 + font-size: var(--text-4xl); 252 187 color: var(--success-text); 253 - margin-bottom: 1rem; 188 + margin-bottom: var(--space-4); 254 189 } 255 190 256 191 .success-message { 257 192 color: var(--text-secondary); 258 - margin-bottom: 0.5rem; 193 + margin-bottom: var(--space-3); 259 194 } 260 195 261 196 .next-steps { 262 197 color: var(--text-muted); 263 - font-size: 0.875rem; 264 - margin-bottom: 1.5rem; 198 + font-size: var(--text-sm); 199 + margin-bottom: var(--space-6); 265 200 } 266 201 </style>
+271 -318
frontend/src/routes/Register.svelte
··· 2 2 import { register, getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 5 + import { _ } from '../lib/i18n' 5 6 6 7 const STORAGE_KEY = 'tranquil_pds_pending_verification' 7 8 ··· 47 48 let handleHasDot = $derived(handle.includes('.')) 48 49 49 50 function validateForm(): string | null { 50 - if (!handle.trim()) return 'Handle is required' 51 - if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 52 - if (!password) return 'Password is required' 53 - if (password.length < 8) return 'Password must be at least 8 characters' 54 - if (password !== confirmPassword) return 'Passwords do not match' 51 + if (!handle.trim()) return $_('register.validation.handleRequired') 52 + if (handle.includes('.')) return $_('register.validation.handleNoDots') 53 + if (!password) return $_('register.validation.passwordRequired') 54 + if (password.length < 8) return $_('register.validation.passwordLength') 55 + if (password !== confirmPassword) return $_('register.validation.passwordsMismatch') 55 56 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) { 56 - return 'Invite code is required' 57 + return $_('register.validation.inviteCodeRequired') 57 58 } 58 59 if (didType === 'web-external') { 59 - if (!externalDid.trim()) return 'External did:web is required' 60 - if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:' 60 + if (!externalDid.trim()) return $_('register.validation.externalDidRequired') 61 + if (!externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 61 62 } 62 63 switch (verificationChannel) { 63 64 case 'email': 64 - if (!email.trim()) return 'Email is required for email verification' 65 + if (!email.trim()) return $_('register.validation.emailRequired') 65 66 break 66 67 case 'discord': 67 - if (!discordId.trim()) return 'Discord ID is required for Discord verification' 68 + if (!discordId.trim()) return $_('register.validation.discordIdRequired') 68 69 break 69 70 case 'telegram': 70 - if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification' 71 + if (!telegramUsername.trim()) return $_('register.validation.telegramRequired') 71 72 break 72 73 case 'signal': 73 - if (!signalNumber.trim()) return 'Phone number is required for Signal verification' 74 + if (!signalNumber.trim()) return $_('register.validation.signalRequired') 74 75 break 75 76 } 76 77 return null ··· 129 130 return handle.trim() 130 131 }) 131 132 </script> 132 - <div class="register-container"> 133 + 134 + <div class="register-page"> 133 135 {#if error} 134 - <div class="error">{error}</div> 136 + <div class="message error">{error}</div> 135 137 {/if} 136 - <h1>Create Account</h1> 137 - <p class="subtitle">Create a new account on this PDS</p> 138 - {#if loadingServerInfo} 139 - <p class="loading">Loading...</p> 140 - {:else} 141 - <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}> 142 - <div class="field"> 143 - <label for="handle">Handle</label> 144 - <input 145 - id="handle" 146 - type="text" 147 - bind:value={handle} 148 - placeholder="yourname" 149 - disabled={submitting} 150 - required 151 - /> 152 - {#if handleHasDot} 153 - <p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p> 154 - {:else if fullHandle()} 155 - <p class="hint">Your full handle will be: @{fullHandle()}</p> 156 - {/if} 138 + 139 + <h1>{$_('register.title')}</h1> 140 + <p class="subtitle">{$_('register.subtitle')}</p> 141 + 142 + {#if loadingServerInfo} 143 + <p class="loading">{$_('common.loading')}</p> 144 + {:else} 145 + <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}> 146 + <div class="field"> 147 + <label for="handle">{$_('register.handle')}</label> 148 + <input 149 + id="handle" 150 + type="text" 151 + bind:value={handle} 152 + placeholder={$_('register.handlePlaceholder')} 153 + disabled={submitting} 154 + required 155 + /> 156 + {#if handleHasDot} 157 + <p class="hint warning">{$_('register.handleDotWarning')}</p> 158 + {:else if fullHandle()} 159 + <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 160 + {/if} 161 + </div> 162 + 163 + <div class="field"> 164 + <label for="password">{$_('register.password')}</label> 165 + <input 166 + id="password" 167 + type="password" 168 + bind:value={password} 169 + placeholder={$_('register.passwordPlaceholder')} 170 + disabled={submitting} 171 + required 172 + minlength="8" 173 + /> 174 + </div> 175 + 176 + <div class="field"> 177 + <label for="confirm-password">{$_('register.confirmPassword')}</label> 178 + <input 179 + id="confirm-password" 180 + type="password" 181 + bind:value={confirmPassword} 182 + placeholder={$_('register.confirmPasswordPlaceholder')} 183 + disabled={submitting} 184 + required 185 + /> 186 + </div> 187 + 188 + <fieldset class="section-fieldset"> 189 + <legend>{$_('register.identityType')}</legend> 190 + <p class="section-hint">{$_('register.identityHint')}</p> 191 + 192 + <div class="radio-group"> 193 + <label class="radio-label"> 194 + <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 195 + <span class="radio-content"> 196 + <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 197 + <span class="radio-hint">{$_('register.didPlcHint')}</span> 198 + </span> 199 + </label> 200 + 201 + <label class="radio-label"> 202 + <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 203 + <span class="radio-content"> 204 + <strong>{$_('register.didWeb')}</strong> 205 + <span class="radio-hint">{$_('register.didWebHint')}</span> 206 + </span> 207 + </label> 208 + 209 + <label class="radio-label"> 210 + <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 211 + <span class="radio-content"> 212 + <strong>{$_('register.didWebBYOD')}</strong> 213 + <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 214 + </span> 215 + </label> 157 216 </div> 217 + 218 + {#if didType === 'web'} 219 + <div class="warning-box"> 220 + <strong>{$_('register.didWebWarningTitle')}</strong> 221 + <ul> 222 + <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 223 + <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 224 + <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 225 + <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 226 + </ul> 227 + </div> 228 + {/if} 229 + 230 + {#if didType === 'web-external'} 231 + <div class="field"> 232 + <label for="external-did">{$_('register.externalDid')}</label> 233 + <input 234 + id="external-did" 235 + type="text" 236 + bind:value={externalDid} 237 + placeholder={$_('register.externalDidPlaceholder')} 238 + disabled={submitting} 239 + required 240 + /> 241 + <p class="hint">{$_('register.externalDidHint')}</p> 242 + </div> 243 + {/if} 244 + </fieldset> 245 + 246 + <fieldset class="section-fieldset"> 247 + <legend>{$_('register.contactMethod')}</legend> 248 + <p class="section-hint">{$_('register.contactMethodHint')}</p> 249 + 158 250 <div class="field"> 159 - <label for="password">Password</label> 160 - <input 161 - id="password" 162 - type="password" 163 - bind:value={password} 164 - placeholder="At least 8 characters" 165 - disabled={submitting} 166 - required 167 - minlength="8" 168 - /> 169 - </div> 170 - <div class="field"> 171 - <label for="confirm-password">Confirm Password</label> 172 - <input 173 - id="confirm-password" 174 - type="password" 175 - bind:value={confirmPassword} 176 - placeholder="Confirm your password" 177 - disabled={submitting} 178 - required 179 - /> 251 + <label for="verification-channel">{$_('register.verificationMethod')}</label> 252 + <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 253 + <option value="email">{$_('register.email')}</option> 254 + <option value="discord">{$_('register.discord')}</option> 255 + <option value="telegram">{$_('register.telegram')}</option> 256 + <option value="signal">{$_('register.signal')}</option> 257 + </select> 180 258 </div> 181 - <fieldset class="identity-section"> 182 - <legend>Identity Type</legend> 183 - <p class="section-hint">Choose how your decentralized identity will be managed.</p> 184 - <div class="radio-group"> 185 - <label class="radio-label"> 186 - <input 187 - type="radio" 188 - name="didType" 189 - value="plc" 190 - bind:group={didType} 191 - disabled={submitting} 192 - /> 193 - <span class="radio-content"> 194 - <strong>did:plc</strong> (Recommended) 195 - <span class="radio-hint">Portable identity managed by PLC Directory</span> 196 - </span> 197 - </label> 198 - <label class="radio-label"> 199 - <input 200 - type="radio" 201 - name="didType" 202 - value="web" 203 - bind:group={didType} 204 - disabled={submitting} 205 - /> 206 - <span class="radio-content"> 207 - <strong>did:web</strong> 208 - <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 209 - </span> 210 - </label> 211 - <label class="radio-label"> 212 - <input 213 - type="radio" 214 - name="didType" 215 - value="web-external" 216 - bind:group={didType} 217 - disabled={submitting} 218 - /> 219 - <span class="radio-content"> 220 - <strong>did:web (BYOD)</strong> 221 - <span class="radio-hint">Bring your own domain</span> 222 - </span> 223 - </label> 259 + 260 + {#if verificationChannel === 'email'} 261 + <div class="field"> 262 + <label for="email">{$_('register.emailAddress')}</label> 263 + <input 264 + id="email" 265 + type="email" 266 + bind:value={email} 267 + placeholder={$_('register.emailPlaceholder')} 268 + disabled={submitting} 269 + required 270 + /> 224 271 </div> 225 - {#if didType === 'web'} 226 - <div class="did-web-warning"> 227 - <strong>Important: Understand the trade-offs</strong> 228 - <ul> 229 - <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li> 230 - <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li> 231 - <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li> 232 - <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li> 233 - </ul> 234 - </div> 235 - {/if} 236 - {#if didType === 'web-external'} 237 - <div class="field"> 238 - <label for="external-did">Your did:web</label> 239 - <input 240 - id="external-did" 241 - type="text" 242 - bind:value={externalDid} 243 - placeholder="did:web:yourdomain.com" 244 - disabled={submitting} 245 - required 246 - /> 247 - <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 248 - </div> 249 - {/if} 250 - </fieldset> 251 - <fieldset class="verification-section"> 252 - <legend>Contact Method</legend> 253 - <p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p> 272 + {:else if verificationChannel === 'discord'} 254 273 <div class="field"> 255 - <label for="verification-channel">Verification Method</label> 256 - <select 257 - id="verification-channel" 258 - bind:value={verificationChannel} 274 + <label for="discord-id">{$_('register.discordId')}</label> 275 + <input 276 + id="discord-id" 277 + type="text" 278 + bind:value={discordId} 279 + placeholder={$_('register.discordIdPlaceholder')} 259 280 disabled={submitting} 260 - > 261 - <option value="email">Email</option> 262 - <option value="discord">Discord</option> 263 - <option value="telegram">Telegram</option> 264 - <option value="signal">Signal</option> 265 - </select> 281 + required 282 + /> 283 + <p class="hint">{$_('register.discordIdHint')}</p> 266 284 </div> 267 - {#if verificationChannel === 'email'} 268 - <div class="field"> 269 - <label for="email">Email Address</label> 270 - <input 271 - id="email" 272 - type="email" 273 - bind:value={email} 274 - placeholder="you@example.com" 275 - disabled={submitting} 276 - required 277 - /> 278 - </div> 279 - {:else if verificationChannel === 'discord'} 280 - <div class="field"> 281 - <label for="discord-id">Discord User ID</label> 282 - <input 283 - id="discord-id" 284 - type="text" 285 - bind:value={discordId} 286 - placeholder="Your Discord user ID" 287 - disabled={submitting} 288 - required 289 - /> 290 - <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 291 - </div> 292 - {:else if verificationChannel === 'telegram'} 293 - <div class="field"> 294 - <label for="telegram-username">Telegram Username</label> 295 - <input 296 - id="telegram-username" 297 - type="text" 298 - bind:value={telegramUsername} 299 - placeholder="@yourusername" 300 - disabled={submitting} 301 - required 302 - /> 303 - </div> 304 - {:else if verificationChannel === 'signal'} 305 - <div class="field"> 306 - <label for="signal-number">Signal Phone Number</label> 307 - <input 308 - id="signal-number" 309 - type="tel" 310 - bind:value={signalNumber} 311 - placeholder="+1234567890" 312 - disabled={submitting} 313 - required 314 - /> 315 - <p class="hint">Include country code (e.g., +1 for US)</p> 316 - </div> 317 - {/if} 318 - </fieldset> 319 - {#if serverInfo?.inviteCodeRequired} 285 + {:else if verificationChannel === 'telegram'} 320 286 <div class="field"> 321 - <label for="invite-code">Invite Code <span class="required">*</span></label> 287 + <label for="telegram-username">{$_('register.telegramUsername')}</label> 322 288 <input 323 - id="invite-code" 289 + id="telegram-username" 324 290 type="text" 325 - bind:value={inviteCode} 326 - placeholder="Enter your invite code" 291 + bind:value={telegramUsername} 292 + placeholder={$_('register.telegramUsernamePlaceholder')} 327 293 disabled={submitting} 328 294 required 329 295 /> 296 + </div> 297 + {:else if verificationChannel === 'signal'} 298 + <div class="field"> 299 + <label for="signal-number">{$_('register.signalNumber')}</label> 300 + <input 301 + id="signal-number" 302 + type="tel" 303 + bind:value={signalNumber} 304 + placeholder={$_('register.signalNumberPlaceholder')} 305 + disabled={submitting} 306 + required 307 + /> 308 + <p class="hint">{$_('register.signalNumberHint')}</p> 330 309 </div> 331 310 {/if} 332 - <button type="submit" disabled={submitting}> 333 - {submitting ? 'Creating account...' : 'Create Account'} 334 - </button> 335 - </form> 336 - <p class="login-link"> 337 - Already have an account? <a href="#/login">Sign in</a> 338 - </p> 339 - <p class="login-link"> 340 - Want passwordless security? <a href="#/register-passkey">Create a passkey account</a> 341 - </p> 342 - {/if} 311 + </fieldset> 312 + 313 + {#if serverInfo?.inviteCodeRequired} 314 + <div class="field"> 315 + <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 316 + <input 317 + id="invite-code" 318 + type="text" 319 + bind:value={inviteCode} 320 + placeholder={$_('register.inviteCodePlaceholder')} 321 + disabled={submitting} 322 + required 323 + /> 324 + </div> 325 + {/if} 326 + 327 + <button type="submit" disabled={submitting}> 328 + {submitting ? $_('register.creating') : $_('register.createButton')} 329 + </button> 330 + </form> 331 + 332 + <p class="link-text"> 333 + {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 334 + </p> 335 + <p class="link-text"> 336 + {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 337 + </p> 338 + {/if} 343 339 </div> 340 + 344 341 <style> 345 - .register-container { 346 - max-width: 400px; 347 - margin: 4rem auto; 348 - padding: 2rem; 342 + .register-page { 343 + max-width: var(--width-sm); 344 + margin: var(--space-9) auto; 345 + padding: var(--space-7); 349 346 } 347 + 350 348 h1 { 351 - margin: 0 0 0.5rem 0; 349 + margin: 0 0 var(--space-3) 0; 352 350 } 351 + 353 352 .subtitle { 354 353 color: var(--text-secondary); 355 - margin: 0 0 2rem 0; 354 + margin: 0 0 var(--space-7) 0; 356 355 } 356 + 357 357 .loading { 358 358 text-align: center; 359 359 color: var(--text-secondary); 360 360 } 361 + 361 362 form { 362 363 display: flex; 363 364 flex-direction: column; 364 - gap: 1rem; 365 - } 366 - .field { 367 - display: flex; 368 - flex-direction: column; 369 - gap: 0.25rem; 370 - } 371 - label { 372 - font-size: 0.875rem; 373 - font-weight: 500; 365 + gap: var(--space-5); 374 366 } 367 + 375 368 .required { 376 369 color: var(--error-text); 377 370 } 378 - input, select { 379 - padding: 0.75rem; 380 - border: 1px solid var(--border-color-light); 381 - border-radius: 4px; 382 - font-size: 1rem; 383 - background: var(--bg-input); 384 - color: var(--text-primary); 371 + 372 + .section-fieldset { 373 + border: 1px solid var(--border-color); 374 + border-radius: var(--radius-lg); 375 + padding: var(--space-5); 385 376 } 386 - input:focus, select:focus { 387 - outline: none; 388 - border-color: var(--accent); 377 + 378 + .section-fieldset legend { 379 + font-weight: var(--font-semibold); 380 + padding: 0 var(--space-3); 389 381 } 390 - .hint { 391 - font-size: 0.75rem; 382 + 383 + .section-hint { 384 + font-size: var(--text-sm); 392 385 color: var(--text-secondary); 393 - margin: 0.25rem 0 0 0; 394 - } 395 - .hint.warning { 396 - color: var(--warning-text, #856404); 397 - } 398 - .verification-section { 399 - border: 1px solid var(--border-color-light); 400 - border-radius: 6px; 401 - padding: 1rem; 402 - margin: 0.5rem 0; 403 - } 404 - .verification-section legend { 405 - font-weight: 600; 406 - padding: 0 0.5rem; 407 - color: var(--text-primary); 408 - } 409 - .identity-section { 410 - border: 1px solid var(--border-color-light); 411 - border-radius: 6px; 412 - padding: 1rem; 413 - margin: 0.5rem 0; 414 - } 415 - .identity-section legend { 416 - font-weight: 600; 417 - padding: 0 0.5rem; 418 - color: var(--text-primary); 386 + margin: 0 0 var(--space-5) 0; 419 387 } 388 + 420 389 .radio-group { 421 390 display: flex; 422 391 flex-direction: column; 423 - gap: 0.75rem; 392 + gap: var(--space-4); 424 393 } 394 + 425 395 .radio-label { 426 396 display: flex; 427 397 align-items: flex-start; 428 - gap: 0.5rem; 398 + gap: var(--space-3); 429 399 cursor: pointer; 400 + font-size: var(--text-base); 401 + font-weight: var(--font-normal); 402 + margin-bottom: 0; 430 403 } 404 + 431 405 .radio-label input[type="radio"] { 432 - margin-top: 0.25rem; 406 + margin-top: var(--space-1); 407 + width: auto; 433 408 } 409 + 434 410 .radio-content { 435 411 display: flex; 436 412 flex-direction: column; 437 - gap: 0.125rem; 413 + gap: var(--space-1); 438 414 } 415 + 439 416 .radio-hint { 440 - font-size: 0.75rem; 417 + font-size: var(--text-xs); 441 418 color: var(--text-secondary); 442 419 } 443 - .section-hint { 444 - font-size: 0.8rem; 445 - color: var(--text-secondary); 446 - margin: 0 0 1rem 0; 420 + 421 + .warning-box { 422 + margin-top: var(--space-5); 423 + padding: var(--space-5); 424 + background: var(--warning-bg); 425 + border: 1px solid var(--warning-border); 426 + border-radius: var(--radius-lg); 427 + font-size: var(--text-sm); 447 428 } 448 - .did-web-warning { 449 - margin-top: 1rem; 450 - padding: 1rem; 451 - background: var(--warning-bg, #fff3cd); 452 - border: 1px solid var(--warning-border, #ffc107); 453 - border-radius: 6px; 454 - font-size: 0.875rem; 429 + 430 + .warning-box strong { 431 + color: var(--warning-text); 455 432 } 456 - .did-web-warning strong { 457 - color: var(--warning-text, #856404); 433 + 434 + .warning-box ul { 435 + margin: var(--space-4) 0 0 0; 436 + padding-left: var(--space-5); 458 437 } 459 - .did-web-warning ul { 460 - margin: 0.75rem 0 0 0; 461 - padding-left: 1.25rem; 462 - } 463 - .did-web-warning li { 464 - margin-bottom: 0.5rem; 465 - line-height: 1.4; 438 + 439 + .warning-box li { 440 + margin-bottom: var(--space-3); 441 + line-height: var(--leading-normal); 466 442 } 467 - .did-web-warning li:last-child { 443 + 444 + .warning-box li:last-child { 468 445 margin-bottom: 0; 469 446 } 470 - .did-web-warning code { 471 - background: rgba(0, 0, 0, 0.1); 472 - padding: 0.125rem 0.25rem; 473 - border-radius: 3px; 474 - font-size: 0.8rem; 447 + 448 + button[type="submit"] { 449 + margin-top: var(--space-3); 475 450 } 476 - button { 477 - padding: 0.75rem; 478 - background: var(--accent); 479 - color: white; 480 - border: none; 481 - border-radius: 4px; 482 - font-size: 1rem; 483 - cursor: pointer; 484 - margin-top: 0.5rem; 485 - } 486 - button:hover:not(:disabled) { 487 - background: var(--accent-hover); 488 - } 489 - button:disabled { 490 - opacity: 0.6; 491 - cursor: not-allowed; 492 - } 493 - .error { 494 - padding: 0.75rem; 495 - background: var(--error-bg); 496 - border: 1px solid var(--error-border); 497 - border-radius: 4px; 498 - color: var(--error-text); 499 - } 500 - .login-link { 451 + 452 + .link-text { 501 453 text-align: center; 502 - margin-top: 1.5rem; 454 + margin-top: var(--space-6); 503 455 color: var(--text-secondary); 504 456 } 505 - .login-link a { 457 + 458 + .link-text a { 506 459 color: var(--accent); 507 460 } 508 461 </style>
+123 -364
frontend/src/routes/RegisterPasskey.svelte
··· 2 2 import { navigate } from '../lib/router.svelte' 3 3 import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api' 4 4 import { getAuthState, confirmSignup, resendVerification } from '../lib/auth.svelte' 5 + import { _ } from '../lib/i18n' 5 6 6 7 const auth = getAuthState() 7 8 ··· 301 302 }) 302 303 </script> 303 304 304 - <div class="register-passkey-container"> 305 + <div class="register-page"> 305 306 <h1>Create Passkey Account</h1> 306 307 <p class="subtitle"> 307 308 {#if step === 'info'} ··· 318 319 </p> 319 320 320 321 {#if error} 321 - <div class="error">{error}</div> 322 + <div class="message error">{error}</div> 322 323 {/if} 323 324 324 325 {#if loadingServerInfo} ··· 342 343 {/if} 343 344 </div> 344 345 345 - <fieldset class="section"> 346 + <fieldset class="section-fieldset"> 346 347 <legend>Contact Method</legend> 347 348 <p class="section-hint">Choose how you'd like to verify your account and receive notifications.</p> 348 349 <div class="field"> 349 350 <label for="verification-channel">Verification Method</label> 350 - <select 351 - id="verification-channel" 352 - bind:value={verificationChannel} 353 - disabled={submitting} 354 - > 351 + <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 355 352 <option value="email">Email</option> 356 353 <option value="discord">Discord</option> 357 354 <option value="telegram">Telegram</option> ··· 361 358 {#if verificationChannel === 'email'} 362 359 <div class="field"> 363 360 <label for="email">Email Address</label> 364 - <input 365 - id="email" 366 - type="email" 367 - bind:value={email} 368 - placeholder="you@example.com" 369 - disabled={submitting} 370 - required 371 - /> 361 + <input id="email" type="email" bind:value={email} placeholder="you@example.com" disabled={submitting} required /> 372 362 </div> 373 363 {:else if verificationChannel === 'discord'} 374 364 <div class="field"> 375 365 <label for="discord-id">Discord User ID</label> 376 - <input 377 - id="discord-id" 378 - type="text" 379 - bind:value={discordId} 380 - placeholder="Your Discord user ID" 381 - disabled={submitting} 382 - required 383 - /> 366 + <input id="discord-id" type="text" bind:value={discordId} placeholder="Your Discord user ID" disabled={submitting} required /> 384 367 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p> 385 368 </div> 386 369 {:else if verificationChannel === 'telegram'} 387 370 <div class="field"> 388 371 <label for="telegram-username">Telegram Username</label> 389 - <input 390 - id="telegram-username" 391 - type="text" 392 - bind:value={telegramUsername} 393 - placeholder="@yourusername" 394 - disabled={submitting} 395 - required 396 - /> 372 + <input id="telegram-username" type="text" bind:value={telegramUsername} placeholder="@yourusername" disabled={submitting} required /> 397 373 </div> 398 374 {:else if verificationChannel === 'signal'} 399 375 <div class="field"> 400 376 <label for="signal-number">Signal Phone Number</label> 401 - <input 402 - id="signal-number" 403 - type="tel" 404 - bind:value={signalNumber} 405 - placeholder="+1234567890" 406 - disabled={submitting} 407 - required 408 - /> 377 + <input id="signal-number" type="tel" bind:value={signalNumber} placeholder="+1234567890" disabled={submitting} required /> 409 378 <p class="hint">Include country code (e.g., +1 for US)</p> 410 379 </div> 411 380 {/if} 412 381 </fieldset> 413 382 414 - <fieldset class="section"> 383 + <fieldset class="section-fieldset"> 415 384 <legend>Identity Type</legend> 416 385 <p class="section-hint">Choose how your decentralized identity will be managed.</p> 417 386 <div class="radio-group"> 418 387 <label class="radio-label"> 419 - <input 420 - type="radio" 421 - name="didType" 422 - value="plc" 423 - bind:group={didType} 424 - disabled={submitting} 425 - /> 388 + <input type="radio" name="didType" value="plc" bind:group={didType} disabled={submitting} /> 426 389 <span class="radio-content"> 427 390 <strong>did:plc</strong> (Recommended) 428 391 <span class="radio-hint">Portable identity managed by PLC Directory</span> 429 392 </span> 430 393 </label> 431 394 <label class="radio-label"> 432 - <input 433 - type="radio" 434 - name="didType" 435 - value="web" 436 - bind:group={didType} 437 - disabled={submitting} 438 - /> 395 + <input type="radio" name="didType" value="web" bind:group={didType} disabled={submitting} /> 439 396 <span class="radio-content"> 440 397 <strong>did:web</strong> 441 398 <span class="radio-hint">Identity hosted on this PDS (read warning below)</span> 442 399 </span> 443 400 </label> 444 401 <label class="radio-label"> 445 - <input 446 - type="radio" 447 - name="didType" 448 - value="web-external" 449 - bind:group={didType} 450 - disabled={submitting} 451 - /> 402 + <input type="radio" name="didType" value="web-external" bind:group={didType} disabled={submitting} /> 452 403 <span class="radio-content"> 453 404 <strong>did:web (BYOD)</strong> 454 405 <span class="radio-hint">Bring your own domain</span> ··· 456 407 </label> 457 408 </div> 458 409 {#if didType === 'web'} 459 - <div class="did-web-warning"> 410 + <div class="warning-box"> 460 411 <strong>Important: Understand the trade-offs</strong> 461 412 <ul> 462 - <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li> 463 - <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li> 464 - <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li> 413 + <li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li> 414 + <li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys.</li> 415 + <li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document.</li> 465 416 <li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li> 466 417 </ul> 467 418 </div> ··· 469 420 {#if didType === 'web-external'} 470 421 <div class="field"> 471 422 <label for="external-did">Your did:web</label> 472 - <input 473 - id="external-did" 474 - type="text" 475 - bind:value={externalDid} 476 - placeholder="did:web:yourdomain.com" 477 - disabled={submitting} 478 - required 479 - /> 423 + <input id="external-did" type="text" bind:value={externalDid} placeholder="did:web:yourdomain.com" disabled={submitting} required /> 480 424 <p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p> 481 425 </div> 482 426 {/if} ··· 485 429 {#if serverInfo?.inviteCodeRequired} 486 430 <div class="field"> 487 431 <label for="invite-code">Invite Code <span class="required">*</span></label> 488 - <input 489 - id="invite-code" 490 - type="text" 491 - bind:value={inviteCode} 492 - placeholder="Enter your invite code" 493 - disabled={submitting} 494 - required 495 - /> 432 + <input id="invite-code" type="text" bind:value={inviteCode} placeholder="Enter your invite code" disabled={submitting} required /> 496 433 </div> 497 434 {/if} 498 435 499 436 <div class="info-box"> 500 437 <strong>Why passkey-only?</strong> 501 - <p> 502 - Passkey accounts are more secure than password-based accounts because they: 503 - </p> 438 + <p>Passkey accounts are more secure than password-based accounts because they:</p> 504 439 <ul> 505 440 <li>Cannot be phished or stolen in data breaches</li> 506 441 <li>Use hardware-backed cryptographic keys</li> ··· 513 448 </button> 514 449 </form> 515 450 516 - <p class="alt-link"> 451 + <p class="link-text"> 517 452 Want a traditional password? <a href="#/register">Register with password</a> 518 453 </p> 519 454 {:else if step === 'passkey'} 520 - <div class="passkey-step"> 455 + <div class="step-content"> 521 456 <div class="field"> 522 457 <label for="passkey-name">Passkey Name (optional)</label> 523 - <input 524 - id="passkey-name" 525 - type="text" 526 - bind:value={passkeyName} 527 - placeholder="e.g., MacBook Touch ID" 528 - disabled={submitting} 529 - /> 458 + <input id="passkey-name" type="text" bind:value={passkeyName} placeholder="e.g., MacBook Touch ID" disabled={submitting} /> 530 459 <p class="hint">A friendly name to identify this passkey</p> 531 460 </div> 532 461 533 - <div class="passkey-instructions"> 462 + <div class="info-box"> 534 463 <p>Click the button below to create your passkey. You'll be prompted to use:</p> 535 464 <ul> 536 465 <li>Touch ID or Face ID</li> ··· 548 477 </button> 549 478 </div> 550 479 {:else if step === 'app-password'} 551 - <div class="app-password-step"> 480 + <div class="step-content"> 552 481 <div class="warning-box"> 553 482 <strong>Important: Save this app password!</strong> 554 - <p> 555 - This app password is required to sign into apps that don't support passkeys yet (like bsky.app). 556 - You will only see this password once. 557 - </p> 483 + <p>This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.</p> 558 484 </div> 559 485 560 486 <div class="app-password-display"> 561 - <div class="app-password-label"> 562 - App Password for: <strong>{appPasswordResult?.appPasswordName}</strong> 563 - </div> 487 + <div class="app-password-label">App Password for: <strong>{appPasswordResult?.appPasswordName}</strong></div> 564 488 <code class="app-password-code">{appPasswordResult?.appPassword}</code> 565 489 <button type="button" class="copy-btn" onclick={copyAppPassword}> 566 490 {appPasswordCopied ? 'Copied!' : 'Copy to Clipboard'} 567 491 </button> 568 492 </div> 569 493 570 - <div class="field acknowledge-field"> 494 + <div class="field"> 571 495 <label class="checkbox-label"> 572 - <input 573 - type="checkbox" 574 - bind:checked={appPasswordAcknowledged} 575 - /> 496 + <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 576 497 <span>I have saved my app password in a secure location</span> 577 498 </label> 578 499 </div> 579 500 580 - <button onclick={handleFinish} disabled={!appPasswordAcknowledged}> 581 - Continue 582 - </button> 501 + <button onclick={handleFinish} disabled={!appPasswordAcknowledged}>Continue</button> 583 502 </div> 584 503 {:else if step === 'verify'} 585 - <div class="verify-step"> 586 - <p class="verify-info"> 587 - We've sent a verification code to your {channelLabel(verificationChannel)}. 588 - Enter it below to complete your account setup. 589 - </p> 504 + <div class="step-content"> 505 + <p class="info-text">We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.</p> 590 506 591 507 {#if resendMessage} 592 - <div class="success">{resendMessage}</div> 508 + <div class="message success">{resendMessage}</div> 593 509 {/if} 594 510 595 511 <form onsubmit={(e) => { e.preventDefault(); handleVerification(); }}> 596 512 <div class="field"> 597 513 <label for="verification-code">Verification Code</label> 598 - <input 599 - id="verification-code" 600 - type="text" 601 - bind:value={verificationCode} 602 - placeholder="Enter 6-digit code" 603 - disabled={submitting} 604 - required 605 - maxlength="6" 606 - inputmode="numeric" 607 - autocomplete="one-time-code" 608 - /> 514 + <input id="verification-code" type="text" bind:value={verificationCode} placeholder="Enter 6-digit code" disabled={submitting} required maxlength="6" inputmode="numeric" autocomplete="one-time-code" /> 609 515 </div> 610 516 611 517 <button type="submit" disabled={submitting || !verificationCode.trim()}> ··· 618 524 </form> 619 525 </div> 620 526 {:else if step === 'success'} 621 - <div class="success-step"> 527 + <div class="success-content"> 622 528 <div class="success-icon">&#x2714;</div> 623 529 <h2>Account Created!</h2> 624 530 <p>Your passkey-only account has been created successfully.</p> 625 531 <p class="handle-display">@{setupData?.handle}</p> 626 - 627 - <button onclick={goToLogin}> 628 - Sign In 629 - </button> 532 + <button onclick={goToLogin}>Sign In</button> 630 533 </div> 631 534 {/if} 632 535 </div> 633 536 634 537 <style> 635 - .register-passkey-container { 636 - max-width: 450px; 637 - margin: 4rem auto; 638 - padding: 2rem; 538 + .register-page { 539 + max-width: var(--width-sm); 540 + margin: var(--space-9) auto; 541 + padding: var(--space-7); 639 542 } 640 543 641 - h1 { 642 - margin: 0 0 0.5rem 0; 643 - } 644 - 645 - h2 { 646 - margin: 0 0 0.5rem 0; 544 + h1, h2 { 545 + margin: 0 0 var(--space-3) 0; 647 546 } 648 547 649 548 .subtitle { 650 549 color: var(--text-secondary); 651 - margin: 0 0 2rem 0; 550 + margin: 0 0 var(--space-7) 0; 652 551 } 653 552 654 553 .loading { ··· 656 555 color: var(--text-secondary); 657 556 } 658 557 659 - form { 660 - display: flex; 661 - flex-direction: column; 662 - gap: 1rem; 663 - } 664 - 665 - .field { 558 + form, .step-content { 666 559 display: flex; 667 560 flex-direction: column; 668 - gap: 0.25rem; 669 - } 670 - 671 - label { 672 - font-size: 0.875rem; 673 - font-weight: 500; 561 + gap: var(--space-4); 674 562 } 675 563 676 564 .required { 677 565 color: var(--error-text); 678 566 } 679 567 680 - input, select { 681 - padding: 0.75rem; 682 - border: 1px solid var(--border-color-light); 683 - border-radius: 4px; 684 - font-size: 1rem; 685 - background: var(--bg-input); 686 - color: var(--text-primary); 568 + .section-fieldset { 569 + border: 1px solid var(--border-color); 570 + border-radius: var(--radius-lg); 571 + padding: var(--space-5); 687 572 } 688 573 689 - input:focus, select:focus { 690 - outline: none; 691 - border-color: var(--accent); 692 - } 693 - 694 - .hint { 695 - font-size: 0.75rem; 696 - color: var(--text-secondary); 697 - margin: 0.25rem 0 0 0; 698 - } 699 - 700 - .hint.warning { 701 - color: var(--warning-text); 702 - } 703 - 704 - .section { 705 - border: 1px solid var(--border-color-light); 706 - border-radius: 6px; 707 - padding: 1rem; 708 - margin: 0.5rem 0; 709 - } 710 - 711 - .section legend { 712 - font-weight: 600; 713 - padding: 0 0.5rem; 714 - color: var(--text-primary); 574 + .section-fieldset legend { 575 + font-weight: var(--font-semibold); 576 + padding: 0 var(--space-3); 715 577 } 716 578 717 579 .section-hint { 718 - font-size: 0.8rem; 580 + font-size: var(--text-sm); 719 581 color: var(--text-secondary); 720 - margin: 0 0 1rem 0; 582 + margin: 0 0 var(--space-5) 0; 721 583 } 722 584 723 585 .radio-group { 724 586 display: flex; 725 587 flex-direction: column; 726 - gap: 0.75rem; 588 + gap: var(--space-4); 727 589 } 728 590 729 591 .radio-label { 730 592 display: flex; 731 593 align-items: flex-start; 732 - gap: 0.5rem; 594 + gap: var(--space-3); 733 595 cursor: pointer; 596 + font-size: var(--text-base); 597 + font-weight: var(--font-normal); 598 + margin-bottom: 0; 734 599 } 735 600 736 601 .radio-label input[type="radio"] { 737 - margin-top: 0.25rem; 602 + margin-top: var(--space-1); 603 + width: auto; 738 604 } 739 605 740 606 .radio-content { 741 607 display: flex; 742 608 flex-direction: column; 743 - gap: 0.125rem; 609 + gap: var(--space-1); 744 610 } 745 611 746 612 .radio-hint { 747 - font-size: 0.75rem; 613 + font-size: var(--text-xs); 748 614 color: var(--text-secondary); 749 615 } 750 616 751 - .did-web-warning { 752 - margin-top: 1rem; 753 - padding: 1rem; 754 - background: var(--warning-bg, #fff3cd); 755 - border: 1px solid var(--warning-border, #ffc107); 756 - border-radius: 6px; 757 - font-size: 0.875rem; 617 + .warning-box { 618 + margin-top: var(--space-5); 619 + padding: var(--space-5); 620 + background: var(--warning-bg); 621 + border: 1px solid var(--warning-border); 622 + border-radius: var(--radius-lg); 623 + font-size: var(--text-sm); 758 624 } 759 625 760 - .did-web-warning strong { 761 - color: var(--warning-text, #856404); 626 + .warning-box strong { 627 + display: block; 628 + margin-bottom: var(--space-3); 629 + color: var(--warning-text); 762 630 } 763 631 764 - .did-web-warning ul { 765 - margin: 0.75rem 0 0 0; 766 - padding-left: 1.25rem; 632 + .warning-box p { 633 + margin: 0; 634 + color: var(--warning-text); 767 635 } 768 636 769 - .did-web-warning li { 770 - margin-bottom: 0.5rem; 771 - line-height: 1.4; 637 + .warning-box ul { 638 + margin: var(--space-4) 0 0 0; 639 + padding-left: var(--space-5); 772 640 } 773 641 774 - .did-web-warning li:last-child { 775 - margin-bottom: 0; 642 + .warning-box li { 643 + margin-bottom: var(--space-3); 644 + line-height: var(--leading-normal); 776 645 } 777 646 778 - .did-web-warning code { 779 - background: rgba(0, 0, 0, 0.1); 780 - padding: 0.125rem 0.25rem; 781 - border-radius: 3px; 782 - font-size: 0.8rem; 647 + .warning-box li:last-child { 648 + margin-bottom: 0; 783 649 } 784 650 785 651 .info-box { 786 652 background: var(--bg-secondary); 787 653 border: 1px solid var(--border-color); 788 - border-radius: 6px; 789 - padding: 1rem; 790 - font-size: 0.875rem; 654 + border-radius: var(--radius-lg); 655 + padding: var(--space-5); 656 + font-size: var(--text-sm); 791 657 } 792 658 793 659 .info-box strong { 794 660 display: block; 795 - margin-bottom: 0.5rem; 661 + margin-bottom: var(--space-3); 796 662 } 797 663 798 664 .info-box p { 799 - margin: 0 0 0.5rem 0; 665 + margin: 0 0 var(--space-3) 0; 800 666 color: var(--text-secondary); 801 667 } 802 668 803 669 .info-box ul { 804 670 margin: 0; 805 - padding-left: 1.25rem; 671 + padding-left: var(--space-5); 806 672 color: var(--text-secondary); 807 673 } 808 674 809 675 .info-box li { 810 - margin-bottom: 0.25rem; 811 - } 812 - 813 - button { 814 - padding: 0.75rem; 815 - background: var(--accent); 816 - color: white; 817 - border: none; 818 - border-radius: 4px; 819 - font-size: 1rem; 820 - cursor: pointer; 821 - margin-top: 0.5rem; 822 - } 823 - 824 - button:hover:not(:disabled) { 825 - background: var(--accent-hover); 826 - } 827 - 828 - button:disabled { 829 - opacity: 0.6; 830 - cursor: not-allowed; 831 - } 832 - 833 - button.secondary { 834 - background: transparent; 835 - color: var(--text-secondary); 836 - border: 1px solid var(--border-color-light); 837 - } 838 - 839 - button.secondary:hover:not(:disabled) { 840 - background: var(--bg-secondary); 841 - } 842 - 843 - .error { 844 - padding: 0.75rem; 845 - background: var(--error-bg); 846 - border: 1px solid var(--error-border); 847 - border-radius: 4px; 848 - color: var(--error-text); 849 - margin-bottom: 1rem; 850 - } 851 - 852 - .alt-link { 853 - text-align: center; 854 - margin-top: 1.5rem; 855 - color: var(--text-secondary); 856 - } 857 - 858 - .alt-link a { 859 - color: var(--accent); 860 - } 861 - 862 - .passkey-step { 863 - display: flex; 864 - flex-direction: column; 865 - gap: 1rem; 866 - } 867 - 868 - .passkey-instructions { 869 - background: var(--bg-secondary); 870 - border-radius: 6px; 871 - padding: 1rem; 872 - } 873 - 874 - .passkey-instructions p { 875 - margin: 0 0 0.5rem 0; 876 - color: var(--text-secondary); 877 - font-size: 0.875rem; 878 - } 879 - 880 - .passkey-instructions ul { 881 - margin: 0; 882 - padding-left: 1.25rem; 883 - color: var(--text-secondary); 884 - font-size: 0.875rem; 676 + margin-bottom: var(--space-2); 885 677 } 886 678 887 679 .passkey-btn { 888 - padding: 1rem; 889 - font-size: 1.125rem; 890 - } 891 - 892 - .app-password-step { 893 - display: flex; 894 - flex-direction: column; 895 - gap: 1.5rem; 896 - } 897 - 898 - .warning-box { 899 - background: var(--warning-bg); 900 - border: 1px solid var(--warning-border, #ffc107); 901 - border-radius: 6px; 902 - padding: 1rem; 903 - } 904 - 905 - .warning-box strong { 906 - display: block; 907 - margin-bottom: 0.5rem; 908 - color: var(--warning-text); 909 - } 910 - 911 - .warning-box p { 912 - margin: 0; 913 - font-size: 0.875rem; 914 - color: var(--warning-text); 680 + padding: var(--space-5); 681 + font-size: var(--text-lg); 915 682 } 916 683 917 684 .app-password-display { 918 685 background: var(--bg-card); 919 686 border: 2px solid var(--accent); 920 - border-radius: 8px; 921 - padding: 1.5rem; 687 + border-radius: var(--radius-xl); 688 + padding: var(--space-6); 922 689 text-align: center; 923 690 } 924 691 925 692 .app-password-label { 926 - font-size: 0.875rem; 693 + font-size: var(--text-sm); 927 694 color: var(--text-secondary); 928 - margin-bottom: 0.75rem; 695 + margin-bottom: var(--space-4); 929 696 } 930 697 931 698 .app-password-code { 932 699 display: block; 933 - font-size: 1.5rem; 934 - font-family: monospace; 700 + font-size: var(--text-xl); 701 + font-family: ui-monospace, monospace; 935 702 letter-spacing: 0.1em; 936 - padding: 1rem; 703 + padding: var(--space-5); 937 704 background: var(--bg-input); 938 - border-radius: 4px; 939 - margin-bottom: 1rem; 705 + border-radius: var(--radius-md); 706 + margin-bottom: var(--space-4); 940 707 user-select: all; 941 708 } 942 709 943 710 .copy-btn { 944 711 margin-top: 0; 945 - padding: 0.5rem 1rem; 946 - font-size: 0.875rem; 947 - } 948 - 949 - .acknowledge-field { 950 - margin-top: 0; 712 + padding: var(--space-3) var(--space-5); 713 + font-size: var(--text-sm); 951 714 } 952 715 953 716 .checkbox-label { 954 717 display: flex; 955 718 align-items: center; 956 - gap: 0.5rem; 719 + gap: var(--space-3); 957 720 cursor: pointer; 958 - font-weight: normal; 721 + font-weight: var(--font-normal); 959 722 } 960 723 961 724 .checkbox-label input[type="checkbox"] { ··· 963 726 padding: 0; 964 727 } 965 728 966 - .success-step { 729 + .success-content { 967 730 text-align: center; 968 731 } 969 732 970 733 .success-icon { 971 - font-size: 4rem; 734 + font-size: var(--text-4xl); 972 735 color: var(--success-text); 973 - margin-bottom: 1rem; 736 + margin-bottom: var(--space-4); 974 737 } 975 738 976 - .success-step p { 739 + .success-content p { 977 740 color: var(--text-secondary); 978 741 } 979 742 980 743 .handle-display { 981 - font-size: 1.25rem; 982 - font-weight: 600; 983 - color: var(--text-primary) !important; 984 - margin: 1rem 0; 744 + font-size: var(--text-xl); 745 + font-weight: var(--font-semibold); 746 + color: var(--text-primary); 747 + margin: var(--space-4) 0; 985 748 } 986 749 987 - .verify-step { 988 - display: flex; 989 - flex-direction: column; 990 - gap: 1rem; 750 + .info-text { 751 + color: var(--text-secondary); 752 + margin: 0; 991 753 } 992 754 993 - .verify-info { 755 + .link-text { 756 + text-align: center; 757 + margin-top: var(--space-6); 994 758 color: var(--text-secondary); 995 - margin: 0; 996 759 } 997 760 998 - .success { 999 - padding: 0.75rem; 1000 - background: var(--success-bg); 1001 - border: 1px solid var(--success-border); 1002 - border-radius: 4px; 1003 - color: var(--success-text); 761 + .link-text a { 762 + color: var(--accent); 1004 763 } 1005 764 </style>
+189 -122
frontend/src/routes/RepoExplorer.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import { _, locale } from '../lib/i18n' 5 6 const auth = getAuthState() 6 7 type View = 'collections' | 'records' | 'record' | 'create' 7 8 let view = $state<View>('collections') ··· 20 21 } else if (e instanceof Error) { 21 22 error = { message: e.message } 22 23 } else { 23 - error = { message: 'An unknown error occurred' } 24 + error = { message: $_('repoExplorer.unknownError') } 24 25 } 25 26 } 26 27 let newCollection = $state('') ··· 101 102 function startCreate(collection?: string) { 102 103 newCollection = collection || 'app.bsky.feed.post' 103 104 newRkey = '' 105 + const currentLocale = $locale?.split('-')[0] || 'en' 104 106 const exampleRecords: Record<string, unknown> = { 105 107 'app.bsky.feed.post': { 106 108 $type: 'app.bsky.feed.post', 107 - text: 'Hello from my PDS! This is my first post.', 109 + text: $_('repoExplorer.demoPostText'), 110 + langs: [currentLocale], 108 111 createdAt: new Date().toISOString(), 109 112 }, 110 113 'app.bsky.actor.profile': { 111 114 $type: 'app.bsky.actor.profile', 112 - displayName: 'Your Display Name', 113 - description: 'A short bio about yourself.', 115 + displayName: $_('repoExplorer.demoDisplayName'), 116 + description: $_('repoExplorer.demoBio'), 114 117 }, 115 118 'app.bsky.graph.follow': { 116 119 $type: 'app.bsky.graph.follow', ··· 139 142 jsonError = null 140 143 return parsed 141 144 } catch (e) { 142 - jsonError = e instanceof Error ? e.message : 'Invalid JSON' 145 + jsonError = e instanceof Error ? e.message : $_('repoExplorer.invalidJson') 143 146 return null 144 147 } 145 148 } ··· 149 152 const record = validateJson() 150 153 if (!record) return 151 154 if (!newCollection.trim()) { 152 - error = { message: 'Collection is required' } 155 + error = { message: $_('repoExplorer.collectionRequired') } 153 156 return 154 157 } 155 158 saving = true ··· 162 165 record, 163 166 newRkey.trim() || undefined 164 167 ) 165 - success = `Record created: ${result.uri}` 168 + success = $_('repoExplorer.recordCreated', { values: { uri: result.uri } }) 166 169 await loadCollections() 167 170 await selectCollection(newCollection.trim()) 168 171 } catch (e) { ··· 186 189 selectedRecord.rkey, 187 190 record 188 191 ) 189 - success = 'Record updated' 192 + success = $_('repoExplorer.recordUpdated') 190 193 const updated = await api.getRecord( 191 194 auth.session.accessJwt, 192 195 auth.session.did, ··· 203 206 } 204 207 async function handleDelete() { 205 208 if (!auth.session || !selectedRecord || !selectedCollection) return 206 - if (!confirm(`Delete record ${selectedRecord.rkey}? This cannot be undone.`)) return 209 + if (!confirm($_('repoExplorer.deleteConfirm', { values: { rkey: selectedRecord.rkey } }))) return 207 210 saving = true 208 211 error = null 209 212 try { ··· 213 216 selectedCollection, 214 217 selectedRecord.rkey 215 218 ) 216 - success = 'Record deleted' 219 + success = $_('repoExplorer.recordDeleted') 217 220 selectedRecord = null 218 221 await selectCollection(selectedCollection) 219 222 } catch (e) { ··· 267 270 <div class="page"> 268 271 <header> 269 272 <div class="breadcrumb"> 270 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 273 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 271 274 {#if view !== 'collections'} 272 275 <span class="sep">/</span> 273 276 <button class="breadcrumb-link" onclick={goBack}> 274 - {view === 'records' || view === 'create' ? 'Collections' : selectedCollection} 277 + {view === 'records' || view === 'create' ? $_('repoExplorer.collections') : selectedCollection} 275 278 </button> 276 279 {/if} 277 280 {#if view === 'record' && selectedRecord} ··· 280 283 {/if} 281 284 {#if view === 'create'} 282 285 <span class="sep">/</span> 283 - <span class="current">New Record</span> 286 + <span class="current">{$_('repoExplorer.newRecord')}</span> 284 287 {/if} 285 288 </div> 286 289 <h1> 287 290 {#if view === 'collections'} 288 - Repository Explorer 291 + {$_('repoExplorer.title')} 289 292 {:else if view === 'records'} 290 293 {selectedCollection} 291 294 {:else if view === 'record'} 292 - Record Detail 295 + {$_('repoExplorer.recordDetails')} 293 296 {:else} 294 - Create Record 297 + {$_('repoExplorer.createRecord')} 295 298 {/if} 296 299 </h1> 297 300 {#if auth.session} ··· 310 313 <div class="message success">{success}</div> 311 314 {/if} 312 315 {#if loading} 313 - <p class="loading-text">Loading...</p> 316 + <p class="loading-text">{$_('common.loading')}</p> 314 317 {:else if view === 'collections'} 315 318 <div class="toolbar"> 316 319 <input 317 320 type="text" 318 - placeholder="Filter collections..." 321 + placeholder={$_('repoExplorer.filterCollections')} 319 322 bind:value={filter} 320 323 class="filter-input" 321 324 /> 322 - <button class="primary" onclick={() => startCreate()}>Create Record</button> 325 + <button class="primary" onclick={() => startCreate()}>{$_('repoExplorer.createRecord')}</button> 323 326 </div> 324 327 {#if collections.length === 0} 325 - <p class="empty">No collections yet. Create your first record to get started.</p> 328 + <p class="empty">{$_('repoExplorer.noCollectionsYet')}</p> 326 329 {:else} 327 330 <div class="collections"> 328 331 {#each [...groupedCollections.entries()] as [authority, nsids]} ··· 346 349 <div class="toolbar"> 347 350 <input 348 351 type="text" 349 - placeholder="Filter records..." 352 + placeholder={$_('repoExplorer.filterRecords')} 350 353 bind:value={filter} 351 354 class="filter-input" 352 355 /> 353 - <button class="primary" onclick={() => startCreate(selectedCollection!)}>Create Record</button> 356 + <button class="primary" onclick={() => startCreate(selectedCollection!)}>{$_('repoExplorer.createRecord')}</button> 354 357 </div> 355 358 {#if records.length === 0} 356 - <p class="empty">No records in this collection.</p> 359 + <p class="empty">{$_('repoExplorer.noRecords')}</p> 357 360 {:else} 358 361 <ul class="record-list"> 359 362 {#each filteredRecords as record} ··· 371 374 {#if recordsCursor} 372 375 <div class="load-more"> 373 376 <button onclick={loadMoreRecords} disabled={loadingMore}> 374 - {loadingMore ? 'Loading...' : 'Load More'} 377 + {loadingMore ? $_('common.loading') : $_('repoExplorer.loadMore')} 375 378 </button> 376 379 </div> 377 380 {/if} ··· 388 391 </div> 389 392 <form onsubmit={handleUpdate}> 390 393 <div class="editor-container"> 391 - <label for="record-json">Record JSON</label> 394 + <label for="record-json">{$_('repoExplorer.recordJson')}</label> 392 395 <textarea 393 396 id="record-json" 394 397 bind:value={recordJson} ··· 402 405 </div> 403 406 <div class="actions"> 404 407 <button type="submit" class="primary" disabled={saving || !!jsonError}> 405 - {saving ? 'Saving...' : 'Update Record'} 408 + {saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')} 406 409 </button> 407 410 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 408 - Delete 411 + {$_('common.delete')} 409 412 </button> 410 413 </div> 411 414 </form> ··· 413 416 {:else if view === 'create'} 414 417 <form class="create-form" onsubmit={handleCreate}> 415 418 <div class="field"> 416 - <label for="collection">Collection (NSID)</label> 419 + <label for="collection">{$_('repoExplorer.collectionNsid')}</label> 417 420 <input 418 421 id="collection" 419 422 type="text" ··· 424 427 /> 425 428 </div> 426 429 <div class="field"> 427 - <label for="rkey">Record Key (optional)</label> 430 + <label for="rkey">{$_('repoExplorer.recordKeyOptional')}</label> 428 431 <input 429 432 id="rkey" 430 433 type="text" 431 434 bind:value={newRkey} 432 - placeholder="Auto-generated if empty (TID)" 435 + placeholder={$_('repoExplorer.autoGenerated')} 433 436 disabled={saving} 434 437 /> 435 - <p class="hint">Leave empty to auto-generate a TID-based key</p> 438 + <p class="hint">{$_('repoExplorer.autoGeneratedHint')}</p> 436 439 </div> 437 440 <div class="editor-container"> 438 - <label for="new-record-json">Record JSON</label> 441 + <label for="new-record-json">{$_('repoExplorer.recordJson')}</label> 439 442 <textarea 440 443 id="new-record-json" 441 444 bind:value={recordJson} ··· 449 452 </div> 450 453 <div class="actions"> 451 454 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 452 - {saving ? 'Creating...' : 'Create Record'} 455 + {saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')} 453 456 </button> 454 457 <button type="button" class="secondary" onclick={goBack}> 455 - Cancel 458 + {$_('common.cancel')} 456 459 </button> 457 460 </div> 458 461 </form> ··· 460 463 </div> 461 464 <style> 462 465 .page { 463 - max-width: 900px; 466 + max-width: var(--width-lg); 464 467 margin: 0 auto; 465 - padding: 2rem; 468 + padding: var(--space-7); 466 469 } 470 + 467 471 header { 468 - margin-bottom: 1.5rem; 472 + margin-bottom: var(--space-6); 469 473 } 474 + 470 475 .breadcrumb { 471 476 display: flex; 472 477 align-items: center; 473 - gap: 0.5rem; 474 - font-size: 0.875rem; 475 - margin-bottom: 0.5rem; 478 + gap: var(--space-2); 479 + font-size: var(--text-sm); 480 + margin-bottom: var(--space-2); 476 481 } 482 + 477 483 .back { 478 484 color: var(--text-secondary); 479 485 text-decoration: none; 480 486 } 487 + 481 488 .back:hover { 482 489 color: var(--accent); 483 490 } 491 + 484 492 .sep { 485 493 color: var(--text-muted); 486 494 } 495 + 487 496 .breadcrumb-link { 488 497 background: none; 489 498 border: none; ··· 492 501 cursor: pointer; 493 502 font-size: inherit; 494 503 } 504 + 495 505 .breadcrumb-link:hover { 496 506 text-decoration: underline; 497 507 } 508 + 498 509 .current { 499 510 color: var(--text-secondary); 500 511 } 512 + 501 513 h1 { 502 514 margin: 0; 503 - font-size: 1.5rem; 515 + font-size: var(--text-xl); 504 516 } 517 + 505 518 .did { 506 - margin: 0.25rem 0 0 0; 519 + margin: var(--space-1) 0 0 0; 507 520 font-family: monospace; 508 - font-size: 0.75rem; 521 + font-size: var(--text-xs); 509 522 color: var(--text-muted); 510 523 word-break: break-all; 511 524 } 525 + 512 526 .message { 513 - padding: 1rem; 514 - border-radius: 8px; 515 - margin-bottom: 1rem; 527 + padding: var(--space-4); 528 + border-radius: var(--radius-xl); 529 + margin-bottom: var(--space-4); 516 530 } 531 + 517 532 .message.error { 518 533 background: var(--error-bg); 519 534 border: 1px solid var(--error-border); 520 535 color: var(--error-text); 521 536 display: flex; 522 537 flex-direction: column; 523 - gap: 0.25rem; 538 + gap: var(--space-1); 524 539 } 540 + 525 541 .error-code { 526 542 font-family: monospace; 527 - font-size: 0.875rem; 543 + font-size: var(--text-sm); 528 544 opacity: 0.9; 529 545 } 546 + 530 547 .error-message { 531 - font-size: 0.9375rem; 548 + font-size: var(--text-sm); 532 549 line-height: 1.5; 533 550 } 551 + 534 552 .message.success { 535 553 background: var(--success-bg); 536 554 border: 1px solid var(--success-border); 537 555 color: var(--success-text); 538 556 } 557 + 539 558 .loading-text { 540 559 text-align: center; 541 560 color: var(--text-secondary); 542 - padding: 2rem; 561 + padding: var(--space-7); 543 562 } 563 + 544 564 .toolbar { 545 565 display: flex; 546 - gap: 0.5rem; 547 - margin-bottom: 1rem; 566 + gap: var(--space-2); 567 + margin-bottom: var(--space-4); 548 568 } 569 + 549 570 .filter-input { 550 571 flex: 1; 551 - padding: 0.5rem 0.75rem; 552 - border: 1px solid var(--border-color-light); 553 - border-radius: 4px; 554 - font-size: 0.875rem; 572 + padding: var(--space-2) var(--space-3); 573 + border: 1px solid var(--border-color); 574 + border-radius: var(--radius-md); 575 + font-size: var(--text-sm); 555 576 background: var(--bg-input); 556 577 color: var(--text-primary); 557 578 } 579 + 558 580 .filter-input:focus { 559 581 outline: none; 560 582 border-color: var(--accent); 561 583 } 584 + 562 585 button.primary { 563 - padding: 0.5rem 1rem; 586 + padding: var(--space-2) var(--space-4); 564 587 background: var(--accent); 565 - color: white; 588 + color: var(--text-inverse); 566 589 border: none; 567 - border-radius: 4px; 590 + border-radius: var(--radius-md); 568 591 cursor: pointer; 569 - font-size: 0.875rem; 592 + font-size: var(--text-sm); 570 593 } 594 + 571 595 button.primary:hover:not(:disabled) { 572 596 background: var(--accent-hover); 573 597 } 598 + 574 599 button.primary:disabled { 575 600 opacity: 0.6; 576 601 cursor: not-allowed; 577 602 } 603 + 578 604 button.secondary { 579 - padding: 0.5rem 1rem; 605 + padding: var(--space-2) var(--space-4); 580 606 background: transparent; 581 607 color: var(--text-secondary); 582 - border: 1px solid var(--border-color-light); 583 - border-radius: 4px; 608 + border: 1px solid var(--border-color); 609 + border-radius: var(--radius-md); 584 610 cursor: pointer; 585 - font-size: 0.875rem; 611 + font-size: var(--text-sm); 586 612 } 613 + 587 614 button.secondary:hover:not(:disabled) { 588 615 background: var(--bg-secondary); 589 616 } 617 + 590 618 button.danger { 591 - padding: 0.5rem 1rem; 619 + padding: var(--space-2) var(--space-4); 592 620 background: transparent; 593 621 color: var(--error-text); 594 622 border: 1px solid var(--error-text); 595 - border-radius: 4px; 623 + border-radius: var(--radius-md); 596 624 cursor: pointer; 597 - font-size: 0.875rem; 625 + font-size: var(--text-sm); 598 626 } 627 + 599 628 button.danger:hover:not(:disabled) { 600 629 background: var(--error-bg); 601 630 } 631 + 602 632 .empty { 603 633 text-align: center; 604 634 color: var(--text-secondary); 605 - padding: 3rem; 635 + padding: var(--space-8); 606 636 background: var(--bg-secondary); 607 - border-radius: 8px; 637 + border-radius: var(--radius-xl); 608 638 } 639 + 609 640 .collections { 610 641 display: flex; 611 642 flex-direction: column; 612 - gap: 1rem; 643 + gap: var(--space-4); 613 644 } 645 + 614 646 .collection-group { 615 647 background: var(--bg-secondary); 616 - border-radius: 8px; 617 - padding: 1rem; 648 + border-radius: var(--radius-xl); 649 + padding: var(--space-4); 618 650 } 651 + 619 652 .authority { 620 - margin: 0 0 0.75rem 0; 621 - font-size: 0.875rem; 653 + margin: 0 0 var(--space-3) 0; 654 + font-size: var(--text-sm); 622 655 color: var(--text-secondary); 623 - font-weight: 500; 656 + font-weight: var(--font-medium); 624 657 } 658 + 625 659 .nsid-list { 626 660 list-style: none; 627 661 padding: 0; 628 662 margin: 0; 629 663 display: flex; 630 664 flex-direction: column; 631 - gap: 0.25rem; 665 + gap: var(--space-1); 632 666 } 667 + 633 668 .collection-link { 634 669 display: flex; 635 670 justify-content: space-between; 636 671 align-items: center; 637 672 width: 100%; 638 - padding: 0.75rem; 673 + padding: var(--space-3); 639 674 background: var(--bg-card); 640 675 border: 1px solid var(--border-color); 641 - border-radius: 4px; 676 + border-radius: var(--radius-md); 642 677 cursor: pointer; 643 678 text-align: left; 644 679 color: var(--text-primary); 645 - transition: border-color 0.15s; 680 + transition: border-color var(--transition-fast); 646 681 } 682 + 647 683 .collection-link:hover { 648 684 border-color: var(--accent); 649 685 } 686 + 650 687 .nsid { 651 - font-weight: 500; 688 + font-weight: var(--font-medium); 652 689 color: var(--accent); 653 690 } 691 + 654 692 .arrow { 655 693 color: var(--text-muted); 656 694 } 695 + 657 696 .record-list { 658 697 list-style: none; 659 698 padding: 0; 660 699 margin: 0; 661 700 display: flex; 662 701 flex-direction: column; 663 - gap: 0.5rem; 702 + gap: var(--space-2); 664 703 } 704 + 665 705 .record-item { 666 706 display: block; 667 707 width: 100%; 668 - padding: 1rem; 708 + padding: var(--space-4); 669 709 background: var(--bg-card); 670 710 border: 1px solid var(--border-color); 671 - border-radius: 4px; 711 + border-radius: var(--radius-md); 672 712 cursor: pointer; 673 713 text-align: left; 674 714 color: var(--text-primary); 675 - transition: border-color 0.15s; 715 + transition: border-color var(--transition-fast); 676 716 } 717 + 677 718 .record-item:hover { 678 719 border-color: var(--accent); 679 720 } 721 + 680 722 .record-info { 681 723 display: flex; 682 724 justify-content: space-between; 683 - margin-bottom: 0.5rem; 725 + margin-bottom: var(--space-2); 684 726 } 727 + 685 728 .rkey { 686 729 font-family: monospace; 687 - font-weight: 500; 730 + font-weight: var(--font-medium); 688 731 color: var(--accent); 689 732 } 733 + 690 734 .cid { 691 735 font-family: monospace; 692 - font-size: 0.75rem; 736 + font-size: var(--text-xs); 693 737 color: var(--text-muted); 694 738 } 739 + 695 740 .record-preview { 696 741 margin: 0; 697 - padding: 0.5rem; 742 + padding: var(--space-2); 698 743 background: var(--bg-secondary); 699 - border-radius: 4px; 744 + border-radius: var(--radius-md); 700 745 font-family: monospace; 701 - font-size: 0.75rem; 746 + font-size: var(--text-xs); 702 747 color: var(--text-secondary); 703 748 white-space: pre-wrap; 704 749 word-break: break-word; 705 750 max-height: 100px; 706 751 overflow: hidden; 707 752 } 753 + 708 754 .load-more { 709 755 text-align: center; 710 - padding: 1rem; 756 + padding: var(--space-4); 711 757 } 758 + 712 759 .load-more button { 713 - padding: 0.5rem 2rem; 760 + padding: var(--space-2) var(--space-7); 714 761 background: var(--bg-secondary); 715 762 border: 1px solid var(--border-color); 716 - border-radius: 4px; 763 + border-radius: var(--radius-md); 717 764 cursor: pointer; 718 765 color: var(--text-primary); 719 766 } 767 + 720 768 .load-more button:hover:not(:disabled) { 721 769 background: var(--bg-card); 722 770 } 771 + 723 772 .record-detail { 724 773 display: flex; 725 774 flex-direction: column; 726 - gap: 1.5rem; 775 + gap: var(--space-6); 727 776 } 777 + 728 778 .record-meta { 729 779 background: var(--bg-secondary); 730 - padding: 1rem; 731 - border-radius: 8px; 780 + padding: var(--space-4); 781 + border-radius: var(--radius-xl); 732 782 } 783 + 733 784 .record-meta dl { 734 785 display: grid; 735 786 grid-template-columns: auto 1fr; 736 - gap: 0.5rem 1rem; 787 + gap: var(--space-2) var(--space-4); 737 788 margin: 0; 738 789 } 790 + 739 791 .record-meta dt { 740 - font-weight: 500; 792 + font-weight: var(--font-medium); 741 793 color: var(--text-secondary); 742 794 } 795 + 743 796 .record-meta dd { 744 797 margin: 0; 745 798 } 799 + 746 800 .mono { 747 801 font-family: monospace; 748 - font-size: 0.75rem; 802 + font-size: var(--text-xs); 749 803 word-break: break-all; 750 804 } 805 + 751 806 .field { 752 - margin-bottom: 1rem; 807 + margin-bottom: var(--space-4); 753 808 } 809 + 754 810 .field label { 755 811 display: block; 756 - font-size: 0.875rem; 757 - font-weight: 500; 758 - margin-bottom: 0.25rem; 812 + font-size: var(--text-sm); 813 + font-weight: var(--font-medium); 814 + margin-bottom: var(--space-1); 759 815 } 816 + 760 817 .field input { 761 818 width: 100%; 762 - padding: 0.75rem; 763 - border: 1px solid var(--border-color-light); 764 - border-radius: 4px; 765 - font-size: 1rem; 819 + padding: var(--space-3); 820 + border: 1px solid var(--border-color); 821 + border-radius: var(--radius-md); 822 + font-size: var(--text-base); 766 823 background: var(--bg-input); 767 824 color: var(--text-primary); 768 825 box-sizing: border-box; 769 826 } 827 + 770 828 .field input:focus { 771 829 outline: none; 772 830 border-color: var(--accent); 773 831 } 832 + 774 833 .hint { 775 - font-size: 0.75rem; 834 + font-size: var(--text-xs); 776 835 color: var(--text-muted); 777 - margin: 0.25rem 0 0 0; 836 + margin: var(--space-1) 0 0 0; 778 837 } 838 + 779 839 .editor-container { 780 - margin-bottom: 1rem; 840 + margin-bottom: var(--space-4); 781 841 } 842 + 782 843 .editor-container label { 783 844 display: block; 784 - font-size: 0.875rem; 785 - font-weight: 500; 786 - margin-bottom: 0.25rem; 845 + font-size: var(--text-sm); 846 + font-weight: var(--font-medium); 847 + margin-bottom: var(--space-1); 787 848 } 849 + 788 850 textarea { 789 851 width: 100%; 790 852 min-height: 300px; 791 - padding: 1rem; 792 - border: 1px solid var(--border-color-light); 793 - border-radius: 4px; 853 + padding: var(--space-4); 854 + border: 1px solid var(--border-color); 855 + border-radius: var(--radius-md); 794 856 font-family: monospace; 795 - font-size: 0.875rem; 857 + font-size: var(--text-sm); 796 858 background: var(--bg-input); 797 859 color: var(--text-primary); 798 860 resize: vertical; 799 861 box-sizing: border-box; 800 862 } 863 + 801 864 textarea:focus { 802 865 outline: none; 803 866 border-color: var(--accent); 804 867 } 868 + 805 869 textarea.has-error { 806 870 border-color: var(--error-text); 807 871 } 872 + 808 873 .json-error { 809 - margin: 0.25rem 0 0 0; 810 - font-size: 0.75rem; 874 + margin: var(--space-1) 0 0 0; 875 + font-size: var(--text-xs); 811 876 color: var(--error-text); 812 877 } 878 + 813 879 .actions { 814 880 display: flex; 815 - gap: 0.5rem; 881 + gap: var(--space-2); 816 882 } 883 + 817 884 .create-form { 818 885 background: var(--bg-secondary); 819 - padding: 1.5rem; 820 - border-radius: 8px; 886 + padding: var(--space-6); 887 + border-radius: var(--radius-xl); 821 888 } 822 889 </style>
+33 -102
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 1 1 <script lang="ts"> 2 2 import { navigate } from '../lib/router.svelte' 3 3 import { api, ApiError } from '../lib/api' 4 + import { _ } from '../lib/i18n' 4 5 5 6 let identifier = $state('') 6 7 let submitting = $state(false) ··· 29 30 } 30 31 </script> 31 32 32 - <div class="recovery-container"> 33 + <div class="recovery-page"> 33 34 {#if success} 34 35 <div class="success-content"> 35 - <h1>Recovery Link Sent</h1> 36 - <p class="subtitle"> 37 - If your account exists and is a passkey-only account, you'll receive a recovery link 38 - at your preferred notification channel. 39 - </p> 40 - <p class="info"> 41 - The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal 42 - depending on your account settings. 43 - </p> 44 - <button onclick={() => navigate('/login')}>Back to Sign In</button> 36 + <h1>{$_('requestPasskeyRecovery.successTitle')}</h1> 37 + <p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p> 38 + <p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p> 39 + <button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button> 45 40 </div> 46 41 {:else} 47 - <h1>Recover Passkey Account</h1> 48 - <p class="subtitle"> 49 - Lost access to your passkey? Enter your handle or email and we'll send you a recovery link. 50 - </p> 42 + <h1>{$_('requestPasskeyRecovery.title')}</h1> 43 + <p class="subtitle">{$_('requestPasskeyRecovery.subtitle')}</p> 51 44 52 45 {#if error} 53 - <div class="error">{error}</div> 46 + <div class="message error">{error}</div> 54 47 {/if} 55 48 56 49 <form onsubmit={handleSubmit}> 57 50 <div class="field"> 58 - <label for="identifier">Handle or Email</label> 51 + <label for="identifier">{$_('requestPasskeyRecovery.handleOrEmail')}</label> 59 52 <input 60 53 id="identifier" 61 54 type="text" 62 55 bind:value={identifier} 63 - placeholder="handle or you@example.com" 56 + placeholder={$_('requestPasskeyRecovery.emailPlaceholder')} 64 57 disabled={submitting} 65 58 required 66 59 /> 67 60 </div> 68 61 69 62 <div class="info-box"> 70 - <strong>How it works</strong> 71 - <p> 72 - We'll send a secure link to your registered notification channel. 73 - Click the link to set a temporary password. Then you can sign in 74 - and add a new passkey. 75 - </p> 63 + <strong>{$_('requestPasskeyRecovery.howItWorks')}</strong> 64 + <p>{$_('requestPasskeyRecovery.howItWorksDetail')}</p> 76 65 </div> 77 66 78 67 <button type="submit" disabled={submitting || !identifier.trim()}> 79 - {submitting ? 'Sending...' : 'Send Recovery Link'} 68 + {submitting ? $_('requestPasskeyRecovery.sending') : $_('requestPasskeyRecovery.sendRecoveryLink')} 80 69 </button> 81 70 </form> 82 71 {/if} 83 72 84 - <p class="back-link"> 85 - <a href="#/login">Back to Sign In</a> 73 + <p class="link-text"> 74 + <a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a> 86 75 </p> 87 76 </div> 88 77 89 78 <style> 90 - .recovery-container { 91 - max-width: 400px; 92 - margin: 4rem auto; 93 - padding: 2rem; 79 + .recovery-page { 80 + max-width: var(--width-sm); 81 + margin: var(--space-9) auto; 82 + padding: var(--space-7); 94 83 } 95 84 96 85 h1 { 97 - margin: 0 0 0.5rem 0; 86 + margin: 0 0 var(--space-3) 0; 98 87 } 99 88 100 89 .subtitle { 101 90 color: var(--text-secondary); 102 - margin: 0 0 2rem 0; 91 + margin: 0 0 var(--space-7) 0; 103 92 } 104 93 105 94 form { 106 95 display: flex; 107 96 flex-direction: column; 108 - gap: 1rem; 109 - } 110 - 111 - .field { 112 - display: flex; 113 - flex-direction: column; 114 - gap: 0.25rem; 115 - } 116 - 117 - label { 118 - font-size: 0.875rem; 119 - font-weight: 500; 120 - } 121 - 122 - input { 123 - padding: 0.75rem; 124 - border: 1px solid var(--border-color-light); 125 - border-radius: 4px; 126 - font-size: 1rem; 127 - background: var(--bg-input); 128 - color: var(--text-primary); 129 - } 130 - 131 - input:focus { 132 - outline: none; 133 - border-color: var(--accent); 97 + gap: var(--space-4); 134 98 } 135 99 136 100 .info-box { 137 101 background: var(--bg-secondary); 138 102 border: 1px solid var(--border-color); 139 - border-radius: 6px; 140 - padding: 1rem; 141 - font-size: 0.875rem; 103 + border-radius: var(--radius-lg); 104 + padding: var(--space-5); 105 + font-size: var(--text-sm); 142 106 } 143 107 144 108 .info-box strong { 145 109 display: block; 146 - margin-bottom: 0.5rem; 110 + margin-bottom: var(--space-3); 147 111 } 148 112 149 113 .info-box p { ··· 151 115 color: var(--text-secondary); 152 116 } 153 117 154 - button { 155 - padding: 0.75rem; 156 - background: var(--accent); 157 - color: white; 158 - border: none; 159 - border-radius: 4px; 160 - font-size: 1rem; 161 - cursor: pointer; 162 - } 163 - 164 - button:hover:not(:disabled) { 165 - background: var(--accent-hover); 166 - } 167 - 168 - button:disabled { 169 - opacity: 0.6; 170 - cursor: not-allowed; 171 - } 172 - 173 - .error { 174 - padding: 0.75rem; 175 - background: var(--error-bg); 176 - border: 1px solid var(--error-border); 177 - border-radius: 4px; 178 - color: var(--error-text); 179 - margin-bottom: 1rem; 180 - } 181 - 182 118 .success-content { 183 119 text-align: center; 184 120 } 185 121 186 - .info { 122 + .info-text { 187 123 color: var(--text-secondary); 188 - font-size: 0.875rem; 189 - margin-bottom: 1.5rem; 124 + font-size: var(--text-sm); 125 + margin-bottom: var(--space-6); 190 126 } 191 127 192 - .back-link { 128 + .link-text { 193 129 text-align: center; 194 - margin-top: 2rem; 130 + margin-top: var(--space-7); 195 131 } 196 132 197 - .back-link a { 133 + .link-text a { 198 134 color: var(--accent); 199 - text-decoration: none; 200 - } 201 - 202 - .back-link a:hover { 203 - text-decoration: underline; 204 135 } 205 136 </style>
+49 -93
frontend/src/routes/ResetPassword.svelte
··· 2 2 import { navigate } from '../lib/router.svelte' 3 3 import { api, ApiError } from '../lib/api' 4 4 import { getAuthState } from '../lib/auth.svelte' 5 + import { _ } from '../lib/i18n' 6 + 5 7 const auth = getAuthState() 8 + 6 9 let email = $state('') 7 10 let token = $state('') 8 11 let newPassword = $state('') ··· 11 14 let error = $state<string | null>(null) 12 15 let success = $state<string | null>(null) 13 16 let tokenSent = $state(false) 17 + 14 18 $effect(() => { 15 19 if (auth.session) { 16 20 navigate('/dashboard') 17 21 } 18 22 }) 23 + 19 24 async function handleRequestReset(e: Event) { 20 25 e.preventDefault() 21 26 if (!email) return ··· 25 30 try { 26 31 await api.requestPasswordReset(email) 27 32 tokenSent = true 28 - success = 'Password reset code sent! Check your preferred notification channel.' 33 + success = $_('resetPassword.codeSent') 29 34 } catch (e) { 30 35 error = e instanceof ApiError ? e.message : 'Failed to send reset code' 31 36 } finally { 32 37 submitting = false 33 38 } 34 39 } 40 + 35 41 async function handleReset(e: Event) { 36 42 e.preventDefault() 37 43 if (!token || !newPassword || !confirmPassword) return 38 44 if (newPassword !== confirmPassword) { 39 - error = 'Passwords do not match' 45 + error = $_('resetPassword.passwordsMismatch') 40 46 return 41 47 } 42 48 if (newPassword.length < 8) { 43 - error = 'Password must be at least 8 characters' 49 + error = $_('resetPassword.passwordLength') 44 50 return 45 51 } 46 52 submitting = true ··· 48 54 success = null 49 55 try { 50 56 await api.resetPassword(token, newPassword) 51 - success = 'Password reset successfully!' 57 + success = $_('resetPassword.success') 52 58 setTimeout(() => navigate('/login'), 2000) 53 59 } catch (e) { 54 60 error = e instanceof ApiError ? e.message : 'Failed to reset password' ··· 57 63 } 58 64 } 59 65 </script> 60 - <div class="reset-container"> 66 + 67 + <div class="reset-page"> 61 68 {#if error} 62 69 <div class="message error">{error}</div> 63 70 {/if} 64 71 {#if success} 65 72 <div class="message success">{success}</div> 66 73 {/if} 74 + 67 75 {#if tokenSent} 68 - <h1>Reset Password</h1> 69 - <p class="subtitle">Enter the code you received and choose a new password.</p> 76 + <h1>{$_('resetPassword.title')}</h1> 77 + <p class="subtitle">{$_('resetPassword.subtitle')}</p> 78 + 70 79 <form onsubmit={handleReset}> 71 80 <div class="field"> 72 - <label for="token">Reset Code</label> 81 + <label for="token">{$_('resetPassword.code')}</label> 73 82 <input 74 83 id="token" 75 84 type="text" 76 85 bind:value={token} 77 - placeholder="Enter reset code" 86 + placeholder={$_('resetPassword.codePlaceholder')} 78 87 disabled={submitting} 79 88 required 80 89 /> 81 90 </div> 82 91 <div class="field"> 83 - <label for="new-password">New Password</label> 92 + <label for="new-password">{$_('resetPassword.newPassword')}</label> 84 93 <input 85 94 id="new-password" 86 95 type="password" 87 96 bind:value={newPassword} 88 - placeholder="At least 8 characters" 97 + placeholder={$_('resetPassword.newPasswordPlaceholder')} 89 98 disabled={submitting} 90 99 required 91 100 minlength="8" 92 101 /> 93 102 </div> 94 103 <div class="field"> 95 - <label for="confirm-password">Confirm Password</label> 104 + <label for="confirm-password">{$_('resetPassword.confirmPassword')}</label> 96 105 <input 97 106 id="confirm-password" 98 107 type="password" 99 108 bind:value={confirmPassword} 100 - placeholder="Confirm new password" 109 + placeholder={$_('resetPassword.confirmPasswordPlaceholder')} 101 110 disabled={submitting} 102 111 required 103 112 /> 104 113 </div> 105 114 <button type="submit" disabled={submitting || !token || !newPassword || !confirmPassword}> 106 - {submitting ? 'Resetting...' : 'Reset Password'} 115 + {submitting ? $_('resetPassword.resetting') : $_('resetPassword.resetButton')} 107 116 </button> 108 117 <button type="button" class="secondary" onclick={() => { tokenSent = false; token = ''; newPassword = ''; confirmPassword = '' }}> 109 - Request New Code 118 + {$_('resetPassword.requestNewCode')} 110 119 </button> 111 120 </form> 112 121 {:else} 113 - <h1>Forgot Password</h1> 114 - <p class="subtitle">Enter your handle or email and we'll send you a code to reset your password.</p> 122 + <h1>{$_('resetPassword.forgotTitle')}</h1> 123 + <p class="subtitle">{$_('resetPassword.forgotSubtitle')}</p> 124 + 115 125 <form onsubmit={handleRequestReset}> 116 126 <div class="field"> 117 - <label for="email">Handle or Email</label> 127 + <label for="email">{$_('resetPassword.handleOrEmail')}</label> 118 128 <input 119 129 id="email" 120 130 type="text" 121 131 bind:value={email} 122 - placeholder="handle or you@example.com" 132 + placeholder={$_('resetPassword.emailPlaceholder')} 123 133 disabled={submitting} 124 134 required 125 135 /> 126 136 </div> 127 137 <button type="submit" disabled={submitting || !email}> 128 - {submitting ? 'Sending...' : 'Send Reset Code'} 138 + {submitting ? $_('resetPassword.sending') : $_('resetPassword.sendCode')} 129 139 </button> 130 140 </form> 131 141 {/if} 132 - <p class="back-link"> 133 - <a href="#/login">Back to Sign In</a> 142 + 143 + <p class="link-text"> 144 + <a href="#/login">{$_('resetPassword.backToLogin')}</a> 134 145 </p> 135 146 </div> 147 + 136 148 <style> 137 - .reset-container { 138 - max-width: 400px; 139 - margin: 4rem auto; 140 - padding: 2rem; 149 + .reset-page { 150 + max-width: var(--width-sm); 151 + margin: var(--space-9) auto; 152 + padding: var(--space-7); 141 153 } 154 + 142 155 h1 { 143 - margin: 0 0 0.5rem 0; 156 + margin: 0 0 var(--space-3) 0; 144 157 } 158 + 145 159 .subtitle { 146 160 color: var(--text-secondary); 147 - margin: 0 0 2rem 0; 161 + margin: 0 0 var(--space-7) 0; 148 162 } 163 + 149 164 form { 150 165 display: flex; 151 166 flex-direction: column; 152 - gap: 1rem; 153 - } 154 - .field { 155 - display: flex; 156 - flex-direction: column; 157 - gap: 0.25rem; 158 - } 159 - label { 160 - font-size: 0.875rem; 161 - font-weight: 500; 162 - } 163 - input { 164 - padding: 0.75rem; 165 - border: 1px solid var(--border-color-light); 166 - border-radius: 4px; 167 - font-size: 1rem; 168 - background: var(--bg-input); 169 - color: var(--text-primary); 170 - } 171 - input:focus { 172 - outline: none; 173 - border-color: var(--accent); 174 - } 175 - button { 176 - padding: 0.75rem; 177 - background: var(--accent); 178 - color: white; 179 - border: none; 180 - border-radius: 4px; 181 - font-size: 1rem; 182 - cursor: pointer; 183 - margin-top: 0.5rem; 167 + gap: var(--space-4); 184 168 } 185 - button:hover:not(:disabled) { 186 - background: var(--accent-hover); 187 - } 188 - button:disabled { 189 - opacity: 0.6; 190 - cursor: not-allowed; 191 - } 192 - button.secondary { 193 - background: transparent; 194 - color: var(--text-secondary); 195 - border: 1px solid var(--border-color-light); 196 - } 197 - button.secondary:hover:not(:disabled) { 198 - background: var(--bg-secondary); 199 - } 200 - .message { 201 - padding: 0.75rem; 202 - border-radius: 4px; 203 - margin-bottom: 1rem; 204 - } 205 - .message.success { 206 - background: var(--success-bg); 207 - border: 1px solid var(--success-border); 208 - color: var(--success-text); 209 - } 210 - .message.error { 211 - background: var(--error-bg); 212 - border: 1px solid var(--error-border); 213 - color: var(--error-text); 214 - } 215 - .back-link { 169 + 170 + .link-text { 216 171 text-align: center; 217 - margin-top: 1.5rem; 172 + margin-top: var(--space-6); 218 173 color: var(--text-secondary); 219 174 } 220 - .back-link a { 175 + 176 + .link-text a { 221 177 color: var(--accent); 222 178 } 223 179 </style>
+209 -308
frontend/src/routes/Security.svelte
··· 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 5 import ReauthModal from '../components/ReauthModal.svelte' 6 + import { _ } from '../lib/i18n' 7 + import { formatDate as formatDateUtil } from '../lib/date' 6 8 7 9 const auth = getAuthState() 8 10 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) ··· 103 105 const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin) 104 106 allowLegacyLogin = result.allowLegacyLogin 105 107 showMessage('success', allowLegacyLogin 106 - ? 'Legacy app login enabled' 107 - : 'Legacy app login disabled - only OAuth apps can sign in') 108 + ? $_('security.legacyLoginEnabled') 109 + : $_('security.legacyLoginDisabled')) 108 110 } catch (e) { 109 111 if (e instanceof ApiError) { 110 112 if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { ··· 115 117 showMessage('error', e.message) 116 118 } 117 119 } else { 118 - showMessage('error', 'Failed to update preference') 120 + showMessage('error', $_('security.failedToUpdatePreference')) 119 121 } 120 122 } finally { 121 123 legacyLoginUpdating = false ··· 129 131 await api.removePassword(auth.session.accessJwt) 130 132 hasPassword = false 131 133 showRemovePasswordForm = false 132 - showMessage('success', 'Password removed. Your account is now passkey-only.') 134 + showMessage('success', $_('security.passwordRemoved')) 133 135 } catch (e) { 134 136 if (e instanceof ApiError) { 135 137 if (e.error === 'ReauthRequired') { ··· 140 142 showMessage('error', e.message) 141 143 } 142 144 } else { 143 - showMessage('error', 'Failed to remove password') 145 + showMessage('error', $_('security.failedToRemovePassword')) 144 146 } 145 147 } finally { 146 148 removePasswordLoading = false ··· 166 168 totpEnabled = status.enabled 167 169 hasBackupCodes = status.hasBackupCodes 168 170 } catch { 169 - showMessage('error', 'Failed to load TOTP status') 171 + showMessage('error', $_('security.failedToLoadTotpStatus')) 170 172 } finally { 171 173 loading = false 172 174 } ··· 217 219 backupCodes = [] 218 220 qrBase64 = '' 219 221 totpUri = '' 220 - showMessage('success', 'Two-factor authentication enabled successfully') 222 + showMessage('success', $_('security.totpEnabledSuccess')) 221 223 } 222 224 223 225 async function handleDisable(e: Event) { ··· 231 233 showDisableForm = false 232 234 disablePassword = '' 233 235 disableCode = '' 234 - showMessage('success', 'Two-factor authentication disabled') 236 + showMessage('success', $_('security.totpDisabledSuccess')) 235 237 } catch (e) { 236 238 showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP') 237 239 } finally { ··· 260 262 function copyBackupCodes() { 261 263 const text = backupCodes.join('\n') 262 264 navigator.clipboard.writeText(text) 263 - showMessage('success', 'Backup codes copied to clipboard') 265 + showMessage('success', $_('security.backupCodesCopied')) 264 266 } 265 267 266 268 async function loadPasskeys() { ··· 270 272 const result = await api.listPasskeys(auth.session.accessJwt) 271 273 passkeys = result.passkeys 272 274 } catch { 273 - showMessage('error', 'Failed to load passkeys') 275 + showMessage('error', $_('security.failedToLoadPasskeys')) 274 276 } finally { 275 277 passkeysLoading = false 276 278 } ··· 279 281 async function handleAddPasskey() { 280 282 if (!auth.session) return 281 283 if (!window.PublicKeyCredential) { 282 - showMessage('error', 'Passkeys are not supported in this browser') 284 + showMessage('error', $_('security.passkeysNotSupported')) 283 285 return 284 286 } 285 287 addingPasskey = true ··· 290 292 publicKey: publicKeyOptions 291 293 }) 292 294 if (!credential) { 293 - showMessage('error', 'Passkey creation was cancelled') 295 + showMessage('error', $_('security.passkeyCreationCancelled')) 294 296 return 295 297 } 296 298 const credentialResponse = { ··· 305 307 await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined) 306 308 await loadPasskeys() 307 309 newPasskeyName = '' 308 - showMessage('success', 'Passkey added successfully') 310 + showMessage('success', $_('security.passkeyAddedSuccess')) 309 311 } catch (e) { 310 312 if (e instanceof DOMException && e.name === 'NotAllowedError') { 311 - showMessage('error', 'Passkey creation was cancelled') 313 + showMessage('error', $_('security.passkeyCreationCancelled')) 312 314 } else { 313 315 showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey') 314 316 } ··· 319 321 320 322 async function handleDeletePasskey(id: string) { 321 323 if (!auth.session) return 322 - if (!confirm('Are you sure you want to delete this passkey?')) return 324 + const passkey = passkeys.find(p => p.id === id) 325 + const name = passkey?.friendlyName || 'this passkey' 326 + if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return 323 327 try { 324 328 await api.deletePasskey(auth.session.accessJwt, id) 325 329 await loadPasskeys() 326 - showMessage('success', 'Passkey deleted') 330 + showMessage('success', $_('security.passkeyDeleted')) 327 331 } catch (e) { 328 332 showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey') 329 333 } ··· 336 340 await loadPasskeys() 337 341 editingPasskeyId = null 338 342 editPasskeyName = '' 339 - showMessage('success', 'Passkey renamed') 343 + showMessage('success', $_('security.passkeyRenamed')) 340 344 } catch (e) { 341 345 showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey') 342 346 } ··· 388 392 } 389 393 390 394 function formatDate(dateStr: string): string { 391 - return new Date(dateStr).toLocaleDateString() 395 + return formatDateUtil(dateStr) 392 396 } 393 397 </script> 394 398 395 399 <div class="page"> 396 400 <header> 397 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 398 - <h1>Security Settings</h1> 401 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 402 + <h1>{$_('security.title')}</h1> 399 403 </header> 400 404 401 405 {#if message} ··· 403 407 {/if} 404 408 405 409 {#if loading} 406 - <div class="loading">Loading...</div> 410 + <div class="loading">{$_('common.loading')}</div> 407 411 {:else} 408 412 <section> 409 - <h2>Two-Factor Authentication</h2> 413 + <h2>{$_('security.totp')}</h2> 410 414 <p class="description"> 411 - Add an extra layer of security to your account using an authenticator app like Google Authenticator, Authy, or 1Password. 415 + {$_('security.totpDescription')} 412 416 </p> 413 417 414 418 {#if setupStep === 'idle'} 415 419 {#if totpEnabled} 416 420 <div class="status enabled"> 417 - <span>Two-factor authentication is <strong>enabled</strong></span> 421 + <span>{$_('security.totpEnabled')}</span> 418 422 </div> 419 423 420 424 {#if !showDisableForm && !showRegenForm} 421 425 <div class="totp-actions"> 422 426 <button type="button" class="secondary" onclick={() => showRegenForm = true}> 423 - Regenerate Backup Codes 427 + {$_('security.regenerateBackupCodes')} 424 428 </button> 425 429 <button type="button" class="danger-outline" onclick={() => showDisableForm = true}> 426 - Disable 2FA 430 + {$_('security.disableTotp')} 427 431 </button> 428 432 </div> 429 433 {/if} 430 434 431 435 {#if showRegenForm} 432 436 <form onsubmit={handleRegenerate} class="inline-form"> 433 - <h3>Regenerate Backup Codes</h3> 434 - <p class="warning-text">This will invalidate all existing backup codes.</p> 437 + <h3>{$_('security.regenerateBackupCodes')}</h3> 438 + <p class="warning-text">{$_('security.regenerateConfirm')}</p> 435 439 <div class="field"> 436 - <label for="regen-password">Password</label> 440 + <label for="regen-password">{$_('security.password')}</label> 437 441 <input 438 442 id="regen-password" 439 443 type="password" 440 444 bind:value={regenPassword} 441 - placeholder="Enter your password" 445 + placeholder={$_('security.enterPassword')} 442 446 disabled={regenLoading} 443 447 required 444 448 /> 445 449 </div> 446 450 <div class="field"> 447 - <label for="regen-code">Authenticator Code</label> 451 + <label for="regen-code">{$_('security.totpCode')}</label> 448 452 <input 449 453 id="regen-code" 450 454 type="text" 451 455 bind:value={regenCode} 452 - placeholder="6-digit code" 456 + placeholder="{$_('security.totpCodePlaceholder')}" 453 457 disabled={regenLoading} 454 458 required 455 459 maxlength="6" ··· 459 463 </div> 460 464 <div class="actions"> 461 465 <button type="button" class="secondary" onclick={() => { showRegenForm = false; regenPassword = ''; regenCode = '' }}> 462 - Cancel 466 + {$_('common.cancel')} 463 467 </button> 464 468 <button type="submit" disabled={regenLoading || !regenPassword || regenCode.length !== 6}> 465 - {regenLoading ? 'Regenerating...' : 'Regenerate'} 469 + {regenLoading ? $_('security.regenerating') : $_('security.regenerateBackupCodes')} 466 470 </button> 467 471 </div> 468 472 </form> ··· 470 474 471 475 {#if showDisableForm} 472 476 <form onsubmit={handleDisable} class="inline-form danger-form"> 473 - <h3>Disable Two-Factor Authentication</h3> 474 - <p class="warning-text">This will make your account less secure.</p> 477 + <h3>{$_('security.disableTotp')}</h3> 478 + <p class="warning-text">{$_('security.disableTotpWarning')}</p> 475 479 <div class="field"> 476 - <label for="disable-password">Password</label> 480 + <label for="disable-password">{$_('security.password')}</label> 477 481 <input 478 482 id="disable-password" 479 483 type="password" 480 484 bind:value={disablePassword} 481 - placeholder="Enter your password" 485 + placeholder={$_('security.enterPassword')} 482 486 disabled={disableLoading} 483 487 required 484 488 /> 485 489 </div> 486 490 <div class="field"> 487 - <label for="disable-code">Authenticator Code</label> 491 + <label for="disable-code">{$_('security.totpCode')}</label> 488 492 <input 489 493 id="disable-code" 490 494 type="text" 491 495 bind:value={disableCode} 492 - placeholder="6-digit code" 496 + placeholder="{$_('security.totpCodePlaceholder')}" 493 497 disabled={disableLoading} 494 498 required 495 499 maxlength="6" ··· 499 503 </div> 500 504 <div class="actions"> 501 505 <button type="button" class="secondary" onclick={() => { showDisableForm = false; disablePassword = ''; disableCode = '' }}> 502 - Cancel 506 + {$_('common.cancel')} 503 507 </button> 504 508 <button type="submit" class="danger" disabled={disableLoading || !disablePassword || disableCode.length !== 6}> 505 - {disableLoading ? 'Disabling...' : 'Disable 2FA'} 509 + {disableLoading ? $_('security.disabling') : $_('security.disableTotp')} 506 510 </button> 507 511 </div> 508 512 </form> 509 513 {/if} 510 514 {:else} 511 515 <div class="status disabled"> 512 - <span>Two-factor authentication is <strong>not enabled</strong></span> 516 + <span>{$_('security.totpDisabled')}</span> 513 517 </div> 514 518 <button onclick={handleStartSetup} disabled={verifyLoading}> 515 - {verifyLoading ? 'Setting up...' : 'Set Up Two-Factor Authentication'} 519 + {$_('security.enableTotp')} 516 520 </button> 517 521 {/if} 518 522 {:else if setupStep === 'qr'} 519 523 <div class="setup-step"> 520 - <h3>Step 1: Scan QR Code</h3> 521 - <p>Scan this QR code with your authenticator app:</p> 524 + <h3>{$_('security.totpSetup')}</h3> 525 + <p>{$_('security.totpSetupInstructions')}</p> 522 526 <div class="qr-container"> 523 527 <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 524 528 </div> 525 529 <details class="manual-entry"> 526 - <summary>Can't scan? Enter manually</summary> 530 + <summary>{$_('security.cantScan')}</summary> 527 531 <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 528 532 </details> 529 533 <button onclick={() => setupStep = 'verify'}> 530 - Next: Verify Code 534 + {$_('security.next')} 531 535 </button> 532 536 </div> 533 537 {:else if setupStep === 'verify'} 534 538 <div class="setup-step"> 535 - <h3>Step 2: Verify Setup</h3> 536 - <p>Enter the 6-digit code from your authenticator app:</p> 539 + <h3>{$_('security.totpSetup')}</h3> 540 + <p>{$_('security.totpCodePlaceholder')}</p> 537 541 <form onsubmit={handleVerifySetup}> 538 542 <div class="field"> 539 543 <input ··· 547 551 </div> 548 552 <div class="actions"> 549 553 <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 550 - Back 554 + {$_('common.back')} 551 555 </button> 552 556 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> 553 - {verifyLoading ? 'Verifying...' : 'Verify & Enable'} 557 + {$_('security.verifyAndEnable')} 554 558 </button> 555 559 </div> 556 560 </form> 557 561 </div> 558 562 {:else if setupStep === 'backup'} 559 563 <div class="setup-step"> 560 - <h3>Step 3: Save Backup Codes</h3> 564 + <h3>{$_('security.backupCodes')}</h3> 561 565 <p class="warning-text"> 562 - Save these backup codes in a secure location. Each code can only be used once. 563 - If you lose access to your authenticator app, you'll need these to sign in. 566 + {$_('security.backupCodesDescription')} 564 567 </p> 565 568 <div class="backup-codes"> 566 569 {#each backupCodes as code} ··· 569 572 </div> 570 573 <div class="actions"> 571 574 <button type="button" class="secondary" onclick={copyBackupCodes}> 572 - Copy to Clipboard 575 + {$_('security.copyToClipboard')} 573 576 </button> 574 577 <button onclick={handleFinishSetup}> 575 - I've Saved My Codes 578 + {$_('security.savedMyCodes')} 576 579 </button> 577 580 </div> 578 581 </div> ··· 580 583 </section> 581 584 582 585 <section> 583 - <h2>Passkeys</h2> 586 + <h2>{$_('security.passkeys')}</h2> 584 587 <p class="description"> 585 - Passkeys are a secure, passwordless way to sign in using biometrics (fingerprint or face), a security key, or your device's screen lock. 588 + {$_('security.passkeysDescription')} 586 589 </p> 587 590 588 591 {#if passkeysLoading} 589 - <div class="loading">Loading passkeys...</div> 592 + <div class="loading">{$_('security.loadingPasskeys')}</div> 590 593 {:else} 591 594 {#if passkeys.length > 0} 592 595 <div class="passkey-list"> ··· 597 600 <input 598 601 type="text" 599 602 bind:value={editPasskeyName} 600 - placeholder="Passkey name" 603 + placeholder="{$_('security.passkeyName')}" 601 604 class="passkey-name-input" 602 605 /> 603 606 <div class="passkey-edit-actions"> 604 - <button type="button" class="small" onclick={handleSavePasskeyName}>Save</button> 605 - <button type="button" class="small secondary" onclick={cancelEditPasskey}>Cancel</button> 607 + <button type="button" class="small" onclick={handleSavePasskeyName}>{$_('common.save')}</button> 608 + <button type="button" class="small secondary" onclick={cancelEditPasskey}>{$_('common.cancel')}</button> 606 609 </div> 607 610 </div> 608 611 {:else} 609 612 <div class="passkey-info"> 610 - <span class="passkey-name">{passkey.friendlyName || 'Unnamed passkey'}</span> 613 + <span class="passkey-name">{passkey.friendlyName || $_('security.unnamedPasskey')}</span> 611 614 <span class="passkey-meta"> 612 - Added {formatDate(passkey.createdAt)} 615 + {$_('security.added')} {formatDate(passkey.createdAt)} 613 616 {#if passkey.lastUsed} 614 - &middot; Last used {formatDate(passkey.lastUsed)} 617 + &middot; {$_('security.lastUsed')} {formatDate(passkey.lastUsed)} 615 618 {/if} 616 619 </span> 617 620 </div> 618 621 <div class="passkey-actions"> 619 622 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 620 - Rename 623 + {$_('security.rename')} 621 624 </button> 622 625 {#if hasPassword || passkeys.length > 1} 623 626 <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 624 - Delete 627 + {$_('security.deletePasskey')} 625 628 </button> 626 629 {/if} 627 630 </div> ··· 631 634 </div> 632 635 {:else} 633 636 <div class="status disabled"> 634 - <span>No passkeys registered</span> 637 + <span>{$_('security.noPasskeys')}</span> 635 638 </div> 636 639 {/if} 637 640 638 641 <div class="add-passkey"> 639 642 <div class="field"> 640 - <label for="passkey-name">Passkey Name (optional)</label> 643 + <label for="passkey-name">{$_('security.passkeyName')}</label> 641 644 <input 642 645 id="passkey-name" 643 646 type="text" 644 647 bind:value={newPasskeyName} 645 - placeholder="e.g., MacBook Touch ID" 648 + placeholder="{$_('security.passkeyNamePlaceholder')}" 646 649 disabled={addingPasskey} 647 650 /> 648 651 </div> 649 652 <button onclick={handleAddPasskey} disabled={addingPasskey}> 650 - {addingPasskey ? 'Adding Passkey...' : 'Add a Passkey'} 653 + {addingPasskey ? $_('security.adding') : $_('security.addPasskey')} 651 654 </button> 652 655 </div> 653 656 {/if} 654 657 </section> 655 658 656 659 <section> 657 - <h2>Password</h2> 660 + <h2>{$_('security.password')}</h2> 658 661 <p class="description"> 659 - Manage your account password. If you have passkeys set up, you can optionally remove your password for a fully passwordless experience. 662 + {$_('security.passwordDescription')} 660 663 </p> 661 664 662 665 {#if passwordLoading} 663 - <div class="loading">Loading...</div> 666 + <div class="loading">{$_('common.loading')}</div> 664 667 {:else if hasPassword} 665 668 <div class="status enabled"> 666 - <span>Password authentication is <strong>enabled</strong></span> 669 + <span>{$_('security.passwordStatus')}</span> 667 670 </div> 668 671 669 672 {#if passkeys.length > 0} 670 673 {#if !showRemovePasswordForm} 671 674 <button type="button" class="danger-outline" onclick={() => showRemovePasswordForm = true}> 672 - Remove Password 675 + {$_('security.removePassword')} 673 676 </button> 674 677 {:else} 675 678 <div class="inline-form danger-form"> 676 - <h3>Remove Password</h3> 679 + <h3>{$_('security.removePassword')}</h3> 677 680 <p class="warning-text"> 678 - This will make your account passkey-only. You'll only be able to sign in using your registered passkeys. 679 - If you lose access to all your passkeys, you can recover your account using your notification channel. 681 + {$_('security.removePasswordWarning')} 680 682 </p> 681 683 <div class="info-box-inline"> 682 - <strong>Before proceeding:</strong> 684 + <strong>{$_('security.beforeProceeding')}</strong> 683 685 <ul> 684 - <li>Make sure you have at least one reliable passkey registered</li> 685 - <li>Consider registering passkeys on multiple devices</li> 686 - <li>Ensure your recovery notification channel is up to date</li> 686 + <li>{$_('security.beforeProceedingItem1')}</li> 687 + <li>{$_('security.beforeProceedingItem2')}</li> 688 + <li>{$_('security.beforeProceedingItem3')}</li> 687 689 </ul> 688 690 </div> 689 691 <div class="actions"> 690 692 <button type="button" class="secondary" onclick={() => showRemovePasswordForm = false}> 691 - Cancel 693 + {$_('common.cancel')} 692 694 </button> 693 695 <button type="button" class="danger" onclick={handleRemovePassword} disabled={removePasswordLoading}> 694 - {removePasswordLoading ? 'Removing...' : 'Remove Password'} 696 + {removePasswordLoading ? $_('security.removing') : $_('security.removePassword')} 695 697 </button> 696 698 </div> 697 699 </div> 698 700 {/if} 699 701 {:else} 700 - <p class="hint">Add at least one passkey before you can remove your password.</p> 702 + <p class="hint">{$_('security.addPasskeyFirst')}</p> 701 703 {/if} 702 704 {:else} 703 705 <div class="status passkey-only"> 704 - <span>Your account is <strong>passkey-only</strong></span> 706 + <span>{$_('security.noPassword')}</span> 705 707 </div> 706 708 <p class="hint"> 707 - You sign in using passkeys only. If you ever lose access to your passkeys, 708 - you can recover your account using the "Lost passkey?" link on the login page. 709 + {$_('security.passkeyOnlyHint')} 709 710 </p> 710 711 {/if} 711 712 </section> 712 713 713 714 <section> 714 - <h2>Trusted Devices</h2> 715 + <h2>{$_('security.trustedDevices')}</h2> 715 716 <p class="description"> 716 - Manage devices that can skip two-factor authentication when signing in. Trust is granted for 30 days and automatically extends when you use the device. 717 + {$_('security.trustedDevicesDescription')} 717 718 </p> 718 719 <a href="#/trusted-devices" class="section-link"> 719 - Manage Trusted Devices &rarr; 720 + {$_('security.manageTrustedDevices')} &rarr; 720 721 </a> 721 722 </section> 722 723 723 724 {#if hasMfa} 724 725 <section> 725 - <h2>App Compatibility</h2> 726 + <h2>{$_('security.appCompatibility')}</h2> 726 727 <p class="description"> 727 - Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account. 728 + {$_('security.legacyLoginDescription')} 728 729 </p> 729 730 730 731 {#if legacyLoginLoading} 731 - <div class="loading">Loading...</div> 732 + <div class="loading">{$_('common.loading')}</div> 732 733 {:else} 733 734 <div class="toggle-row"> 734 735 <div class="toggle-info"> 735 - <span class="toggle-label">Allow legacy app login</span> 736 + <span class="toggle-label">{$_('security.legacyLogin')}</span> 736 737 <span class="toggle-description"> 737 738 {#if allowLegacyLogin} 738 - Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification. 739 + {$_('security.legacyLoginOn')} 739 740 {:else} 740 - Only OAuth-compatible apps can sign in. Legacy apps will be blocked. 741 + {$_('security.legacyLoginOff')} 741 742 {/if} 742 743 </span> 743 744 </div> ··· 753 754 754 755 {#if totpEnabled} 755 756 <div class="warning-box"> 756 - <strong>Important: Password changes in Bluesky app will fail</strong> 757 - <p> 758 - With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked. 759 - To change your password, you have two options: 760 - </p> 757 + <strong>{$_('security.legacyLoginWarning')}</strong> 758 + <p>{$_('security.totpPasswordWarning')}</p> 761 759 <ol> 762 - <li><strong>Change it here:</strong> Use this website's <a href="#/settings">Settings page</a> where you can verify with your authenticator app.</li> 763 - <li><strong>Verify your session first:</strong> Use the <a href="#/settings">re-authenticate option</a> to verify your Bluesky session with TOTP, then password changes will work temporarily.</li> 760 + <li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="#/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li> 761 + <li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="#/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li> 764 762 </ol> 765 763 </div> 766 764 {/if} 767 765 768 766 <div class="info-box-inline"> 769 - <strong>What are legacy apps?</strong> 770 - <p> 771 - Some apps (like the official Bluesky app) use older authentication that only requires your password. 772 - When you have MFA enabled, these apps bypass your second factor. 773 - Disabling legacy login forces all apps to use OAuth, which properly enforces MFA. 774 - </p> 767 + <strong>{$_('security.legacyAppsTitle')}</strong> 768 + <p>{$_('security.legacyAppsDescription')}</p> 775 769 </div> 776 770 {/if} 777 771 </section> ··· 788 782 789 783 <style> 790 784 .page { 791 - max-width: 600px; 785 + max-width: var(--width-md); 792 786 margin: 0 auto; 793 - padding: 2rem; 787 + padding: var(--space-7); 794 788 } 795 789 796 790 header { 797 - margin-bottom: 2rem; 791 + margin-bottom: var(--space-7); 798 792 } 799 793 800 794 .back { 801 795 color: var(--text-secondary); 802 796 text-decoration: none; 803 - font-size: 0.875rem; 797 + font-size: var(--text-sm); 804 798 } 805 799 806 800 .back:hover { ··· 808 802 } 809 803 810 804 h1 { 811 - margin: 0.5rem 0 0 0; 812 - } 813 - 814 - .message { 815 - padding: 0.75rem; 816 - border-radius: 4px; 817 - margin-bottom: 1rem; 818 - } 819 - 820 - .message.success { 821 - background: var(--success-bg); 822 - border: 1px solid var(--success-border); 823 - color: var(--success-text); 824 - } 825 - 826 - .message.error { 827 - background: var(--error-bg); 828 - border: 1px solid var(--error-border); 829 - color: var(--error-text); 805 + margin: var(--space-2) 0 0 0; 830 806 } 831 807 832 808 .loading { 833 809 text-align: center; 834 810 color: var(--text-secondary); 835 - padding: 2rem; 811 + padding: var(--space-7); 836 812 } 837 813 838 814 section { 839 - padding: 1.5rem; 815 + padding: var(--space-6); 840 816 background: var(--bg-secondary); 841 - border-radius: 8px; 842 - margin-bottom: 1.5rem; 817 + border-radius: var(--radius-xl); 818 + margin-bottom: var(--space-6); 843 819 } 844 820 845 821 section h2 { 846 - margin: 0 0 0.5rem 0; 847 - font-size: 1.125rem; 822 + margin: 0 0 var(--space-2) 0; 823 + font-size: var(--text-lg); 848 824 } 849 825 850 826 .description { 851 827 color: var(--text-secondary); 852 - font-size: 0.875rem; 853 - margin-bottom: 1.5rem; 828 + font-size: var(--text-sm); 829 + margin-bottom: var(--space-6); 854 830 } 855 831 856 832 .status { 857 833 display: flex; 858 834 align-items: center; 859 - gap: 0.5rem; 860 - padding: 0.75rem; 861 - border-radius: 4px; 862 - margin-bottom: 1rem; 835 + gap: var(--space-2); 836 + padding: var(--space-3); 837 + border-radius: var(--radius-md); 838 + margin-bottom: var(--space-4); 863 839 } 864 840 865 841 .status.enabled { ··· 872 848 background: var(--warning-bg); 873 849 border: 1px solid var(--border-color); 874 850 color: var(--warning-text); 851 + } 852 + 853 + .status.passkey-only { 854 + background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 855 + border: 1px solid var(--accent); 856 + color: var(--accent); 875 857 } 876 858 877 859 .totp-actions { 878 860 display: flex; 879 - gap: 0.5rem; 861 + gap: var(--space-2); 880 862 flex-wrap: wrap; 881 863 } 882 864 883 - .field { 884 - margin-bottom: 1rem; 885 - } 886 - 887 - label { 888 - display: block; 889 - font-size: 0.875rem; 890 - font-weight: 500; 891 - margin-bottom: 0.25rem; 892 - } 893 - 894 - input { 895 - width: 100%; 896 - padding: 0.75rem; 897 - border: 1px solid var(--border-color-light); 898 - border-radius: 4px; 899 - font-size: 1rem; 900 - box-sizing: border-box; 901 - background: var(--bg-input); 902 - color: var(--text-primary); 903 - } 904 - 905 - input:focus { 906 - outline: none; 907 - border-color: var(--accent); 908 - } 909 - 910 865 .code-input { 911 - font-size: 1.5rem; 866 + font-size: var(--text-2xl); 912 867 letter-spacing: 0.5em; 913 868 text-align: center; 914 869 max-width: 200px; ··· 916 871 display: block; 917 872 } 918 873 919 - button { 920 - padding: 0.75rem 1.5rem; 921 - background: var(--accent); 922 - color: white; 923 - border: none; 924 - border-radius: 4px; 925 - cursor: pointer; 926 - font-size: 1rem; 927 - } 928 - 929 - button:hover:not(:disabled) { 930 - background: var(--accent-hover); 931 - } 932 - 933 - button:disabled { 934 - opacity: 0.6; 935 - cursor: not-allowed; 936 - } 937 - 938 - button.secondary { 939 - background: transparent; 940 - color: var(--text-secondary); 941 - border: 1px solid var(--border-color-light); 942 - } 943 - 944 - button.secondary:hover:not(:disabled) { 945 - background: var(--bg-card); 946 - } 947 - 948 - button.danger { 949 - background: var(--error-text); 950 - } 951 - 952 - button.danger:hover:not(:disabled) { 953 - background: #900; 954 - } 955 - 956 - button.danger-outline { 957 - background: transparent; 958 - color: var(--error-text); 959 - border: 1px solid var(--error-border); 960 - } 961 - 962 - button.danger-outline:hover:not(:disabled) { 963 - background: var(--error-bg); 964 - } 965 - 966 874 .actions { 967 875 display: flex; 968 - gap: 0.5rem; 969 - margin-top: 1rem; 876 + gap: var(--space-2); 877 + margin-top: var(--space-4); 970 878 } 971 879 972 880 .inline-form { 973 - margin-top: 1rem; 974 - padding: 1rem; 881 + margin-top: var(--space-4); 882 + padding: var(--space-4); 975 883 background: var(--bg-card); 976 - border: 1px solid var(--border-color-light); 977 - border-radius: 6px; 884 + border: 1px solid var(--border-color); 885 + border-radius: var(--radius-lg); 978 886 } 979 887 980 888 .inline-form h3 { 981 - margin: 0 0 0.5rem 0; 982 - font-size: 1rem; 889 + margin: 0 0 var(--space-2) 0; 890 + font-size: var(--text-base); 983 891 } 984 892 985 893 .danger-form { ··· 989 897 990 898 .warning-text { 991 899 color: var(--error-text); 992 - font-size: 0.875rem; 993 - margin-bottom: 1rem; 900 + font-size: var(--text-sm); 901 + margin-bottom: var(--space-4); 994 902 } 995 903 996 904 .setup-step { 997 - padding: 1rem; 905 + padding: var(--space-4); 998 906 background: var(--bg-card); 999 - border: 1px solid var(--border-color-light); 1000 - border-radius: 6px; 907 + border: 1px solid var(--border-color); 908 + border-radius: var(--radius-lg); 1001 909 } 1002 910 1003 911 .setup-step h3 { 1004 - margin: 0 0 0.5rem 0; 912 + margin: 0 0 var(--space-2) 0; 1005 913 } 1006 914 1007 915 .setup-step p { 1008 916 color: var(--text-secondary); 1009 - font-size: 0.875rem; 1010 - margin-bottom: 1rem; 917 + font-size: var(--text-sm); 918 + margin-bottom: var(--space-4); 1011 919 } 1012 920 1013 921 .qr-container { 1014 922 display: flex; 1015 923 justify-content: center; 1016 - margin: 1.5rem 0; 924 + margin: var(--space-6) 0; 1017 925 } 1018 926 1019 927 .qr-code { ··· 1023 931 } 1024 932 1025 933 .manual-entry { 1026 - margin-bottom: 1rem; 1027 - font-size: 0.875rem; 934 + margin-bottom: var(--space-4); 935 + font-size: var(--text-sm); 1028 936 } 1029 937 1030 938 .manual-entry summary { ··· 1034 942 1035 943 .secret-code { 1036 944 display: block; 1037 - margin-top: 0.5rem; 1038 - padding: 0.5rem; 945 + margin-top: var(--space-2); 946 + padding: var(--space-2); 1039 947 background: var(--bg-input); 1040 - border-radius: 4px; 948 + border-radius: var(--radius-md); 1041 949 word-break: break-all; 1042 - font-size: 0.75rem; 950 + font-size: var(--text-xs); 1043 951 } 1044 952 1045 953 .backup-codes { 1046 954 display: grid; 1047 955 grid-template-columns: repeat(2, 1fr); 1048 - gap: 0.5rem; 1049 - margin: 1rem 0; 956 + gap: var(--space-2); 957 + margin: var(--space-4) 0; 1050 958 } 1051 959 1052 960 .backup-code { 1053 - padding: 0.5rem; 961 + padding: var(--space-2); 1054 962 background: var(--bg-input); 1055 - border-radius: 4px; 963 + border-radius: var(--radius-md); 1056 964 text-align: center; 1057 - font-size: 0.875rem; 1058 - font-family: monospace; 965 + font-size: var(--text-sm); 966 + font-family: ui-monospace, monospace; 1059 967 } 1060 968 1061 969 .passkey-list { 1062 970 display: flex; 1063 971 flex-direction: column; 1064 - gap: 0.5rem; 1065 - margin-bottom: 1rem; 972 + gap: var(--space-2); 973 + margin-bottom: var(--space-4); 1066 974 } 1067 975 1068 976 .passkey-item { 1069 977 display: flex; 1070 978 justify-content: space-between; 1071 979 align-items: center; 1072 - padding: 0.75rem; 980 + padding: var(--space-3); 1073 981 background: var(--bg-card); 1074 - border: 1px solid var(--border-color-light); 1075 - border-radius: 6px; 1076 - gap: 1rem; 982 + border: 1px solid var(--border-color); 983 + border-radius: var(--radius-lg); 984 + gap: var(--space-4); 1077 985 } 1078 986 1079 987 .passkey-info { 1080 988 display: flex; 1081 989 flex-direction: column; 1082 - gap: 0.25rem; 990 + gap: var(--space-1); 1083 991 flex: 1; 1084 992 min-width: 0; 1085 993 } 1086 994 1087 995 .passkey-name { 1088 - font-weight: 500; 996 + font-weight: var(--font-medium); 1089 997 overflow: hidden; 1090 998 text-overflow: ellipsis; 1091 999 white-space: nowrap; 1092 1000 } 1093 1001 1094 1002 .passkey-meta { 1095 - font-size: 0.75rem; 1003 + font-size: var(--text-xs); 1096 1004 color: var(--text-secondary); 1097 1005 } 1098 1006 1099 1007 .passkey-actions { 1100 1008 display: flex; 1101 - gap: 0.5rem; 1009 + gap: var(--space-2); 1102 1010 flex-shrink: 0; 1103 1011 } 1104 1012 1105 1013 .passkey-edit { 1106 1014 display: flex; 1107 1015 flex: 1; 1108 - gap: 0.5rem; 1016 + gap: var(--space-2); 1109 1017 align-items: center; 1110 1018 } 1111 1019 1112 1020 .passkey-name-input { 1113 1021 flex: 1; 1114 - padding: 0.5rem; 1115 - font-size: 0.875rem; 1022 + padding: var(--space-2); 1023 + font-size: var(--text-sm); 1116 1024 } 1117 1025 1118 1026 .passkey-edit-actions { 1119 1027 display: flex; 1120 - gap: 0.25rem; 1028 + gap: var(--space-1); 1121 1029 } 1122 1030 1123 1031 button.small { 1124 - padding: 0.375rem 0.75rem; 1125 - font-size: 0.75rem; 1032 + padding: var(--space-2) var(--space-3); 1033 + font-size: var(--text-xs); 1126 1034 } 1127 1035 1128 1036 .add-passkey { 1129 - margin-top: 1rem; 1130 - padding-top: 1rem; 1131 - border-top: 1px solid var(--border-color-light); 1037 + margin-top: var(--space-4); 1038 + padding-top: var(--space-4); 1039 + border-top: 1px solid var(--border-color); 1132 1040 } 1133 1041 1134 1042 .add-passkey .field { 1135 - margin-bottom: 0.75rem; 1043 + margin-bottom: var(--space-3); 1136 1044 } 1137 1045 1138 1046 .section-link { 1139 1047 display: inline-block; 1140 1048 color: var(--accent); 1141 1049 text-decoration: none; 1142 - font-weight: 500; 1050 + font-weight: var(--font-medium); 1143 1051 } 1144 1052 1145 1053 .section-link:hover { 1146 1054 text-decoration: underline; 1147 - } 1148 - 1149 - .status.passkey-only { 1150 - background: var(--accent); 1151 - background: linear-gradient(135deg, rgba(77, 166, 255, 0.15), rgba(128, 90, 213, 0.15)); 1152 - border: 1px solid var(--accent); 1153 - color: var(--accent); 1154 1055 } 1155 1056 1156 1057 .hint { 1157 - font-size: 0.875rem; 1058 + font-size: var(--text-sm); 1158 1059 color: var(--text-secondary); 1159 1060 margin: 0; 1160 1061 } ··· 1162 1063 .info-box-inline { 1163 1064 background: var(--bg-card); 1164 1065 border: 1px solid var(--border-color); 1165 - border-radius: 6px; 1166 - padding: 1rem; 1167 - margin-bottom: 1rem; 1168 - font-size: 0.875rem; 1066 + border-radius: var(--radius-lg); 1067 + padding: var(--space-4); 1068 + margin-bottom: var(--space-4); 1069 + font-size: var(--text-sm); 1169 1070 } 1170 1071 1171 1072 .info-box-inline strong { 1172 1073 display: block; 1173 - margin-bottom: 0.5rem; 1074 + margin-bottom: var(--space-2); 1174 1075 } 1175 1076 1176 1077 .info-box-inline ul { 1177 1078 margin: 0; 1178 - padding-left: 1.25rem; 1079 + padding-left: var(--space-5); 1179 1080 color: var(--text-secondary); 1180 1081 } 1181 1082 1182 1083 .info-box-inline li { 1183 - margin-bottom: 0.25rem; 1084 + margin-bottom: var(--space-1); 1184 1085 } 1185 1086 1186 1087 .info-box-inline p { ··· 1192 1093 display: flex; 1193 1094 justify-content: space-between; 1194 1095 align-items: flex-start; 1195 - gap: 1rem; 1196 - padding: 1rem; 1096 + gap: var(--space-4); 1097 + padding: var(--space-4); 1197 1098 background: var(--bg-card); 1198 - border: 1px solid var(--border-color-light); 1199 - border-radius: 6px; 1200 - margin-bottom: 1rem; 1099 + border: 1px solid var(--border-color); 1100 + border-radius: var(--radius-lg); 1101 + margin-bottom: var(--space-4); 1201 1102 } 1202 1103 1203 1104 .toggle-info { 1204 1105 display: flex; 1205 1106 flex-direction: column; 1206 - gap: 0.25rem; 1107 + gap: var(--space-1); 1207 1108 } 1208 1109 1209 1110 .toggle-label { 1210 - font-weight: 500; 1111 + font-weight: var(--font-medium); 1211 1112 } 1212 1113 1213 1114 .toggle-description { 1214 - font-size: 0.875rem; 1115 + font-size: var(--text-sm); 1215 1116 color: var(--text-secondary); 1216 1117 } 1217 1118 ··· 1223 1124 border: none; 1224 1125 border-radius: 13px; 1225 1126 cursor: pointer; 1226 - transition: background 0.2s; 1127 + transition: background var(--transition-fast); 1227 1128 flex-shrink: 0; 1228 1129 } 1229 1130 ··· 1247 1148 height: 20px; 1248 1149 background: white; 1249 1150 border-radius: 50%; 1250 - transition: left 0.2s; 1151 + transition: left var(--transition-fast); 1251 1152 } 1252 1153 1253 1154 .toggle-button.on .toggle-slider { ··· 1260 1161 1261 1162 .warning-box { 1262 1163 background: var(--warning-bg); 1263 - border: 1px solid var(--warning-border, var(--border-color)); 1164 + border: 1px solid var(--warning-border); 1264 1165 border-left: 4px solid var(--warning-text); 1265 - border-radius: 6px; 1266 - padding: 1rem; 1267 - margin-bottom: 1rem; 1166 + border-radius: var(--radius-lg); 1167 + padding: var(--space-4); 1168 + margin-bottom: var(--space-4); 1268 1169 } 1269 1170 1270 1171 .warning-box strong { 1271 1172 display: block; 1272 - margin-bottom: 0.5rem; 1173 + margin-bottom: var(--space-2); 1273 1174 color: var(--warning-text); 1274 1175 } 1275 1176 1276 1177 .warning-box p { 1277 - margin: 0 0 0.75rem 0; 1278 - font-size: 0.875rem; 1178 + margin: 0 0 var(--space-3) 0; 1179 + font-size: var(--text-sm); 1279 1180 color: var(--text-primary); 1280 1181 } 1281 1182 1282 1183 .warning-box ol { 1283 1184 margin: 0; 1284 - padding-left: 1.25rem; 1285 - font-size: 0.875rem; 1185 + padding-left: var(--space-5); 1186 + font-size: var(--text-sm); 1286 1187 } 1287 1188 1288 1189 .warning-box li { 1289 - margin-bottom: 0.5rem; 1190 + margin-bottom: var(--space-2); 1290 1191 } 1291 1192 1292 1193 .warning-box a {
+88 -68
frontend/src/routes/Sessions.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import { _ } from '../lib/i18n' 6 + import { formatDateTime } from '../lib/date' 5 7 const auth = getAuthState() 6 8 let loading = $state(true) 7 9 let error = $state<string | null>(null) ··· 31 33 const result = await api.listSessions(auth.session.accessJwt) 32 34 sessions = result.sessions 33 35 } catch (e) { 34 - error = e instanceof ApiError ? e.message : 'Failed to load sessions' 36 + error = e instanceof ApiError ? e.message : $_('sessions.failedToLoad') 35 37 } finally { 36 38 loading = false 37 39 } ··· 39 41 async function revokeSession(sessionId: string, isCurrent: boolean) { 40 42 if (!auth.session) return 41 43 const msg = isCurrent 42 - ? 'This will log you out of this session. Continue?' 43 - : 'Revoke this session?' 44 + ? $_('sessions.revokeCurrentConfirm') 45 + : $_('sessions.revokeConfirm') 44 46 if (!confirm(msg)) return 45 47 try { 46 48 await api.revokeSession(auth.session.accessJwt, sessionId) ··· 50 52 sessions = sessions.filter(s => s.id !== sessionId) 51 53 } 52 54 } catch (e) { 53 - error = e instanceof ApiError ? e.message : 'Failed to revoke session' 55 + error = e instanceof ApiError ? e.message : $_('sessions.failedToRevoke') 54 56 } 55 57 } 56 58 async function revokeAllSessions() { 57 59 if (!auth.session) return 58 - const otherCount = sessions.filter(s => !s.isCurrent).length 59 - if (otherCount === 0) { 60 - error = 'No other sessions to revoke' 60 + const otherSessions = sessions.filter(s => !s.isCurrent) 61 + if (otherSessions.length === 0) { 62 + error = $_('sessions.noOtherSessions') 61 63 return 62 64 } 63 - if (!confirm(`This will revoke ${otherCount} other session${otherCount > 1 ? 's' : ''}. Continue?`)) return 65 + if (!confirm($_('sessions.revokeAllConfirm', { values: { count: otherSessions.length } }))) return 64 66 try { 65 67 await api.revokeAllSessions(auth.session.accessJwt) 66 68 sessions = sessions.filter(s => s.isCurrent) 67 69 } catch (e) { 68 - error = e instanceof ApiError ? e.message : 'Failed to revoke sessions' 70 + error = e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll') 69 71 } 70 72 } 71 73 function formatDate(dateStr: string): string { 72 - return new Date(dateStr).toLocaleString() 74 + return formatDateTime(dateStr) 73 75 } 74 76 function timeAgo(dateStr: string): string { 75 77 const date = new Date(dateStr) ··· 78 80 const days = Math.floor(diff / (1000 * 60 * 60 * 24)) 79 81 const hours = Math.floor(diff / (1000 * 60 * 60)) 80 82 const minutes = Math.floor(diff / (1000 * 60)) 81 - if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago` 82 - if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago` 83 - if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago` 84 - return 'Just now' 83 + if (days > 0) return $_('sessions.daysAgo', { values: { count: days } }) 84 + if (hours > 0) return $_('sessions.hoursAgo', { values: { count: hours } }) 85 + if (minutes > 0) return $_('sessions.minutesAgo', { values: { count: minutes } }) 86 + return $_('sessions.justNow') 85 87 } 86 88 </script> 87 89 <div class="page"> 88 90 <header> 89 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 90 - <h1>Active Sessions</h1> 91 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 92 + <h1>{$_('sessions.title')}</h1> 91 93 </header> 92 94 {#if loading} 93 - <p class="loading">Loading sessions...</p> 95 + <p class="loading">{$_('sessions.loadingSessions')}</p> 94 96 {:else} 95 97 {#if error} 96 98 <div class="message error">{error}</div> 97 99 {/if} 98 100 {#if sessions.length === 0} 99 - <p class="empty">No active sessions found.</p> 101 + <p class="empty">{$_('sessions.noSessions')}</p> 100 102 {:else} 101 103 <div class="sessions-list"> 102 104 {#each sessions as session} ··· 104 106 <div class="session-info"> 105 107 <div class="session-header"> 106 108 {#if session.isCurrent} 107 - <span class="badge current">Current</span> 109 + <span class="badge current">{$_('sessions.current')}</span> 108 110 {/if} 109 111 <span class="badge type" class:oauth={session.sessionType === 'oauth'}> 110 - {session.sessionType === 'oauth' ? 'OAuth' : 'Session'} 112 + {session.sessionType === 'oauth' ? $_('sessions.oauth') : $_('sessions.session')} 111 113 </span> 112 114 {#if session.clientName} 113 115 <span class="client-name">{session.clientName}</span> ··· 115 117 </div> 116 118 <div class="session-details"> 117 119 <div class="detail"> 118 - <span class="label">Created:</span> 120 + <span class="label">{$_('sessions.created')}</span> 119 121 <span class="value">{timeAgo(session.createdAt)}</span> 120 122 </div> 121 123 <div class="detail"> 122 - <span class="label">Expires:</span> 124 + <span class="label">{$_('sessions.expires')}</span> 123 125 <span class="value">{formatDate(session.expiresAt)}</span> 124 126 </div> 125 127 </div> ··· 130 132 class:danger={!session.isCurrent} 131 133 onclick={() => revokeSession(session.id, session.isCurrent)} 132 134 > 133 - {session.isCurrent ? 'Sign Out' : 'Revoke'} 135 + {session.isCurrent ? $_('sessions.signOut') : $_('sessions.revoke')} 134 136 </button> 135 137 </div> 136 138 </div> 137 139 {/each} 138 140 </div> 139 141 <div class="actions-bar"> 140 - <button class="refresh-btn" onclick={loadSessions}>Refresh</button> 142 + <button class="refresh-btn" onclick={loadSessions}>{$_('common.refresh')}</button> 141 143 {#if sessions.filter(s => !s.isCurrent).length > 0} 142 - <button class="revoke-all-btn" onclick={revokeAllSessions}>Revoke All Other Sessions</button> 144 + <button class="revoke-all-btn" onclick={revokeAllSessions}>{$_('sessions.revokeAll')}</button> 143 145 {/if} 144 146 </div> 145 147 {/if} ··· 147 149 </div> 148 150 <style> 149 151 .page { 150 - max-width: 600px; 152 + max-width: var(--width-md); 151 153 margin: 0 auto; 152 - padding: 2rem; 154 + padding: var(--space-7); 153 155 } 156 + 154 157 header { 155 - margin-bottom: 2rem; 158 + margin-bottom: var(--space-7); 156 159 } 160 + 157 161 .back { 158 162 color: var(--text-secondary); 159 163 text-decoration: none; 160 - font-size: 0.875rem; 164 + font-size: var(--text-sm); 161 165 } 166 + 162 167 .back:hover { 163 168 color: var(--accent); 164 169 } 170 + 165 171 h1 { 166 - margin: 0.5rem 0 0 0; 172 + margin: var(--space-2) 0 0 0; 167 173 } 168 - .loading, .empty { 174 + 175 + .loading, 176 + .empty { 169 177 text-align: center; 170 178 color: var(--text-secondary); 171 - padding: 2rem; 172 - } 173 - .message { 174 - padding: 0.75rem; 175 - border-radius: 4px; 176 - margin-bottom: 1rem; 177 - } 178 - .message.error { 179 - background: var(--error-bg); 180 - border: 1px solid var(--error-border); 181 - color: var(--error-text); 179 + padding: var(--space-7); 182 180 } 181 + 183 182 .sessions-list { 184 183 display: flex; 185 184 flex-direction: column; 186 - gap: 1rem; 185 + gap: var(--space-4); 187 186 } 187 + 188 188 .session-card { 189 189 background: var(--bg-secondary); 190 190 border: 1px solid var(--border-color); 191 - border-radius: 8px; 192 - padding: 1rem; 191 + border-radius: var(--radius-xl); 192 + padding: var(--space-4); 193 193 display: flex; 194 194 justify-content: space-between; 195 195 align-items: center; 196 196 } 197 + 197 198 .session-card.current { 198 199 border-color: var(--accent); 199 200 background: var(--bg-card); 200 201 } 202 + 201 203 .session-header { 202 - margin-bottom: 0.5rem; 204 + margin-bottom: var(--space-2); 203 205 display: flex; 204 206 align-items: center; 205 - gap: 0.5rem; 207 + gap: var(--space-2); 206 208 flex-wrap: wrap; 207 209 } 210 + 208 211 .client-name { 209 - font-weight: 500; 212 + font-weight: var(--font-medium); 210 213 color: var(--text-primary); 211 214 } 215 + 212 216 .badge { 213 217 display: inline-block; 214 - padding: 0.125rem 0.5rem; 215 - border-radius: 4px; 216 - font-size: 0.75rem; 217 - font-weight: 500; 218 + padding: var(--space-1) var(--space-2); 219 + border-radius: var(--radius-md); 220 + font-size: var(--text-xs); 221 + font-weight: var(--font-medium); 218 222 } 223 + 219 224 .badge.current { 220 225 background: var(--accent); 221 - color: white; 226 + color: var(--text-inverse); 222 227 } 228 + 223 229 .badge.type { 224 230 background: var(--bg-secondary); 225 231 color: var(--text-secondary); 226 232 border: 1px solid var(--border-color); 227 233 } 234 + 228 235 .badge.type.oauth { 229 - background: #e6f4ea; 230 - color: #1e7e34; 231 - border-color: #b8d9c5; 236 + background: var(--success-bg); 237 + color: var(--success-text); 238 + border-color: var(--success-border); 232 239 } 240 + 233 241 .session-details { 234 242 display: flex; 235 243 flex-direction: column; 236 - gap: 0.25rem; 244 + gap: var(--space-1); 237 245 } 246 + 238 247 .detail { 239 - font-size: 0.875rem; 248 + font-size: var(--text-sm); 240 249 } 250 + 241 251 .detail .label { 242 252 color: var(--text-secondary); 243 - margin-right: 0.5rem; 253 + margin-right: var(--space-2); 244 254 } 255 + 245 256 .detail .value { 246 257 color: var(--text-primary); 247 258 } 259 + 248 260 .revoke-btn { 249 - padding: 0.5rem 1rem; 261 + padding: var(--space-2) var(--space-4); 250 262 border: 1px solid var(--border-color); 251 - border-radius: 4px; 263 + border-radius: var(--radius-md); 252 264 background: transparent; 253 265 color: var(--text-primary); 254 266 cursor: pointer; 255 - font-size: 0.875rem; 267 + font-size: var(--text-sm); 256 268 } 269 + 257 270 .revoke-btn:hover { 258 271 background: var(--bg-card); 259 272 } 273 + 260 274 .revoke-btn.danger { 261 275 border-color: var(--error-text); 262 276 color: var(--error-text); 263 277 } 278 + 264 279 .revoke-btn.danger:hover { 265 280 background: var(--error-bg); 266 281 } 282 + 267 283 .actions-bar { 268 - margin-top: 1rem; 284 + margin-top: var(--space-4); 269 285 display: flex; 270 - gap: 0.5rem; 286 + gap: var(--space-2); 271 287 flex-wrap: wrap; 272 288 } 289 + 273 290 .refresh-btn { 274 - padding: 0.5rem 1rem; 291 + padding: var(--space-2) var(--space-4); 275 292 background: transparent; 276 293 border: 1px solid var(--border-color); 277 - border-radius: 4px; 294 + border-radius: var(--radius-md); 278 295 cursor: pointer; 279 296 color: var(--text-primary); 280 297 } 298 + 281 299 .refresh-btn:hover { 282 300 background: var(--bg-card); 283 301 border-color: var(--accent); 284 302 } 303 + 285 304 .revoke-all-btn { 286 - padding: 0.5rem 1rem; 305 + padding: var(--space-2) var(--space-4); 287 306 background: transparent; 288 307 border: 1px solid var(--error-text); 289 - border-radius: 4px; 308 + border-radius: var(--radius-md); 290 309 cursor: pointer; 291 310 color: var(--error-text); 292 311 } 312 + 293 313 .revoke-all-btn:hover { 294 314 background: var(--error-bg); 295 315 }
+220 -184
frontend/src/routes/Settings.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState, logout } from '../lib/auth.svelte' 2 + import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 5 6 const auth = getAuthState() 7 + const supportedLocales = getSupportedLocales() 8 + let localeLoading = $state(false) 9 + async function handleLocaleChange(newLocale: SupportedLocale) { 10 + if (!auth.session) return 11 + setLocale(newLocale) 12 + localeLoading = true 13 + try { 14 + await api.updateLocale(auth.session.accessJwt, newLocale) 15 + } catch (e) { 16 + console.error('Failed to save locale preference:', e) 17 + } finally { 18 + localeLoading = false 19 + } 20 + } 6 21 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 7 22 let emailLoading = $state(false) 8 23 let newEmail = $state('') ··· 40 55 const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) 41 56 emailTokenRequired = result.tokenRequired 42 57 if (emailTokenRequired) { 43 - showMessage('success', 'Verification code sent to your current email') 58 + showMessage('success', $_('settings.messages.verificationCodeSent')) 44 59 } else { 45 60 await api.updateEmail(auth.session.accessJwt, newEmail) 46 - showMessage('success', 'Email updated successfully') 61 + await refreshSession() 62 + showMessage('success', $_('settings.messages.emailUpdated')) 47 63 newEmail = '' 48 64 } 49 65 } catch (e) { 50 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email') 66 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 51 67 } finally { 52 68 emailLoading = false 53 69 } ··· 59 75 message = null 60 76 try { 61 77 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken) 62 - showMessage('success', 'Email updated successfully') 78 + await refreshSession() 79 + showMessage('success', $_('settings.messages.emailUpdated')) 63 80 newEmail = '' 64 81 emailToken = '' 65 82 emailTokenRequired = false 66 83 } catch (e) { 67 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email') 84 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 68 85 } finally { 69 86 emailLoading = false 70 87 } ··· 75 92 handleLoading = true 76 93 message = null 77 94 try { 78 - await api.updateHandle(auth.session.accessJwt, newHandle) 79 - showMessage('success', 'Handle updated successfully') 95 + const fullHandle = showBYOHandle 96 + ? newHandle 97 + : `${newHandle}.${window.location.hostname}` 98 + await api.updateHandle(auth.session.accessJwt, fullHandle) 99 + await refreshSession() 100 + showMessage('success', $_('settings.messages.handleUpdated')) 80 101 newHandle = '' 81 102 } catch (e) { 82 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to update handle') 103 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed')) 83 104 } finally { 84 105 handleLoading = false 85 106 } ··· 91 112 try { 92 113 await api.requestAccountDelete(auth.session.accessJwt) 93 114 deleteTokenSent = true 94 - showMessage('success', 'Deletion confirmation sent to your email') 115 + showMessage('success', $_('settings.messages.deletionConfirmationSent')) 95 116 } catch (e) { 96 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to request deletion') 117 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed')) 97 118 } finally { 98 119 deleteLoading = false 99 120 } ··· 101 122 async function handleConfirmDelete(e: Event) { 102 123 e.preventDefault() 103 124 if (!auth.session || !deletePassword || !deleteToken) return 104 - if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) { 125 + if (!confirm($_('settings.messages.deleteConfirmation'))) { 105 126 return 106 127 } 107 128 deleteLoading = true ··· 111 132 await logout() 112 133 navigate('/login') 113 134 } catch (e) { 114 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete account') 135 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed')) 115 136 } finally { 116 137 deleteLoading = false 117 138 } ··· 139 160 a.click() 140 161 document.body.removeChild(a) 141 162 URL.revokeObjectURL(url) 142 - showMessage('success', 'Repository exported successfully') 163 + showMessage('success', $_('settings.messages.repoExported')) 143 164 } catch (e) { 144 - showMessage('error', e instanceof Error ? e.message : 'Failed to export repository') 165 + showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 145 166 } finally { 146 167 exportLoading = false 147 168 } ··· 150 171 e.preventDefault() 151 172 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return 152 173 if (newPassword !== confirmNewPassword) { 153 - showMessage('error', 'Passwords do not match') 174 + showMessage('error', $_('settings.messages.passwordsDoNotMatch')) 154 175 return 155 176 } 156 177 if (newPassword.length < 8) { 157 - showMessage('error', 'Password must be at least 8 characters') 178 + showMessage('error', $_('settings.messages.passwordTooShort')) 158 179 return 159 180 } 160 181 passwordLoading = true 161 182 message = null 162 183 try { 163 184 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) 164 - showMessage('success', 'Password changed successfully') 185 + showMessage('success', $_('settings.messages.passwordChanged')) 165 186 currentPassword = '' 166 187 newPassword = '' 167 188 confirmNewPassword = '' 168 189 } catch (e) { 169 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password') 190 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed')) 170 191 } finally { 171 192 passwordLoading = false 172 193 } ··· 174 195 </script> 175 196 <div class="page"> 176 197 <header> 177 - <a href="#/dashboard" class="back">&larr; Dashboard</a> 178 - <h1>Account Settings</h1> 198 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 199 + <h1>{$_('settings.title')}</h1> 179 200 </header> 180 201 {#if message} 181 202 <div class="message {message.type}">{message.text}</div> 182 203 {/if} 183 204 <section> 184 - <h2>Change Email</h2> 205 + <h2>{$_('settings.language')}</h2> 206 + <p class="description">{$_('settings.languageDescription')}</p> 207 + <select 208 + class="language-select" 209 + value={$locale} 210 + disabled={localeLoading} 211 + onchange={(e) => handleLocaleChange(e.currentTarget.value as SupportedLocale)} 212 + > 213 + {#each supportedLocales as loc} 214 + <option value={loc}>{localeNames[loc]}</option> 215 + {/each} 216 + </select> 217 + </section> 218 + <section> 219 + <h2>{$_('settings.changeEmail')}</h2> 185 220 {#if auth.session?.email} 186 - <p class="current">Current: {auth.session.email}</p> 221 + <p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p> 187 222 {/if} 188 223 {#if emailTokenRequired} 189 224 <form onsubmit={handleConfirmEmailUpdate}> 190 225 <div class="field"> 191 - <label for="email-token">Verification Code</label> 226 + <label for="email-token">{$_('settings.verificationCode')}</label> 192 227 <input 193 228 id="email-token" 194 229 type="text" 195 230 bind:value={emailToken} 196 - placeholder="Enter code from email" 231 + placeholder={$_('settings.verificationCodePlaceholder')} 197 232 disabled={emailLoading} 198 233 required 199 234 /> 200 235 </div> 201 236 <div class="actions"> 202 237 <button type="submit" disabled={emailLoading || !emailToken}> 203 - {emailLoading ? 'Updating...' : 'Confirm Email Change'} 238 + {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 204 239 </button> 205 240 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}> 206 - Cancel 241 + {$_('common.cancel')} 207 242 </button> 208 243 </div> 209 244 </form> 210 245 {:else} 211 246 <form onsubmit={handleRequestEmailUpdate}> 212 247 <div class="field"> 213 - <label for="new-email">New Email</label> 248 + <label for="new-email">{$_('settings.newEmail')}</label> 214 249 <input 215 250 id="new-email" 216 251 type="email" 217 252 bind:value={newEmail} 218 - placeholder="new@example.com" 253 + placeholder={$_('settings.newEmailPlaceholder')} 219 254 disabled={emailLoading} 220 255 required 221 256 /> 222 257 </div> 223 258 <button type="submit" disabled={emailLoading || !newEmail}> 224 - {emailLoading ? 'Requesting...' : 'Change Email'} 259 + {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 225 260 </button> 226 261 </form> 227 262 {/if} 228 263 </section> 229 264 <section> 230 - <h2>Change Handle</h2> 265 + <h2>{$_('settings.changeHandle')}</h2> 231 266 {#if auth.session} 232 - <p class="current">Current: @{auth.session.handle}</p> 267 + <p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p> 233 268 {/if} 234 269 <div class="tabs"> 235 270 <button ··· 238 273 class:active={!showBYOHandle} 239 274 onclick={() => showBYOHandle = false} 240 275 > 241 - PDS Handle 276 + {$_('settings.pdsHandle')} 242 277 </button> 243 278 <button 244 279 type="button" ··· 246 281 class:active={showBYOHandle} 247 282 onclick={() => showBYOHandle = true} 248 283 > 249 - Custom Domain 284 + {$_('settings.customDomain')} 250 285 </button> 251 286 </div> 252 287 {#if showBYOHandle} 253 288 <div class="byo-handle"> 254 - <p class="description">Use your own domain as your handle. You need to verify domain ownership first.</p> 289 + <p class="description">{$_('settings.customDomainDescription')}</p> 255 290 {#if auth.session} 256 291 <div class="verification-info"> 257 - <h3>Setup Instructions</h3> 258 - <p>Choose one of these verification methods:</p> 292 + <h3>{$_('settings.setupInstructions')}</h3> 293 + <p>{$_('settings.setupMethodsIntro')}</p> 259 294 <div class="method"> 260 - <h4>Option 1: DNS TXT Record (Recommended)</h4> 261 - <p>Add this TXT record to your domain:</p> 295 + <h4>{$_('settings.dnsMethod')}</h4> 296 + <p>{$_('settings.dnsMethodDesc')}</p> 262 297 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code> 263 298 </div> 264 299 <div class="method"> 265 - <h4>Option 2: HTTP Well-Known File</h4> 266 - <p>Serve your DID at this URL:</p> 300 + <h4>{$_('settings.httpMethod')}</h4> 301 + <p>{$_('settings.httpMethodDesc')}</p> 267 302 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code> 268 - <p>The file should contain only:</p> 303 + <p>{$_('settings.httpMethodContent')}</p> 269 304 <code class="record">{auth.session.did}</code> 270 305 </div> 271 306 </div> 272 307 {/if} 273 308 <form onsubmit={handleUpdateHandle}> 274 309 <div class="field"> 275 - <label for="new-handle-byo">Your Domain</label> 310 + <label for="new-handle-byo">{$_('settings.yourDomain')}</label> 276 311 <input 277 312 id="new-handle-byo" 278 313 type="text" 279 314 bind:value={newHandle} 280 - placeholder="example.com" 315 + placeholder={$_('settings.yourDomainPlaceholder')} 281 316 disabled={handleLoading} 282 317 required 283 318 /> 284 319 </div> 285 320 <button type="submit" disabled={handleLoading || !newHandle}> 286 - {handleLoading ? 'Verifying...' : 'Verify & Update Handle'} 321 + {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 287 322 </button> 288 323 </form> 289 324 </div> 290 325 {:else} 291 326 <form onsubmit={handleUpdateHandle}> 292 327 <div class="field"> 293 - <label for="new-handle">New Handle</label> 294 - <input 295 - id="new-handle" 296 - type="text" 297 - bind:value={newHandle} 298 - placeholder="yourhandle" 299 - disabled={handleLoading} 300 - required 301 - /> 328 + <label for="new-handle">{$_('settings.newHandle')}</label> 329 + <div class="handle-input-wrapper"> 330 + <input 331 + id="new-handle" 332 + type="text" 333 + bind:value={newHandle} 334 + placeholder={$_('settings.newHandlePlaceholder')} 335 + disabled={handleLoading} 336 + required 337 + /> 338 + <span class="handle-suffix">.{window.location.hostname}</span> 339 + </div> 302 340 </div> 303 341 <button type="submit" disabled={handleLoading || !newHandle}> 304 - {handleLoading ? 'Updating...' : 'Change Handle'} 342 + {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 305 343 </button> 306 344 </form> 307 345 {/if} 308 346 </section> 309 347 <section> 310 - <h2>Change Password</h2> 348 + <h2>{$_('settings.changePassword')}</h2> 311 349 <form onsubmit={handleChangePassword}> 312 350 <div class="field"> 313 - <label for="current-password">Current Password</label> 351 + <label for="current-password">{$_('settings.currentPassword')}</label> 314 352 <input 315 353 id="current-password" 316 354 type="password" 317 355 bind:value={currentPassword} 318 - placeholder="Enter current password" 356 + placeholder={$_('settings.currentPasswordPlaceholder')} 319 357 disabled={passwordLoading} 320 358 required 321 359 /> 322 360 </div> 323 361 <div class="field"> 324 - <label for="new-password">New Password</label> 362 + <label for="new-password">{$_('settings.newPassword')}</label> 325 363 <input 326 364 id="new-password" 327 365 type="password" 328 366 bind:value={newPassword} 329 - placeholder="At least 8 characters" 367 + placeholder={$_('settings.newPasswordPlaceholder')} 330 368 disabled={passwordLoading} 331 369 required 332 370 minlength="8" 333 371 /> 334 372 </div> 335 373 <div class="field"> 336 - <label for="confirm-new-password">Confirm New Password</label> 374 + <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label> 337 375 <input 338 376 id="confirm-new-password" 339 377 type="password" 340 378 bind:value={confirmNewPassword} 341 - placeholder="Confirm new password" 379 + placeholder={$_('settings.confirmNewPasswordPlaceholder')} 342 380 disabled={passwordLoading} 343 381 required 344 382 /> 345 383 </div> 346 384 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 347 - {passwordLoading ? 'Changing...' : 'Change Password'} 385 + {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')} 348 386 </button> 349 387 </form> 350 388 </section> 351 389 <section> 352 - <h2>Export Data</h2> 353 - <p class="description">Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.</p> 390 + <h2>{$_('settings.exportData')}</h2> 391 + <p class="description">{$_('settings.exportDataDescription')}</p> 354 392 <button onclick={handleExportRepo} disabled={exportLoading}> 355 - {exportLoading ? 'Exporting...' : 'Download Repository'} 393 + {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 356 394 </button> 357 395 </section> 358 396 <section class="danger-zone"> 359 - <h2>Delete Account</h2> 360 - <p class="warning">This action is irreversible. All your data will be permanently deleted.</p> 397 + <h2>{$_('settings.deleteAccount')}</h2> 398 + <p class="warning">{$_('settings.deleteWarning')}</p> 361 399 {#if deleteTokenSent} 362 400 <form onsubmit={handleConfirmDelete}> 363 401 <div class="field"> 364 - <label for="delete-token">Confirmation Code (from email)</label> 402 + <label for="delete-token">{$_('settings.confirmationCode')}</label> 365 403 <input 366 404 id="delete-token" 367 405 type="text" 368 406 bind:value={deleteToken} 369 - placeholder="Enter confirmation code" 407 + placeholder={$_('settings.confirmationCodePlaceholder')} 370 408 disabled={deleteLoading} 371 409 required 372 410 /> 373 411 </div> 374 412 <div class="field"> 375 - <label for="delete-password">Your Password</label> 413 + <label for="delete-password">{$_('settings.yourPassword')}</label> 376 414 <input 377 415 id="delete-password" 378 416 type="password" 379 417 bind:value={deletePassword} 380 - placeholder="Enter your password" 418 + placeholder={$_('settings.yourPasswordPlaceholder')} 381 419 disabled={deleteLoading} 382 420 required 383 421 /> 384 422 </div> 385 423 <div class="actions"> 386 424 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}> 387 - {deleteLoading ? 'Deleting...' : 'Permanently Delete Account'} 425 + {deleteLoading ? $_('settings.deleting') : $_('settings.permanentlyDelete')} 388 426 </button> 389 427 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}> 390 - Cancel 428 + {$_('common.cancel')} 391 429 </button> 392 430 </div> 393 431 </form> 394 432 {:else} 395 433 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}> 396 - {deleteLoading ? 'Requesting...' : 'Request Account Deletion'} 434 + {deleteLoading ? $_('settings.requesting') : $_('settings.requestDeletion')} 397 435 </button> 398 436 {/if} 399 437 </section> 400 438 </div> 401 439 <style> 402 440 .page { 403 - max-width: 600px; 441 + max-width: var(--width-md); 404 442 margin: 0 auto; 405 - padding: 2rem; 443 + padding: var(--space-7); 406 444 } 445 + 407 446 header { 408 - margin-bottom: 2rem; 447 + margin-bottom: var(--space-7); 409 448 } 449 + 410 450 .back { 411 451 color: var(--text-secondary); 412 452 text-decoration: none; 413 - font-size: 0.875rem; 453 + font-size: var(--text-sm); 414 454 } 455 + 415 456 .back:hover { 416 457 color: var(--accent); 417 458 } 459 + 418 460 h1 { 419 - margin: 0.5rem 0 0 0; 420 - } 421 - .message { 422 - padding: 0.75rem; 423 - border-radius: 4px; 424 - margin-bottom: 1rem; 425 - } 426 - .message.success { 427 - background: var(--success-bg); 428 - border: 1px solid var(--success-border); 429 - color: var(--success-text); 430 - } 431 - .message.error { 432 - background: var(--error-bg); 433 - border: 1px solid var(--error-border); 434 - color: var(--error-text); 461 + margin: var(--space-2) 0 0 0; 435 462 } 463 + 436 464 section { 437 - padding: 1.5rem; 465 + padding: var(--space-6); 438 466 background: var(--bg-secondary); 439 - border-radius: 8px; 440 - margin-bottom: 1.5rem; 467 + border-radius: var(--radius-xl); 468 + margin-bottom: var(--space-6); 441 469 } 470 + 442 471 section h2 { 443 - margin: 0 0 0.5rem 0; 444 - font-size: 1.125rem; 472 + margin: 0 0 var(--space-2) 0; 473 + font-size: var(--text-lg); 445 474 } 446 - .current, .description { 475 + 476 + .current, 477 + .description { 447 478 color: var(--text-secondary); 448 - font-size: 0.875rem; 449 - margin-bottom: 1rem; 479 + font-size: var(--text-sm); 480 + margin-bottom: var(--space-4); 450 481 } 451 - .field { 452 - margin-bottom: 1rem; 453 - } 454 - label { 455 - display: block; 456 - font-size: 0.875rem; 457 - font-weight: 500; 458 - margin-bottom: 0.25rem; 459 - } 460 - input { 482 + 483 + .language-select { 461 484 width: 100%; 462 - padding: 0.75rem; 463 - border: 1px solid var(--border-color-light); 464 - border-radius: 4px; 465 - font-size: 1rem; 466 - box-sizing: border-box; 467 - background: var(--bg-input); 468 - color: var(--text-primary); 469 485 } 470 - input:focus { 471 - outline: none; 472 - border-color: var(--accent); 473 - } 474 - button { 475 - padding: 0.75rem 1.5rem; 476 - background: var(--accent); 477 - color: white; 478 - border: none; 479 - border-radius: 4px; 480 - cursor: pointer; 481 - font-size: 1rem; 482 - } 483 - button:hover:not(:disabled) { 484 - background: var(--accent-hover); 485 - } 486 - button:disabled { 487 - opacity: 0.6; 488 - cursor: not-allowed; 489 - } 490 - button.secondary { 491 - background: transparent; 492 - color: var(--text-secondary); 493 - border: 1px solid var(--border-color-light); 494 - } 495 - button.secondary:hover:not(:disabled) { 496 - background: var(--bg-secondary); 497 - } 498 - button.danger { 499 - background: var(--error-text); 500 - } 501 - button.danger:hover:not(:disabled) { 502 - background: #900; 503 - } 486 + 504 487 .actions { 505 488 display: flex; 506 - gap: 0.5rem; 489 + gap: var(--space-2); 507 490 } 491 + 508 492 .danger-zone { 509 493 background: var(--error-bg); 510 494 border: 1px solid var(--error-border); 511 495 } 496 + 512 497 .danger-zone h2 { 513 498 color: var(--error-text); 514 499 } 500 + 515 501 .warning { 516 502 color: var(--error-text); 517 - font-size: 0.875rem; 518 - margin-bottom: 1rem; 503 + font-size: var(--text-sm); 504 + margin-bottom: var(--space-4); 519 505 } 506 + 520 507 .tabs { 521 508 display: flex; 522 - gap: 0.25rem; 523 - margin-bottom: 1rem; 509 + gap: var(--space-1); 510 + margin-bottom: var(--space-4); 524 511 } 512 + 525 513 .tab { 526 514 flex: 1; 527 - padding: 0.5rem 1rem; 515 + padding: var(--space-2) var(--space-4); 528 516 background: transparent; 529 - border: 1px solid var(--border-color-light); 517 + border: 1px solid var(--border-color); 530 518 cursor: pointer; 531 - font-size: 0.875rem; 519 + font-size: var(--text-sm); 532 520 color: var(--text-secondary); 533 521 } 522 + 534 523 .tab:first-child { 535 - border-radius: 4px 0 0 4px; 524 + border-radius: var(--radius-md) 0 0 var(--radius-md); 536 525 } 526 + 537 527 .tab:last-child { 538 - border-radius: 0 4px 4px 0; 528 + border-radius: 0 var(--radius-md) var(--radius-md) 0; 539 529 } 530 + 540 531 .tab.active { 541 532 background: var(--accent); 542 533 border-color: var(--accent); 543 - color: white; 534 + color: var(--text-inverse); 544 535 } 536 + 545 537 .tab:hover:not(.active) { 546 538 background: var(--bg-card); 547 539 } 540 + 548 541 .byo-handle .description { 549 - margin-bottom: 1rem; 542 + margin-bottom: var(--space-4); 550 543 } 544 + 551 545 .verification-info { 552 546 background: var(--bg-card); 553 - border: 1px solid var(--border-color-light); 554 - border-radius: 6px; 555 - padding: 1rem; 556 - margin-bottom: 1rem; 547 + border: 1px solid var(--border-color); 548 + border-radius: var(--radius-lg); 549 + padding: var(--space-4); 550 + margin-bottom: var(--space-4); 557 551 } 552 + 558 553 .verification-info h3 { 559 - margin: 0 0 0.5rem 0; 560 - font-size: 1rem; 554 + margin: 0 0 var(--space-2) 0; 555 + font-size: var(--text-base); 561 556 } 557 + 562 558 .verification-info h4 { 563 - margin: 0.75rem 0 0.25rem 0; 564 - font-size: 0.875rem; 559 + margin: var(--space-3) 0 var(--space-1) 0; 560 + font-size: var(--text-sm); 565 561 color: var(--text-secondary); 566 562 } 563 + 567 564 .verification-info p { 568 - margin: 0.25rem 0; 569 - font-size: 0.8rem; 565 + margin: var(--space-1) 0; 566 + font-size: var(--text-xs); 570 567 color: var(--text-secondary); 571 568 } 569 + 572 570 .method { 573 - margin-top: 0.75rem; 574 - padding-top: 0.75rem; 575 - border-top: 1px solid var(--border-color-light); 571 + margin-top: var(--space-3); 572 + padding-top: var(--space-3); 573 + border-top: 1px solid var(--border-color); 576 574 } 575 + 577 576 .method:first-of-type { 578 - margin-top: 0.5rem; 577 + margin-top: var(--space-2); 579 578 padding-top: 0; 580 579 border-top: none; 581 580 } 581 + 582 582 code.record { 583 583 display: block; 584 584 background: var(--bg-input); 585 - padding: 0.5rem; 586 - border-radius: 4px; 587 - font-size: 0.75rem; 585 + padding: var(--space-2); 586 + border-radius: var(--radius-md); 587 + font-size: var(--text-xs); 588 588 word-break: break-all; 589 - margin: 0.25rem 0; 589 + margin: var(--space-1) 0; 590 + } 591 + 592 + .handle-input-wrapper { 593 + display: flex; 594 + align-items: center; 595 + background: var(--bg-input); 596 + border: 1px solid var(--border-color); 597 + border-radius: var(--radius-md); 598 + overflow: hidden; 599 + } 600 + 601 + .handle-input-wrapper input { 602 + flex: 1; 603 + border: none; 604 + border-radius: 0; 605 + background: transparent; 606 + min-width: 0; 607 + } 608 + 609 + .handle-input-wrapper input:focus { 610 + outline: none; 611 + box-shadow: none; 612 + } 613 + 614 + .handle-input-wrapper:focus-within { 615 + border-color: var(--accent); 616 + box-shadow: 0 0 0 2px var(--accent-muted); 617 + } 618 + 619 + .handle-suffix { 620 + padding: 0 var(--space-3); 621 + color: var(--text-secondary); 622 + font-size: var(--text-sm); 623 + white-space: nowrap; 624 + border-left: 1px solid var(--border-color); 625 + background: var(--bg-card); 590 626 } 591 627 </style>
+65 -92
frontend/src/routes/TrustedDevices.svelte
··· 2 2 import { getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { api, ApiError } from '../lib/api' 5 + import { _ } from '../lib/i18n' 6 + import { formatDateTime } from '../lib/date' 5 7 6 8 interface TrustedDevice { 7 9 id: string ··· 53 55 54 56 async function handleRevoke(deviceId: string) { 55 57 if (!auth.session) return 56 - if (!confirm('Are you sure you want to revoke trust for this device? You will need to enter your 2FA code next time you log in from this device.')) return 58 + if (!confirm($_('trustedDevices.revokeConfirm'))) return 57 59 try { 58 60 await api.revokeTrustedDevice(auth.session.accessJwt, deviceId) 59 61 await loadDevices() 60 - showMessage('success', 'Device trust revoked') 62 + showMessage('success', $_('trustedDevices.deviceRevoked')) 61 63 } catch (e) { 62 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to revoke device') 64 + showMessage('error', e instanceof ApiError ? e.message : $_('common.error')) 63 65 } 64 66 } 65 67 ··· 80 82 await loadDevices() 81 83 editingDeviceId = null 82 84 editDeviceName = '' 83 - showMessage('success', 'Device renamed') 85 + showMessage('success', $_('trustedDevices.deviceRenamed')) 84 86 } catch (e) { 85 - showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename device') 87 + showMessage('error', e instanceof ApiError ? e.message : $_('common.error')) 86 88 } 87 89 } 88 90 89 91 function formatDate(dateStr: string): string { 90 - return new Date(dateStr).toLocaleDateString(undefined, { 91 - year: 'numeric', 92 - month: 'short', 93 - day: 'numeric', 94 - hour: '2-digit', 95 - minute: '2-digit' 96 - }) 92 + return formatDateTime(dateStr) 97 93 } 98 94 99 95 function parseUserAgent(ua: string | null): string { 100 - if (!ua) return 'Unknown device' 96 + if (!ua) return $_('trustedDevices.unknownDevice') 101 97 if (ua.includes('Firefox')) return 'Firefox' 102 98 if (ua.includes('Chrome')) return 'Chrome' 103 99 if (ua.includes('Safari')) return 'Safari' ··· 116 112 117 113 <div class="page"> 118 114 <header> 119 - <a href="#/security" class="back">&larr; Security Settings</a> 120 - <h1>Trusted Devices</h1> 115 + <a href="#/security" class="back">{$_('trustedDevices.backToSecurity')}</a> 116 + <h1>{$_('trustedDevices.title')}</h1> 121 117 </header> 122 118 123 119 {#if message} ··· 126 122 127 123 <div class="description"> 128 124 <p> 129 - Trusted devices can skip two-factor authentication when logging in. 130 - Trust is granted for 30 days and automatically extends when you use the device. 125 + {$_('trustedDevices.description')} 131 126 </p> 132 127 </div> 133 128 134 129 {#if loading} 135 - <div class="loading">Loading...</div> 130 + <div class="loading">{$_('common.loading')}</div> 136 131 {:else if devices.length === 0} 137 132 <div class="empty-state"> 138 - <p>No trusted devices yet.</p> 139 - <p class="hint">When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.</p> 133 + <p>{$_('trustedDevices.noDevices')}</p> 134 + <p class="hint">{$_('trustedDevices.noDevicesHint')}</p> 140 135 </div> 141 136 {:else} 142 137 <div class="device-list"> ··· 148 143 type="text" 149 144 class="edit-name-input" 150 145 bind:value={editDeviceName} 151 - placeholder="Device name" 146 + placeholder={$_('trustedDevices.deviceNamePlaceholder')} 152 147 /> 153 148 <div class="edit-actions"> 154 - <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>Save</button> 155 - <button class="btn-small btn-secondary" onclick={cancelEditDevice}>Cancel</button> 149 + <button class="btn-small btn-primary" onclick={handleSaveDeviceName}>{$_('common.save')}</button> 150 + <button class="btn-small btn-secondary" onclick={cancelEditDevice}>{$_('common.cancel')}</button> 156 151 </div> 157 152 {:else} 158 153 <h3>{device.friendlyName || parseUserAgent(device.userAgent)}</h3> 159 - <button class="btn-icon" onclick={() => startEditDevice(device)} title="Rename"> 154 + <button class="btn-icon" onclick={() => startEditDevice(device)} title={$_('security.rename')}> 160 155 &#9998; 161 156 </button> 162 157 {/if} ··· 164 159 165 160 <div class="device-details"> 166 161 {#if device.userAgent && !device.friendlyName} 167 - <p class="detail"><span class="label">Browser:</span> {device.userAgent}</p> 162 + <p class="detail"><span class="label">{$_('trustedDevices.browser')}</span> {device.userAgent}</p> 168 163 {:else if device.userAgent} 169 - <p class="detail"><span class="label">Browser:</span> {parseUserAgent(device.userAgent)}</p> 164 + <p class="detail"><span class="label">{$_('trustedDevices.browser')}</span> {parseUserAgent(device.userAgent)}</p> 170 165 {/if} 171 166 <p class="detail"> 172 - <span class="label">Last seen:</span> {formatDate(device.lastSeenAt)} 167 + <span class="label">{$_('trustedDevices.lastSeen')}</span> {formatDate(device.lastSeenAt)} 173 168 </p> 174 169 {#if device.trustedAt} 175 170 <p class="detail"> 176 - <span class="label">Trusted since:</span> {formatDate(device.trustedAt)} 171 + <span class="label">{$_('trustedDevices.trustedSince')}</span> {formatDate(device.trustedAt)} 177 172 </p> 178 173 {/if} 179 174 {#if device.trustedUntil} 180 175 {@const daysRemaining = getDaysRemaining(device.trustedUntil)} 181 176 <p class="detail trust-expiry" class:expiring-soon={daysRemaining <= 7}> 182 - <span class="label">Trust expires:</span> 177 + <span class="label">{$_('trustedDevices.trustExpires')}</span> 183 178 {#if daysRemaining <= 0} 184 - Expired 179 + {$_('trustedDevices.expired')} 185 180 {:else if daysRemaining === 1} 186 - Tomorrow 181 + {$_('trustedDevices.tomorrow')} 187 182 {:else} 188 - In {daysRemaining} days 183 + {$_('trustedDevices.inDays', { values: { days: daysRemaining } })} 189 184 {/if} 190 185 </p> 191 186 {/if} ··· 193 188 194 189 <div class="device-actions"> 195 190 <button class="btn-danger" onclick={() => handleRevoke(device.id)}> 196 - Revoke Trust 191 + {$_('trustedDevices.revoke')} 197 192 </button> 198 193 </div> 199 194 </div> ··· 204 199 205 200 <style> 206 201 .page { 207 - max-width: 600px; 202 + max-width: var(--width-md); 208 203 margin: 0 auto; 209 - padding: 2rem 1rem; 204 + padding: var(--space-7) var(--space-4); 210 205 } 211 206 212 207 header { 213 - margin-bottom: 2rem; 208 + margin-bottom: var(--space-7); 214 209 } 215 210 216 211 .back { 217 212 display: inline-block; 218 - margin-bottom: 1rem; 213 + margin-bottom: var(--space-4); 219 214 color: var(--accent); 220 215 text-decoration: none; 221 - font-size: 0.875rem; 216 + font-size: var(--text-sm); 222 217 } 223 218 224 219 .back:hover { ··· 227 222 228 223 h1 { 229 224 margin: 0; 230 - font-size: 1.75rem; 231 - } 232 - 233 - .message { 234 - padding: 0.75rem 1rem; 235 - border-radius: 4px; 236 - margin-bottom: 1rem; 237 - } 238 - 239 - .message.success { 240 - background: var(--success-bg); 241 - color: var(--success-text); 242 - border: 1px solid var(--success-border); 243 - } 244 - 245 - .message.error { 246 - background: var(--error-bg); 247 - color: var(--error-text); 248 - border: 1px solid var(--error-border); 225 + font-size: var(--text-2xl); 249 226 } 250 227 251 228 .description { 252 229 background: var(--bg-card); 253 230 border: 1px solid var(--border-color); 254 - border-radius: 8px; 255 - padding: 1rem; 256 - margin-bottom: 1.5rem; 231 + border-radius: var(--radius-xl); 232 + padding: var(--space-4); 233 + margin-bottom: var(--space-6); 257 234 } 258 235 259 236 .description p { 260 237 margin: 0; 261 238 color: var(--text-secondary); 262 - font-size: 0.9rem; 239 + font-size: var(--text-sm); 263 240 } 264 241 265 242 .loading { 266 243 text-align: center; 267 - padding: 2rem; 244 + padding: var(--space-7); 268 245 color: var(--text-secondary); 269 246 } 270 247 271 248 .empty-state { 272 249 text-align: center; 273 - padding: 3rem 1rem; 250 + padding: var(--space-8) var(--space-4); 274 251 background: var(--bg-card); 275 252 border: 1px solid var(--border-color); 276 - border-radius: 8px; 253 + border-radius: var(--radius-xl); 277 254 } 278 255 279 256 .empty-state p { ··· 282 259 } 283 260 284 261 .empty-state .hint { 285 - margin-top: 0.5rem; 286 - font-size: 0.875rem; 262 + margin-top: var(--space-2); 263 + font-size: var(--text-sm); 287 264 color: var(--text-muted); 288 265 } 289 266 290 267 .device-list { 291 268 display: flex; 292 269 flex-direction: column; 293 - gap: 1rem; 270 + gap: var(--space-4); 294 271 } 295 272 296 273 .device-card { 297 274 background: var(--bg-card); 298 275 border: 1px solid var(--border-color); 299 - border-radius: 8px; 300 - padding: 1rem; 276 + border-radius: var(--radius-xl); 277 + padding: var(--space-4); 301 278 } 302 279 303 280 .device-header { 304 281 display: flex; 305 282 align-items: center; 306 - gap: 0.5rem; 307 - margin-bottom: 0.75rem; 283 + gap: var(--space-2); 284 + margin-bottom: var(--space-3); 308 285 } 309 286 310 287 .device-header h3 { 311 288 margin: 0; 312 289 flex: 1; 313 - font-size: 1rem; 290 + font-size: var(--text-base); 314 291 } 315 292 316 293 .edit-name-input { 317 294 flex: 1; 318 - padding: 0.5rem; 319 - border: 1px solid var(--border-color); 320 - border-radius: 4px; 321 - background: var(--bg-input); 322 - color: var(--text-primary); 323 - font-size: 0.9rem; 295 + padding: var(--space-2); 296 + font-size: var(--text-sm); 324 297 } 325 298 326 299 .edit-actions { 327 300 display: flex; 328 - gap: 0.5rem; 301 + gap: var(--space-2); 329 302 } 330 303 331 304 .btn-icon { ··· 333 306 border: none; 334 307 color: var(--text-secondary); 335 308 cursor: pointer; 336 - padding: 0.25rem; 337 - font-size: 1rem; 309 + padding: var(--space-1); 310 + font-size: var(--text-base); 338 311 } 339 312 340 313 .btn-icon:hover { ··· 342 315 } 343 316 344 317 .device-details { 345 - margin-bottom: 0.75rem; 318 + margin-bottom: var(--space-3); 346 319 } 347 320 348 321 .detail { 349 - margin: 0.25rem 0; 350 - font-size: 0.875rem; 322 + margin: var(--space-1) 0; 323 + font-size: var(--text-sm); 351 324 color: var(--text-secondary); 352 325 } 353 326 ··· 362 335 .device-actions { 363 336 display: flex; 364 337 justify-content: flex-end; 365 - padding-top: 0.75rem; 338 + padding-top: var(--space-3); 366 339 border-top: 1px solid var(--border-color); 367 340 } 368 341 369 342 .btn-small { 370 - padding: 0.375rem 0.75rem; 371 - border-radius: 4px; 372 - font-size: 0.8rem; 343 + padding: var(--space-2) var(--space-3); 344 + border-radius: var(--radius-md); 345 + font-size: var(--text-xs); 373 346 cursor: pointer; 374 347 } 375 348 376 349 .btn-primary { 377 350 background: var(--accent); 378 - color: white; 351 + color: var(--text-inverse); 379 352 border: none; 380 353 } 381 354 ··· 397 370 background: transparent; 398 371 border: 1px solid var(--error-border); 399 372 color: var(--error-text); 400 - padding: 0.5rem 1rem; 401 - border-radius: 4px; 373 + padding: var(--space-2) var(--space-4); 374 + border-radius: var(--radius-md); 402 375 cursor: pointer; 403 - font-size: 0.875rem; 376 + font-size: var(--text-sm); 404 377 } 405 378 406 379 .btn-danger:hover {
+56 -107
frontend/src/routes/Verify.svelte
··· 1 1 <script lang="ts"> 2 2 import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 4 5 5 6 const STORAGE_KEY = 'tranquil_pds_pending_verification' 6 7 ··· 79 80 80 81 function channelLabel(ch: string): string { 81 82 switch (ch) { 82 - case 'email': return 'Email' 83 - case 'discord': return 'Discord' 84 - case 'telegram': return 'Telegram' 85 - case 'signal': return 'Signal' 83 + case 'email': return $_('register.email') 84 + case 'discord': return $_('register.discord') 85 + case 'telegram': return $_('register.telegram') 86 + case 'signal': return $_('register.signal') 86 87 default: return ch 87 88 } 88 89 } 89 90 </script> 90 91 91 - <div class="verify-container"> 92 + <div class="verify-page"> 92 93 {#if error} 93 - <div class="error">{error}</div> 94 + <div class="message error">{error}</div> 94 95 {/if} 95 96 96 97 {#if pendingVerification} 97 - <h1>Verify Your Account</h1> 98 + <h1>{$_('verify.title')}</h1> 98 99 <p class="subtitle"> 99 - We've sent a verification code to your {channelLabel(pendingVerification.channel)}. 100 - Enter it below to complete registration. 100 + {$_('verify.subtitle', { values: { channel: channelLabel(pendingVerification.channel) } })} 101 101 </p> 102 - <p class="handle-info">Verifying account: <strong>@{pendingVerification.handle}</strong></p> 102 + <p class="handle-info">{$_('verify.verifyingAccount', { values: { handle: pendingVerification.handle } })}</p> 103 103 104 104 {#if resendMessage} 105 - <div class="success">{resendMessage}</div> 105 + <div class="message success">{resendMessage}</div> 106 106 {/if} 107 107 108 108 <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}> 109 109 <div class="field"> 110 - <label for="verification-code">Verification Code</label> 110 + <label for="verification-code">{$_('verify.codeLabel')}</label> 111 111 <input 112 112 id="verification-code" 113 113 type="text" 114 114 bind:value={verificationCode} 115 - placeholder="Enter 6-digit code" 115 + placeholder={$_('verify.codePlaceholder')} 116 116 disabled={submitting} 117 117 required 118 118 maxlength="6" ··· 122 122 </div> 123 123 124 124 <button type="submit" disabled={submitting || !verificationCode.trim()}> 125 - {submitting ? 'Verifying...' : 'Verify Account'} 125 + {submitting ? $_('verify.verifying') : $_('verify.verifyButton')} 126 126 </button> 127 127 128 128 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 129 - {resendingCode ? 'Resending...' : 'Resend Code'} 129 + {resendingCode ? $_('verify.resending') : $_('verify.resendCode')} 130 130 </button> 131 131 </form> 132 132 133 - <p class="cancel-link"> 134 - <a href="#/register" onclick={() => clearPendingVerification()}>Start over with a different account</a> 133 + <p class="link-text"> 134 + <a href="#/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a> 135 135 </p> 136 136 {:else} 137 - <h1>Account Verification</h1> 138 - <p class="subtitle">No pending verification found.</p> 139 - <p class="no-pending-info"> 140 - If you recently created an account and need to verify it, you may need to create a new account. 141 - If you already verified your account, you can sign in. 142 - </p> 137 + <h1>{$_('verify.title')}</h1> 138 + <p class="subtitle">{$_('verify.noPending')}</p> 139 + <p class="info-text">{$_('verify.noPendingInfo')}</p> 140 + 143 141 <div class="actions"> 144 - <a href="#/register" class="btn">Create Account</a> 145 - <a href="#/login" class="btn secondary">Sign In</a> 142 + <a href="#/register" class="btn">{$_('verify.createAccount')}</a> 143 + <a href="#/login" class="btn secondary">{$_('verify.signIn')}</a> 146 144 </div> 147 145 {/if} 148 146 </div> 149 147 150 148 <style> 151 - .verify-container { 152 - max-width: 400px; 153 - margin: 4rem auto; 154 - padding: 2rem; 149 + .verify-page { 150 + max-width: var(--width-sm); 151 + margin: var(--space-9) auto; 152 + padding: var(--space-7); 155 153 } 156 154 157 155 h1 { 158 - margin: 0 0 0.5rem 0; 156 + margin: 0 0 var(--space-3) 0; 159 157 } 160 158 161 159 .subtitle { 162 160 color: var(--text-secondary); 163 - margin: 0 0 1rem 0; 161 + margin: 0 0 var(--space-4) 0; 164 162 } 165 163 166 164 .handle-info { 167 - font-size: 0.9rem; 165 + font-size: var(--text-sm); 168 166 color: var(--text-secondary); 169 - margin: 0 0 1.5rem 0; 167 + margin: 0 0 var(--space-6) 0; 170 168 } 171 169 172 - .no-pending-info { 170 + .info-text { 173 171 color: var(--text-secondary); 174 - margin: 1rem 0 1.5rem 0; 172 + margin: var(--space-4) 0 var(--space-6) 0; 175 173 } 176 174 177 175 form { 178 176 display: flex; 179 177 flex-direction: column; 180 - gap: 1rem; 178 + gap: var(--space-4); 181 179 } 182 180 183 - .field { 184 - display: flex; 185 - flex-direction: column; 186 - gap: 0.25rem; 181 + .link-text { 182 + text-align: center; 183 + margin-top: var(--space-6); 184 + font-size: var(--text-sm); 187 185 } 188 186 189 - label { 190 - font-size: 0.875rem; 191 - font-weight: 500; 187 + .link-text a { 188 + color: var(--text-secondary); 192 189 } 193 190 194 - input { 195 - padding: 0.75rem; 196 - border: 1px solid var(--border-color-light); 197 - border-radius: 4px; 198 - font-size: 1rem; 199 - background: var(--bg-input); 200 - color: var(--text-primary); 191 + .actions { 192 + display: flex; 193 + gap: var(--space-4); 201 194 } 202 195 203 - input:focus { 204 - outline: none; 205 - border-color: var(--accent); 206 - } 207 - 208 - button, .btn { 209 - padding: 0.75rem; 196 + .btn { 197 + flex: 1; 198 + display: inline-block; 199 + padding: var(--space-4); 210 200 background: var(--accent); 211 - color: white; 201 + color: var(--text-inverse); 212 202 border: none; 213 - border-radius: 4px; 214 - font-size: 1rem; 203 + border-radius: var(--radius-md); 204 + font-size: var(--text-base); 205 + font-weight: var(--font-medium); 215 206 cursor: pointer; 216 207 text-decoration: none; 217 208 text-align: center; 218 - display: inline-block; 219 209 } 220 210 221 - button:hover:not(:disabled), .btn:hover { 211 + .btn:hover { 222 212 background: var(--accent-hover); 223 - } 224 - 225 - button:disabled { 226 - opacity: 0.6; 227 - cursor: not-allowed; 213 + text-decoration: none; 228 214 } 229 215 230 - button.secondary, .btn.secondary { 216 + .btn.secondary { 231 217 background: transparent; 232 218 color: var(--accent); 233 219 border: 1px solid var(--accent); 234 220 } 235 221 236 - button.secondary:hover:not(:disabled), .btn.secondary:hover { 222 + .btn.secondary:hover { 237 223 background: var(--accent); 238 - color: white; 239 - } 240 - 241 - .error { 242 - padding: 0.75rem; 243 - background: var(--error-bg); 244 - border: 1px solid var(--error-border); 245 - border-radius: 4px; 246 - color: var(--error-text); 247 - margin-bottom: 1rem; 248 - } 249 - 250 - .success { 251 - padding: 0.75rem; 252 - background: var(--success-bg); 253 - border: 1px solid var(--success-border); 254 - border-radius: 4px; 255 - color: var(--success-text); 256 - margin-bottom: 1rem; 257 - } 258 - 259 - .cancel-link { 260 - text-align: center; 261 - margin-top: 1.5rem; 262 - font-size: 0.875rem; 263 - } 264 - 265 - .cancel-link a { 266 - color: var(--text-secondary); 267 - } 268 - 269 - .actions { 270 - display: flex; 271 - gap: 1rem; 272 - } 273 - 274 - .actions .btn { 275 - flex: 1; 224 + color: var(--text-inverse); 276 225 } 277 226 </style>
+349
frontend/src/styles/base.css
··· 1 + @import './tokens.css'; 2 + 3 + *, 4 + *::before, 5 + *::after { 6 + box-sizing: border-box; 7 + } 8 + 9 + body { 10 + margin: 0; 11 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 12 + font-size: var(--text-base); 13 + line-height: var(--leading-normal); 14 + color: var(--text-primary); 15 + background: var(--bg-primary); 16 + -webkit-font-smoothing: antialiased; 17 + -moz-osx-font-smoothing: grayscale; 18 + } 19 + 20 + h1, h2, h3, h4, h5, h6 { 21 + margin: 0; 22 + line-height: var(--leading-tight); 23 + } 24 + 25 + h1 { font-size: var(--text-2xl); } 26 + h2 { font-size: var(--text-xl); } 27 + h3 { font-size: var(--text-lg); } 28 + h4 { font-size: var(--text-base); } 29 + 30 + p { 31 + margin: 0; 32 + } 33 + 34 + a { 35 + color: var(--accent); 36 + text-decoration: none; 37 + } 38 + 39 + a:hover { 40 + text-decoration: underline; 41 + } 42 + 43 + input, 44 + select, 45 + textarea { 46 + font-family: inherit; 47 + font-size: var(--text-base); 48 + line-height: var(--leading-normal); 49 + padding: var(--space-4); 50 + border: 1px solid var(--border-dark); 51 + border-radius: var(--radius-md); 52 + background: var(--bg-input); 53 + color: var(--text-primary); 54 + transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 55 + width: 100%; 56 + } 57 + 58 + input:focus, 59 + select:focus, 60 + textarea:focus { 61 + outline: none; 62 + border-color: var(--accent); 63 + box-shadow: var(--shadow-focus); 64 + } 65 + 66 + input:disabled, 67 + select:disabled, 68 + textarea:disabled { 69 + background: var(--bg-input-disabled); 70 + color: var(--text-muted); 71 + cursor: not-allowed; 72 + } 73 + 74 + input::placeholder, 75 + textarea::placeholder { 76 + color: var(--text-muted); 77 + } 78 + 79 + select { 80 + cursor: pointer; 81 + appearance: none; 82 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); 83 + background-repeat: no-repeat; 84 + background-position: right var(--space-4) center; 85 + padding-right: var(--space-7); 86 + } 87 + 88 + button { 89 + font-family: inherit; 90 + font-size: var(--text-base); 91 + font-weight: var(--font-medium); 92 + line-height: var(--leading-normal); 93 + padding: var(--space-4) var(--space-6); 94 + border: none; 95 + border-radius: var(--radius-md); 96 + cursor: pointer; 97 + transition: background var(--transition-normal), border-color var(--transition-normal), opacity var(--transition-normal); 98 + background: var(--accent); 99 + color: var(--text-inverse); 100 + } 101 + 102 + button:hover:not(:disabled) { 103 + background: var(--accent-hover); 104 + } 105 + 106 + button:disabled { 107 + opacity: 0.6; 108 + cursor: not-allowed; 109 + } 110 + 111 + button.secondary { 112 + background: transparent; 113 + color: var(--accent); 114 + border: 1px solid var(--accent); 115 + } 116 + 117 + button.secondary:hover:not(:disabled) { 118 + background: var(--accent); 119 + color: var(--text-inverse); 120 + } 121 + 122 + button.tertiary { 123 + background: transparent; 124 + color: var(--text-secondary); 125 + padding: var(--space-3) var(--space-4); 126 + } 127 + 128 + button.tertiary:hover:not(:disabled) { 129 + color: var(--text-primary); 130 + background: var(--bg-tertiary); 131 + } 132 + 133 + button.danger { 134 + background: var(--error-text); 135 + } 136 + 137 + button.danger:hover:not(:disabled) { 138 + background: #900; 139 + } 140 + 141 + button.ghost { 142 + background: transparent; 143 + color: var(--text-secondary); 144 + border: 1px solid var(--border-dark); 145 + } 146 + 147 + button.ghost:hover:not(:disabled) { 148 + background: var(--bg-secondary); 149 + color: var(--text-primary); 150 + } 151 + 152 + label { 153 + display: block; 154 + font-size: var(--text-sm); 155 + font-weight: var(--font-medium); 156 + color: var(--text-primary); 157 + margin-bottom: var(--space-2); 158 + } 159 + 160 + fieldset { 161 + border: 1px solid var(--border-dark); 162 + border-radius: var(--radius-lg); 163 + padding: var(--space-5); 164 + margin: 0; 165 + } 166 + 167 + fieldset legend { 168 + font-weight: var(--font-semibold); 169 + padding: 0 var(--space-3); 170 + color: var(--text-primary); 171 + } 172 + 173 + code { 174 + font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace; 175 + font-size: 0.9em; 176 + background: var(--bg-tertiary); 177 + padding: var(--space-1) var(--space-2); 178 + border-radius: var(--radius-sm); 179 + } 180 + 181 + pre { 182 + font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace; 183 + font-size: var(--text-sm); 184 + background: var(--bg-tertiary); 185 + padding: var(--space-4); 186 + border-radius: var(--radius-md); 187 + overflow-x: auto; 188 + margin: 0; 189 + } 190 + 191 + hr { 192 + border: none; 193 + border-top: 1px solid var(--border-color); 194 + margin: var(--space-6) 0; 195 + } 196 + 197 + .field { 198 + display: flex; 199 + flex-direction: column; 200 + gap: var(--space-2); 201 + } 202 + 203 + .field + .field { 204 + margin-top: var(--space-5); 205 + } 206 + 207 + .hint { 208 + font-size: var(--text-xs); 209 + color: var(--text-secondary); 210 + margin-top: var(--space-1); 211 + } 212 + 213 + .hint.warning { 214 + color: var(--warning-text); 215 + } 216 + 217 + .hint.error { 218 + color: var(--error-text); 219 + } 220 + 221 + .message { 222 + padding: var(--space-4); 223 + border-radius: var(--radius-md); 224 + font-size: var(--text-sm); 225 + } 226 + 227 + .message.success { 228 + background: var(--success-bg); 229 + border: 1px solid var(--success-border); 230 + color: var(--success-text); 231 + } 232 + 233 + .message.error { 234 + background: var(--error-bg); 235 + border: 1px solid var(--error-border); 236 + color: var(--error-text); 237 + } 238 + 239 + .message.warning { 240 + background: var(--warning-bg); 241 + border: 1px solid var(--warning-border); 242 + color: var(--warning-text); 243 + } 244 + 245 + .badge { 246 + display: inline-block; 247 + padding: var(--space-1) var(--space-3); 248 + border-radius: var(--radius-md); 249 + font-size: var(--text-xs); 250 + font-weight: var(--font-medium); 251 + } 252 + 253 + .badge.success { 254 + background: var(--success-bg); 255 + color: var(--success-text); 256 + } 257 + 258 + .badge.warning { 259 + background: var(--warning-bg); 260 + color: var(--warning-text); 261 + } 262 + 263 + .badge.error { 264 + background: var(--error-bg); 265 + color: var(--error-text); 266 + } 267 + 268 + .badge.accent { 269 + background: var(--accent); 270 + color: var(--text-inverse); 271 + } 272 + 273 + .card { 274 + background: var(--bg-card); 275 + border: 1px solid var(--border-color); 276 + border-radius: var(--radius-xl); 277 + padding: var(--space-6); 278 + } 279 + 280 + .section { 281 + background: var(--bg-secondary); 282 + border-radius: var(--radius-xl); 283 + padding: var(--space-6); 284 + } 285 + 286 + .section + .section { 287 + margin-top: var(--space-6); 288 + } 289 + 290 + .page { 291 + max-width: var(--width-md); 292 + margin: 0 auto; 293 + padding: var(--space-7); 294 + } 295 + 296 + .page-sm { 297 + max-width: var(--width-sm); 298 + margin: 0 auto; 299 + padding: var(--space-7); 300 + } 301 + 302 + .page-lg { 303 + max-width: var(--width-lg); 304 + margin: 0 auto; 305 + padding: var(--space-7); 306 + } 307 + 308 + .back-link { 309 + display: inline-block; 310 + color: var(--text-secondary); 311 + font-size: var(--text-sm); 312 + margin-bottom: var(--space-3); 313 + } 314 + 315 + .back-link:hover { 316 + color: var(--accent); 317 + text-decoration: none; 318 + } 319 + 320 + .text-muted { 321 + color: var(--text-muted); 322 + } 323 + 324 + .text-secondary { 325 + color: var(--text-secondary); 326 + } 327 + 328 + .text-sm { 329 + font-size: var(--text-sm); 330 + } 331 + 332 + .text-xs { 333 + font-size: var(--text-xs); 334 + } 335 + 336 + .text-center { 337 + text-align: center; 338 + } 339 + 340 + .mono { 341 + font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace; 342 + } 343 + 344 + .mt-4 { margin-top: var(--space-4); } 345 + .mt-5 { margin-top: var(--space-5); } 346 + .mt-6 { margin-top: var(--space-6); } 347 + .mb-4 { margin-bottom: var(--space-4); } 348 + .mb-5 { margin-bottom: var(--space-5); } 349 + .mb-6 { margin-bottom: var(--space-6); }
+120
frontend/src/styles/tokens.css
··· 1 + :root { 2 + --space-0: 0; 3 + --space-1: 0.125rem; 4 + --space-2: 0.25rem; 5 + --space-3: 0.5rem; 6 + --space-4: 0.75rem; 7 + --space-5: 1rem; 8 + --space-6: 1.5rem; 9 + --space-7: 2rem; 10 + --space-8: 3rem; 11 + --space-9: 4rem; 12 + 13 + --text-xs: 0.75rem; 14 + --text-sm: 0.875rem; 15 + --text-base: 1rem; 16 + --text-lg: 1.125rem; 17 + --text-xl: 1.25rem; 18 + --text-2xl: 1.5rem; 19 + --text-3xl: 2rem; 20 + --text-4xl: 2.5rem; 21 + 22 + --font-normal: 400; 23 + --font-medium: 500; 24 + --font-semibold: 600; 25 + --font-bold: 700; 26 + 27 + --leading-tight: 1.25; 28 + --leading-normal: 1.5; 29 + --leading-relaxed: 1.75; 30 + 31 + --radius-sm: 3px; 32 + --radius-md: 4px; 33 + --radius-lg: 6px; 34 + --radius-xl: 8px; 35 + 36 + --width-xs: 320px; 37 + --width-sm: 400px; 38 + --width-md: 600px; 39 + --width-lg: 800px; 40 + --width-xl: 1000px; 41 + 42 + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); 43 + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); 44 + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); 45 + --shadow-focus: 0 0 0 2px var(--accent-muted); 46 + 47 + --transition-fast: 0.1s ease; 48 + --transition-normal: 0.15s ease; 49 + --transition-slow: 0.25s ease; 50 + 51 + --bg-primary: #fafafa; 52 + --bg-secondary: #f5f5f5; 53 + --bg-tertiary: #eeeeee; 54 + --bg-card: #ffffff; 55 + --bg-input: #ffffff; 56 + --bg-input-disabled: #f5f5f5; 57 + 58 + --text-primary: #333333; 59 + --text-secondary: #666666; 60 + --text-muted: #999999; 61 + --text-inverse: #ffffff; 62 + 63 + --border-color: #dddddd; 64 + --border-light: #eeeeee; 65 + --border-dark: #cccccc; 66 + 67 + --accent: #0066cc; 68 + --accent-hover: #0052a3; 69 + --accent-muted: rgba(0, 102, 204, 0.15); 70 + 71 + --success-bg: #dfd; 72 + --success-border: #8c8; 73 + --success-text: #060; 74 + 75 + --error-bg: #fee; 76 + --error-border: #fcc; 77 + --error-text: #c00; 78 + 79 + --warning-bg: #ffd; 80 + --warning-border: #d4a03c; 81 + --warning-text: #856404; 82 + 83 + --border-color-light: var(--border-dark); 84 + } 85 + 86 + @media (prefers-color-scheme: dark) { 87 + :root { 88 + --bg-primary: #1a1a1a; 89 + --bg-secondary: #222222; 90 + --bg-tertiary: #2a2a2a; 91 + --bg-card: #2a2a2a; 92 + --bg-input: #333333; 93 + --bg-input-disabled: #2a2a2a; 94 + 95 + --text-primary: #e0e0e0; 96 + --text-secondary: #a0a0a0; 97 + --text-muted: #707070; 98 + --text-inverse: #1a1a1a; 99 + 100 + --border-color: #404040; 101 + --border-light: #333333; 102 + --border-dark: #505050; 103 + 104 + --accent: #4da6ff; 105 + --accent-hover: #7abbff; 106 + --accent-muted: rgba(77, 166, 255, 0.2); 107 + 108 + --success-bg: #1a3d1a; 109 + --success-border: #2d5a2d; 110 + --success-text: #7bc67b; 111 + 112 + --error-bg: #3d1a1a; 113 + --error-border: #5a2d2d; 114 + --error-text: #ff7b7b; 115 + 116 + --warning-bg: #3d3d1a; 117 + --warning-border: #5a5a2d; 118 + --warning-text: #c6c67b; 119 + } 120 + }
+1 -1
frontend/src/tests/Dashboard.test.ts
··· 60 60 { name: /app passwords/i, href: '#/app-passwords' }, 61 61 { name: /invite codes/i, href: '#/invite-codes' }, 62 62 { name: /account settings/i, href: '#/settings' }, 63 - { name: /notification preferences/i, href: '#/notifications' }, 63 + { name: /communication preferences/i, href: '#/comms' }, 64 64 { name: /repository explorer/i, href: '#/repo' }, 65 65 ] 66 66 for (const { name, href } of navCards) {
+25 -25
frontend/src/tests/Notifications.test.ts frontend/src/tests/Comms.test.ts
··· 1 1 import { describe, it, expect, beforeEach } from 'vitest' 2 2 import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' 3 - import Notifications from '../routes/Notifications.svelte' 3 + import Comms from '../routes/Comms.svelte' 4 4 import { 5 5 setupFetchMock, 6 6 mockEndpoint, ··· 11 11 setupAuthenticatedUser, 12 12 setupUnauthenticatedUser, 13 13 } from './mocks' 14 - describe('Notifications', () => { 14 + describe('Comms', () => { 15 15 beforeEach(() => { 16 16 clearMocks() 17 17 setupFetchMock() ··· 19 19 describe('authentication guard', () => { 20 20 it('redirects to login when not authenticated', async () => { 21 21 setupUnauthenticatedUser() 22 - render(Notifications) 22 + render(Comms) 23 23 await waitFor(() => { 24 24 expect(window.location.hash).toBe('#/login') 25 25 }) ··· 33 33 ) 34 34 }) 35 35 it('displays all page elements and sections', async () => { 36 - render(Notifications) 36 + render(Comms) 37 37 await waitFor(() => { 38 38 expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument() 39 39 expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') ··· 52 52 await new Promise(resolve => setTimeout(resolve, 100)) 53 53 return jsonResponse(mockData.notificationPrefs()) 54 54 }) 55 - render(Notifications) 55 + render(Comms) 56 56 expect(screen.getByText(/loading/i)).toBeInTheDocument() 57 57 }) 58 58 }) ··· 64 64 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 65 65 jsonResponse(mockData.notificationPrefs()) 66 66 ) 67 - render(Notifications) 67 + render(Comms) 68 68 await waitFor(() => { 69 69 expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument() 70 70 expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument() ··· 76 76 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 77 77 jsonResponse(mockData.notificationPrefs()) 78 78 ) 79 - render(Notifications) 79 + render(Comms) 80 80 await waitFor(() => { 81 81 const emailRadio = screen.getByRole('radio', { name: /email/i }) 82 82 expect(emailRadio).not.toBeDisabled() ··· 86 86 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 87 87 jsonResponse(mockData.notificationPrefs({ discordId: null })) 88 88 ) 89 - render(Notifications) 89 + render(Comms) 90 90 await waitFor(() => { 91 91 const discordRadio = screen.getByRole('radio', { name: /discord/i }) 92 92 expect(discordRadio).toBeDisabled() ··· 96 96 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 97 97 jsonResponse(mockData.notificationPrefs({ discordId: '123456789' })) 98 98 ) 99 - render(Notifications) 99 + render(Comms) 100 100 await waitFor(() => { 101 101 const discordRadio = screen.getByRole('radio', { name: /discord/i }) 102 102 expect(discordRadio).not.toBeDisabled() ··· 106 106 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 107 107 jsonResponse(mockData.notificationPrefs()) 108 108 ) 109 - render(Notifications) 109 + render(Comms) 110 110 await waitFor(() => { 111 111 expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0) 112 112 }) ··· 115 115 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 116 116 jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' })) 117 117 ) 118 - render(Notifications) 118 + render(Comms) 119 119 await waitFor(() => { 120 120 const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement 121 121 expect(emailRadio.checked).toBe(true) ··· 130 130 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 131 131 jsonResponse(mockData.notificationPrefs()) 132 132 ) 133 - render(Notifications) 133 + render(Comms) 134 134 await waitFor(() => { 135 135 const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement 136 136 expect(emailInput).toBeDisabled() ··· 145 145 signalNumber: '+1234567890', 146 146 })) 147 147 ) 148 - render(Notifications) 148 + render(Comms) 149 149 await waitFor(() => { 150 150 expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789') 151 151 expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser') ··· 161 161 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 162 162 jsonResponse(mockData.notificationPrefs()) 163 163 ) 164 - render(Notifications) 164 + render(Comms) 165 165 await waitFor(() => { 166 166 expect(screen.getByText('Primary')).toBeInTheDocument() 167 167 }) ··· 173 173 discordVerified: true, 174 174 })) 175 175 ) 176 - render(Notifications) 176 + render(Comms) 177 177 await waitFor(() => { 178 178 const verifiedBadges = screen.getAllByText('Verified') 179 179 expect(verifiedBadges.length).toBeGreaterThan(0) ··· 186 186 discordVerified: false, 187 187 })) 188 188 ) 189 - render(Notifications) 189 + render(Comms) 190 190 await waitFor(() => { 191 191 expect(screen.getByText('Not verified')).toBeInTheDocument() 192 192 }) ··· 195 195 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 196 196 jsonResponse(mockData.notificationPrefs()) 197 197 ) 198 - render(Notifications) 198 + render(Comms) 199 199 await waitFor(() => { 200 200 expect(screen.getByText('Primary')).toBeInTheDocument() 201 201 expect(screen.queryByText('Not verified')).not.toBeInTheDocument() ··· 215 215 capturedBody = JSON.parse((options?.body as string) || '{}') 216 216 return jsonResponse({ success: true }) 217 217 }) 218 - render(Notifications) 218 + render(Comms) 219 219 await waitFor(() => { 220 220 expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument() 221 221 }) ··· 235 235 await new Promise(resolve => setTimeout(resolve, 100)) 236 236 return jsonResponse({ success: true }) 237 237 }) 238 - render(Notifications) 238 + render(Comms) 239 239 await waitFor(() => { 240 240 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 241 241 }) ··· 250 250 mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 251 251 jsonResponse({ success: true }) 252 252 ) 253 - render(Notifications) 253 + render(Comms) 254 254 await waitFor(() => { 255 255 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 256 256 }) ··· 266 266 mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 267 267 errorResponse('InvalidRequest', 'Invalid channel configuration', 400) 268 268 ) 269 - render(Notifications) 269 + render(Comms) 270 270 await waitFor(() => { 271 271 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 272 272 }) ··· 285 285 mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 286 286 jsonResponse({ success: true }) 287 287 ) 288 - render(Notifications) 288 + render(Comms) 289 289 await waitFor(() => { 290 290 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 291 291 }) ··· 304 304 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 305 305 jsonResponse(mockData.notificationPrefs()) 306 306 ) 307 - render(Notifications) 307 + render(Comms) 308 308 await waitFor(() => { 309 309 expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled() 310 310 }) ··· 320 320 discordVerified: true, 321 321 })) 322 322 ) 323 - render(Notifications) 323 + render(Comms) 324 324 await waitFor(() => { 325 325 expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled() 326 326 }) ··· 337 337 mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 338 338 errorResponse('InternalError', 'Database connection failed', 500) 339 339 ) 340 - render(Notifications) 340 + render(Comms) 341 341 await waitFor(() => { 342 342 expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() 343 343 })
+1
migrations/20251230_add_preferred_locale.sql
··· 1 + ALTER TABLE users ADD COLUMN preferred_locale VARCHAR(10);
+1
src/api/identity/account.rs
··· 985 985 verification_channel, 986 986 recipient, 987 987 &verification_code, 988 + None, 988 989 ) 989 990 .await 990 991 {
+1 -1
src/api/server/mod.rs
··· 41 41 pub use session::{ 42 42 confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session, 43 43 list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, 44 - update_legacy_login_preference, 44 + update_legacy_login_preference, update_locale, 45 45 }; 46 46 pub use signing_key::reserve_signing_key; 47 47 pub use totp::{
+1
src/api/server/passkey_account.rs
··· 637 637 verification_channel, 638 638 &verification_recipient, 639 639 &verification_code, 640 + None, 640 641 ) 641 642 .await 642 643 {
+65 -2
src/api/server/session.rs
··· 249 249 250 250 match sqlx::query!( 251 251 r#"SELECT 252 - handle, email, email_verified, is_admin, deactivated_at, 252 + handle, email, email_verified, is_admin, deactivated_at, preferred_locale, 253 253 preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 254 254 discord_verified, telegram_verified, signal_verified 255 255 FROM users WHERE did = $1"#, ··· 282 282 "emailVerified": email_verified_value, 283 283 "preferredChannel": preferred_channel, 284 284 "preferredChannelVerified": preferred_channel_verified, 285 + "preferredLocale": row.preferred_locale, 285 286 "isAdmin": row.is_admin, 286 287 "active": is_active, 287 288 "status": if is_active { "active" } else { "deactivated" }, ··· 481 482 } 482 483 match sqlx::query!( 483 484 r#"SELECT 484 - handle, email, email_verified, is_admin, 485 + handle, email, email_verified, is_admin, preferred_locale, 485 486 preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", 486 487 discord_verified, telegram_verified, signal_verified 487 488 FROM users WHERE did = $1"#, ··· 509 510 "emailVerified": u.email_verified, 510 511 "preferredChannel": preferred_channel, 511 512 "preferredChannelVerified": preferred_channel_verified, 513 + "preferredLocale": u.preferred_locale, 512 514 "isAdmin": u.is_admin, 513 515 "active": true 514 516 })) ··· 777 779 channel_str, 778 780 &recipient, 779 781 &verification_code, 782 + None, 780 783 ) 781 784 .await 782 785 { ··· 1205 1208 } 1206 1209 } 1207 1210 } 1211 + 1212 + const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko"]; 1213 + 1214 + #[derive(Deserialize)] 1215 + #[serde(rename_all = "camelCase")] 1216 + pub struct UpdateLocaleInput { 1217 + pub preferred_locale: String, 1218 + } 1219 + 1220 + pub async fn update_locale( 1221 + State(state): State<AppState>, 1222 + auth: BearerAuth, 1223 + Json(input): Json<UpdateLocaleInput>, 1224 + ) -> Response { 1225 + if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) { 1226 + return ( 1227 + StatusCode::BAD_REQUEST, 1228 + Json(json!({ 1229 + "error": "InvalidRequest", 1230 + "message": format!("Invalid locale. Valid options: {}", VALID_LOCALES.join(", ")) 1231 + })), 1232 + ) 1233 + .into_response(); 1234 + } 1235 + 1236 + let result = sqlx::query!( 1237 + "UPDATE users SET preferred_locale = $1 WHERE did = $2 RETURNING did", 1238 + input.preferred_locale, 1239 + auth.0.did 1240 + ) 1241 + .fetch_optional(&state.db) 1242 + .await; 1243 + 1244 + match result { 1245 + Ok(Some(_)) => { 1246 + info!( 1247 + did = %auth.0.did, 1248 + locale = %input.preferred_locale, 1249 + "User locale preference updated" 1250 + ); 1251 + Json(json!({ 1252 + "preferredLocale": input.preferred_locale 1253 + })) 1254 + .into_response() 1255 + } 1256 + Ok(None) => ( 1257 + StatusCode::NOT_FOUND, 1258 + Json(json!({"error": "AccountNotFound"})), 1259 + ) 1260 + .into_response(), 1261 + Err(e) => { 1262 + error!("DB error updating locale: {:?}", e); 1263 + ( 1264 + StatusCode::INTERNAL_SERVER_ERROR, 1265 + Json(json!({"error": "InternalError"})), 1266 + ) 1267 + .into_response() 1268 + } 1269 + } 1270 + }
+4 -4
src/auth/token.rs
··· 38 38 SCOPE_ACCESS, 39 39 TOKEN_TYPE_ACCESS, 40 40 key_bytes, 41 - Duration::minutes(120), 41 + Duration::minutes(15), 42 42 ) 43 43 } 44 44 ··· 51 51 SCOPE_REFRESH, 52 52 TOKEN_TYPE_REFRESH, 53 53 key_bytes, 54 - Duration::days(90), 54 + Duration::days(14), 55 55 ) 56 56 } 57 57 ··· 156 156 SCOPE_ACCESS, 157 157 TOKEN_TYPE_ACCESS, 158 158 secret, 159 - Duration::minutes(120), 159 + Duration::minutes(15), 160 160 ) 161 161 } 162 162 ··· 169 169 SCOPE_REFRESH, 170 170 TOKEN_TYPE_REFRESH, 171 171 secret, 172 - Duration::days(90), 172 + Duration::days(14), 173 173 ) 174 174 } 175 175
+174
src/comms/locale.rs
··· 1 + pub const DEFAULT_LOCALE: &str = "en"; 2 + pub const VALID_LOCALES: &[&str] = &["en", "zh", "ja", "ko"]; 3 + 4 + pub fn validate_locale(locale: &str) -> &str { 5 + if VALID_LOCALES.contains(&locale) { 6 + locale 7 + } else { 8 + DEFAULT_LOCALE 9 + } 10 + } 11 + 12 + pub struct NotificationStrings { 13 + pub welcome_subject: &'static str, 14 + pub welcome_body: &'static str, 15 + pub email_verification_subject: &'static str, 16 + pub email_verification_body: &'static str, 17 + pub password_reset_subject: &'static str, 18 + pub password_reset_body: &'static str, 19 + pub email_update_subject: &'static str, 20 + pub email_update_body: &'static str, 21 + pub account_deletion_subject: &'static str, 22 + pub account_deletion_body: &'static str, 23 + pub plc_operation_subject: &'static str, 24 + pub plc_operation_body: &'static str, 25 + pub two_factor_code_subject: &'static str, 26 + pub two_factor_code_body: &'static str, 27 + pub passkey_recovery_subject: &'static str, 28 + pub passkey_recovery_body: &'static str, 29 + pub signup_verification_subject: &'static str, 30 + pub signup_verification_body: &'static str, 31 + pub legacy_login_subject: &'static str, 32 + pub legacy_login_body: &'static str, 33 + } 34 + 35 + pub fn get_strings(locale: &str) -> &'static NotificationStrings { 36 + match validate_locale(locale) { 37 + "zh" => &STRINGS_ZH, 38 + "ja" => &STRINGS_JA, 39 + "ko" => &STRINGS_KO, 40 + _ => &STRINGS_EN, 41 + } 42 + } 43 + 44 + static STRINGS_EN: NotificationStrings = NotificationStrings { 45 + welcome_subject: "Welcome to {hostname}", 46 + welcome_body: "Welcome to {hostname}!\n\nYour handle is: @{handle}\n\nThank you for joining us.", 47 + email_verification_subject: "Verify your email - {hostname}", 48 + 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.", 49 + password_reset_subject: "Password Reset - {hostname}", 50 + password_reset_body: "Hello @{handle},\n\nYour password reset code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", 51 + email_update_subject: "Confirm your new email - {hostname}", 52 + email_update_body: "Hello @{handle},\n\nYour 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.", 53 + account_deletion_subject: "Account Deletion Request - {hostname}", 54 + account_deletion_body: "Hello @{handle},\n\nYour account deletion confirmation code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", 55 + plc_operation_subject: "{hostname} - PLC Operation Token", 56 + plc_operation_body: "Hello @{handle},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {token}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.", 57 + two_factor_code_subject: "Sign-in Verification - {hostname}", 58 + two_factor_code_body: "Hello @{handle},\n\nYour sign-in verification code is: {code}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", 59 + passkey_recovery_subject: "Account Recovery - {hostname}", 60 + passkey_recovery_body: "Hello @{handle},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{url}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", 61 + signup_verification_subject: "Verify your account - {hostname}", 62 + signup_verification_body: "Welcome! Your account verification code is: {code}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {hostname}.", 63 + legacy_login_subject: "Security Alert: Legacy Login Detected - {hostname}", 64 + legacy_login_body: "Hello @{handle},\n\nA login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\nDetails:\n- Time: {timestamp}\n- IP Address: {ip}\n\nYour TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\nIf this wasn't you, please:\n1. Change your password immediately\n2. Review your active sessions\n3. Consider disabling legacy app logins in your security settings\n\nStay safe,\n{hostname}", 65 + }; 66 + 67 + static STRINGS_ZH: NotificationStrings = NotificationStrings { 68 + welcome_subject: "欢迎加入 {hostname}", 69 + welcome_body: "欢迎加入 {hostname}!\n\n您的用户名是:@{handle}\n\n感谢您的加入。", 70 + email_verification_subject: "验证您的邮箱 - {hostname}", 71 + email_verification_body: "您好 @{handle},\n\n您的邮箱验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。", 72 + password_reset_subject: "密码重置 - {hostname}", 73 + password_reset_body: "您好 @{handle},\n\n您的密码重置验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此消息。", 74 + email_update_subject: "确认您的新邮箱 - {hostname}", 75 + email_update_body: "您好 @{handle},\n\n您的邮箱更新确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请忽略此邮件。", 76 + account_deletion_subject: "账户删除请求 - {hostname}", 77 + account_deletion_body: "您好 @{handle},\n\n您的账户删除确认码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", 78 + plc_operation_subject: "{hostname} - PLC 操作令牌", 79 + plc_operation_body: "您好 @{handle},\n\n您请求为账户签署 PLC 操作。\n\n您的验证令牌是:{token}\n\n此令牌将在10分钟后过期。\n\n如果这不是您的操作,您可以安全地忽略此消息。", 80 + two_factor_code_subject: "登录验证 - {hostname}", 81 + two_factor_code_body: "您好 @{handle},\n\n您的登录验证码是:{code}\n\n此验证码将在10分钟后过期。\n\n如果这不是您的操作,请立即保护您的账户。", 82 + passkey_recovery_subject: "账户恢复 - {hostname}", 83 + passkey_recovery_body: "您好 @{handle},\n\n您请求恢复仅通行密钥账户的访问权限。\n\n点击以下链接设置临时密码并恢复访问:\n{url}\n\n此链接将在1小时后过期。\n\n如果这不是您的操作,请忽略此消息。您的账户仍然安全。", 84 + signup_verification_subject: "验证您的账户 - {hostname}", 85 + signup_verification_body: "欢迎!您的账户验证码是:{code}\n\n此验证码将在30分钟后过期。\n\n请输入此验证码完成在 {hostname} 上的注册。", 86 + legacy_login_subject: "安全提醒:检测到传统应用登录 - {hostname}", 87 + legacy_login_body: "您好 @{handle},\n\n检测到使用不支持 TOTP 验证的传统应用(如 Bluesky)登录您的账户。\n\n详细信息:\n- 时间:{timestamp}\n- IP 地址:{ip}\n\n此次登录绕过了 TOTP 保护。该会话对敏感操作的权限有限。\n\n如果这不是您的操作,请:\n1. 立即更改密码\n2. 检查您的活跃会话\n3. 考虑在安全设置中禁用传统应用登录\n\n请注意安全,\n{hostname}", 88 + }; 89 + 90 + static STRINGS_JA: NotificationStrings = NotificationStrings { 91 + welcome_subject: "{hostname} へようこそ", 92 + welcome_body: "{hostname} へようこそ!\n\nお客様のハンドル:@{handle}\n\nご登録ありがとうございます。", 93 + email_verification_subject: "メール認証 - {hostname}", 94 + email_verification_body: "@{handle} 様\n\nメール認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。", 95 + password_reset_subject: "パスワードリセット - {hostname}", 96 + password_reset_body: "@{handle} 様\n\nパスワードリセットコードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。", 97 + email_update_subject: "新しいメールアドレスの確認 - {hostname}", 98 + email_update_body: "@{handle} 様\n\nメールアドレス更新の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメールを無視してください。", 99 + account_deletion_subject: "アカウント削除リクエスト - {hostname}", 100 + account_deletion_body: "@{handle} 様\n\nアカウント削除の確認コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", 101 + plc_operation_subject: "{hostname} - PLC 操作トークン", 102 + plc_operation_body: "@{handle} 様\n\nアカウントの PLC 操作の署名をリクエストされました。\n\n認証トークンは:{token}\n\nこのトークンは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視しても問題ありません。", 103 + two_factor_code_subject: "ログイン認証 - {hostname}", 104 + two_factor_code_body: "@{handle} 様\n\nログイン認証コードは:{code}\n\nこのコードは10分後に期限切れとなります。\n\nこの操作に心当たりがない場合は、直ちにアカウントを保護してください。", 105 + passkey_recovery_subject: "アカウント復旧 - {hostname}", 106 + passkey_recovery_body: "@{handle} 様\n\nパスキー専用アカウントの復旧をリクエストされました。\n\n以下のリンクをクリックして一時パスワードを設定し、アクセスを回復してください:\n{url}\n\nこのリンクは1時間後に期限切れとなります。\n\nこの操作に心当たりがない場合は、このメッセージを無視してください。アカウントは安全なままです。", 107 + signup_verification_subject: "アカウント認証 - {hostname}", 108 + signup_verification_body: "ようこそ!アカウント認証コードは:{code}\n\nこのコードは30分後に期限切れとなります。\n\n{hostname} への登録を完了するには、このコードを入力してください。", 109 + legacy_login_subject: "セキュリティ警告:レガシーログインを検出 - {hostname}", 110 + legacy_login_body: "@{handle} 様\n\nTOTP 認証に対応していないレガシーアプリ(Bluesky など)からのログインが検出されました。\n\n詳細:\n- 時刻:{timestamp}\n- IP アドレス:{ip}\n\nこのログインでは TOTP 保護がバイパスされました。このセッションは機密操作に対する権限が制限されています。\n\n心当たりがない場合は:\n1. 直ちにパスワードを変更してください\n2. アクティブなセッションを確認してください\n3. セキュリティ設定でレガシーアプリのログインを無効にすることを検討してください\n\nご注意ください。\n{hostname}", 111 + }; 112 + 113 + static STRINGS_KO: NotificationStrings = NotificationStrings { 114 + welcome_subject: "{hostname}에 오신 것을 환영합니다", 115 + welcome_body: "{hostname}에 오신 것을 환영합니다!\n\n회원님의 핸들은: @{handle}\n\n가입해 주셔서 감사합니다.", 116 + email_verification_subject: "이메일 인증 - {hostname}", 117 + email_verification_body: "안녕하세요 @{handle}님,\n\n이메일 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", 118 + password_reset_subject: "비밀번호 재설정 - {hostname}", 119 + password_reset_body: "안녕하세요 @{handle}님,\n\n비밀번호 재설정 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요.", 120 + email_update_subject: "새 이메일 확인 - {hostname}", 121 + email_update_body: "안녕하세요 @{handle}님,\n\n이메일 업데이트 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 이메일을 무시하세요.", 122 + account_deletion_subject: "계정 삭제 요청 - {hostname}", 123 + account_deletion_body: "안녕하세요 @{handle}님,\n\n계정 삭제 확인 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", 124 + plc_operation_subject: "{hostname} - PLC 작업 토큰", 125 + plc_operation_body: "안녕하세요 @{handle}님,\n\n계정의 PLC 작업 서명을 요청하셨습니다.\n\n인증 토큰은: {token}\n\n이 토큰은 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 안전하게 무시하셔도 됩니다.", 126 + two_factor_code_subject: "로그인 인증 - {hostname}", 127 + two_factor_code_body: "안녕하세요 @{handle}님,\n\n로그인 인증 코드는: {code}\n\n이 코드는 10분 후에 만료됩니다.\n\n요청하지 않으셨다면 즉시 계정을 보호하세요.", 128 + passkey_recovery_subject: "계정 복구 - {hostname}", 129 + passkey_recovery_body: "안녕하세요 @{handle}님,\n\n패스키 전용 계정 복구를 요청하셨습니다.\n\n아래 링크를 클릭하여 임시 비밀번호를 설정하고 액세스를 복구하세요:\n{url}\n\n이 링크는 1시간 후에 만료됩니다.\n\n요청하지 않으셨다면 이 메시지를 무시하세요. 계정은 안전하게 유지됩니다.", 130 + signup_verification_subject: "계정 인증 - {hostname}", 131 + signup_verification_body: "환영합니다! 계정 인증 코드는: {code}\n\n이 코드는 30분 후에 만료됩니다.\n\n{hostname}에서 등록을 완료하려면 이 코드를 입력하세요.", 132 + legacy_login_subject: "보안 알림: 레거시 로그인 감지 - {hostname}", 133 + legacy_login_body: "안녕하세요 @{handle}님,\n\nTOTP 인증을 지원하지 않는 레거시 앱(예: Bluesky)을 사용한 로그인이 감지되었습니다.\n\n세부 정보:\n- 시간: {timestamp}\n- IP 주소: {ip}\n\n이 로그인에서 TOTP 보호가 우회되었습니다. 이 세션은 민감한 작업에 대한 권한이 제한됩니다.\n\n본인이 아닌 경우:\n1. 즉시 비밀번호를 변경하세요\n2. 활성 세션을 검토하세요\n3. 보안 설정에서 레거시 앱 로그인 비활성화를 고려하세요\n\n{hostname} 드림", 134 + }; 135 + 136 + pub fn format_message(template: &str, vars: &[(&str, &str)]) -> String { 137 + let mut result = template.to_string(); 138 + for (key, value) in vars { 139 + result = result.replace(&format!("{{{}}}", key), value); 140 + } 141 + result 142 + } 143 + 144 + #[cfg(test)] 145 + mod tests { 146 + use super::*; 147 + 148 + #[test] 149 + fn test_validate_locale() { 150 + assert_eq!(validate_locale("en"), "en"); 151 + assert_eq!(validate_locale("zh"), "zh"); 152 + assert_eq!(validate_locale("ja"), "ja"); 153 + assert_eq!(validate_locale("ko"), "ko"); 154 + assert_eq!(validate_locale("invalid"), DEFAULT_LOCALE); 155 + assert_eq!(validate_locale(""), DEFAULT_LOCALE); 156 + } 157 + 158 + #[test] 159 + fn test_format_message() { 160 + let template = "Hello {name}, your code is {code}"; 161 + let result = format_message(template, &[("name", "Alice"), ("code", "123456")]); 162 + assert_eq!(result, "Hello Alice, your code is 123456"); 163 + } 164 + 165 + #[test] 166 + fn test_get_strings() { 167 + let en = get_strings("en"); 168 + assert!(en.welcome_subject.contains("{hostname}")); 169 + 170 + let zh = get_strings("zh"); 171 + assert!(zh.welcome_subject.contains("{hostname}")); 172 + assert!(zh.welcome_body.contains("欢迎")); 173 + } 174 + }
+1
src/comms/mod.rs
··· 1 + mod locale; 1 2 mod sender; 2 3 mod service; 3 4 mod types;
+75 -53
src/comms/service.rs
··· 9 9 use tracing::{debug, error, info, warn}; 10 10 use uuid::Uuid; 11 11 12 + use super::locale::{format_message, get_strings}; 12 13 use super::sender::{CommsSender, SendError}; 13 14 use super::types::{CommsChannel, CommsStatus, NewComms, QueuedComms}; 14 15 ··· 257 258 pub channel: CommsChannel, 258 259 pub email: Option<String>, 259 260 pub handle: String, 261 + pub locale: String, 260 262 } 261 263 262 264 pub async fn get_user_comms_prefs( ··· 268 270 SELECT 269 271 email, 270 272 handle, 271 - preferred_comms_channel as "channel: CommsChannel" 273 + preferred_comms_channel as "channel: CommsChannel", 274 + preferred_locale 272 275 FROM users 273 276 WHERE id = $1 274 277 "#, ··· 280 283 channel: row.channel, 281 284 email: row.email, 282 285 handle: row.handle, 286 + locale: row.preferred_locale.unwrap_or_else(|| "en".to_string()), 283 287 }) 284 288 } 285 289 ··· 289 293 hostname: &str, 290 294 ) -> Result<Uuid, sqlx::Error> { 291 295 let prefs = get_user_comms_prefs(db, user_id).await?; 292 - let body = format!( 293 - "Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.", 294 - hostname, prefs.handle 296 + let strings = get_strings(&prefs.locale); 297 + let body = format_message( 298 + strings.welcome_body, 299 + &[("hostname", hostname), ("handle", &prefs.handle)], 295 300 ); 301 + let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]); 296 302 enqueue_comms( 297 303 db, 298 304 NewComms::new( ··· 300 306 prefs.channel, 301 307 super::types::CommsType::Welcome, 302 308 prefs.email.clone().unwrap_or_default(), 303 - Some(format!("Welcome to {}", hostname)), 309 + Some(subject), 304 310 body, 305 311 ), 306 312 ) ··· 315 321 code: &str, 316 322 hostname: &str, 317 323 ) -> Result<Uuid, sqlx::Error> { 318 - let body = format!( 319 - "Hello @{},\n\nYour email verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", 320 - handle, code 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)], 321 329 ); 330 + let subject = format_message(strings.email_verification_subject, &[("hostname", hostname)]); 322 331 enqueue_comms( 323 332 db, 324 333 NewComms::email( 325 334 user_id, 326 335 super::types::CommsType::EmailVerification, 327 336 email.to_string(), 328 - format!("Verify your email - {}", hostname), 337 + subject, 329 338 body, 330 339 ), 331 340 ) ··· 339 348 hostname: &str, 340 349 ) -> Result<Uuid, sqlx::Error> { 341 350 let prefs = get_user_comms_prefs(db, user_id).await?; 342 - let body = format!( 343 - "Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", 344 - prefs.handle, code 351 + let strings = get_strings(&prefs.locale); 352 + let body = format_message( 353 + strings.password_reset_body, 354 + &[("handle", &prefs.handle), ("code", code)], 345 355 ); 356 + let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]); 346 357 enqueue_comms( 347 358 db, 348 359 NewComms::new( ··· 350 361 prefs.channel, 351 362 super::types::CommsType::PasswordReset, 352 363 prefs.email.clone().unwrap_or_default(), 353 - Some(format!("Password Reset - {}", hostname)), 364 + Some(subject), 354 365 body, 355 366 ), 356 367 ) ··· 365 376 code: &str, 366 377 hostname: &str, 367 378 ) -> Result<Uuid, sqlx::Error> { 368 - let body = format!( 369 - "Hello @{},\n\nYour email update confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", 370 - handle, code 379 + let prefs = get_user_comms_prefs(db, user_id).await?; 380 + let strings = get_strings(&prefs.locale); 381 + let body = format_message( 382 + strings.email_update_body, 383 + &[("handle", handle), ("code", code)], 371 384 ); 385 + let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]); 372 386 enqueue_comms( 373 387 db, 374 388 NewComms::email( 375 389 user_id, 376 390 super::types::CommsType::EmailUpdate, 377 391 new_email.to_string(), 378 - format!("Confirm your new email - {}", hostname), 392 + subject, 379 393 body, 380 394 ), 381 395 ) ··· 389 403 hostname: &str, 390 404 ) -> Result<Uuid, sqlx::Error> { 391 405 let prefs = get_user_comms_prefs(db, user_id).await?; 392 - let body = format!( 393 - "Hello @{},\n\nYour account deletion confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", 394 - prefs.handle, code 406 + let strings = get_strings(&prefs.locale); 407 + let body = format_message( 408 + strings.account_deletion_body, 409 + &[("handle", &prefs.handle), ("code", code)], 395 410 ); 411 + let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]); 396 412 enqueue_comms( 397 413 db, 398 414 NewComms::new( ··· 400 416 prefs.channel, 401 417 super::types::CommsType::AccountDeletion, 402 418 prefs.email.clone().unwrap_or_default(), 403 - Some(format!("Account Deletion Request - {}", hostname)), 419 + Some(subject), 404 420 body, 405 421 ), 406 422 ) ··· 414 430 hostname: &str, 415 431 ) -> Result<Uuid, sqlx::Error> { 416 432 let prefs = get_user_comms_prefs(db, user_id).await?; 417 - let body = format!( 418 - "Hello @{},\n\nYou requested to sign a PLC operation for your account.\n\nYour verification token is: {}\n\nThis token will expire in 10 minutes.\n\nIf you did not request this, you can safely ignore this message.", 419 - prefs.handle, token 433 + let strings = get_strings(&prefs.locale); 434 + let body = format_message( 435 + strings.plc_operation_body, 436 + &[("handle", &prefs.handle), ("token", token)], 420 437 ); 438 + let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]); 421 439 enqueue_comms( 422 440 db, 423 441 NewComms::new( ··· 425 443 prefs.channel, 426 444 super::types::CommsType::PlcOperation, 427 445 prefs.email.clone().unwrap_or_default(), 428 - Some(format!("{} - PLC Operation Token", hostname)), 446 + Some(subject), 429 447 body, 430 448 ), 431 449 ) ··· 439 457 hostname: &str, 440 458 ) -> Result<Uuid, sqlx::Error> { 441 459 let prefs = get_user_comms_prefs(db, user_id).await?; 442 - let body = format!( 443 - "Hello @{},\n\nYour sign-in verification code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", 444 - prefs.handle, code 460 + let strings = get_strings(&prefs.locale); 461 + let body = format_message( 462 + strings.two_factor_code_body, 463 + &[("handle", &prefs.handle), ("code", code)], 445 464 ); 465 + let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]); 446 466 enqueue_comms( 447 467 db, 448 468 NewComms::new( ··· 450 470 prefs.channel, 451 471 super::types::CommsType::TwoFactorCode, 452 472 prefs.email.clone().unwrap_or_default(), 453 - Some(format!("Sign-in Verification - {}", hostname)), 473 + Some(subject), 454 474 body, 455 475 ), 456 476 ) ··· 464 484 hostname: &str, 465 485 ) -> Result<Uuid, sqlx::Error> { 466 486 let prefs = get_user_comms_prefs(db, user_id).await?; 467 - let body = format!( 468 - "Hello @{},\n\nYou requested to recover your passkey-only account.\n\nClick the link below to set a temporary password and regain access:\n{}\n\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this message. Your account remains secure.", 469 - prefs.handle, recovery_url 487 + let strings = get_strings(&prefs.locale); 488 + let body = format_message( 489 + strings.passkey_recovery_body, 490 + &[("handle", &prefs.handle), ("url", recovery_url)], 470 491 ); 492 + let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]); 471 493 enqueue_comms( 472 494 db, 473 495 NewComms::new( ··· 475 497 prefs.channel, 476 498 super::types::CommsType::PasskeyRecovery, 477 499 prefs.email.clone().unwrap_or_default(), 478 - Some(format!("Account Recovery - {}", hostname)), 500 + Some(subject), 479 501 body, 480 502 ), 481 503 ) ··· 497 519 channel: &str, 498 520 recipient: &str, 499 521 code: &str, 522 + locale: Option<&str>, 500 523 ) -> Result<Uuid, sqlx::Error> { 501 524 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 502 525 let comms_channel = match channel { ··· 506 529 "signal" => CommsChannel::Signal, 507 530 _ => CommsChannel::Email, 508 531 }; 509 - let body = format!( 510 - "Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.", 511 - code, hostname 532 + let strings = get_strings(locale.unwrap_or("en")); 533 + let body = format_message( 534 + strings.signup_verification_body, 535 + &[("code", code), ("hostname", &hostname)], 512 536 ); 513 537 let subject = match comms_channel { 514 - CommsChannel::Email => Some(format!("Verify your account - {}", hostname)), 538 + CommsChannel::Email => { 539 + Some(format_message(strings.signup_verification_subject, &[("hostname", &hostname)])) 540 + } 515 541 _ => None, 516 542 }; 517 543 enqueue_comms( ··· 536 562 channel: CommsChannel, 537 563 ) -> Result<Uuid, sqlx::Error> { 538 564 let prefs = get_user_comms_prefs(db, user_id).await?; 539 - let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); 540 - let body = format!( 541 - "Hello @{},\n\n\ 542 - A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\n\ 543 - Details:\n\ 544 - - Time: {}\n\ 545 - - IP Address: {}\n\n\ 546 - Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\n\ 547 - If this wasn't you, please:\n\ 548 - 1. Change your password immediately\n\ 549 - 2. Review your active sessions\n\ 550 - 3. Consider disabling legacy app logins in your security settings\n\n\ 551 - Stay safe,\n\ 552 - {}", 553 - prefs.handle, timestamp, client_ip, hostname 565 + let strings = get_strings(&prefs.locale); 566 + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); 567 + let body = format_message( 568 + strings.legacy_login_body, 569 + &[ 570 + ("handle", &prefs.handle), 571 + ("timestamp", &timestamp), 572 + ("ip", client_ip), 573 + ("hostname", hostname), 574 + ], 554 575 ); 576 + let subject = format_message(strings.legacy_login_subject, &[("hostname", hostname)]); 555 577 enqueue_comms( 556 578 db, 557 579 NewComms::new( ··· 559 581 channel, 560 582 super::types::CommsType::LegacyLoginAlert, 561 583 prefs.email.clone().unwrap_or_default(), 562 - Some(format!("Security Alert: Legacy Login Detected - {}", hostname)), 584 + Some(subject), 563 585 body, 564 586 ), 565 587 )
+4
src/lib.rs
··· 243 243 post(api::server::update_legacy_login_preference), 244 244 ) 245 245 .route( 246 + "/xrpc/com.tranquil.account.updateLocale", 247 + post(api::server::update_locale), 248 + ) 249 + .route( 246 250 "/xrpc/com.tranquil.account.listTrustedDevices", 247 251 get(api::server::list_trusted_devices), 248 252 )
+4 -1
src/oauth/endpoints/metadata.rs
··· 39 39 #[serde(skip_serializing_if = "Option::is_none")] 40 40 pub require_pushed_authorization_requests: Option<bool>, 41 41 #[serde(skip_serializing_if = "Option::is_none")] 42 + pub require_request_uri_registration: Option<bool>, 43 + #[serde(skip_serializing_if = "Option::is_none")] 42 44 pub dpop_signing_alg_values_supported: Option<Vec<String>>, 43 45 #[serde(skip_serializing_if = "Option::is_none")] 44 46 pub authorization_response_iss_parameter_supported: Option<bool>, ··· 110 112 code_challenge_methods_supported: Some(vec!["S256".to_string()]), 111 113 pushed_authorization_request_endpoint: Some(format!("{}/oauth/par", issuer)), 112 114 require_pushed_authorization_requests: Some(true), 115 + require_request_uri_registration: Some(true), 113 116 dpop_signing_alg_values_supported: Some(vec![ 114 117 "ES256".to_string(), 115 118 "ES384".to_string(), ··· 172 175 scope: "atproto transition:generic".to_string(), 173 176 token_endpoint_auth_method: "none".to_string(), 174 177 application_type: "web".to_string(), 175 - dpop_bound_access_tokens: false, 178 + dpop_bound_access_tokens: true, 176 179 }) 177 180 }
+17 -5
src/oauth/endpoints/token/grants.rs
··· 12 12 use axum::http::HeaderMap; 13 13 use chrono::{Duration, Utc}; 14 14 15 - const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; 16 - const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60; 15 + const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 300; 16 + const REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL: i64 = 60; 17 + const REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC: i64 = 14; 17 18 18 19 pub async fn handle_authorization_code_grant( 19 20 state: AppState, ··· 111 112 dpop_jkt.as_deref(), 112 113 auth_request.parameters.scope.as_deref(), 113 114 )?; 115 + let stored_client_auth = auth_request.client_auth.unwrap_or(ClientAuth::None); 116 + let refresh_expiry_days = if matches!(stored_client_auth, ClientAuth::None) { 117 + REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC 118 + } else { 119 + REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 120 + }; 114 121 let token_data = TokenData { 115 122 did: did.clone(), 116 123 token_id: token_id.0.clone(), 117 124 created_at: now, 118 125 updated_at: now, 119 - expires_at: now + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS), 126 + expires_at: now + Duration::days(refresh_expiry_days), 120 127 client_id: auth_request.client_id.clone(), 121 - client_auth: auth_request.client_auth.unwrap_or(ClientAuth::None), 128 + client_auth: stored_client_auth, 122 129 device_id: auth_request.device_id, 123 130 parameters: auth_request.parameters.clone(), 124 131 details: None, ··· 206 213 }; 207 214 let new_token_id = TokenId::generate(); 208 215 let new_refresh_token = RefreshToken::generate(); 209 - let new_expires_at = Utc::now() + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS); 216 + let refresh_expiry_days = if matches!(token_data.client_auth, ClientAuth::None) { 217 + REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC 218 + } else { 219 + REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL 220 + }; 221 + let new_expires_at = Utc::now() + Duration::days(refresh_expiry_days); 210 222 db::rotate_token( 211 223 &state.db, 212 224 db_id,
+1 -1
src/oauth/endpoints/token/helpers.rs
··· 7 7 use sha2::{Digest, Sha256}; 8 8 use subtle::ConstantTimeEq; 9 9 10 - const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; 10 + const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 300; 11 11 12 12 pub struct TokenClaims { 13 13 pub jti: String,
+2 -2
tests/oauth_client_metadata.rs
··· 83 83 ); 84 84 assert_eq!( 85 85 body["dpop_bound_access_tokens"].as_bool(), 86 - Some(false), 87 - "Should not require DPoP" 86 + Some(true), 87 + "AT Protocol requires DPoP-bound access tokens" 88 88 ); 89 89 let scope = body["scope"].as_str().unwrap(); 90 90 assert!(scope.contains("atproto"), "Scope should include atproto");
+1 -1
tests/session_management.rs
··· 235 235 base_url().await 236 236 )) 237 237 .bearer_auth(&jwt) 238 - .json(&json!({"sessionId": "999999999"})) 238 + .json(&json!({"sessionId": "jwt:999999999"})) 239 239 .send() 240 240 .await 241 241 .expect("Failed to send request");