+4
-4
.sqlx/query-17da8b6f6b46eae067bd8842a369a406699888f689122d2bae8bef13b532bcd2.json
.sqlx/query-7fea217210a7a97f02d981692ba1cdda4f8037c7feba39610e3dd4d4d2f7ee8c.json
+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
+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
+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
+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
+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
+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
+3
frontend/package.json
+10
-66
frontend/src/App.svelte
+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
frontend/src/components/ReauthModal.svelte
+75
frontend/src/components/ui/Button.svelte
+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
+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
+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
+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
+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">← {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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
frontend/src/main.ts
+145
-77
frontend/src/routes/Admin.svelte
+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
+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">← 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
+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
+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
+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">← 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
+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
+
×
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> · <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">·</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
+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">← 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
+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
+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
+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
+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
+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> · <a href="#/request-passkey-recovery">Lost passkey?</a>
405
+
<a href="#/reset-password">{$_('login.forgotPassword')}</a> · <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
+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
+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
+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">✔</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
+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
+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">✔</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
+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">← 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
+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
+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
+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">← 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
-
· Last used {formatDate(passkey.lastUsed)}
617
+
· {$_('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 →
720
+
{$_('security.manageTrustedDevices')} →
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
+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">← 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
+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">← 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
+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">← 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
✎
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
+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
+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
+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
+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
+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
migrations/20251230_add_preferred_locale.sql
···
1
+
ALTER TABLE users ADD COLUMN preferred_locale VARCHAR(10);
+1
src/api/identity/account.rs
+1
src/api/identity/account.rs
+1
-1
src/api/server/mod.rs
+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
+1
src/api/server/passkey_account.rs
+65
-2
src/api/server/session.rs
+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
+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
+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
+
}
+75
-53
src/comms/service.rs
+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", ×tamp),
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
+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
+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
+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
+1
-1
src/oauth/endpoints/token/helpers.rs
+2
-2
tests/oauth_client_metadata.rs
+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");