+28
.sqlx/query-017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e.json
+28
.sqlx/query-017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, backup_enabled FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "backup_enabled",
14
+
"type_info": "Bool"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Text"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
false
25
+
]
26
+
},
27
+
"hash": "017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e"
28
+
}
-16
.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
-16
.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text",
9
-
"Timestamptz",
10
-
"Text"
11
-
]
12
-
},
13
-
"nullable": []
14
-
},
15
-
"hash": "0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe"
16
-
}
···
+52
.sqlx/query-2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c.json
+52
.sqlx/query-2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.did = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "backup_enabled",
19
+
"type_info": "Bool"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "deactivated_at",
24
+
"type_info": "Timestamptz"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "repo_root_cid",
29
+
"type_info": "Text"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "repo_rev",
34
+
"type_info": "Text"
35
+
}
36
+
],
37
+
"parameters": {
38
+
"Left": [
39
+
"Text"
40
+
]
41
+
},
42
+
"nullable": [
43
+
false,
44
+
false,
45
+
false,
46
+
true,
47
+
false,
48
+
true
49
+
]
50
+
},
51
+
"hash": "2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c"
52
+
}
-14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
-14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Text"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1"
14
-
}
···
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "email_verified",
9
+
"type_info": "Bool"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
22
+
}
+7
-7
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
+7
-7
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
4
"describe": {
5
"columns": [
6
{
···
10
},
11
{
12
"ordinal": 1,
13
-
"name": "migrated_to_pds",
14
"type_info": "Text"
15
},
16
{
17
"ordinal": 2,
18
-
"name": "handle",
19
-
"type_info": "Text"
20
}
21
],
22
"parameters": {
···
26
},
27
"nullable": [
28
false,
29
-
true,
30
-
false
31
]
32
},
33
-
"hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073"
34
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1",
4
"describe": {
5
"columns": [
6
{
···
10
},
11
{
12
"ordinal": 1,
13
+
"name": "handle",
14
"type_info": "Text"
15
},
16
{
17
"ordinal": 2,
18
+
"name": "deactivated_at",
19
+
"type_info": "Timestamptz"
20
}
21
],
22
"parameters": {
···
26
},
27
"nullable": [
28
false,
29
+
false,
30
+
true
31
]
32
},
33
+
"hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7"
34
}
+2
-2
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json
.sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
+2
-2
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json
.sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ",
4
"describe": {
5
"columns": [
6
{
···
26
false
27
]
28
},
29
-
"hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115"
30
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ",
4
"describe": {
5
"columns": [
6
{
···
26
false
27
]
28
},
29
+
"hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf"
30
}
+35
.sqlx/query-72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d.json
+35
.sqlx/query-72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT ab.id, ab.storage_key, u.deactivated_at\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "storage_key",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "deactivated_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Uuid",
25
+
"Text"
26
+
]
27
+
},
28
+
"nullable": [
29
+
false,
30
+
false,
31
+
true
32
+
]
33
+
},
34
+
"hash": "72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d"
35
+
}
-34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
-34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "did",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "migrated_to_pds",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "migrated_at",
19
-
"type_info": "Timestamptz"
20
-
}
21
-
],
22
-
"parameters": {
23
-
"Left": [
24
-
"Text"
25
-
]
26
-
},
27
-
"nullable": [
28
-
false,
29
-
true,
30
-
true
31
-
]
32
-
},
33
-
"hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e"
34
-
}
···
+19
.sqlx/query-7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c.json
+19
.sqlx/query-7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Text",
10
+
"Text",
11
+
"Text",
12
+
"Int4",
13
+
"Int8"
14
+
]
15
+
},
16
+
"nullable": []
17
+
},
18
+
"hash": "7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c"
19
+
}
+29
.sqlx/query-95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f.json
+29
.sqlx/query-95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT ab.storage_key, ab.repo_rev\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "storage_key",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "repo_rev",
14
+
"type_info": "Text"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Uuid",
20
+
"Text"
21
+
]
22
+
},
23
+
"nullable": [
24
+
false,
25
+
false
26
+
]
27
+
},
28
+
"hash": "95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f"
29
+
}
+29
.sqlx/query-a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144.json
+29
.sqlx/query-a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, storage_key\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n OFFSET $2\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "storage_key",
14
+
"type_info": "Text"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Uuid",
20
+
"Int8"
21
+
]
22
+
},
23
+
"nullable": [
24
+
false,
25
+
false
26
+
]
27
+
},
28
+
"hash": "a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144"
29
+
}
+52
.sqlx/query-b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4.json
+52
.sqlx/query-b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "repo_rev",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "repo_root_cid",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "block_count",
24
+
"type_info": "Int4"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "size_bytes",
29
+
"type_info": "Int8"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "created_at",
34
+
"type_info": "Timestamptz"
35
+
}
36
+
],
37
+
"parameters": {
38
+
"Left": [
39
+
"Uuid"
40
+
]
41
+
},
42
+
"nullable": [
43
+
false,
44
+
false,
45
+
false,
46
+
false,
47
+
false,
48
+
false
49
+
]
50
+
},
51
+
"hash": "b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4"
52
+
}
+40
.sqlx/query-d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab.json
+40
.sqlx/query-d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.backup_enabled = true\n AND u.deactivated_at IS NULL\n AND (\n NOT EXISTS (\n SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id\n )\n OR (\n SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id\n ) < NOW() - make_interval(secs => $1)\n )\n LIMIT 50\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "user_id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "repo_root_cid",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "repo_rev",
24
+
"type_info": "Text"
25
+
}
26
+
],
27
+
"parameters": {
28
+
"Left": [
29
+
"Float8"
30
+
]
31
+
},
32
+
"nullable": [
33
+
false,
34
+
false,
35
+
false,
36
+
true
37
+
]
38
+
},
39
+
"hash": "d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab"
40
+
}
+34
.sqlx/query-f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1.json
+34
.sqlx/query-f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT DISTINCT b.cid, b.storage_key, b.mime_type\n FROM blobs b\n JOIN record_blobs rb ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "cid",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "storage_key",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "mime_type",
19
+
"type_info": "Text"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Uuid"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
false
31
+
]
32
+
},
33
+
"hash": "f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1"
34
+
}
+27
.sqlx/query-f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183.json
+27
.sqlx/query-f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Uuid",
15
+
"Text",
16
+
"Text",
17
+
"Text",
18
+
"Int4",
19
+
"Int8"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false
24
+
]
25
+
},
26
+
"hash": "f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183"
27
+
}
+15
.sqlx/query-f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70.json
+15
.sqlx/query-f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET backup_enabled = $1 WHERE did = $2",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Bool",
9
+
"Text"
10
+
]
11
+
},
12
+
"nullable": []
13
+
},
14
+
"hash": "f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70"
15
+
}
+14
.sqlx/query-f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532.json
+14
.sqlx/query-f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "DELETE FROM account_backups WHERE id = $1",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532"
14
+
}
+64
Cargo.lock
+64
Cargo.lock
···
111
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
112
113
[[package]]
114
name = "arc-swap"
115
version = "1.7.1"
116
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1621
]
1622
1623
[[package]]
1624
name = "derive_more"
1625
version = "1.0.0"
1626
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1973
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
1974
dependencies = [
1975
"crc32fast",
1976
"miniz_oxide",
1977
]
1978
···
3457
dependencies = [
3458
"pkg-config",
3459
"vcpkg",
3460
]
3461
3462
[[package]]
···
6286
"ed25519-dalek",
6287
"futures",
6288
"governor",
6289
"hickory-resolver",
6290
"hkdf",
6291
"hmac",
···
6329
"webauthn-rs",
6330
"webauthn-rs-proto",
6331
"wiremock",
6332
]
6333
6334
[[package]]
···
7289
"proc-macro2",
7290
"quote",
7291
"syn 2.0.111",
7292
]
7293
7294
[[package]]
···
111
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
112
113
[[package]]
114
+
name = "arbitrary"
115
+
version = "1.4.2"
116
+
source = "registry+https://github.com/rust-lang/crates.io-index"
117
+
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
118
+
dependencies = [
119
+
"derive_arbitrary",
120
+
]
121
+
122
+
[[package]]
123
name = "arc-swap"
124
version = "1.7.1"
125
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1630
]
1631
1632
[[package]]
1633
+
name = "derive_arbitrary"
1634
+
version = "1.4.2"
1635
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1636
+
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
1637
+
dependencies = [
1638
+
"proc-macro2",
1639
+
"quote",
1640
+
"syn 2.0.111",
1641
+
]
1642
+
1643
+
[[package]]
1644
name = "derive_more"
1645
version = "1.0.0"
1646
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1993
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
1994
dependencies = [
1995
"crc32fast",
1996
+
"libz-rs-sys",
1997
"miniz_oxide",
1998
]
1999
···
3478
dependencies = [
3479
"pkg-config",
3480
"vcpkg",
3481
+
]
3482
+
3483
+
[[package]]
3484
+
name = "libz-rs-sys"
3485
+
version = "0.5.5"
3486
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3487
+
checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
3488
+
dependencies = [
3489
+
"zlib-rs",
3490
]
3491
3492
[[package]]
···
6316
"ed25519-dalek",
6317
"futures",
6318
"governor",
6319
+
"hex",
6320
"hickory-resolver",
6321
"hkdf",
6322
"hmac",
···
6360
"webauthn-rs",
6361
"webauthn-rs-proto",
6362
"wiremock",
6363
+
"zip",
6364
]
6365
6366
[[package]]
···
7321
"proc-macro2",
7322
"quote",
7323
"syn 2.0.111",
7324
+
]
7325
+
7326
+
[[package]]
7327
+
name = "zip"
7328
+
version = "7.0.0"
7329
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7330
+
checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037"
7331
+
dependencies = [
7332
+
"arbitrary",
7333
+
"crc32fast",
7334
+
"flate2",
7335
+
"indexmap 2.12.1",
7336
+
"memchr",
7337
+
"zopfli",
7338
+
]
7339
+
7340
+
[[package]]
7341
+
name = "zlib-rs"
7342
+
version = "0.5.5"
7343
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7344
+
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
7345
+
7346
+
[[package]]
7347
+
name = "zopfli"
7348
+
version = "0.8.3"
7349
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7350
+
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
7351
+
dependencies = [
7352
+
"bumpalo",
7353
+
"crc32fast",
7354
+
"log",
7355
+
"simd-adler32",
7356
]
7357
7358
[[package]]
+2
Cargo.toml
+2
Cargo.toml
···
19
dotenvy = "0.15.7"
20
futures = "0.3.30"
21
governor = "0.10"
22
hkdf = "0.12"
23
hmac = "0.12"
24
aes-gcm = "0.10"
···
62
totp-rs = { version = "5", features = ["qr"] }
63
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
64
webauthn-rs-proto = "0.5.4"
65
[features]
66
external-infra = []
67
[dev-dependencies]
···
19
dotenvy = "0.15.7"
20
futures = "0.3.30"
21
governor = "0.10"
22
+
hex = "0.4"
23
hkdf = "0.12"
24
hmac = "0.12"
25
aes-gcm = "0.10"
···
63
totp-rs = { version = "5", features = ["qr"] }
64
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
65
webauthn-rs-proto = "0.5.4"
66
+
zip = { version = "7.0.0", default-features = false, features = ["deflate"] }
67
[features]
68
external-infra = []
69
[dev-dependencies]
+1
-1
README.md
+1
-1
README.md
···
14
15
This software isn't an afterthought by a company with limited resources.
16
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, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
18
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
···
14
15
This software isn't an afterthought by a company with limited resources.
16
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, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
18
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
+2
-15
TODO.md
+2
-15
TODO.md
···
2
3
## Active development
4
5
-
### Migration tool
6
-
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
7
-
8
-
- [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
9
-
- [x] Support `createAccount` with existing DID + service auth token
10
-
- [x] Progress tracking with resume capability
11
-
- [ ] Scheduled automatic backups (CAR export)
12
-
- [ ] One-click restore from backup
13
-
14
-
Outbound migration wizard exists but is disabled. Rethinking the approach: instead of a managed flow with `migratingTo` state, pds-hosted did:web users should just have direct control over their DID document. They can independently update serviceEndpoint, add/remove keys, export their repo, deactivate their account.
15
-
16
-
- [ ] Remove `migratingTo` field and related state machine
17
-
- [ ] Let did:web users edit their DID doc fields (serviceEndpoint, keys) whenever
18
-
- [ ] Repo export as standalone feature, not tied to migration wizard
19
-
20
### Plugin system
21
Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language.
22
···
69
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
70
71
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
···
2
3
## Active development
4
5
### Plugin system
6
Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language.
7
···
54
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
55
56
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
57
+
58
+
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
+94
frontend/deno.lock
+94
frontend/deno.lock
···
1
{
2
"version": "5",
3
"specifiers": {
4
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
5
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
6
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
···
30
"lru-cache"
31
]
32
},
33
"@babel/code-frame@7.27.1": {
34
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
35
"dependencies": [
···
43
},
44
"@babel/runtime@7.28.4": {
45
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
46
},
47
"@csstools/color-helpers@5.1.0": {
48
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
···
498
"@noble/secp256k1@2.3.0": {
499
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
500
},
501
"@rollup/rollup-android-arm-eabi@4.53.3": {
502
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
503
"os": ["android"],
···
607
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
608
"os": ["win32"],
609
"cpu": ["x64"]
610
},
611
"@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
612
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
···
1545
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1546
"bin": true
1547
},
1548
"vite-node@2.1.9": {
1549
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
1550
"dependencies": [
···
1671
"workspace": {
1672
"packageJson": {
1673
"dependencies": [
1674
"npm:@noble/secp256k1@^2.1.0",
1675
"npm:@sveltejs/vite-plugin-svelte@5",
1676
"npm:@testing-library/jest-dom@^6.6.3",
···
1
{
2
"version": "5",
3
"specifiers": {
4
+
"npm:@atcute/cbor@^2.2.8": "2.2.8",
5
+
"npm:@atcute/crypto@^2.3.0": "2.3.0",
6
+
"npm:@atcute/did-plc@~0.3.1": "0.3.1",
7
+
"npm:@atcute/multibase@^1.1.6": "1.1.6",
8
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
9
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
10
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
···
34
"lru-cache"
35
]
36
},
37
+
"@atcute/cbor@2.2.8": {
38
+
"integrity": "sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==",
39
+
"dependencies": [
40
+
"@atcute/cid",
41
+
"@atcute/multibase",
42
+
"@atcute/uint8array"
43
+
]
44
+
},
45
+
"@atcute/cid@2.3.0": {
46
+
"integrity": "sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==",
47
+
"dependencies": [
48
+
"@atcute/multibase",
49
+
"@atcute/uint8array"
50
+
]
51
+
},
52
+
"@atcute/crypto@2.3.0": {
53
+
"integrity": "sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==",
54
+
"dependencies": [
55
+
"@atcute/multibase",
56
+
"@atcute/uint8array",
57
+
"@noble/secp256k1@3.0.0"
58
+
]
59
+
},
60
+
"@atcute/did-plc@0.3.1": {
61
+
"integrity": "sha512-KsuVdRtaaIPMmlcCDcxZzLg6OWm7rajczquhIHfA3s57+c34PFQbdY4Lsc2BvDwZ0fUjmbwzvQI3Zio2VcZa7w==",
62
+
"dependencies": [
63
+
"@atcute/cbor",
64
+
"@atcute/cid",
65
+
"@atcute/crypto",
66
+
"@atcute/identity",
67
+
"@atcute/lexicons",
68
+
"@atcute/multibase",
69
+
"@atcute/uint8array",
70
+
"@atcute/util-fetch",
71
+
"@badrap/valita"
72
+
]
73
+
},
74
+
"@atcute/identity@1.1.3": {
75
+
"integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==",
76
+
"dependencies": [
77
+
"@atcute/lexicons",
78
+
"@badrap/valita"
79
+
]
80
+
},
81
+
"@atcute/lexicons@1.2.6": {
82
+
"integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==",
83
+
"dependencies": [
84
+
"@atcute/uint8array",
85
+
"@atcute/util-text",
86
+
"@standard-schema/spec",
87
+
"esm-env"
88
+
]
89
+
},
90
+
"@atcute/multibase@1.1.6": {
91
+
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
92
+
"dependencies": [
93
+
"@atcute/uint8array"
94
+
]
95
+
},
96
+
"@atcute/uint8array@1.0.6": {
97
+
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
98
+
},
99
+
"@atcute/util-fetch@1.0.4": {
100
+
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
101
+
"dependencies": [
102
+
"@badrap/valita"
103
+
]
104
+
},
105
+
"@atcute/util-text@0.0.1": {
106
+
"integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==",
107
+
"dependencies": [
108
+
"unicode-segmenter"
109
+
]
110
+
},
111
"@babel/code-frame@7.27.1": {
112
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
113
"dependencies": [
···
121
},
122
"@babel/runtime@7.28.4": {
123
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
124
+
},
125
+
"@badrap/valita@0.4.6": {
126
+
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
127
},
128
"@csstools/color-helpers@5.1.0": {
129
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
···
579
"@noble/secp256k1@2.3.0": {
580
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
581
},
582
+
"@noble/secp256k1@3.0.0": {
583
+
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
584
+
},
585
"@rollup/rollup-android-arm-eabi@4.53.3": {
586
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
587
"os": ["android"],
···
691
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
692
"os": ["win32"],
693
"cpu": ["x64"]
694
+
},
695
+
"@standard-schema/spec@1.1.0": {
696
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
697
},
698
"@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
699
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
···
1632
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1633
"bin": true
1634
},
1635
+
"unicode-segmenter@0.14.4": {
1636
+
"integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg=="
1637
+
},
1638
"vite-node@2.1.9": {
1639
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
1640
"dependencies": [
···
1761
"workspace": {
1762
"packageJson": {
1763
"dependencies": [
1764
+
"npm:@atcute/cbor@^2.2.8",
1765
+
"npm:@atcute/crypto@^2.3.0",
1766
+
"npm:@atcute/did-plc@~0.3.1",
1767
+
"npm:@atcute/multibase@^1.1.6",
1768
"npm:@noble/secp256k1@^2.1.0",
1769
"npm:@sveltejs/vite-plugin-svelte@5",
1770
"npm:@testing-library/jest-dom@^6.6.3",
+4
frontend/package.json
+4
frontend/package.json
+2
-2
frontend/src/components/ReauthModal.svelte
+2
-2
frontend/src/components/ReauthModal.svelte
···
228
/>
229
</div>
230
<button type="submit" class="btn-primary" disabled={loading || !password}>
231
-
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
232
</button>
233
</form>
234
{:else if activeMethod === 'totp'}
···
247
/>
248
</div>
249
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250
-
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
251
</button>
252
</form>
253
{:else if activeMethod === 'passkey'}
···
228
/>
229
</div>
230
<button type="submit" class="btn-primary" disabled={loading || !password}>
231
+
{loading ? $_('common.verifying') : $_('common.verify')}
232
</button>
233
</form>
234
{:else if activeMethod === 'totp'}
···
247
/>
248
</div>
249
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250
+
{loading ? $_('common.verifying') : $_('common.verify')}
251
</button>
252
</form>
253
{:else if activeMethod === 'passkey'}
+86
frontend/src/components/migration/AppPasswordStep.svelte
+86
frontend/src/components/migration/AppPasswordStep.svelte
···
···
1
+
<script lang="ts">
2
+
import { _ } from '../../lib/i18n'
3
+
4
+
interface Props {
5
+
appPassword: string
6
+
appPasswordName: string
7
+
loading: boolean
8
+
onContinue: () => void
9
+
}
10
+
11
+
let {
12
+
appPassword,
13
+
appPasswordName,
14
+
loading,
15
+
onContinue,
16
+
}: Props = $props()
17
+
18
+
let copied = $state(false)
19
+
let acknowledged = $state(false)
20
+
21
+
function copyPassword() {
22
+
navigator.clipboard.writeText(appPassword)
23
+
copied = true
24
+
}
25
+
</script>
26
+
27
+
<div class="step-content">
28
+
<h2>{$_('migration.inbound.appPassword.title')}</h2>
29
+
<p>{$_('migration.inbound.appPassword.desc')}</p>
30
+
31
+
<div class="warning-box">
32
+
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
33
+
</div>
34
+
35
+
<div class="app-password-display">
36
+
<div class="app-password-label">
37
+
{$_('migration.inbound.appPassword.label')}: <strong>{appPasswordName}</strong>
38
+
</div>
39
+
<code class="app-password-code">{appPassword}</code>
40
+
<button type="button" class="copy-btn" onclick={copyPassword}>
41
+
{copied ? $_('common.copied') : $_('common.copyToClipboard')}
42
+
</button>
43
+
</div>
44
+
45
+
<label class="checkbox-label">
46
+
<input type="checkbox" bind:checked={acknowledged} />
47
+
<span>{$_('migration.inbound.appPassword.saved')}</span>
48
+
</label>
49
+
50
+
<div class="button-row">
51
+
<button onclick={onContinue} disabled={!acknowledged || loading}>
52
+
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
53
+
</button>
54
+
</div>
55
+
</div>
56
+
57
+
<style>
58
+
.app-password-display {
59
+
background: var(--bg-card);
60
+
border: 2px solid var(--accent);
61
+
border-radius: var(--radius-xl);
62
+
padding: var(--space-6);
63
+
text-align: center;
64
+
margin: var(--space-4) 0;
65
+
}
66
+
.app-password-label {
67
+
font-size: var(--text-sm);
68
+
color: var(--text-secondary);
69
+
margin-bottom: var(--space-4);
70
+
}
71
+
.app-password-code {
72
+
display: block;
73
+
font-size: var(--text-xl);
74
+
font-family: ui-monospace, monospace;
75
+
letter-spacing: 0.1em;
76
+
padding: var(--space-5);
77
+
background: var(--bg-input);
78
+
border-radius: var(--radius-md);
79
+
margin-bottom: var(--space-4);
80
+
user-select: all;
81
+
}
82
+
.copy-btn {
83
+
padding: var(--space-3) var(--space-5);
84
+
font-size: var(--text-sm);
85
+
}
86
+
</style>
+185
frontend/src/components/migration/ChooseHandleStep.svelte
+185
frontend/src/components/migration/ChooseHandleStep.svelte
···
···
1
+
<script lang="ts">
2
+
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
3
+
import { _ } from '../../lib/i18n'
4
+
5
+
interface Props {
6
+
handleInput: string
7
+
selectedDomain: string
8
+
handleAvailable: boolean | null
9
+
checkingHandle: boolean
10
+
email: string
11
+
password: string
12
+
authMethod: AuthMethod
13
+
inviteCode: string
14
+
serverInfo: ServerDescription | null
15
+
migratingFromLabel: string
16
+
migratingFromValue: string
17
+
loading?: boolean
18
+
onHandleChange: (handle: string) => void
19
+
onDomainChange: (domain: string) => void
20
+
onCheckHandle: () => void
21
+
onEmailChange: (email: string) => void
22
+
onPasswordChange: (password: string) => void
23
+
onAuthMethodChange: (method: AuthMethod) => void
24
+
onInviteCodeChange: (code: string) => void
25
+
onBack: () => void
26
+
onContinue: () => void
27
+
}
28
+
29
+
let {
30
+
handleInput,
31
+
selectedDomain,
32
+
handleAvailable,
33
+
checkingHandle,
34
+
email,
35
+
password,
36
+
authMethod,
37
+
inviteCode,
38
+
serverInfo,
39
+
migratingFromLabel,
40
+
migratingFromValue,
41
+
loading = false,
42
+
onHandleChange,
43
+
onDomainChange,
44
+
onCheckHandle,
45
+
onEmailChange,
46
+
onPasswordChange,
47
+
onAuthMethodChange,
48
+
onInviteCodeChange,
49
+
onBack,
50
+
onContinue,
51
+
}: Props = $props()
52
+
53
+
const canContinue = $derived(
54
+
handleInput.trim() &&
55
+
email &&
56
+
(authMethod === 'passkey' || password) &&
57
+
handleAvailable !== false
58
+
)
59
+
</script>
60
+
61
+
<div class="step-content">
62
+
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
63
+
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
64
+
65
+
<div class="current-info">
66
+
<span class="label">{migratingFromLabel}:</span>
67
+
<span class="value">{migratingFromValue}</span>
68
+
</div>
69
+
70
+
<div class="field">
71
+
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
72
+
<div class="handle-input-group">
73
+
<input
74
+
id="new-handle"
75
+
type="text"
76
+
placeholder="username"
77
+
value={handleInput}
78
+
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
79
+
onblur={onCheckHandle}
80
+
/>
81
+
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
82
+
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
83
+
{#each serverInfo.availableUserDomains as domain}
84
+
<option value={domain}>.{domain}</option>
85
+
{/each}
86
+
</select>
87
+
{/if}
88
+
</div>
89
+
90
+
{#if checkingHandle}
91
+
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
92
+
{:else if handleAvailable === true}
93
+
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
94
+
{:else if handleAvailable === false}
95
+
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
96
+
{:else}
97
+
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
98
+
{/if}
99
+
</div>
100
+
101
+
<div class="field">
102
+
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
103
+
<input
104
+
id="email"
105
+
type="email"
106
+
placeholder="you@example.com"
107
+
value={email}
108
+
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
109
+
required
110
+
/>
111
+
</div>
112
+
113
+
<div class="field">
114
+
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
115
+
<div class="auth-method-options">
116
+
<label class="auth-option" class:selected={authMethod === 'password'}>
117
+
<input
118
+
type="radio"
119
+
name="auth-method"
120
+
value="password"
121
+
checked={authMethod === 'password'}
122
+
onchange={() => onAuthMethodChange('password')}
123
+
/>
124
+
<div class="auth-option-content">
125
+
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
126
+
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
127
+
</div>
128
+
</label>
129
+
<label class="auth-option" class:selected={authMethod === 'passkey'}>
130
+
<input
131
+
type="radio"
132
+
name="auth-method"
133
+
value="passkey"
134
+
checked={authMethod === 'passkey'}
135
+
onchange={() => onAuthMethodChange('passkey')}
136
+
/>
137
+
<div class="auth-option-content">
138
+
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
139
+
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
140
+
</div>
141
+
</label>
142
+
</div>
143
+
</div>
144
+
145
+
{#if authMethod === 'password'}
146
+
<div class="field">
147
+
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
148
+
<input
149
+
id="new-password"
150
+
type="password"
151
+
placeholder="Password for your new account"
152
+
value={password}
153
+
oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)}
154
+
required
155
+
minlength={8}
156
+
/>
157
+
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
158
+
</div>
159
+
{:else}
160
+
<div class="info-box">
161
+
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
162
+
</div>
163
+
{/if}
164
+
165
+
{#if serverInfo?.inviteCodeRequired}
166
+
<div class="field">
167
+
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
168
+
<input
169
+
id="invite"
170
+
type="text"
171
+
placeholder="Enter invite code"
172
+
value={inviteCode}
173
+
oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)}
174
+
required
175
+
/>
176
+
</div>
177
+
{/if}
178
+
179
+
<div class="button-row">
180
+
<button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
181
+
<button disabled={!canContinue || loading} onclick={onContinue}>
182
+
{$_('migration.inbound.common.continue')}
183
+
</button>
184
+
</div>
185
+
</div>
+64
frontend/src/components/migration/EmailVerifyStep.svelte
+64
frontend/src/components/migration/EmailVerifyStep.svelte
···
···
1
+
<script lang="ts">
2
+
import { _ } from '../../lib/i18n'
3
+
4
+
interface Props {
5
+
email: string
6
+
token: string
7
+
loading: boolean
8
+
error: string | null
9
+
onTokenChange: (token: string) => void
10
+
onSubmit: (e: Event) => void
11
+
onResend: () => void
12
+
}
13
+
14
+
let {
15
+
email,
16
+
token,
17
+
loading,
18
+
error,
19
+
onTokenChange,
20
+
onSubmit,
21
+
onResend,
22
+
}: Props = $props()
23
+
</script>
24
+
25
+
<div class="step-content">
26
+
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
27
+
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p>
28
+
29
+
<div class="info-box">
30
+
<p>
31
+
{$_('migration.inbound.emailVerify.hint')}
32
+
</p>
33
+
</div>
34
+
35
+
{#if error}
36
+
<div class="message error">
37
+
{error}
38
+
</div>
39
+
{/if}
40
+
41
+
<form onsubmit={onSubmit}>
42
+
<div class="field">
43
+
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
44
+
<input
45
+
id="email-verify-token"
46
+
type="text"
47
+
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
48
+
value={token}
49
+
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
50
+
disabled={loading}
51
+
required
52
+
/>
53
+
</div>
54
+
55
+
<div class="button-row">
56
+
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
57
+
{$_('migration.inbound.emailVerify.resend')}
58
+
</button>
59
+
<button type="submit" disabled={loading || !token}>
60
+
{loading ? $_('common.verifying') : $_('common.verify')}
61
+
</button>
62
+
</div>
63
+
</form>
64
+
</div>
+23
frontend/src/components/migration/ErrorStep.svelte
+23
frontend/src/components/migration/ErrorStep.svelte
···
···
1
+
<script lang="ts">
2
+
import { _ } from '../../lib/i18n'
3
+
4
+
interface Props {
5
+
error: string | null
6
+
onStartOver: () => void
7
+
}
8
+
9
+
let { error, onStartOver }: Props = $props()
10
+
</script>
11
+
12
+
<div class="step-content">
13
+
<h2>{$_('migration.inbound.error.title')}</h2>
14
+
<p>{$_('migration.inbound.error.desc')}</p>
15
+
16
+
<div class="message error">
17
+
{error || $_('migration.inbound.error.unknown')}
18
+
</div>
19
+
20
+
<div class="button-row">
21
+
<button class="ghost" onclick={onStartOver}>{$_('migration.inbound.error.startOver')}</button>
22
+
</div>
23
+
</div>
+64
-306
frontend/src/components/migration/InboundWizard.svelte
+64
-306
frontend/src/components/migration/InboundWizard.svelte
···
5
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
import { _ } from '../../lib/i18n'
7
import '../../styles/migration.css'
8
9
interface ResumeInfo {
10
-
direction: 'inbound' | 'outbound'
11
sourceHandle: string
12
targetHandle: string
13
sourcePdsUrl: string
···
37
let checkingHandle = $state(false)
38
let selectedAuthMethod = $state<AuthMethod>('password')
39
let passkeyName = $state('')
40
-
let appPasswordCopied = $state(false)
41
-
let appPasswordAcknowledged = $state(false)
42
43
const isResuming = $derived(flow.state.needsReauth === true)
44
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
···
234
}
235
}
236
237
-
function copyAppPassword() {
238
-
if (flow.state.generatedAppPassword) {
239
-
navigator.clipboard.writeText(flow.state.generatedAppPassword)
240
-
appPasswordCopied = true
241
-
}
242
-
}
243
-
244
async function handleProceedFromAppPassword() {
245
loading = true
246
try {
···
352
</label>
353
354
<div class="button-row">
355
-
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
356
-
<button disabled={!understood} onclick={() => flow.setStep('source-handle')}>
357
{$_('migration.inbound.common.continue')}
358
</button>
359
</div>
···
409
</div>
410
411
{:else if flow.state.step === 'choose-handle'}
412
-
<div class="step-content">
413
-
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
414
-
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
415
-
416
-
<div class="current-info">
417
-
<span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span>
418
-
<span class="value">{flow.state.sourceHandle}</span>
419
-
</div>
420
-
421
-
<div class="field">
422
-
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
423
-
<div class="handle-input-group">
424
-
<input
425
-
id="new-handle"
426
-
type="text"
427
-
placeholder="username"
428
-
bind:value={handleInput}
429
-
onblur={checkHandle}
430
-
/>
431
-
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
432
-
<select bind:value={selectedDomain}>
433
-
{#each serverInfo.availableUserDomains as domain}
434
-
<option value={domain}>.{domain}</option>
435
-
{/each}
436
-
</select>
437
-
{/if}
438
-
</div>
439
-
440
-
{#if checkingHandle}
441
-
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
442
-
{:else if handleAvailable === true}
443
-
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
444
-
{:else if handleAvailable === false}
445
-
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
446
-
{:else}
447
-
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
448
-
{/if}
449
-
</div>
450
-
451
-
<div class="field">
452
-
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
453
-
<input
454
-
id="email"
455
-
type="email"
456
-
placeholder="you@example.com"
457
-
bind:value={flow.state.targetEmail}
458
-
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
459
-
required
460
-
/>
461
-
</div>
462
-
463
-
<div class="field">
464
-
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
465
-
<div class="auth-method-options">
466
-
<label class="auth-option" class:selected={selectedAuthMethod === 'password'}>
467
-
<input
468
-
type="radio"
469
-
name="auth-method"
470
-
value="password"
471
-
bind:group={selectedAuthMethod}
472
-
/>
473
-
<div class="auth-option-content">
474
-
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
475
-
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
476
-
</div>
477
-
</label>
478
-
<label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}>
479
-
<input
480
-
type="radio"
481
-
name="auth-method"
482
-
value="passkey"
483
-
bind:group={selectedAuthMethod}
484
-
/>
485
-
<div class="auth-option-content">
486
-
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
487
-
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
488
-
</div>
489
-
</label>
490
-
</div>
491
-
</div>
492
-
493
-
{#if selectedAuthMethod === 'password'}
494
-
<div class="field">
495
-
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
496
-
<input
497
-
id="new-password"
498
-
type="password"
499
-
placeholder="Password for your new account"
500
-
bind:value={flow.state.targetPassword}
501
-
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
502
-
required
503
-
minlength="8"
504
-
/>
505
-
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
506
-
</div>
507
-
{:else}
508
-
<div class="info-box">
509
-
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
510
-
</div>
511
-
{/if}
512
-
513
-
{#if serverInfo?.inviteCodeRequired}
514
-
<div class="field">
515
-
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
516
-
<input
517
-
id="invite"
518
-
type="text"
519
-
placeholder="Enter invite code"
520
-
bind:value={flow.state.inviteCode}
521
-
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
522
-
required
523
-
/>
524
-
</div>
525
-
{/if}
526
-
527
-
<div class="button-row">
528
-
<button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button>
529
-
<button
530
-
disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false}
531
-
onclick={proceedToReviewWithAuth}
532
-
>
533
-
{$_('migration.inbound.common.continue')}
534
-
</button>
535
-
</div>
536
-
</div>
537
538
{:else if flow.state.step === 'review'}
539
<div class="step-content">
···
620
</div>
621
622
{:else if flow.state.step === 'passkey-setup'}
623
-
<div class="step-content">
624
-
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
625
-
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
626
-
627
-
{#if flow.state.error}
628
-
<div class="message error">
629
-
{flow.state.error}
630
-
</div>
631
-
{/if}
632
-
633
-
<div class="field">
634
-
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
635
-
<input
636
-
id="passkey-name"
637
-
type="text"
638
-
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
639
-
bind:value={passkeyName}
640
-
disabled={loading}
641
-
/>
642
-
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
643
-
</div>
644
-
645
-
<div class="passkey-section">
646
-
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
647
-
<button class="primary" onclick={registerPasskey} disabled={loading}>
648
-
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
649
-
</button>
650
-
</div>
651
-
</div>
652
653
{:else if flow.state.step === 'app-password'}
654
-
<div class="step-content">
655
-
<h2>{$_('migration.inbound.appPassword.title')}</h2>
656
-
<p>{$_('migration.inbound.appPassword.desc')}</p>
657
-
658
-
<div class="warning-box">
659
-
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
660
-
</div>
661
-
662
-
<div class="app-password-display">
663
-
<div class="app-password-label">
664
-
{$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong>
665
-
</div>
666
-
<code class="app-password-code">{flow.state.generatedAppPassword}</code>
667
-
<button type="button" class="copy-btn" onclick={copyAppPassword}>
668
-
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
669
-
</button>
670
-
</div>
671
-
672
-
<label class="checkbox-label">
673
-
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
674
-
<span>{$_('migration.inbound.appPassword.saved')}</span>
675
-
</label>
676
-
677
-
<div class="button-row">
678
-
<button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}>
679
-
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
680
-
</button>
681
-
</div>
682
-
</div>
683
684
{:else if flow.state.step === 'email-verify'}
685
-
<div class="step-content">
686
-
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
687
-
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p>
688
-
689
-
<div class="info-box">
690
-
<p>
691
-
{$_('migration.inbound.emailVerify.hint')}
692
-
</p>
693
-
</div>
694
-
695
-
{#if flow.state.error}
696
-
<div class="message error">
697
-
{flow.state.error}
698
-
</div>
699
-
{/if}
700
-
701
-
<form onsubmit={submitEmailVerify}>
702
-
<div class="field">
703
-
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
704
-
<input
705
-
id="email-verify-token"
706
-
type="text"
707
-
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
708
-
bind:value={flow.state.emailVerifyToken}
709
-
oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)}
710
-
disabled={loading}
711
-
required
712
-
/>
713
-
</div>
714
-
715
-
<div class="button-row">
716
-
<button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}>
717
-
{$_('migration.inbound.emailVerify.resend')}
718
-
</button>
719
-
<button type="submit" disabled={loading || !flow.state.emailVerifyToken}>
720
-
{loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')}
721
-
</button>
722
-
</div>
723
-
</form>
724
-
</div>
725
726
{:else if flow.state.step === 'plc-token'}
727
<div class="step-content">
···
837
</div>
838
839
{:else if flow.state.step === 'success'}
840
-
<div class="step-content success-content">
841
-
<div class="success-icon">✓</div>
842
-
<h2>{$_('migration.inbound.success.title')}</h2>
843
-
<p>{$_('migration.inbound.success.desc')}</p>
844
-
845
-
<div class="success-details">
846
-
<div class="detail-row">
847
-
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
848
-
<span class="value">{flow.state.targetHandle}</span>
849
-
</div>
850
-
<div class="detail-row">
851
-
<span class="label">{$_('migration.inbound.success.did')}:</span>
852
-
<span class="value mono">{flow.state.sourceDid}</span>
853
-
</div>
854
-
</div>
855
-
856
-
{#if flow.state.progress.blobsFailed.length > 0}
857
-
<div class="message warning">
858
-
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
859
-
</div>
860
-
{/if}
861
-
862
-
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
863
-
</div>
864
865
{:else if flow.state.step === 'error'}
866
-
<div class="step-content">
867
-
<h2>{$_('migration.inbound.error.title')}</h2>
868
-
<p>{$_('migration.inbound.error.desc')}</p>
869
-
870
-
<div class="message error">
871
-
{flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
872
-
</div>
873
-
874
-
<div class="button-row">
875
-
<button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button>
876
-
</div>
877
-
</div>
878
{/if}
879
</div>
880
881
<style>
882
-
.passkey-section {
883
-
margin-top: 16px;
884
-
}
885
-
.passkey-section button {
886
-
width: 100%;
887
-
margin-top: 12px;
888
-
}
889
-
.app-password-display {
890
-
background: var(--bg-card);
891
-
border: 2px solid var(--accent);
892
-
border-radius: var(--radius-xl);
893
-
padding: var(--space-6);
894
-
text-align: center;
895
-
margin: var(--space-4) 0;
896
-
}
897
-
.app-password-label {
898
-
font-size: var(--text-sm);
899
-
color: var(--text-secondary);
900
-
margin-bottom: var(--space-4);
901
-
}
902
-
.app-password-code {
903
-
display: block;
904
-
font-size: var(--text-xl);
905
-
font-family: ui-monospace, monospace;
906
-
letter-spacing: 0.1em;
907
-
padding: var(--space-5);
908
-
background: var(--bg-input);
909
-
border-radius: var(--radius-md);
910
-
margin-bottom: var(--space-4);
911
-
user-select: all;
912
-
}
913
-
.copy-btn {
914
-
padding: var(--space-3) var(--space-5);
915
-
font-size: var(--text-sm);
916
-
}
917
.resume-info {
918
margin-bottom: var(--space-5);
919
}
···
5
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
import { _ } from '../../lib/i18n'
7
import '../../styles/migration.css'
8
+
import ErrorStep from './ErrorStep.svelte'
9
+
import SuccessStep from './SuccessStep.svelte'
10
+
import ChooseHandleStep from './ChooseHandleStep.svelte'
11
+
import EmailVerifyStep from './EmailVerifyStep.svelte'
12
+
import PasskeySetupStep from './PasskeySetupStep.svelte'
13
+
import AppPasswordStep from './AppPasswordStep.svelte'
14
15
interface ResumeInfo {
16
+
direction: 'inbound'
17
sourceHandle: string
18
targetHandle: string
19
sourcePdsUrl: string
···
43
let checkingHandle = $state(false)
44
let selectedAuthMethod = $state<AuthMethod>('password')
45
let passkeyName = $state('')
46
47
const isResuming = $derived(flow.state.needsReauth === true)
48
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
···
238
}
239
}
240
241
async function handleProceedFromAppPassword() {
242
loading = true
243
try {
···
349
</label>
350
351
<div class="button-row">
352
+
<button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
353
+
<button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}>
354
{$_('migration.inbound.common.continue')}
355
</button>
356
</div>
···
406
</div>
407
408
{:else if flow.state.step === 'choose-handle'}
409
+
<ChooseHandleStep
410
+
{handleInput}
411
+
{selectedDomain}
412
+
{handleAvailable}
413
+
{checkingHandle}
414
+
email={flow.state.targetEmail}
415
+
password={flow.state.targetPassword}
416
+
authMethod={selectedAuthMethod}
417
+
inviteCode={flow.state.inviteCode}
418
+
{serverInfo}
419
+
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
420
+
migratingFromValue={flow.state.sourceHandle}
421
+
{loading}
422
+
onHandleChange={(h) => handleInput = h}
423
+
onDomainChange={(d) => selectedDomain = d}
424
+
onCheckHandle={checkHandle}
425
+
onEmailChange={(e) => flow.updateField('targetEmail', e)}
426
+
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
427
+
onAuthMethodChange={(m) => selectedAuthMethod = m}
428
+
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
429
+
onBack={() => flow.setStep('source-handle')}
430
+
onContinue={proceedToReviewWithAuth}
431
+
/>
432
433
{:else if flow.state.step === 'review'}
434
<div class="step-content">
···
515
</div>
516
517
{:else if flow.state.step === 'passkey-setup'}
518
+
<PasskeySetupStep
519
+
{passkeyName}
520
+
{loading}
521
+
error={flow.state.error}
522
+
onPasskeyNameChange={(n) => passkeyName = n}
523
+
onRegister={registerPasskey}
524
+
/>
525
526
{:else if flow.state.step === 'app-password'}
527
+
<AppPasswordStep
528
+
appPassword={flow.state.generatedAppPassword || ''}
529
+
appPasswordName={flow.state.generatedAppPasswordName || ''}
530
+
{loading}
531
+
onContinue={handleProceedFromAppPassword}
532
+
/>
533
534
{:else if flow.state.step === 'email-verify'}
535
+
<EmailVerifyStep
536
+
email={flow.state.targetEmail}
537
+
token={flow.state.emailVerifyToken}
538
+
{loading}
539
+
error={flow.state.error}
540
+
onTokenChange={(t) => flow.updateField('emailVerifyToken', t)}
541
+
onSubmit={submitEmailVerify}
542
+
onResend={resendEmailVerify}
543
+
/>
544
545
{:else if flow.state.step === 'plc-token'}
546
<div class="step-content">
···
656
</div>
657
658
{:else if flow.state.step === 'success'}
659
+
<SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}>
660
+
{#snippet extraContent()}
661
+
{#if flow.state.progress.blobsFailed.length > 0}
662
+
<div class="message warning">
663
+
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
664
+
</div>
665
+
{/if}
666
+
{/snippet}
667
+
</SuccessStep>
668
669
{:else if flow.state.step === 'error'}
670
+
<ErrorStep error={flow.state.error} onStartOver={onBack} />
671
{/if}
672
</div>
673
674
<style>
675
.resume-info {
676
margin-bottom: var(--space-5);
677
}
+591
frontend/src/components/migration/OfflineInboundWizard.svelte
+591
frontend/src/components/migration/OfflineInboundWizard.svelte
···
···
1
+
<script lang="ts">
2
+
import type { OfflineInboundMigrationFlow } from '../../lib/migration'
3
+
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
4
+
import { getErrorMessage } from '../../lib/migration/types'
5
+
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
+
import { _ } from '../../lib/i18n'
7
+
import '../../styles/migration.css'
8
+
import ErrorStep from './ErrorStep.svelte'
9
+
import SuccessStep from './SuccessStep.svelte'
10
+
import ChooseHandleStep from './ChooseHandleStep.svelte'
11
+
import EmailVerifyStep from './EmailVerifyStep.svelte'
12
+
import PasskeySetupStep from './PasskeySetupStep.svelte'
13
+
import AppPasswordStep from './AppPasswordStep.svelte'
14
+
15
+
interface Props {
16
+
flow: OfflineInboundMigrationFlow
17
+
onBack: () => void
18
+
onComplete: () => void
19
+
}
20
+
21
+
let { flow, onBack, onComplete }: Props = $props()
22
+
23
+
let serverInfo = $state<ServerDescription | null>(null)
24
+
let loading = $state(false)
25
+
let understood = $state(false)
26
+
let handleInput = $state('')
27
+
let selectedDomain = $state('')
28
+
let handleAvailable = $state<boolean | null>(null)
29
+
let checkingHandle = $state(false)
30
+
let validatingKey = $state(false)
31
+
let keyValid = $state<boolean | null>(null)
32
+
let fileInputRef = $state<HTMLInputElement | null>(null)
33
+
let selectedAuthMethod = $state<AuthMethod>('password')
34
+
let passkeyName = $state('')
35
+
36
+
let redirectTriggered = $state(false)
37
+
38
+
$effect(() => {
39
+
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
40
+
loadServerInfo()
41
+
}
42
+
if (flow.state.step === 'choose-handle') {
43
+
handleInput = ''
44
+
handleAvailable = null
45
+
}
46
+
})
47
+
48
+
$effect(() => {
49
+
if (flow.state.step === 'success' && !redirectTriggered) {
50
+
redirectTriggered = true
51
+
setTimeout(() => {
52
+
onComplete()
53
+
}, 2000)
54
+
}
55
+
})
56
+
57
+
$effect(() => {
58
+
if (flow.state.step === 'email-verify') {
59
+
const interval = setInterval(async () => {
60
+
if (flow.state.emailVerifyToken.trim()) return
61
+
await flow.checkEmailVerifiedAndProceed()
62
+
}, 3000)
63
+
return () => clearInterval(interval)
64
+
}
65
+
})
66
+
67
+
async function loadServerInfo() {
68
+
if (!serverInfo) {
69
+
serverInfo = await flow.loadLocalServerInfo()
70
+
if (serverInfo.availableUserDomains.length > 0) {
71
+
selectedDomain = serverInfo.availableUserDomains[0]
72
+
}
73
+
}
74
+
}
75
+
76
+
function handleFileSelect(e: Event) {
77
+
const input = e.target as HTMLInputElement
78
+
const file = input.files?.[0]
79
+
if (!file) return
80
+
81
+
const reader = new FileReader()
82
+
reader.onload = () => {
83
+
const arrayBuffer = reader.result as ArrayBuffer
84
+
flow.setCarFile(new Uint8Array(arrayBuffer), file.name)
85
+
}
86
+
reader.readAsArrayBuffer(file)
87
+
}
88
+
89
+
async function validateRotationKey() {
90
+
if (!flow.state.rotationKey || !flow.state.userDid) return
91
+
92
+
validatingKey = true
93
+
keyValid = null
94
+
95
+
try {
96
+
const isValid = await flow.validateRotationKey()
97
+
keyValid = isValid
98
+
if (isValid) {
99
+
flow.setStep('choose-handle')
100
+
}
101
+
} catch (err) {
102
+
flow.setError(getErrorMessage(err))
103
+
keyValid = false
104
+
} finally {
105
+
validatingKey = false
106
+
}
107
+
}
108
+
109
+
async function startMigration() {
110
+
loading = true
111
+
try {
112
+
await flow.runMigration()
113
+
} catch (err) {
114
+
flow.setError(getErrorMessage(err))
115
+
} finally {
116
+
loading = false
117
+
}
118
+
}
119
+
120
+
const steps = $derived(
121
+
flow.state.authMethod === 'passkey'
122
+
? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete']
123
+
: ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete']
124
+
)
125
+
126
+
function getCurrentStepIndex(): number {
127
+
const isPasskey = flow.state.authMethod === 'passkey'
128
+
switch (flow.state.step) {
129
+
case 'welcome': return 0
130
+
case 'provide-did': return 0
131
+
case 'upload-car': return 1
132
+
case 'provide-rotation-key': return 2
133
+
case 'choose-handle': return 3
134
+
case 'review': return 4
135
+
case 'creating':
136
+
case 'importing': return 5
137
+
case 'migrating-blobs': return 6
138
+
case 'email-verify': return 7
139
+
case 'passkey-setup': return isPasskey ? 8 : 7
140
+
case 'app-password': return 9
141
+
case 'plc-signing':
142
+
case 'finalizing': return isPasskey ? 10 : 8
143
+
case 'success': return isPasskey ? 10 : 8
144
+
default: return 0
145
+
}
146
+
}
147
+
148
+
async function checkHandle() {
149
+
if (!handleInput.trim()) return
150
+
151
+
const fullHandle = handleInput.includes('.')
152
+
? handleInput
153
+
: `${handleInput}.${selectedDomain}`
154
+
155
+
checkingHandle = true
156
+
handleAvailable = null
157
+
158
+
try {
159
+
handleAvailable = await flow.checkHandleAvailability(fullHandle)
160
+
} catch {
161
+
handleAvailable = true
162
+
} finally {
163
+
checkingHandle = false
164
+
}
165
+
}
166
+
167
+
function proceedToReview() {
168
+
const fullHandle = handleInput.includes('.')
169
+
? handleInput
170
+
: `${handleInput}.${selectedDomain}`
171
+
172
+
flow.setTargetHandle(fullHandle)
173
+
flow.setAuthMethod(selectedAuthMethod)
174
+
flow.setStep('review')
175
+
}
176
+
177
+
async function submitEmailVerify(e: Event) {
178
+
e.preventDefault()
179
+
loading = true
180
+
try {
181
+
await flow.submitEmailVerifyToken(flow.state.emailVerifyToken)
182
+
} catch (err) {
183
+
flow.setError(getErrorMessage(err))
184
+
} finally {
185
+
loading = false
186
+
}
187
+
}
188
+
189
+
async function resendEmailVerify() {
190
+
loading = true
191
+
try {
192
+
await flow.resendEmailVerification()
193
+
flow.setError(null)
194
+
} catch (err) {
195
+
flow.setError(getErrorMessage(err))
196
+
} finally {
197
+
loading = false
198
+
}
199
+
}
200
+
201
+
async function registerPasskey() {
202
+
loading = true
203
+
flow.setError(null)
204
+
205
+
try {
206
+
if (!window.PublicKeyCredential) {
207
+
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
208
+
}
209
+
210
+
await flow.registerPasskey(passkeyName || undefined)
211
+
} catch (err) {
212
+
const message = getErrorMessage(err)
213
+
if (message.includes('cancelled') || message.includes('AbortError')) {
214
+
flow.setError('Passkey registration was cancelled. Please try again.')
215
+
} else {
216
+
flow.setError(message)
217
+
}
218
+
} finally {
219
+
loading = false
220
+
}
221
+
}
222
+
223
+
async function handleProceedFromAppPassword() {
224
+
loading = true
225
+
try {
226
+
await flow.proceedFromAppPassword()
227
+
} catch (err) {
228
+
flow.setError(getErrorMessage(err))
229
+
} finally {
230
+
loading = false
231
+
}
232
+
}
233
+
</script>
234
+
235
+
<div class="migration-wizard">
236
+
<div class="step-indicator">
237
+
{#each steps as _, i}
238
+
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
239
+
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
240
+
</div>
241
+
{#if i < steps.length - 1}
242
+
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
243
+
{/if}
244
+
{/each}
245
+
</div>
246
+
<div class="current-step-label">
247
+
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
248
+
</div>
249
+
250
+
{#if flow.state.error}
251
+
<div class="message error">{flow.state.error}</div>
252
+
{/if}
253
+
254
+
{#if flow.state.step === 'welcome'}
255
+
<div class="step-content">
256
+
<h2>{$_('migration.offline.welcome.title')}</h2>
257
+
<p>{$_('migration.offline.welcome.desc')}</p>
258
+
259
+
<div class="warning-box">
260
+
<strong>{$_('migration.offline.welcome.warningTitle')}</strong>
261
+
<p>{$_('migration.offline.welcome.warningDesc')}</p>
262
+
</div>
263
+
264
+
<div class="info-box">
265
+
<h3>{$_('migration.offline.welcome.requirementsTitle')}</h3>
266
+
<ul>
267
+
<li>{$_('migration.offline.welcome.requirement1')}</li>
268
+
<li>{$_('migration.offline.welcome.requirement2')}</li>
269
+
<li>{$_('migration.offline.welcome.requirement3')}</li>
270
+
</ul>
271
+
</div>
272
+
273
+
<label class="checkbox-label">
274
+
<input type="checkbox" bind:checked={understood} />
275
+
<span>{$_('migration.offline.welcome.understand')}</span>
276
+
</label>
277
+
278
+
<div class="button-row">
279
+
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
280
+
<button disabled={!understood} onclick={() => flow.setStep('provide-did')}>
281
+
{$_('migration.inbound.common.continue')}
282
+
</button>
283
+
</div>
284
+
</div>
285
+
286
+
{:else if flow.state.step === 'provide-did'}
287
+
<div class="step-content">
288
+
<h2>{$_('migration.offline.provideDid.title')}</h2>
289
+
<p>{$_('migration.offline.provideDid.desc')}</p>
290
+
291
+
<div class="field">
292
+
<label for="user-did">{$_('migration.offline.provideDid.label')}</label>
293
+
<input
294
+
id="user-did"
295
+
type="text"
296
+
placeholder="did:plc:abc123..."
297
+
value={flow.state.userDid}
298
+
oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)}
299
+
/>
300
+
<p class="hint">{$_('migration.offline.provideDid.hint')}</p>
301
+
</div>
302
+
303
+
<div class="button-row">
304
+
<button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button>
305
+
<button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}>
306
+
{$_('migration.inbound.common.continue')}
307
+
</button>
308
+
</div>
309
+
</div>
310
+
311
+
{:else if flow.state.step === 'upload-car'}
312
+
<div class="step-content">
313
+
<h2>{$_('migration.offline.uploadCar.title')}</h2>
314
+
<p>{$_('migration.offline.uploadCar.desc')}</p>
315
+
316
+
{#if flow.state.carNeedsReupload}
317
+
<div class="warning-box">
318
+
<strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong>
319
+
<p>{$_('migration.offline.uploadCar.reuploadWarning')}</p>
320
+
{#if flow.state.carFileName}
321
+
<p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p>
322
+
{/if}
323
+
</div>
324
+
{/if}
325
+
326
+
<div class="field">
327
+
<label for="car-file">{$_('migration.offline.uploadCar.label')}</label>
328
+
<div class="file-input-container">
329
+
<input
330
+
id="car-file"
331
+
type="file"
332
+
accept=".car"
333
+
onchange={handleFileSelect}
334
+
bind:this={fileInputRef}
335
+
/>
336
+
{#if flow.state.carFile && flow.state.carFileName}
337
+
<div class="file-info">
338
+
<span class="file-name">{flow.state.carFileName}</span>
339
+
<span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
340
+
</div>
341
+
{/if}
342
+
</div>
343
+
<p class="hint">{$_('migration.offline.uploadCar.hint')}</p>
344
+
</div>
345
+
346
+
<div class="button-row">
347
+
<button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button>
348
+
<button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}>
349
+
{$_('migration.inbound.common.continue')}
350
+
</button>
351
+
</div>
352
+
</div>
353
+
354
+
{:else if flow.state.step === 'provide-rotation-key'}
355
+
<div class="step-content">
356
+
<h2>{$_('migration.offline.rotationKey.title')}</h2>
357
+
<p>{$_('migration.offline.rotationKey.desc')}</p>
358
+
359
+
<div class="warning-box">
360
+
<strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong>
361
+
<ul>
362
+
<li>{$_('migration.offline.rotationKey.securityWarning1')}</li>
363
+
<li>{$_('migration.offline.rotationKey.securityWarning2')}</li>
364
+
<li>{$_('migration.offline.rotationKey.securityWarning3')}</li>
365
+
</ul>
366
+
</div>
367
+
368
+
<div class="field">
369
+
<label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label>
370
+
<textarea
371
+
id="rotation-key"
372
+
rows={4}
373
+
placeholder={$_('migration.offline.rotationKey.placeholder')}
374
+
value={flow.state.rotationKey}
375
+
oninput={(e) => {
376
+
flow.setRotationKey((e.target as HTMLTextAreaElement).value)
377
+
keyValid = null
378
+
}}
379
+
></textarea>
380
+
<p class="hint">{$_('migration.offline.rotationKey.hint')}</p>
381
+
</div>
382
+
383
+
{#if keyValid === true}
384
+
<div class="message success">{$_('migration.offline.rotationKey.valid')}</div>
385
+
{:else if keyValid === false}
386
+
<div class="message error">{$_('migration.offline.rotationKey.invalid')}</div>
387
+
{/if}
388
+
389
+
<div class="button-row">
390
+
<button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button>
391
+
<button
392
+
disabled={!flow.state.rotationKey || validatingKey}
393
+
onclick={validateRotationKey}
394
+
>
395
+
{validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')}
396
+
</button>
397
+
</div>
398
+
</div>
399
+
400
+
{:else if flow.state.step === 'choose-handle'}
401
+
<ChooseHandleStep
402
+
{handleInput}
403
+
{selectedDomain}
404
+
{handleAvailable}
405
+
{checkingHandle}
406
+
email={flow.state.targetEmail}
407
+
password={flow.state.targetPassword}
408
+
authMethod={selectedAuthMethod}
409
+
inviteCode={flow.state.inviteCode}
410
+
{serverInfo}
411
+
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
412
+
migratingFromValue={flow.state.userDid}
413
+
{loading}
414
+
onHandleChange={(h) => handleInput = h}
415
+
onDomainChange={(d) => selectedDomain = d}
416
+
onCheckHandle={checkHandle}
417
+
onEmailChange={(e) => flow.setTargetEmail(e)}
418
+
onPasswordChange={(p) => flow.setTargetPassword(p)}
419
+
onAuthMethodChange={(m) => selectedAuthMethod = m}
420
+
onInviteCodeChange={(c) => flow.setInviteCode(c)}
421
+
onBack={() => flow.setStep('provide-rotation-key')}
422
+
onContinue={proceedToReview}
423
+
/>
424
+
425
+
{:else if flow.state.step === 'review'}
426
+
<div class="step-content">
427
+
<h2>{$_('migration.inbound.review.title')}</h2>
428
+
<p>{$_('migration.offline.review.desc')}</p>
429
+
430
+
<div class="review-card">
431
+
<div class="review-row">
432
+
<span class="label">{$_('migration.inbound.review.did')}:</span>
433
+
<span class="value mono">{flow.state.userDid}</span>
434
+
</div>
435
+
<div class="review-row">
436
+
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
437
+
<span class="value">{flow.state.targetHandle}</span>
438
+
</div>
439
+
<div class="review-row">
440
+
<span class="label">{$_('migration.offline.review.carFile')}:</span>
441
+
<span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
442
+
</div>
443
+
<div class="review-row">
444
+
<span class="label">{$_('migration.offline.review.rotationKey')}:</span>
445
+
<span class="value mono">{flow.state.rotationKeyDidKey}</span>
446
+
</div>
447
+
<div class="review-row">
448
+
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
449
+
<span class="value">{window.location.origin}</span>
450
+
</div>
451
+
<div class="review-row">
452
+
<span class="label">{$_('migration.inbound.review.email')}:</span>
453
+
<span class="value">{flow.state.targetEmail}</span>
454
+
</div>
455
+
<div class="review-row">
456
+
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
457
+
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
458
+
</div>
459
+
</div>
460
+
461
+
<div class="warning-box">
462
+
<strong>{$_('migration.offline.review.plcWarningTitle')}</strong>
463
+
<p>{$_('migration.offline.review.plcWarning')}</p>
464
+
</div>
465
+
466
+
<div class="button-row">
467
+
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
468
+
<button onclick={startMigration} disabled={loading}>
469
+
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
470
+
</button>
471
+
</div>
472
+
</div>
473
+
474
+
{:else if flow.state.step === 'creating' || flow.state.step === 'importing'}
475
+
<div class="step-content">
476
+
<h2>{$_('migration.offline.migrating.title')}</h2>
477
+
<p>{$_('migration.offline.migrating.desc')}</p>
478
+
479
+
<div class="progress-section">
480
+
<div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}>
481
+
<span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span>
482
+
<span>{$_('migration.offline.migrating.creating')}</span>
483
+
</div>
484
+
<div class="progress-item" class:active={flow.state.step === 'importing'}>
485
+
<span class="icon">○</span>
486
+
<span>{$_('migration.offline.migrating.importing')}</span>
487
+
</div>
488
+
</div>
489
+
490
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
491
+
</div>
492
+
493
+
{:else if flow.state.step === 'migrating-blobs'}
494
+
<div class="step-content">
495
+
<h2>{$_('migration.offline.blobs.title')}</h2>
496
+
<p>{$_('migration.offline.blobs.desc')}</p>
497
+
498
+
<div class="progress-section">
499
+
<div class="progress-item completed">
500
+
<span class="icon">✓</span>
501
+
<span>{$_('migration.offline.migrating.importing')}</span>
502
+
</div>
503
+
<div class="progress-item active">
504
+
<span class="icon">○</span>
505
+
<span>{$_('migration.offline.blobs.migrating')}</span>
506
+
</div>
507
+
</div>
508
+
509
+
{#if flow.state.progress.blobsTotal > 0}
510
+
<div class="blob-progress">
511
+
<div class="blob-progress-bar">
512
+
<div
513
+
class="blob-progress-fill"
514
+
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
515
+
></div>
516
+
</div>
517
+
<p class="blob-progress-text">
518
+
{flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs
519
+
</p>
520
+
</div>
521
+
{/if}
522
+
523
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
524
+
525
+
{#if flow.state.progress.blobsFailed.length > 0}
526
+
<div class="warning-box">
527
+
<strong>{$_('migration.offline.blobs.failedTitle')}</strong>
528
+
<p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p>
529
+
</div>
530
+
{/if}
531
+
</div>
532
+
533
+
{:else if flow.state.step === 'email-verify'}
534
+
<EmailVerifyStep
535
+
email={flow.state.targetEmail}
536
+
token={flow.state.emailVerifyToken}
537
+
{loading}
538
+
error={flow.state.error}
539
+
onTokenChange={(t) => flow.updateField('emailVerifyToken', t)}
540
+
onSubmit={submitEmailVerify}
541
+
onResend={resendEmailVerify}
542
+
/>
543
+
544
+
{:else if flow.state.step === 'passkey-setup'}
545
+
<PasskeySetupStep
546
+
{passkeyName}
547
+
{loading}
548
+
error={flow.state.error}
549
+
onPasskeyNameChange={(n) => passkeyName = n}
550
+
onRegister={registerPasskey}
551
+
/>
552
+
553
+
{:else if flow.state.step === 'app-password'}
554
+
<AppPasswordStep
555
+
appPassword={flow.state.generatedAppPassword || ''}
556
+
appPasswordName={flow.state.generatedAppPasswordName || ''}
557
+
{loading}
558
+
onContinue={handleProceedFromAppPassword}
559
+
/>
560
+
561
+
{:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'}
562
+
<div class="step-content">
563
+
<h2>{$_('migration.inbound.finalizing.title')}</h2>
564
+
<p>{$_('migration.inbound.finalizing.desc')}</p>
565
+
566
+
<div class="progress-section">
567
+
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
568
+
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
569
+
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
570
+
</div>
571
+
<div class="progress-item" class:completed={flow.state.progress.activated}>
572
+
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
573
+
<span>{$_('migration.inbound.finalizing.activating')}</span>
574
+
</div>
575
+
</div>
576
+
577
+
<p class="status-text">{flow.state.progress.currentOperation}</p>
578
+
</div>
579
+
580
+
{:else if flow.state.step === 'success'}
581
+
<SuccessStep
582
+
handle={flow.state.targetHandle}
583
+
did={flow.state.userDid}
584
+
description={$_('migration.offline.success.desc')}
585
+
/>
586
+
587
+
{:else if flow.state.step === 'error'}
588
+
<ErrorStep error={flow.state.error} onStartOver={onBack} />
589
+
{/if}
590
+
</div>
591
+
-546
frontend/src/components/migration/OutboundWizard.svelte
-546
frontend/src/components/migration/OutboundWizard.svelte
···
1
-
<script lang="ts">
2
-
import type { OutboundMigrationFlow } from '../../lib/migration'
3
-
import type { ServerDescription } from '../../lib/migration/types'
4
-
import { getAuthState, logout } from '../../lib/auth.svelte'
5
-
import '../../styles/migration.css'
6
-
7
-
interface Props {
8
-
flow: OutboundMigrationFlow
9
-
onBack: () => void
10
-
onComplete: () => void
11
-
}
12
-
13
-
let { flow, onBack, onComplete }: Props = $props()
14
-
15
-
const auth = getAuthState()
16
-
17
-
let loading = $state(false)
18
-
let understood = $state(false)
19
-
let pdsUrlInput = $state('')
20
-
let handleInput = $state('')
21
-
let selectedDomain = $state('')
22
-
let confirmFinal = $state(false)
23
-
24
-
$effect(() => {
25
-
if (flow.state.step === 'success') {
26
-
setTimeout(async () => {
27
-
await logout()
28
-
onComplete()
29
-
}, 3000)
30
-
}
31
-
})
32
-
33
-
$effect(() => {
34
-
if (flow.state.targetServerInfo?.availableUserDomains?.length) {
35
-
selectedDomain = flow.state.targetServerInfo.availableUserDomains[0]
36
-
}
37
-
})
38
-
39
-
async function validatePds(e: Event) {
40
-
e.preventDefault()
41
-
loading = true
42
-
flow.updateField('error', null)
43
-
44
-
try {
45
-
let url = pdsUrlInput.trim()
46
-
if (!url.startsWith('http://') && !url.startsWith('https://')) {
47
-
url = `https://${url}`
48
-
}
49
-
await flow.validateTargetPds(url)
50
-
flow.setStep('new-account')
51
-
} catch (err) {
52
-
flow.setError((err as Error).message)
53
-
} finally {
54
-
loading = false
55
-
}
56
-
}
57
-
58
-
function proceedToReview() {
59
-
const fullHandle = handleInput.includes('.')
60
-
? handleInput
61
-
: `${handleInput}.${selectedDomain}`
62
-
63
-
flow.updateField('targetHandle', fullHandle)
64
-
flow.setStep('review')
65
-
}
66
-
67
-
async function startMigration() {
68
-
if (!auth.session) return
69
-
loading = true
70
-
try {
71
-
await flow.startMigration(auth.session.did)
72
-
} catch (err) {
73
-
flow.setError((err as Error).message)
74
-
} finally {
75
-
loading = false
76
-
}
77
-
}
78
-
79
-
async function submitPlcToken(e: Event) {
80
-
e.preventDefault()
81
-
loading = true
82
-
try {
83
-
await flow.submitPlcToken(flow.state.plcToken)
84
-
} catch (err) {
85
-
flow.setError((err as Error).message)
86
-
} finally {
87
-
loading = false
88
-
}
89
-
}
90
-
91
-
async function resendToken() {
92
-
loading = true
93
-
try {
94
-
await flow.resendPlcToken()
95
-
flow.setError(null)
96
-
} catch (err) {
97
-
flow.setError((err as Error).message)
98
-
} finally {
99
-
loading = false
100
-
}
101
-
}
102
-
103
-
function isDidWeb(): boolean {
104
-
return auth.session?.did?.startsWith('did:web:') ?? false
105
-
}
106
-
107
-
const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete']
108
-
function getCurrentStepIndex(): number {
109
-
switch (flow.state.step) {
110
-
case 'welcome': return -1
111
-
case 'target-pds': return 0
112
-
case 'new-account': return 1
113
-
case 'review': return 2
114
-
case 'migrating': return 3
115
-
case 'plc-token':
116
-
case 'finalizing': return 4
117
-
case 'success': return 5
118
-
default: return 0
119
-
}
120
-
}
121
-
</script>
122
-
123
-
<div class="migration-wizard">
124
-
{#if flow.state.step !== 'welcome'}
125
-
<div class="step-indicator">
126
-
{#each steps as stepName, i}
127
-
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
128
-
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
129
-
<span class="step-label">{stepName}</span>
130
-
</div>
131
-
{#if i < steps.length - 1}
132
-
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
133
-
{/if}
134
-
{/each}
135
-
</div>
136
-
{/if}
137
-
138
-
{#if flow.state.error}
139
-
<div class="migration-message error">{flow.state.error}</div>
140
-
{/if}
141
-
142
-
{#if flow.state.step === 'welcome'}
143
-
<div class="step-content">
144
-
<h2>Migrate Your Account Away</h2>
145
-
<p>This wizard will help you move your AT Protocol account from this PDS to another one.</p>
146
-
147
-
<div class="current-account">
148
-
<span class="label">Current account:</span>
149
-
<span class="value">@{auth.session?.handle}</span>
150
-
</div>
151
-
152
-
{#if isDidWeb()}
153
-
<div class="migration-warning-box">
154
-
<strong>did:web Migration Notice</strong>
155
-
<p>
156
-
Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
157
-
continue serving your DID document with an updated service endpoint pointing to your new PDS.
158
-
</p>
159
-
<p>
160
-
You can return here anytime to update the forwarding if you migrate again in the future.
161
-
</p>
162
-
</div>
163
-
{/if}
164
-
165
-
<div class="migration-info-box">
166
-
<h3>What will happen:</h3>
167
-
<ol>
168
-
<li>Choose your new PDS</li>
169
-
<li>Set up your account on the new server</li>
170
-
<li>Your repository and blobs will be transferred</li>
171
-
<li>Verify the migration via email</li>
172
-
<li>Your identity will be updated to point to the new PDS</li>
173
-
<li>Your account here will be deactivated</li>
174
-
</ol>
175
-
</div>
176
-
177
-
<div class="migration-warning-box">
178
-
<strong>Before you proceed:</strong>
179
-
<ul>
180
-
<li>You need access to the email registered with this account</li>
181
-
<li>You will lose access to this account on this PDS</li>
182
-
<li>Make sure you trust the destination PDS</li>
183
-
<li>Large accounts may take several minutes to transfer</li>
184
-
</ul>
185
-
</div>
186
-
187
-
<label class="checkbox-label">
188
-
<input type="checkbox" bind:checked={understood} />
189
-
<span>I understand that my account will be moved and deactivated here</span>
190
-
</label>
191
-
192
-
<div class="button-row">
193
-
<button class="ghost" onclick={onBack}>Cancel</button>
194
-
<button disabled={!understood} onclick={() => flow.setStep('target-pds')}>
195
-
Continue
196
-
</button>
197
-
</div>
198
-
</div>
199
-
200
-
{:else if flow.state.step === 'target-pds'}
201
-
<div class="step-content">
202
-
<h2>Choose Your New PDS</h2>
203
-
<p>Enter the URL of the PDS you want to migrate to.</p>
204
-
205
-
<form onsubmit={validatePds}>
206
-
<div class="migration-field">
207
-
<label for="pds-url">PDS URL</label>
208
-
<input
209
-
id="pds-url"
210
-
type="text"
211
-
placeholder="pds.example.com"
212
-
bind:value={pdsUrlInput}
213
-
disabled={loading}
214
-
required
215
-
/>
216
-
<p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
217
-
</div>
218
-
219
-
<div class="button-row">
220
-
<button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button>
221
-
<button type="submit" disabled={loading || !pdsUrlInput.trim()}>
222
-
{loading ? 'Checking...' : 'Connect'}
223
-
</button>
224
-
</div>
225
-
</form>
226
-
227
-
{#if flow.state.targetServerInfo}
228
-
<div class="server-info">
229
-
<h3>Connected to PDS</h3>
230
-
<div class="info-row">
231
-
<span class="label">Server:</span>
232
-
<span class="value">{flow.state.targetPdsUrl}</span>
233
-
</div>
234
-
{#if flow.state.targetServerInfo.availableUserDomains.length > 0}
235
-
<div class="info-row">
236
-
<span class="label">Available domains:</span>
237
-
<span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span>
238
-
</div>
239
-
{/if}
240
-
<div class="info-row">
241
-
<span class="label">Invite required:</span>
242
-
<span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span>
243
-
</div>
244
-
{#if flow.state.targetServerInfo.links?.termsOfService}
245
-
<a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener">
246
-
Terms of Service
247
-
</a>
248
-
{/if}
249
-
{#if flow.state.targetServerInfo.links?.privacyPolicy}
250
-
<a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener">
251
-
Privacy Policy
252
-
</a>
253
-
{/if}
254
-
</div>
255
-
{/if}
256
-
</div>
257
-
258
-
{:else if flow.state.step === 'new-account'}
259
-
<div class="step-content">
260
-
<h2>Set Up Your New Account</h2>
261
-
<p>Configure your account details on the new PDS.</p>
262
-
263
-
<div class="current-info">
264
-
<span class="label">Migrating to:</span>
265
-
<span class="value">{flow.state.targetPdsUrl}</span>
266
-
</div>
267
-
268
-
<div class="migration-field">
269
-
<label for="new-handle">New Handle</label>
270
-
<div class="handle-input-group">
271
-
<input
272
-
id="new-handle"
273
-
type="text"
274
-
placeholder="username"
275
-
bind:value={handleInput}
276
-
/>
277
-
{#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
278
-
<select bind:value={selectedDomain}>
279
-
{#each flow.state.targetServerInfo.availableUserDomains as domain}
280
-
<option value={domain}>.{domain}</option>
281
-
{/each}
282
-
</select>
283
-
{/if}
284
-
</div>
285
-
<p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
286
-
</div>
287
-
288
-
<div class="migration-field">
289
-
<label for="email">Email Address</label>
290
-
<input
291
-
id="email"
292
-
type="email"
293
-
placeholder="you@example.com"
294
-
bind:value={flow.state.targetEmail}
295
-
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
296
-
required
297
-
/>
298
-
</div>
299
-
300
-
<div class="migration-field">
301
-
<label for="new-password">Password</label>
302
-
<input
303
-
id="new-password"
304
-
type="password"
305
-
placeholder="Password for your new account"
306
-
bind:value={flow.state.targetPassword}
307
-
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
308
-
required
309
-
minlength="8"
310
-
/>
311
-
<p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p>
312
-
</div>
313
-
314
-
{#if flow.state.targetServerInfo?.inviteCodeRequired}
315
-
<div class="migration-field">
316
-
<label for="invite">Invite Code</label>
317
-
<input
318
-
id="invite"
319
-
type="text"
320
-
placeholder="Enter invite code"
321
-
bind:value={flow.state.inviteCode}
322
-
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
323
-
required
324
-
/>
325
-
<p class="migration-hint">Required by this PDS to create an account</p>
326
-
</div>
327
-
{/if}
328
-
329
-
<div class="button-row">
330
-
<button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button>
331
-
<button
332
-
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword}
333
-
onclick={proceedToReview}
334
-
>
335
-
Continue
336
-
</button>
337
-
</div>
338
-
</div>
339
-
340
-
{:else if flow.state.step === 'review'}
341
-
<div class="step-content">
342
-
<h2>Review Migration</h2>
343
-
<p>Please confirm the details of your migration.</p>
344
-
345
-
<div class="review-card">
346
-
<div class="review-row">
347
-
<span class="label">Current Handle:</span>
348
-
<span class="value">@{auth.session?.handle}</span>
349
-
</div>
350
-
<div class="review-row">
351
-
<span class="label">New Handle:</span>
352
-
<span class="value">@{flow.state.targetHandle}</span>
353
-
</div>
354
-
<div class="review-row">
355
-
<span class="label">DID:</span>
356
-
<span class="value mono">{auth.session?.did}</span>
357
-
</div>
358
-
<div class="review-row">
359
-
<span class="label">From PDS:</span>
360
-
<span class="value">{window.location.origin}</span>
361
-
</div>
362
-
<div class="review-row">
363
-
<span class="label">To PDS:</span>
364
-
<span class="value">{flow.state.targetPdsUrl}</span>
365
-
</div>
366
-
<div class="review-row">
367
-
<span class="label">New Email:</span>
368
-
<span class="value">{flow.state.targetEmail}</span>
369
-
</div>
370
-
</div>
371
-
372
-
<div class="migration-warning-box final-warning">
373
-
<strong>This action cannot be easily undone!</strong>
374
-
<p>
375
-
After migration completes, your account on this PDS will be deactivated.
376
-
To return, you would need to migrate back from the new PDS.
377
-
</p>
378
-
</div>
379
-
380
-
<label class="checkbox-label">
381
-
<input type="checkbox" bind:checked={confirmFinal} />
382
-
<span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span>
383
-
</label>
384
-
385
-
<div class="button-row">
386
-
<button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button>
387
-
<button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}>
388
-
{loading ? 'Starting...' : 'Start Migration'}
389
-
</button>
390
-
</div>
391
-
</div>
392
-
393
-
{:else if flow.state.step === 'migrating'}
394
-
<div class="step-content">
395
-
<h2>Migration in Progress</h2>
396
-
<p>Please wait while your account is being transferred...</p>
397
-
398
-
<div class="progress-section">
399
-
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
400
-
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
401
-
<span>Export repository</span>
402
-
</div>
403
-
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
404
-
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
405
-
<span>Import repository to new PDS</span>
406
-
</div>
407
-
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
408
-
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
409
-
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
410
-
</div>
411
-
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
412
-
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
413
-
<span>Migrate preferences</span>
414
-
</div>
415
-
</div>
416
-
417
-
{#if flow.state.progress.blobsTotal > 0}
418
-
<div class="progress-bar">
419
-
<div
420
-
class="progress-fill"
421
-
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
422
-
></div>
423
-
</div>
424
-
{/if}
425
-
426
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
427
-
</div>
428
-
429
-
{:else if flow.state.step === 'plc-token'}
430
-
<div class="step-content">
431
-
<h2>Verify Migration</h2>
432
-
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
433
-
434
-
<div class="migration-info-box">
435
-
<p>
436
-
This code confirms you have access to the account and authorizes updating your identity
437
-
to point to the new PDS.
438
-
</p>
439
-
</div>
440
-
441
-
<form onsubmit={submitPlcToken}>
442
-
<div class="migration-field">
443
-
<label for="plc-token">Verification Code</label>
444
-
<input
445
-
id="plc-token"
446
-
type="text"
447
-
placeholder="Enter code from email"
448
-
bind:value={flow.state.plcToken}
449
-
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
450
-
disabled={loading}
451
-
required
452
-
/>
453
-
</div>
454
-
455
-
<div class="button-row">
456
-
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
457
-
Resend Code
458
-
</button>
459
-
<button type="submit" disabled={loading || !flow.state.plcToken}>
460
-
{loading ? 'Verifying...' : 'Complete Migration'}
461
-
</button>
462
-
</div>
463
-
</form>
464
-
</div>
465
-
466
-
{:else if flow.state.step === 'finalizing'}
467
-
<div class="step-content">
468
-
<h2>Finalizing Migration</h2>
469
-
<p>Please wait while we complete the migration...</p>
470
-
471
-
<div class="progress-section">
472
-
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
473
-
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
474
-
<span>Sign identity update</span>
475
-
</div>
476
-
<div class="progress-item" class:completed={flow.state.progress.activated}>
477
-
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
478
-
<span>Activate account on new PDS</span>
479
-
</div>
480
-
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
481
-
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
482
-
<span>Deactivate account here</span>
483
-
</div>
484
-
</div>
485
-
486
-
<p class="status-text">{flow.state.progress.currentOperation}</p>
487
-
</div>
488
-
489
-
{:else if flow.state.step === 'success'}
490
-
<div class="step-content success-content">
491
-
<div class="success-icon">✓</div>
492
-
<h2>Migration Complete!</h2>
493
-
<p>Your account has been successfully migrated to your new PDS.</p>
494
-
495
-
<div class="success-details">
496
-
<div class="detail-row">
497
-
<span class="label">Your new handle:</span>
498
-
<span class="value">@{flow.state.targetHandle}</span>
499
-
</div>
500
-
<div class="detail-row">
501
-
<span class="label">New PDS:</span>
502
-
<span class="value">{flow.state.targetPdsUrl}</span>
503
-
</div>
504
-
<div class="detail-row">
505
-
<span class="label">DID:</span>
506
-
<span class="value mono">{auth.session?.did}</span>
507
-
</div>
508
-
</div>
509
-
510
-
{#if flow.state.progress.blobsFailed.length > 0}
511
-
<div class="migration-warning-box">
512
-
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
513
-
These may be images or other media that are no longer available.
514
-
</div>
515
-
{/if}
516
-
517
-
<div class="next-steps">
518
-
<h3>Next Steps</h3>
519
-
<ol>
520
-
<li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li>
521
-
<li>Log in with your new credentials</li>
522
-
<li>Your followers and following will continue to work</li>
523
-
</ol>
524
-
</div>
525
-
526
-
<p class="redirect-text">Logging out in a moment...</p>
527
-
</div>
528
-
529
-
{:else if flow.state.step === 'error'}
530
-
<div class="step-content">
531
-
<h2>Migration Error</h2>
532
-
<p>An error occurred during migration.</p>
533
-
534
-
<div class="migration-error-box">
535
-
{flow.state.error}
536
-
</div>
537
-
538
-
<div class="button-row">
539
-
<button class="ghost" onclick={onBack}>Start Over</button>
540
-
</div>
541
-
</div>
542
-
{/if}
543
-
</div>
544
-
545
-
<style>
546
-
</style>
···
+60
frontend/src/components/migration/PasskeySetupStep.svelte
+60
frontend/src/components/migration/PasskeySetupStep.svelte
···
···
1
+
<script lang="ts">
2
+
import { _ } from '../../lib/i18n'
3
+
4
+
interface Props {
5
+
passkeyName: string
6
+
loading: boolean
7
+
error: string | null
8
+
onPasskeyNameChange: (name: string) => void
9
+
onRegister: () => void
10
+
}
11
+
12
+
let {
13
+
passkeyName,
14
+
loading,
15
+
error,
16
+
onPasskeyNameChange,
17
+
onRegister,
18
+
}: Props = $props()
19
+
</script>
20
+
21
+
<div class="step-content">
22
+
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
23
+
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
24
+
25
+
{#if error}
26
+
<div class="message error">
27
+
{error}
28
+
</div>
29
+
{/if}
30
+
31
+
<div class="field">
32
+
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
33
+
<input
34
+
id="passkey-name"
35
+
type="text"
36
+
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
37
+
value={passkeyName}
38
+
oninput={(e) => onPasskeyNameChange((e.target as HTMLInputElement).value)}
39
+
disabled={loading}
40
+
/>
41
+
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
42
+
</div>
43
+
44
+
<div class="passkey-section">
45
+
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
46
+
<button class="primary" onclick={onRegister} disabled={loading}>
47
+
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
48
+
</button>
49
+
</div>
50
+
</div>
51
+
52
+
<style>
53
+
.passkey-section {
54
+
margin-top: 16px;
55
+
}
56
+
.passkey-section button {
57
+
width: 100%;
58
+
margin-top: 12px;
59
+
}
60
+
</style>
+36
frontend/src/components/migration/SuccessStep.svelte
+36
frontend/src/components/migration/SuccessStep.svelte
···
···
1
+
<script lang="ts">
2
+
import type { Snippet } from 'svelte'
3
+
import { _ } from '../../lib/i18n'
4
+
5
+
interface Props {
6
+
handle: string
7
+
did: string
8
+
description?: string
9
+
extraContent?: Snippet
10
+
}
11
+
12
+
let { handle, did, description, extraContent }: Props = $props()
13
+
</script>
14
+
15
+
<div class="step-content success-content">
16
+
<div class="success-icon">✓</div>
17
+
<h2>{$_('migration.inbound.success.title')}</h2>
18
+
<p>{description || $_('migration.inbound.success.desc')}</p>
19
+
20
+
<div class="success-details">
21
+
<div class="detail-row">
22
+
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
23
+
<span class="value">{handle}</span>
24
+
</div>
25
+
<div class="detail-row">
26
+
<span class="label">{$_('migration.inbound.success.did')}:</span>
27
+
<span class="value mono">{did}</span>
28
+
</div>
29
+
</div>
30
+
31
+
{#if extraContent}
32
+
{@render extraContent()}
33
+
{/if}
34
+
35
+
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
36
+
</div>
+155
-47
frontend/src/lib/api.ts
+155
-47
frontend/src/lib/api.ts
···
205
return data;
206
},
207
208
async confirmSignup(
209
did: string,
210
verificationCode: string,
···
226
return xrpc("com.atproto.server.createSession", {
227
method: "POST",
228
body: { identifier, password },
229
});
230
},
231
···
379
signalNumber: string | null;
380
signalVerified: boolean;
381
}> {
382
-
return xrpc("com.tranquil.account.getNotificationPrefs", { token });
383
},
384
385
async updateNotificationPrefs(token: string, prefs: {
···
388
telegramUsername?: string;
389
signalNumber?: string;
390
}): Promise<{ success: boolean }> {
391
-
return xrpc("com.tranquil.account.updateNotificationPrefs", {
392
method: "POST",
393
token,
394
body: prefs,
···
401
identifier: string,
402
code: string,
403
): Promise<{ success: boolean }> {
404
-
return xrpc("com.tranquil.account.confirmChannelVerification", {
405
method: "POST",
406
token,
407
body: { channel, identifier, code },
···
418
body: string;
419
}>;
420
}> {
421
-
return xrpc("com.tranquil.account.getNotificationHistory", { token });
422
},
423
424
async getServerStats(token: string): Promise<{
···
427
recordCount: number;
428
blobStorageBytes: number;
429
}> {
430
-
return xrpc("com.tranquil.admin.getServerStats", { token });
431
},
432
433
async getServerConfig(): Promise<{
···
438
secondaryColorDark: string | null;
439
logoCid: string | null;
440
}> {
441
-
return xrpc("com.tranquil.server.getConfig");
442
},
443
444
async updateServerConfig(
···
452
logoCid?: string;
453
},
454
): Promise<{ success: boolean }> {
455
-
return xrpc("com.tranquil.admin.updateServerConfig", {
456
method: "POST",
457
token,
458
body: config,
···
495
currentPassword: string,
496
newPassword: string,
497
): Promise<void> {
498
-
await xrpc("com.tranquil.account.changePassword", {
499
method: "POST",
500
token,
501
body: { currentPassword, newPassword },
···
503
},
504
505
async removePassword(token: string): Promise<{ success: boolean }> {
506
-
return xrpc("com.tranquil.account.removePassword", {
507
method: "POST",
508
token,
509
});
510
},
511
512
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
513
-
return xrpc("com.tranquil.account.getPasswordStatus", { token });
514
},
515
516
async getLegacyLoginPreference(
517
token: string,
518
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
519
-
return xrpc("com.tranquil.account.getLegacyLoginPreference", { token });
520
},
521
522
async updateLegacyLoginPreference(
523
token: string,
524
allowLegacyLogin: boolean,
525
): Promise<{ allowLegacyLogin: boolean }> {
526
-
return xrpc("com.tranquil.account.updateLegacyLoginPreference", {
527
method: "POST",
528
token,
529
body: { allowLegacyLogin },
···
534
token: string,
535
preferredLocale: string,
536
): Promise<{ preferredLocale: string }> {
537
-
return xrpc("com.tranquil.account.updateLocale", {
538
method: "POST",
539
token,
540
body: { preferredLocale },
···
551
isCurrent: boolean;
552
}>;
553
}> {
554
-
return xrpc("com.tranquil.account.listSessions", { token });
555
},
556
557
async revokeSession(token: string, sessionId: string): Promise<void> {
558
-
await xrpc("com.tranquil.account.revokeSession", {
559
method: "POST",
560
token,
561
body: { sessionId },
···
563
},
564
565
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
566
-
return xrpc("com.tranquil.account.revokeAllSessions", {
567
method: "POST",
568
token,
569
});
···
868
lastSeenAt: string;
869
}>;
870
}> {
871
-
return xrpc("com.tranquil.account.listTrustedDevices", { token });
872
},
873
874
async revokeTrustedDevice(
875
token: string,
876
deviceId: string,
877
): Promise<{ success: boolean }> {
878
-
return xrpc("com.tranquil.account.revokeTrustedDevice", {
879
method: "POST",
880
token,
881
body: { deviceId },
···
887
deviceId: string,
888
friendlyName: string,
889
): Promise<{ success: boolean }> {
890
-
return xrpc("com.tranquil.account.updateTrustedDevice", {
891
method: "POST",
892
token,
893
body: { deviceId, friendlyName },
···
899
lastReauthAt: string | null;
900
availableMethods: string[];
901
}> {
902
-
return xrpc("com.tranquil.account.getReauthStatus", { token });
903
},
904
905
async reauthPassword(
906
token: string,
907
password: string,
908
): Promise<{ success: boolean; reauthAt: string }> {
909
-
return xrpc("com.tranquil.account.reauthPassword", {
910
method: "POST",
911
token,
912
body: { password },
···
917
token: string,
918
code: string,
919
): Promise<{ success: boolean; reauthAt: string }> {
920
-
return xrpc("com.tranquil.account.reauthTotp", {
921
method: "POST",
922
token,
923
body: { code },
···
925
},
926
927
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
928
-
return xrpc("com.tranquil.account.reauthPasskeyStart", {
929
method: "POST",
930
token,
931
});
···
935
token: string,
936
credential: unknown,
937
): Promise<{ success: boolean; reauthAt: string }> {
938
-
return xrpc("com.tranquil.account.reauthPasskeyFinish", {
939
method: "POST",
940
token,
941
body: { credential },
···
982
setupToken: string;
983
setupExpiresAt: string;
984
}> {
985
-
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`;
986
const headers: Record<string, string> = {
987
"Content-Type": "application/json",
988
};
···
1009
setupToken: string,
1010
friendlyName?: string,
1011
): Promise<{ options: unknown }> {
1012
-
return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
1013
method: "POST",
1014
body: { did, setupToken, friendlyName },
1015
});
···
1026
appPassword: string;
1027
appPasswordName: string;
1028
}> {
1029
-
return xrpc("com.tranquil.account.completePasskeySetup", {
1030
method: "POST",
1031
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
1032
});
1033
},
1034
1035
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
1036
-
return xrpc("com.tranquil.account.requestPasskeyRecovery", {
1037
method: "POST",
1038
body: { email },
1039
});
···
1044
recoveryToken: string,
1045
newPassword: string,
1046
): Promise<{ success: boolean }> {
1047
-
return xrpc("com.tranquil.account.recoverPasskeyAccount", {
1048
method: "POST",
1049
body: { did, recoveryToken, newPassword },
1050
});
···
1077
purpose: string;
1078
channel: string;
1079
}> {
1080
-
return xrpc("com.tranquil.account.verifyToken", {
1081
method: "POST",
1082
body: { token, identifier },
1083
token: accessToken,
···
1085
},
1086
1087
async getDidDocument(token: string): Promise<DidDocument> {
1088
-
return xrpc("com.tranquil.account.getDidDocument", { token });
1089
},
1090
1091
async updateDidDocument(
···
1096
serviceEndpoint?: string;
1097
},
1098
): Promise<{ success: boolean }> {
1099
-
return xrpc("com.tranquil.account.updateDidDocument", {
1100
method: "POST",
1101
token,
1102
body: params,
···
1106
async deactivateAccount(
1107
token: string,
1108
deleteAfter?: string,
1109
-
migratingTo?: string,
1110
): Promise<void> {
1111
await xrpc("com.atproto.server.deactivateAccount", {
1112
method: "POST",
1113
token,
1114
-
body: { deleteAfter, migratingTo },
1115
});
1116
},
1117
1118
-
async getMigrationStatus(token: string): Promise<{
1119
-
migratedToPds?: string;
1120
-
migratedAt?: string;
1121
-
forwardingEnabled: boolean;
1122
}> {
1123
-
return xrpc("com.tranquil.account.getMigrationStatus", { token });
1124
},
1125
1126
-
async updateMigrationForwarding(
1127
-
token: string,
1128
-
forwardingPds?: string,
1129
-
): Promise<{ success: boolean }> {
1130
-
return xrpc("com.tranquil.account.updateMigrationForwarding", {
1131
method: "POST",
1132
token,
1133
-
body: { forwardingPds },
1134
});
1135
},
1136
1137
-
async clearMigrationForwarding(token: string): Promise<{ success: boolean }> {
1138
-
return xrpc("com.tranquil.account.clearMigrationForwarding", {
1139
method: "POST",
1140
token,
1141
});
1142
},
1143
};
···
205
return data;
206
},
207
208
+
async createAccountWithServiceAuth(
209
+
serviceAuthToken: string,
210
+
params: {
211
+
did: string;
212
+
handle: string;
213
+
email: string;
214
+
password: string;
215
+
inviteCode?: string;
216
+
},
217
+
): Promise<Session> {
218
+
const url = `${API_BASE}/com.atproto.server.createAccount`;
219
+
const response = await fetch(url, {
220
+
method: "POST",
221
+
headers: {
222
+
"Content-Type": "application/json",
223
+
"Authorization": `Bearer ${serviceAuthToken}`,
224
+
},
225
+
body: JSON.stringify({
226
+
did: params.did,
227
+
handle: params.handle,
228
+
email: params.email,
229
+
password: params.password,
230
+
inviteCode: params.inviteCode,
231
+
}),
232
+
});
233
+
const data = await response.json();
234
+
if (!response.ok) {
235
+
throw new ApiError(response.status, data.error, data.message);
236
+
}
237
+
return data;
238
+
},
239
+
240
async confirmSignup(
241
did: string,
242
verificationCode: string,
···
258
return xrpc("com.atproto.server.createSession", {
259
method: "POST",
260
body: { identifier, password },
261
+
});
262
+
},
263
+
264
+
async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
265
+
return xrpc("_checkEmailVerified", {
266
+
method: "POST",
267
+
body: { identifier },
268
});
269
},
270
···
418
signalNumber: string | null;
419
signalVerified: boolean;
420
}> {
421
+
return xrpc("_account.getNotificationPrefs", { token });
422
},
423
424
async updateNotificationPrefs(token: string, prefs: {
···
427
telegramUsername?: string;
428
signalNumber?: string;
429
}): Promise<{ success: boolean }> {
430
+
return xrpc("_account.updateNotificationPrefs", {
431
method: "POST",
432
token,
433
body: prefs,
···
440
identifier: string,
441
code: string,
442
): Promise<{ success: boolean }> {
443
+
return xrpc("_account.confirmChannelVerification", {
444
method: "POST",
445
token,
446
body: { channel, identifier, code },
···
457
body: string;
458
}>;
459
}> {
460
+
return xrpc("_account.getNotificationHistory", { token });
461
},
462
463
async getServerStats(token: string): Promise<{
···
466
recordCount: number;
467
blobStorageBytes: number;
468
}> {
469
+
return xrpc("_admin.getServerStats", { token });
470
},
471
472
async getServerConfig(): Promise<{
···
477
secondaryColorDark: string | null;
478
logoCid: string | null;
479
}> {
480
+
return xrpc("_server.getConfig");
481
},
482
483
async updateServerConfig(
···
491
logoCid?: string;
492
},
493
): Promise<{ success: boolean }> {
494
+
return xrpc("_admin.updateServerConfig", {
495
method: "POST",
496
token,
497
body: config,
···
534
currentPassword: string,
535
newPassword: string,
536
): Promise<void> {
537
+
await xrpc("_account.changePassword", {
538
method: "POST",
539
token,
540
body: { currentPassword, newPassword },
···
542
},
543
544
async removePassword(token: string): Promise<{ success: boolean }> {
545
+
return xrpc("_account.removePassword", {
546
method: "POST",
547
token,
548
});
549
},
550
551
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
552
+
return xrpc("_account.getPasswordStatus", { token });
553
},
554
555
async getLegacyLoginPreference(
556
token: string,
557
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
558
+
return xrpc("_account.getLegacyLoginPreference", { token });
559
},
560
561
async updateLegacyLoginPreference(
562
token: string,
563
allowLegacyLogin: boolean,
564
): Promise<{ allowLegacyLogin: boolean }> {
565
+
return xrpc("_account.updateLegacyLoginPreference", {
566
method: "POST",
567
token,
568
body: { allowLegacyLogin },
···
573
token: string,
574
preferredLocale: string,
575
): Promise<{ preferredLocale: string }> {
576
+
return xrpc("_account.updateLocale", {
577
method: "POST",
578
token,
579
body: { preferredLocale },
···
590
isCurrent: boolean;
591
}>;
592
}> {
593
+
return xrpc("_account.listSessions", { token });
594
},
595
596
async revokeSession(token: string, sessionId: string): Promise<void> {
597
+
await xrpc("_account.revokeSession", {
598
method: "POST",
599
token,
600
body: { sessionId },
···
602
},
603
604
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
605
+
return xrpc("_account.revokeAllSessions", {
606
method: "POST",
607
token,
608
});
···
907
lastSeenAt: string;
908
}>;
909
}> {
910
+
return xrpc("_account.listTrustedDevices", { token });
911
},
912
913
async revokeTrustedDevice(
914
token: string,
915
deviceId: string,
916
): Promise<{ success: boolean }> {
917
+
return xrpc("_account.revokeTrustedDevice", {
918
method: "POST",
919
token,
920
body: { deviceId },
···
926
deviceId: string,
927
friendlyName: string,
928
): Promise<{ success: boolean }> {
929
+
return xrpc("_account.updateTrustedDevice", {
930
method: "POST",
931
token,
932
body: { deviceId, friendlyName },
···
938
lastReauthAt: string | null;
939
availableMethods: string[];
940
}> {
941
+
return xrpc("_account.getReauthStatus", { token });
942
},
943
944
async reauthPassword(
945
token: string,
946
password: string,
947
): Promise<{ success: boolean; reauthAt: string }> {
948
+
return xrpc("_account.reauthPassword", {
949
method: "POST",
950
token,
951
body: { password },
···
956
token: string,
957
code: string,
958
): Promise<{ success: boolean; reauthAt: string }> {
959
+
return xrpc("_account.reauthTotp", {
960
method: "POST",
961
token,
962
body: { code },
···
964
},
965
966
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
967
+
return xrpc("_account.reauthPasskeyStart", {
968
method: "POST",
969
token,
970
});
···
974
token: string,
975
credential: unknown,
976
): Promise<{ success: boolean; reauthAt: string }> {
977
+
return xrpc("_account.reauthPasskeyFinish", {
978
method: "POST",
979
token,
980
body: { credential },
···
1021
setupToken: string;
1022
setupExpiresAt: string;
1023
}> {
1024
+
const url = `${API_BASE}/_account.createPasskeyAccount`;
1025
const headers: Record<string, string> = {
1026
"Content-Type": "application/json",
1027
};
···
1048
setupToken: string,
1049
friendlyName?: string,
1050
): Promise<{ options: unknown }> {
1051
+
return xrpc("_account.startPasskeyRegistrationForSetup", {
1052
method: "POST",
1053
body: { did, setupToken, friendlyName },
1054
});
···
1065
appPassword: string;
1066
appPasswordName: string;
1067
}> {
1068
+
return xrpc("_account.completePasskeySetup", {
1069
method: "POST",
1070
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
1071
});
1072
},
1073
1074
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
1075
+
return xrpc("_account.requestPasskeyRecovery", {
1076
method: "POST",
1077
body: { email },
1078
});
···
1083
recoveryToken: string,
1084
newPassword: string,
1085
): Promise<{ success: boolean }> {
1086
+
return xrpc("_account.recoverPasskeyAccount", {
1087
method: "POST",
1088
body: { did, recoveryToken, newPassword },
1089
});
···
1116
purpose: string;
1117
channel: string;
1118
}> {
1119
+
return xrpc("_account.verifyToken", {
1120
method: "POST",
1121
body: { token, identifier },
1122
token: accessToken,
···
1124
},
1125
1126
async getDidDocument(token: string): Promise<DidDocument> {
1127
+
return xrpc("_account.getDidDocument", { token });
1128
},
1129
1130
async updateDidDocument(
···
1135
serviceEndpoint?: string;
1136
},
1137
): Promise<{ success: boolean }> {
1138
+
return xrpc("_account.updateDidDocument", {
1139
method: "POST",
1140
token,
1141
body: params,
···
1145
async deactivateAccount(
1146
token: string,
1147
deleteAfter?: string,
1148
): Promise<void> {
1149
await xrpc("com.atproto.server.deactivateAccount", {
1150
method: "POST",
1151
token,
1152
+
body: { deleteAfter },
1153
+
});
1154
+
},
1155
+
1156
+
async getRepo(token: string, did: string): Promise<ArrayBuffer> {
1157
+
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
1158
+
encodeURIComponent(did)
1159
+
}`;
1160
+
const res = await fetch(url, {
1161
+
headers: { Authorization: `Bearer ${token}` },
1162
+
});
1163
+
if (!res.ok) {
1164
+
const err = await res.json().catch(() => ({
1165
+
error: "Unknown",
1166
+
message: res.statusText,
1167
+
}));
1168
+
throw new ApiError(res.status, err.error, err.message);
1169
+
}
1170
+
return res.arrayBuffer();
1171
+
},
1172
+
1173
+
async listBackups(token: string): Promise<{
1174
+
backups: Array<{
1175
+
id: string;
1176
+
repoRev: string;
1177
+
repoRootCid: string;
1178
+
blockCount: number;
1179
+
sizeBytes: number;
1180
+
createdAt: string;
1181
+
}>;
1182
+
backupEnabled: boolean;
1183
+
}> {
1184
+
return xrpc("_backup.listBackups", { token });
1185
+
},
1186
+
1187
+
async getBackup(token: string, id: string): Promise<Blob> {
1188
+
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1189
+
const res = await fetch(url, {
1190
+
headers: { Authorization: `Bearer ${token}` },
1191
});
1192
+
if (!res.ok) {
1193
+
const err = await res.json().catch(() => ({
1194
+
error: "Unknown",
1195
+
message: res.statusText,
1196
+
}));
1197
+
throw new ApiError(res.status, err.error, err.message);
1198
+
}
1199
+
return res.blob();
1200
},
1201
1202
+
async createBackup(token: string): Promise<{
1203
+
id: string;
1204
+
repoRev: string;
1205
+
sizeBytes: number;
1206
+
blockCount: number;
1207
}> {
1208
+
return xrpc("_backup.createBackup", {
1209
+
method: "POST",
1210
+
token,
1211
+
});
1212
},
1213
1214
+
async deleteBackup(token: string, id: string): Promise<void> {
1215
+
await xrpc("_backup.deleteBackup", {
1216
method: "POST",
1217
token,
1218
+
params: { id },
1219
});
1220
},
1221
1222
+
async setBackupEnabled(
1223
+
token: string,
1224
+
enabled: boolean,
1225
+
): Promise<{ enabled: boolean }> {
1226
+
return xrpc("_backup.setEnabled", {
1227
method: "POST",
1228
token,
1229
+
body: { enabled },
1230
});
1231
+
},
1232
+
1233
+
async importRepo(token: string, car: Uint8Array): Promise<void> {
1234
+
const url = `${API_BASE}/com.atproto.repo.importRepo`;
1235
+
const res = await fetch(url, {
1236
+
method: "POST",
1237
+
headers: {
1238
+
Authorization: `Bearer ${token}`,
1239
+
"Content-Type": "application/vnd.ipld.car",
1240
+
},
1241
+
body: car,
1242
+
});
1243
+
if (!res.ok) {
1244
+
const err = await res.json().catch(() => ({
1245
+
error: "Unknown",
1246
+
message: res.statusText,
1247
+
}));
1248
+
throw new ApiError(res.status, err.error, err.message);
1249
+
}
1250
},
1251
};
+16
-42
frontend/src/lib/migration/atproto-client.ts
+16
-42
frontend/src/lib/migration/atproto-client.ts
···
372
);
373
}
374
375
-
async deactivateAccount(migratingTo?: string): Promise<void> {
376
apiLog(
377
"POST",
378
`${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`,
379
-
{
380
-
migratingTo,
381
-
},
382
);
383
const start = Date.now();
384
try {
385
-
const body: { migratingTo?: string } = {};
386
-
if (migratingTo) {
387
-
body.migratingTo = migratingTo;
388
-
}
389
await this.xrpc("com.atproto.server.deactivateAccount", {
390
httpMethod: "POST",
391
-
body,
392
});
393
apiLog(
394
"POST",
···
396
{
397
durationMs: Date.now() - start,
398
success: true,
399
-
migratingTo,
400
},
401
);
402
} catch (e) {
···
409
error: err.message,
410
errorCode: err.error,
411
status: err.status,
412
-
migratingTo,
413
},
414
);
415
throw e;
···
420
return this.xrpc("com.atproto.server.checkAccountStatus");
421
}
422
423
-
async getMigrationStatus(): Promise<{
424
-
did: string;
425
-
didType: string;
426
-
migrated: boolean;
427
-
migratedToPds?: string;
428
-
migratedAt?: string;
429
-
}> {
430
-
return this.xrpc("com.tranquil.account.getMigrationStatus");
431
-
}
432
-
433
-
async updateMigrationForwarding(pdsUrl: string): Promise<{
434
-
success: boolean;
435
-
migratedToPds: string;
436
-
migratedAt: string;
437
-
}> {
438
-
return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
439
-
httpMethod: "POST",
440
-
body: { pdsUrl },
441
-
});
442
-
}
443
-
444
-
async clearMigrationForwarding(): Promise<{ success: boolean }> {
445
-
return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
446
-
httpMethod: "POST",
447
-
});
448
-
}
449
-
450
async resolveHandle(handle: string): Promise<{ did: string }> {
451
return this.xrpc("com.atproto.identity.resolveHandle", {
452
params: { handle },
···
468
return session;
469
}
470
471
async verifyToken(
472
token: string,
473
identifier: string,
474
): Promise<
475
{ success: boolean; did: string; purpose: string; channel: string }
476
> {
477
-
return this.xrpc("com.tranquil.account.verifyToken", {
478
httpMethod: "POST",
479
body: { token, identifier },
480
});
···
498
}
499
500
const res = await fetch(
501
-
`${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`,
502
{
503
method: "POST",
504
headers,
···
530
setupToken: string,
531
friendlyName?: string,
532
): Promise<StartPasskeyRegistrationResponse> {
533
-
return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
534
httpMethod: "POST",
535
body: { did, setupToken, friendlyName },
536
});
···
542
passkeyCredential: unknown,
543
passkeyFriendlyName?: string,
544
): Promise<CompletePasskeySetupResponse> {
545
-
return this.xrpc("com.tranquil.account.completePasskeySetup", {
546
httpMethod: "POST",
547
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
548
});
···
372
);
373
}
374
375
+
async deactivateAccount(): Promise<void> {
376
apiLog(
377
"POST",
378
`${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`,
379
);
380
const start = Date.now();
381
try {
382
await this.xrpc("com.atproto.server.deactivateAccount", {
383
httpMethod: "POST",
384
});
385
apiLog(
386
"POST",
···
388
{
389
durationMs: Date.now() - start,
390
success: true,
391
},
392
);
393
} catch (e) {
···
400
error: err.message,
401
errorCode: err.error,
402
status: err.status,
403
},
404
);
405
throw e;
···
410
return this.xrpc("com.atproto.server.checkAccountStatus");
411
}
412
413
async resolveHandle(handle: string): Promise<{ did: string }> {
414
return this.xrpc("com.atproto.identity.resolveHandle", {
415
params: { handle },
···
431
return session;
432
}
433
434
+
async checkEmailVerified(identifier: string): Promise<boolean> {
435
+
const result = await this.xrpc<{ verified: boolean }>(
436
+
"_checkEmailVerified",
437
+
{
438
+
httpMethod: "POST",
439
+
body: { identifier },
440
+
},
441
+
);
442
+
return result.verified;
443
+
}
444
+
445
async verifyToken(
446
token: string,
447
identifier: string,
448
): Promise<
449
{ success: boolean; did: string; purpose: string; channel: string }
450
> {
451
+
return this.xrpc("_account.verifyToken", {
452
httpMethod: "POST",
453
body: { token, identifier },
454
});
···
472
}
473
474
const res = await fetch(
475
+
`${this.baseUrl}/xrpc/_account.createPasskeyAccount`,
476
{
477
method: "POST",
478
headers,
···
504
setupToken: string,
505
friendlyName?: string,
506
): Promise<StartPasskeyRegistrationResponse> {
507
+
return this.xrpc("_account.startPasskeyRegistrationForSetup", {
508
httpMethod: "POST",
509
body: { did, setupToken, friendlyName },
510
});
···
516
passkeyCredential: unknown,
517
passkeyFriendlyName?: string,
518
): Promise<CompletePasskeySetupResponse> {
519
+
return this.xrpc("_account.completePasskeySetup", {
520
httpMethod: "POST",
521
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
522
});
+156
frontend/src/lib/migration/blob-migration.ts
+156
frontend/src/lib/migration/blob-migration.ts
···
···
1
+
import type { AtprotoClient } from "./atproto-client";
2
+
import type { MigrationProgress } from "./types";
3
+
4
+
export interface BlobMigrationResult {
5
+
migrated: number;
6
+
failed: string[];
7
+
total: number;
8
+
sourceUnreachable: boolean;
9
+
}
10
+
11
+
export async function migrateBlobs(
12
+
localClient: AtprotoClient,
13
+
sourceClient: AtprotoClient | null,
14
+
userDid: string,
15
+
onProgress: (update: Partial<MigrationProgress>) => void,
16
+
): Promise<BlobMigrationResult> {
17
+
const missingBlobs: string[] = [];
18
+
let cursor: string | undefined;
19
+
20
+
console.log("[blob-migration] Starting blob migration for", userDid);
21
+
console.log(
22
+
"[blob-migration] Source client:",
23
+
sourceClient ? "available" : "NOT AVAILABLE",
24
+
);
25
+
26
+
onProgress({ currentOperation: "Checking for missing blobs..." });
27
+
28
+
do {
29
+
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
30
+
cursor,
31
+
100,
32
+
);
33
+
console.log(
34
+
"[blob-migration] listMissingBlobs returned",
35
+
blobs.length,
36
+
"blobs, cursor:",
37
+
nextCursor,
38
+
);
39
+
for (const blob of blobs) {
40
+
missingBlobs.push(blob.cid);
41
+
}
42
+
cursor = nextCursor;
43
+
} while (cursor);
44
+
45
+
console.log("[blob-migration] Total missing blobs:", missingBlobs.length);
46
+
onProgress({ blobsTotal: missingBlobs.length });
47
+
48
+
if (missingBlobs.length === 0) {
49
+
console.log("[blob-migration] No blobs to migrate");
50
+
onProgress({ currentOperation: "No blobs to migrate" });
51
+
return { migrated: 0, failed: [], total: 0, sourceUnreachable: false };
52
+
}
53
+
54
+
if (!sourceClient) {
55
+
console.warn(
56
+
"[blob-migration] No source client available, cannot fetch blobs",
57
+
);
58
+
onProgress({
59
+
currentOperation:
60
+
`${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`,
61
+
});
62
+
return {
63
+
migrated: 0,
64
+
failed: missingBlobs,
65
+
total: missingBlobs.length,
66
+
sourceUnreachable: true,
67
+
};
68
+
}
69
+
70
+
onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` });
71
+
72
+
let migrated = 0;
73
+
const failed: string[] = [];
74
+
let sourceUnreachable = false;
75
+
76
+
for (const cid of missingBlobs) {
77
+
if (sourceUnreachable) {
78
+
failed.push(cid);
79
+
continue;
80
+
}
81
+
82
+
try {
83
+
onProgress({
84
+
currentOperation: `Migrating blob ${
85
+
migrated + 1
86
+
}/${missingBlobs.length}...`,
87
+
});
88
+
89
+
console.log("[blob-migration] Fetching blob", cid, "from source");
90
+
const blobData = await sourceClient.getBlob(userDid, cid);
91
+
console.log(
92
+
"[blob-migration] Got blob",
93
+
cid,
94
+
"size:",
95
+
blobData.byteLength,
96
+
);
97
+
await localClient.uploadBlob(blobData, "application/octet-stream");
98
+
console.log("[blob-migration] Uploaded blob", cid);
99
+
migrated++;
100
+
onProgress({ blobsMigrated: migrated });
101
+
} catch (e) {
102
+
const errorMessage = (e as Error).message || String(e);
103
+
console.error(
104
+
"[blob-migration] Failed to migrate blob",
105
+
cid,
106
+
":",
107
+
errorMessage,
108
+
);
109
+
110
+
const isNetworkError =
111
+
errorMessage.includes("fetch") ||
112
+
errorMessage.includes("network") ||
113
+
errorMessage.includes("CORS") ||
114
+
errorMessage.includes("Failed to fetch") ||
115
+
errorMessage.includes("NetworkError") ||
116
+
errorMessage.includes("blocked by CORS");
117
+
118
+
if (isNetworkError) {
119
+
sourceUnreachable = true;
120
+
console.warn(
121
+
"[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs",
122
+
);
123
+
const remaining = missingBlobs.length - migrated - 1;
124
+
if (migrated > 0) {
125
+
onProgress({
126
+
currentOperation:
127
+
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`,
128
+
});
129
+
} else {
130
+
onProgress({
131
+
currentOperation:
132
+
`Cannot reach source PDS (browser security restriction). This commonly happens when the old server has shut down or doesn't allow cross-origin requests. Your posts will work, but ${missingBlobs.length} media files couldn't be recovered.`,
133
+
});
134
+
}
135
+
}
136
+
failed.push(cid);
137
+
}
138
+
}
139
+
140
+
if (migrated === missingBlobs.length) {
141
+
onProgress({
142
+
currentOperation: `All ${migrated} blobs migrated successfully`,
143
+
});
144
+
} else if (migrated > 0) {
145
+
onProgress({
146
+
currentOperation:
147
+
`${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`,
148
+
});
149
+
} else {
150
+
onProgress({
151
+
currentOperation: `Could not migrate blobs (${failed.length} missing)`,
152
+
});
153
+
}
154
+
155
+
return { migrated, failed, total: missingBlobs.length, sourceUnreachable };
156
+
}
+17
-318
frontend/src/lib/migration/flow.svelte.ts
+17
-318
frontend/src/lib/migration/flow.svelte.ts
···
2
InboundMigrationState,
3
InboundStep,
4
MigrationProgress,
5
-
OutboundMigrationState,
6
-
OutboundStep,
7
PasskeyAccountSetup,
8
ServerDescription,
9
StoredMigrationState,
···
30
updateProgress,
31
updateStep,
32
} from "./storage";
33
34
function migrationLog(stage: string, data?: Record<string, unknown>) {
35
const timestamp = new Date().toISOString();
···
85
let sourceClient: AtprotoClient | null = null;
86
let localClient: AtprotoClient | null = null;
87
let localServerInfo: ServerDescription | null = null;
88
89
function setStep(step: InboundStep) {
90
state.step = step;
91
state.error = null;
92
-
saveMigrationState(state);
93
-
updateStep(step);
94
}
95
96
function setError(error: string) {
···
458
async function migrateBlobs(): Promise<void> {
459
if (!sourceClient || !localClient) return;
460
461
-
let cursor: string | undefined;
462
-
let migrated = 0;
463
-
464
-
do {
465
-
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
466
-
cursor,
467
-
100,
468
-
);
469
-
470
-
for (const blob of blobs) {
471
-
try {
472
-
setProgress({
473
-
currentOperation: `Migrating blob ${
474
-
migrated + 1
475
-
}/${state.progress.blobsTotal}...`,
476
-
});
477
-
478
-
const blobData = await sourceClient.getBlob(
479
-
state.sourceDid,
480
-
blob.cid,
481
-
);
482
-
await localClient.uploadBlob(blobData, "application/octet-stream");
483
-
migrated++;
484
-
setProgress({ blobsMigrated: migrated });
485
-
} catch {
486
-
state.progress.blobsFailed.push(blob.cid);
487
-
}
488
-
}
489
490
-
cursor = nextCursor;
491
-
} while (cursor);
492
}
493
494
async function migratePreferences(): Promise<void> {
···
578
579
checkingEmailVerification = true;
580
try {
581
await localClient.loginDeactivated(
582
state.targetEmail,
583
state.targetPassword,
···
978
};
979
}
980
981
-
export function createOutboundMigrationFlow() {
982
-
let state = $state<OutboundMigrationState>({
983
-
direction: "outbound",
984
-
step: "welcome",
985
-
localDid: "",
986
-
localHandle: "",
987
-
targetPdsUrl: "",
988
-
targetPdsDid: "",
989
-
targetHandle: "",
990
-
targetEmail: "",
991
-
targetPassword: "",
992
-
inviteCode: "",
993
-
targetAccessToken: null,
994
-
targetRefreshToken: null,
995
-
serviceAuthToken: null,
996
-
plcToken: "",
997
-
progress: createInitialProgress(),
998
-
error: null,
999
-
targetServerInfo: null,
1000
-
});
1001
-
1002
-
let localClient: AtprotoClient | null = null;
1003
-
let targetClient: AtprotoClient | null = null;
1004
-
1005
-
function setStep(step: OutboundStep) {
1006
-
state.step = step;
1007
-
state.error = null;
1008
-
saveMigrationState(state);
1009
-
updateStep(step);
1010
-
}
1011
-
1012
-
function setError(error: string) {
1013
-
state.error = error;
1014
-
saveMigrationState(state);
1015
-
}
1016
-
1017
-
function setProgress(updates: Partial<MigrationProgress>) {
1018
-
state.progress = { ...state.progress, ...updates };
1019
-
updateProgress(updates);
1020
-
}
1021
-
1022
-
async function validateTargetPds(url: string): Promise<ServerDescription> {
1023
-
const normalizedUrl = url.replace(/\/$/, "");
1024
-
targetClient = new AtprotoClient(normalizedUrl);
1025
-
1026
-
try {
1027
-
const serverInfo = await targetClient.describeServer();
1028
-
state.targetPdsUrl = normalizedUrl;
1029
-
state.targetPdsDid = serverInfo.did;
1030
-
state.targetServerInfo = serverInfo;
1031
-
return serverInfo;
1032
-
} catch (e) {
1033
-
throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
1034
-
}
1035
-
}
1036
-
1037
-
function initLocalClient(
1038
-
accessToken: string,
1039
-
did?: string,
1040
-
handle?: string,
1041
-
): void {
1042
-
localClient = createLocalClient();
1043
-
localClient.setAccessToken(accessToken);
1044
-
if (did) {
1045
-
state.localDid = did;
1046
-
}
1047
-
if (handle) {
1048
-
state.localHandle = handle;
1049
-
}
1050
-
}
1051
-
1052
-
async function startMigration(currentDid: string): Promise<void> {
1053
-
if (!localClient || !targetClient) {
1054
-
throw new Error("Not connected to PDSes");
1055
-
}
1056
-
1057
-
setStep("migrating");
1058
-
setProgress({ currentOperation: "Getting service auth token..." });
1059
-
1060
-
try {
1061
-
const { token } = await localClient.getServiceAuth(
1062
-
state.targetPdsDid,
1063
-
"com.atproto.server.createAccount",
1064
-
);
1065
-
state.serviceAuthToken = token;
1066
-
1067
-
setProgress({ currentOperation: "Creating account on new PDS..." });
1068
-
1069
-
const accountParams = {
1070
-
did: currentDid,
1071
-
handle: state.targetHandle,
1072
-
email: state.targetEmail,
1073
-
password: state.targetPassword,
1074
-
inviteCode: state.inviteCode || undefined,
1075
-
};
1076
-
1077
-
const session = await targetClient.createAccount(accountParams, token);
1078
-
state.targetAccessToken = session.accessJwt;
1079
-
state.targetRefreshToken = session.refreshJwt;
1080
-
targetClient.setAccessToken(session.accessJwt);
1081
-
1082
-
setProgress({ currentOperation: "Exporting repository..." });
1083
-
1084
-
const car = await localClient.getRepo(currentDid);
1085
-
setProgress({
1086
-
repoExported: true,
1087
-
currentOperation: "Importing repository...",
1088
-
});
1089
-
1090
-
await targetClient.importRepo(car);
1091
-
setProgress({
1092
-
repoImported: true,
1093
-
currentOperation: "Counting blobs...",
1094
-
});
1095
-
1096
-
const accountStatus = await targetClient.checkAccountStatus();
1097
-
setProgress({
1098
-
blobsTotal: accountStatus.expectedBlobs,
1099
-
currentOperation: "Migrating blobs...",
1100
-
});
1101
-
1102
-
await migrateBlobs(currentDid);
1103
-
1104
-
setProgress({ currentOperation: "Migrating preferences..." });
1105
-
await migratePreferences();
1106
-
1107
-
setProgress({ currentOperation: "Requesting PLC operation token..." });
1108
-
await localClient.requestPlcOperationSignature();
1109
-
1110
-
setStep("plc-token");
1111
-
} catch (e) {
1112
-
const err = e as Error & { error?: string; status?: number };
1113
-
const message = err.message || err.error ||
1114
-
`Unknown error (status ${err.status || "unknown"})`;
1115
-
setError(message);
1116
-
setStep("error");
1117
-
}
1118
-
}
1119
-
1120
-
async function migrateBlobs(did: string): Promise<void> {
1121
-
if (!localClient || !targetClient) return;
1122
-
1123
-
let cursor: string | undefined;
1124
-
let migrated = 0;
1125
-
1126
-
do {
1127
-
const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
1128
-
cursor,
1129
-
100,
1130
-
);
1131
-
1132
-
for (const blob of blobs) {
1133
-
try {
1134
-
setProgress({
1135
-
currentOperation: `Migrating blob ${
1136
-
migrated + 1
1137
-
}/${state.progress.blobsTotal}...`,
1138
-
});
1139
-
1140
-
const blobData = await localClient.getBlob(did, blob.cid);
1141
-
await targetClient.uploadBlob(blobData, "application/octet-stream");
1142
-
migrated++;
1143
-
setProgress({ blobsMigrated: migrated });
1144
-
} catch {
1145
-
state.progress.blobsFailed.push(blob.cid);
1146
-
}
1147
-
}
1148
-
1149
-
cursor = nextCursor;
1150
-
} while (cursor);
1151
-
}
1152
-
1153
-
async function migratePreferences(): Promise<void> {
1154
-
if (!localClient || !targetClient) return;
1155
-
1156
-
try {
1157
-
const prefs = await localClient.getPreferences();
1158
-
await targetClient.putPreferences(prefs);
1159
-
setProgress({ prefsMigrated: true });
1160
-
} catch { /* optional, best-effort */ }
1161
-
}
1162
-
1163
-
async function submitPlcToken(token: string): Promise<void> {
1164
-
if (!localClient || !targetClient) {
1165
-
throw new Error("Not connected to PDSes");
1166
-
}
1167
-
1168
-
state.plcToken = token;
1169
-
setStep("finalizing");
1170
-
setProgress({ currentOperation: "Signing PLC operation..." });
1171
-
1172
-
try {
1173
-
const credentials = await targetClient.getRecommendedDidCredentials();
1174
-
1175
-
const { operation } = await localClient.signPlcOperation({
1176
-
token,
1177
-
...credentials,
1178
-
});
1179
-
1180
-
setProgress({
1181
-
plcSigned: true,
1182
-
currentOperation: "Submitting PLC operation...",
1183
-
});
1184
-
1185
-
await targetClient.submitPlcOperation(operation);
1186
-
1187
-
setProgress({ currentOperation: "Activating account on new PDS..." });
1188
-
await targetClient.activateAccount();
1189
-
setProgress({ activated: true });
1190
-
1191
-
setProgress({ currentOperation: "Deactivating old account..." });
1192
-
try {
1193
-
await localClient.deactivateAccount(state.targetPdsUrl);
1194
-
setProgress({ deactivated: true });
1195
-
} catch { /* optional, best-effort */ }
1196
-
1197
-
setStep("success");
1198
-
clearMigrationState();
1199
-
} catch (e) {
1200
-
const err = e as Error & { error?: string; status?: number };
1201
-
const message = err.message || err.error ||
1202
-
`Unknown error (status ${err.status || "unknown"})`;
1203
-
setError(message);
1204
-
setStep("plc-token");
1205
-
}
1206
-
}
1207
-
1208
-
async function resendPlcToken(): Promise<void> {
1209
-
if (!localClient) {
1210
-
throw new Error("Not connected to local PDS");
1211
-
}
1212
-
await localClient.requestPlcOperationSignature();
1213
-
}
1214
-
1215
-
function reset(): void {
1216
-
state = {
1217
-
direction: "outbound",
1218
-
step: "welcome",
1219
-
localDid: "",
1220
-
localHandle: "",
1221
-
targetPdsUrl: "",
1222
-
targetPdsDid: "",
1223
-
targetHandle: "",
1224
-
targetEmail: "",
1225
-
targetPassword: "",
1226
-
inviteCode: "",
1227
-
targetAccessToken: null,
1228
-
targetRefreshToken: null,
1229
-
serviceAuthToken: null,
1230
-
plcToken: "",
1231
-
progress: createInitialProgress(),
1232
-
error: null,
1233
-
targetServerInfo: null,
1234
-
};
1235
-
localClient = null;
1236
-
targetClient = null;
1237
-
clearMigrationState();
1238
-
}
1239
-
1240
-
return {
1241
-
get state() {
1242
-
return state;
1243
-
},
1244
-
setStep,
1245
-
setError,
1246
-
validateTargetPds,
1247
-
initLocalClient,
1248
-
startMigration,
1249
-
submitPlcToken,
1250
-
resendPlcToken,
1251
-
reset,
1252
-
1253
-
updateField<K extends keyof OutboundMigrationState>(
1254
-
field: K,
1255
-
value: OutboundMigrationState[K],
1256
-
) {
1257
-
state[field] = value;
1258
-
},
1259
-
};
1260
-
}
1261
-
1262
export type InboundMigrationFlow = ReturnType<
1263
typeof createInboundMigrationFlow
1264
>;
1265
-
export type OutboundMigrationFlow = ReturnType<
1266
-
typeof createOutboundMigrationFlow
1267
-
>;
···
2
InboundMigrationState,
3
InboundStep,
4
MigrationProgress,
5
PasskeyAccountSetup,
6
ServerDescription,
7
StoredMigrationState,
···
28
updateProgress,
29
updateStep,
30
} from "./storage";
31
+
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
32
33
function migrationLog(stage: string, data?: Record<string, unknown>) {
34
const timestamp = new Date().toISOString();
···
84
let sourceClient: AtprotoClient | null = null;
85
let localClient: AtprotoClient | null = null;
86
let localServerInfo: ServerDescription | null = null;
87
+
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> =
88
+
null;
89
90
function setStep(step: InboundStep) {
91
state.step = step;
92
state.error = null;
93
+
if (step !== "success") {
94
+
saveMigrationState(state);
95
+
updateStep(step);
96
+
}
97
}
98
99
function setError(error: string) {
···
461
async function migrateBlobs(): Promise<void> {
462
if (!sourceClient || !localClient) return;
463
464
+
const result = await migrateBlobsUtil(
465
+
localClient,
466
+
sourceClient,
467
+
state.sourceDid,
468
+
setProgress,
469
+
);
470
471
+
state.progress.blobsFailed = result.failed;
472
}
473
474
async function migratePreferences(): Promise<void> {
···
558
559
checkingEmailVerification = true;
560
try {
561
+
const verified = await localClient.checkEmailVerified(state.targetEmail);
562
+
if (!verified) return false;
563
+
564
await localClient.loginDeactivated(
565
state.targetEmail,
566
state.targetPassword,
···
961
};
962
}
963
964
export type InboundMigrationFlow = ReturnType<
965
typeof createInboundMigrationFlow
966
>;
+8
-2
frontend/src/lib/migration/index.ts
+8
-2
frontend/src/lib/migration/index.ts
···
1
export * from "./types";
2
export * from "./atproto-client";
3
export * from "./storage";
4
+
export * from "./blob-migration";
5
export {
6
createInboundMigrationFlow,
7
type InboundMigrationFlow,
8
} from "./flow.svelte";
9
+
export {
10
+
clearOfflineState,
11
+
createOfflineInboundMigrationFlow,
12
+
getOfflineResumeInfo,
13
+
hasPendingOfflineMigration,
14
+
} from "./offline-flow.svelte";
15
+
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
+765
frontend/src/lib/migration/offline-flow.svelte.ts
+765
frontend/src/lib/migration/offline-flow.svelte.ts
···
···
1
+
import type {
2
+
AuthMethod,
3
+
MigrationProgress,
4
+
OfflineInboundMigrationState,
5
+
OfflineInboundStep,
6
+
ServerDescription,
7
+
} from "./types";
8
+
import {
9
+
AtprotoClient,
10
+
base64UrlEncode,
11
+
createLocalClient,
12
+
prepareWebAuthnCreationOptions,
13
+
} from "./atproto-client";
14
+
import { api } from "../api";
15
+
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops";
16
+
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
17
+
import { Secp256k1PrivateKeyExportable } from "@atcute/crypto";
18
+
19
+
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
20
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
21
+
22
+
interface StoredOfflineMigrationState {
23
+
version: number;
24
+
step: OfflineInboundStep;
25
+
startedAt: string;
26
+
userDid: string;
27
+
carFileName: string;
28
+
carSizeBytes: number;
29
+
rotationKeyDidKey: string;
30
+
targetHandle: string;
31
+
targetEmail: string;
32
+
authMethod: AuthMethod;
33
+
passkeySetupToken?: string;
34
+
oldPdsUrl?: string;
35
+
plcUpdatedTemporarily?: boolean;
36
+
progress: {
37
+
accountCreated: boolean;
38
+
repoImported: boolean;
39
+
plcSigned: boolean;
40
+
activated: boolean;
41
+
};
42
+
lastError?: string;
43
+
}
44
+
45
+
function saveOfflineState(state: OfflineInboundMigrationState): void {
46
+
const stored: StoredOfflineMigrationState = {
47
+
version: 1,
48
+
step: state.step,
49
+
startedAt: new Date().toISOString(),
50
+
userDid: state.userDid,
51
+
carFileName: state.carFileName,
52
+
carSizeBytes: state.carSizeBytes,
53
+
rotationKeyDidKey: state.rotationKeyDidKey,
54
+
targetHandle: state.targetHandle,
55
+
targetEmail: state.targetEmail,
56
+
authMethod: state.authMethod,
57
+
passkeySetupToken: state.passkeySetupToken ?? undefined,
58
+
oldPdsUrl: state.oldPdsUrl ?? undefined,
59
+
plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined,
60
+
progress: {
61
+
accountCreated: state.progress.repoExported,
62
+
repoImported: state.progress.repoImported,
63
+
plcSigned: state.progress.plcSigned,
64
+
activated: state.progress.activated,
65
+
},
66
+
lastError: state.error ?? undefined,
67
+
};
68
+
try {
69
+
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored));
70
+
} catch { /* ignore localStorage errors */ }
71
+
}
72
+
73
+
function loadOfflineState(): StoredOfflineMigrationState | null {
74
+
try {
75
+
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
76
+
if (!stored) return null;
77
+
const state = JSON.parse(stored) as StoredOfflineMigrationState;
78
+
if (state.version !== 1) {
79
+
clearOfflineState();
80
+
return null;
81
+
}
82
+
const startedAt = new Date(state.startedAt).getTime();
83
+
if (Date.now() - startedAt > MAX_AGE_MS) {
84
+
clearOfflineState();
85
+
return null;
86
+
}
87
+
return state;
88
+
} catch {
89
+
/* ignore parse errors */
90
+
clearOfflineState();
91
+
return null;
92
+
}
93
+
}
94
+
95
+
function clearOfflineState(): void {
96
+
try {
97
+
localStorage.removeItem(OFFLINE_STORAGE_KEY);
98
+
} catch { /* ignore localStorage errors */ }
99
+
}
100
+
101
+
export function hasPendingOfflineMigration(): boolean {
102
+
return loadOfflineState() !== null;
103
+
}
104
+
105
+
export function getOfflineResumeInfo(): {
106
+
step: OfflineInboundStep;
107
+
userDid: string;
108
+
targetHandle: string;
109
+
} | null {
110
+
const state = loadOfflineState();
111
+
if (!state) return null;
112
+
return {
113
+
step: state.step,
114
+
userDid: state.userDid,
115
+
targetHandle: state.targetHandle,
116
+
};
117
+
}
118
+
119
+
export { clearOfflineState };
120
+
121
+
function createInitialProgress(): MigrationProgress {
122
+
return {
123
+
repoExported: false,
124
+
repoImported: false,
125
+
blobsTotal: 0,
126
+
blobsMigrated: 0,
127
+
blobsFailed: [],
128
+
prefsMigrated: false,
129
+
plcSigned: false,
130
+
activated: false,
131
+
deactivated: false,
132
+
currentOperation: "",
133
+
};
134
+
}
135
+
136
+
export type OfflineInboundMigrationFlow = ReturnType<
137
+
typeof createOfflineInboundMigrationFlow
138
+
>;
139
+
140
+
export function createOfflineInboundMigrationFlow() {
141
+
let state = $state<OfflineInboundMigrationState>({
142
+
direction: "offline-inbound",
143
+
step: "welcome",
144
+
userDid: "",
145
+
carFile: null,
146
+
carFileName: "",
147
+
carSizeBytes: 0,
148
+
carNeedsReupload: false,
149
+
rotationKey: "",
150
+
rotationKeyDidKey: "",
151
+
oldPdsUrl: null,
152
+
targetHandle: "",
153
+
targetEmail: "",
154
+
targetPassword: "",
155
+
inviteCode: "",
156
+
authMethod: "password",
157
+
localAccessToken: null,
158
+
localRefreshToken: null,
159
+
passkeySetupToken: null,
160
+
generatedAppPassword: null,
161
+
generatedAppPasswordName: null,
162
+
emailVerifyToken: "",
163
+
progress: createInitialProgress(),
164
+
error: null,
165
+
plcUpdatedTemporarily: false,
166
+
});
167
+
168
+
let localServerInfo: ServerDescription | null = null;
169
+
let userRotationKeypair: KeypairInfo | null = null;
170
+
let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null;
171
+
172
+
function setStep(step: OfflineInboundStep) {
173
+
state.step = step;
174
+
state.error = null;
175
+
if (step !== "success") {
176
+
saveOfflineState(state);
177
+
}
178
+
}
179
+
180
+
function setError(error: string | null) {
181
+
state.error = error;
182
+
saveOfflineState(state);
183
+
}
184
+
185
+
function setProgress(updates: Partial<MigrationProgress>) {
186
+
state.progress = { ...state.progress, ...updates };
187
+
saveOfflineState(state);
188
+
}
189
+
190
+
async function loadLocalServerInfo(): Promise<ServerDescription> {
191
+
if (!localServerInfo) {
192
+
const client = createLocalClient();
193
+
localServerInfo = await client.describeServer();
194
+
}
195
+
return localServerInfo;
196
+
}
197
+
198
+
async function checkHandleAvailability(handle: string): Promise<boolean> {
199
+
const client = createLocalClient();
200
+
try {
201
+
await client.resolveHandle(handle);
202
+
return false;
203
+
} catch {
204
+
return true;
205
+
}
206
+
}
207
+
208
+
async function validateRotationKey(): Promise<boolean> {
209
+
if (!state.userDid || !state.rotationKey) {
210
+
throw new Error("DID and rotation key are required");
211
+
}
212
+
213
+
try {
214
+
userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim());
215
+
const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid);
216
+
const currentRotationKeys = lastOperation.rotationKeys || [];
217
+
218
+
if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) {
219
+
state.rotationKeyDidKey = "";
220
+
return false;
221
+
}
222
+
223
+
state.rotationKeyDidKey = userRotationKeypair.didPublicKey;
224
+
225
+
const pdsService = lastOperation.services?.atproto_pds;
226
+
if (pdsService?.endpoint) {
227
+
state.oldPdsUrl = pdsService.endpoint;
228
+
console.log(
229
+
"[offline-migration] Captured old PDS URL:",
230
+
state.oldPdsUrl,
231
+
);
232
+
} else {
233
+
console.warn(
234
+
"[offline-migration] No PDS service endpoint found in PLC document",
235
+
);
236
+
console.log(
237
+
"[offline-migration] PLC services:",
238
+
JSON.stringify(lastOperation.services),
239
+
);
240
+
}
241
+
242
+
saveOfflineState(state);
243
+
return true;
244
+
} catch (e) {
245
+
throw new Error(`Failed to parse rotation key: ${(e as Error).message}`);
246
+
}
247
+
}
248
+
249
+
async function prepareTempCredentials(): Promise<string> {
250
+
if (!userRotationKeypair) {
251
+
throw new Error("Rotation key not validated");
252
+
}
253
+
254
+
setProgress({ currentOperation: "Preparing temporary credentials..." });
255
+
256
+
tempVerificationKeypair = await Secp256k1PrivateKeyExportable
257
+
.createKeypair();
258
+
const tempVerificationPublicKey = await tempVerificationKeypair
259
+
.exportPublicKey("did");
260
+
261
+
const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc(
262
+
state.userDid,
263
+
);
264
+
const prevCid = base.cid;
265
+
266
+
setProgress({ currentOperation: "Updating DID document temporarily..." });
267
+
268
+
const localPdsUrl = globalThis.location.origin;
269
+
await plcOps.signAndPublishNewOp(
270
+
state.userDid,
271
+
userRotationKeypair.keypair,
272
+
lastOperation.alsoKnownAs || [],
273
+
[userRotationKeypair.didPublicKey],
274
+
localPdsUrl,
275
+
tempVerificationPublicKey,
276
+
prevCid,
277
+
);
278
+
279
+
state.plcUpdatedTemporarily = true;
280
+
saveOfflineState(state);
281
+
282
+
const serverInfo = await loadLocalServerInfo();
283
+
const serviceAuthToken = await plcOps.createServiceAuthToken(
284
+
state.userDid,
285
+
serverInfo.did,
286
+
tempVerificationKeypair as unknown as PrivateKey,
287
+
"com.atproto.server.createAccount",
288
+
);
289
+
290
+
return serviceAuthToken;
291
+
}
292
+
293
+
async function createPasswordAccount(
294
+
serviceAuthToken: string,
295
+
): Promise<void> {
296
+
setProgress({ currentOperation: "Creating account on new PDS..." });
297
+
298
+
const serverInfo = await loadLocalServerInfo();
299
+
const fullHandle = state.targetHandle.includes(".")
300
+
? state.targetHandle
301
+
: `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
302
+
303
+
const createResult = await api.createAccountWithServiceAuth(
304
+
serviceAuthToken,
305
+
{
306
+
did: state.userDid,
307
+
handle: fullHandle,
308
+
email: state.targetEmail,
309
+
password: state.targetPassword,
310
+
inviteCode: state.inviteCode || undefined,
311
+
},
312
+
);
313
+
314
+
state.targetHandle = fullHandle;
315
+
state.localAccessToken = createResult.accessJwt;
316
+
state.localRefreshToken = createResult.refreshJwt;
317
+
setProgress({ repoExported: true });
318
+
}
319
+
320
+
async function createPasskeyAccount(serviceAuthToken: string): Promise<void> {
321
+
setProgress({ currentOperation: "Creating passkey account on new PDS..." });
322
+
323
+
const serverInfo = await loadLocalServerInfo();
324
+
const fullHandle = state.targetHandle.includes(".")
325
+
? state.targetHandle
326
+
: `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
327
+
328
+
const createResult = await api.createPasskeyAccount({
329
+
did: state.userDid,
330
+
handle: fullHandle,
331
+
email: state.targetEmail,
332
+
inviteCode: state.inviteCode || undefined,
333
+
}, serviceAuthToken);
334
+
335
+
state.targetHandle = fullHandle;
336
+
state.passkeySetupToken = createResult.setupToken;
337
+
setProgress({ repoExported: true });
338
+
saveOfflineState(state);
339
+
}
340
+
341
+
async function signFinalPlcOperation(): Promise<void> {
342
+
if (!userRotationKeypair || !state.localAccessToken) {
343
+
throw new Error("Prerequisites not met for PLC signing");
344
+
}
345
+
346
+
setProgress({ currentOperation: "Finalizing DID document..." });
347
+
348
+
const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid);
349
+
const prevCid = base.cid;
350
+
351
+
const credentials = await api.getRecommendedDidCredentials(
352
+
state.localAccessToken,
353
+
);
354
+
355
+
await plcOps.signPlcOperationWithCredentials(
356
+
state.userDid,
357
+
userRotationKeypair.keypair,
358
+
{
359
+
rotationKeys: credentials.rotationKeys,
360
+
alsoKnownAs: credentials.alsoKnownAs,
361
+
verificationMethods: credentials.verificationMethods,
362
+
services: credentials.services,
363
+
},
364
+
[userRotationKeypair.didPublicKey],
365
+
prevCid,
366
+
);
367
+
368
+
setProgress({ plcSigned: true });
369
+
}
370
+
371
+
async function importRepository(): Promise<void> {
372
+
if (!state.carFile || !state.localAccessToken) {
373
+
throw new Error("CAR file and access token are required");
374
+
}
375
+
376
+
setProgress({ currentOperation: "Importing repository..." });
377
+
await api.importRepo(state.localAccessToken, state.carFile);
378
+
setProgress({ repoImported: true });
379
+
}
380
+
381
+
async function migrateBlobs(): Promise<void> {
382
+
if (!state.localAccessToken) {
383
+
throw new Error("Access token required");
384
+
}
385
+
386
+
const localClient = createLocalClient();
387
+
localClient.setAccessToken(state.localAccessToken);
388
+
389
+
if (state.oldPdsUrl) {
390
+
setProgress({
391
+
currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`,
392
+
});
393
+
} else {
394
+
setProgress({
395
+
currentOperation: "No source PDS URL available for blob migration",
396
+
});
397
+
}
398
+
399
+
const sourceClient = state.oldPdsUrl
400
+
? new AtprotoClient(state.oldPdsUrl)
401
+
: null;
402
+
403
+
const result = await migrateBlobsUtil(
404
+
localClient,
405
+
sourceClient,
406
+
state.userDid,
407
+
setProgress,
408
+
);
409
+
410
+
state.progress.blobsFailed = result.failed;
411
+
state.progress.blobsTotal = result.total;
412
+
state.progress.blobsMigrated = result.migrated;
413
+
414
+
if (result.total === 0) {
415
+
setProgress({ currentOperation: "No blobs to migrate" });
416
+
} else if (result.sourceUnreachable) {
417
+
setProgress({
418
+
currentOperation:
419
+
`Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`,
420
+
});
421
+
} else if (result.failed.length > 0) {
422
+
setProgress({
423
+
currentOperation:
424
+
`${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`,
425
+
});
426
+
} else {
427
+
setProgress({
428
+
currentOperation: `All ${result.migrated} blobs migrated successfully`,
429
+
});
430
+
}
431
+
}
432
+
433
+
async function activateAccount(): Promise<void> {
434
+
if (!state.localAccessToken) {
435
+
throw new Error("Access token required");
436
+
}
437
+
438
+
setProgress({ currentOperation: "Activating account..." });
439
+
await api.activateAccount(state.localAccessToken);
440
+
setProgress({ activated: true });
441
+
}
442
+
443
+
async function submitEmailVerifyToken(token: string): Promise<void> {
444
+
state.emailVerifyToken = token;
445
+
setError(null);
446
+
447
+
try {
448
+
await api.verifyMigrationEmail(token, state.targetEmail);
449
+
450
+
if (state.authMethod === "passkey") {
451
+
setStep("passkey-setup");
452
+
} else {
453
+
const session = await api.createSession(
454
+
state.targetEmail,
455
+
state.targetPassword,
456
+
);
457
+
state.localAccessToken = session.accessJwt;
458
+
state.localRefreshToken = session.refreshJwt;
459
+
saveOfflineState(state);
460
+
461
+
setStep("plc-signing");
462
+
await signFinalPlcOperation();
463
+
464
+
setStep("finalizing");
465
+
await activateAccount();
466
+
467
+
cleanup();
468
+
setStep("success");
469
+
}
470
+
} catch (e) {
471
+
const err = e as Error & { error?: string };
472
+
setError(err.message || err.error || "Email verification failed");
473
+
}
474
+
}
475
+
476
+
async function resendEmailVerification(): Promise<void> {
477
+
await api.resendMigrationVerification(state.targetEmail);
478
+
}
479
+
480
+
let checkingEmailVerification = false;
481
+
482
+
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
483
+
if (checkingEmailVerification) return false;
484
+
if (state.authMethod === "passkey") return false;
485
+
486
+
checkingEmailVerification = true;
487
+
try {
488
+
const { verified } = await api.checkEmailVerified(state.targetEmail);
489
+
if (!verified) return false;
490
+
491
+
const session = await api.createSession(
492
+
state.targetEmail,
493
+
state.targetPassword,
494
+
);
495
+
state.localAccessToken = session.accessJwt;
496
+
state.localRefreshToken = session.refreshJwt;
497
+
saveOfflineState(state);
498
+
499
+
setStep("plc-signing");
500
+
await signFinalPlcOperation();
501
+
502
+
setStep("finalizing");
503
+
await activateAccount();
504
+
505
+
cleanup();
506
+
setStep("success");
507
+
return true;
508
+
} catch {
509
+
return false;
510
+
} finally {
511
+
checkingEmailVerification = false;
512
+
}
513
+
}
514
+
515
+
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
516
+
if (!state.passkeySetupToken) {
517
+
throw new Error("No passkey setup token");
518
+
}
519
+
520
+
return api.startPasskeyRegistrationForSetup(
521
+
state.userDid,
522
+
state.passkeySetupToken,
523
+
);
524
+
}
525
+
526
+
async function registerPasskey(passkeyName?: string): Promise<void> {
527
+
if (!state.passkeySetupToken) {
528
+
throw new Error("No passkey setup token");
529
+
}
530
+
531
+
if (!globalThis.PublicKeyCredential) {
532
+
throw new Error("Passkeys are not supported in this browser");
533
+
}
534
+
535
+
const { options } = await startPasskeyRegistration();
536
+
537
+
const publicKeyOptions = prepareWebAuthnCreationOptions(
538
+
options as { publicKey: Record<string, unknown> },
539
+
);
540
+
const credential = await navigator.credentials.create({
541
+
publicKey: publicKeyOptions,
542
+
});
543
+
544
+
if (!credential) {
545
+
throw new Error("Passkey creation was cancelled");
546
+
}
547
+
548
+
const publicKeyCredential = credential as PublicKeyCredential;
549
+
const response = publicKeyCredential
550
+
.response as AuthenticatorAttestationResponse;
551
+
552
+
const credentialData = {
553
+
id: publicKeyCredential.id,
554
+
rawId: base64UrlEncode(publicKeyCredential.rawId),
555
+
type: publicKeyCredential.type,
556
+
response: {
557
+
clientDataJSON: base64UrlEncode(response.clientDataJSON),
558
+
attestationObject: base64UrlEncode(response.attestationObject),
559
+
},
560
+
};
561
+
562
+
const result = await api.completePasskeySetup(
563
+
state.userDid,
564
+
state.passkeySetupToken,
565
+
credentialData,
566
+
passkeyName,
567
+
);
568
+
569
+
state.generatedAppPassword = result.appPassword;
570
+
state.generatedAppPasswordName = result.appPasswordName;
571
+
572
+
const session = await api.createSession(
573
+
state.targetEmail,
574
+
result.appPassword,
575
+
);
576
+
state.localAccessToken = session.accessJwt;
577
+
state.localRefreshToken = session.refreshJwt;
578
+
saveOfflineState(state);
579
+
580
+
setStep("app-password");
581
+
}
582
+
583
+
async function proceedFromAppPassword(): Promise<void> {
584
+
setStep("plc-signing");
585
+
await signFinalPlcOperation();
586
+
587
+
setStep("finalizing");
588
+
await activateAccount();
589
+
590
+
cleanup();
591
+
setStep("success");
592
+
}
593
+
594
+
function cleanup(): void {
595
+
clearOfflineState();
596
+
userRotationKeypair = null;
597
+
tempVerificationKeypair = null;
598
+
state.rotationKey = "";
599
+
}
600
+
601
+
async function runMigration(): Promise<void> {
602
+
try {
603
+
setStep("creating");
604
+
605
+
const serviceAuthToken = await prepareTempCredentials();
606
+
607
+
if (state.authMethod === "passkey") {
608
+
await createPasskeyAccount(serviceAuthToken);
609
+
} else {
610
+
await createPasswordAccount(serviceAuthToken);
611
+
}
612
+
613
+
setStep("importing");
614
+
await importRepository();
615
+
616
+
setStep("migrating-blobs");
617
+
await migrateBlobs();
618
+
619
+
if (
620
+
state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0
621
+
) {
622
+
await new Promise((resolve) => setTimeout(resolve, 3000));
623
+
}
624
+
625
+
setStep("email-verify");
626
+
} catch (e) {
627
+
setError((e as Error).message);
628
+
setStep("error");
629
+
}
630
+
}
631
+
632
+
function reset() {
633
+
clearOfflineState();
634
+
userRotationKeypair = null;
635
+
tempVerificationKeypair = null;
636
+
state = {
637
+
direction: "offline-inbound",
638
+
step: "welcome",
639
+
userDid: "",
640
+
carFile: null,
641
+
carFileName: "",
642
+
carSizeBytes: 0,
643
+
carNeedsReupload: false,
644
+
rotationKey: "",
645
+
rotationKeyDidKey: "",
646
+
oldPdsUrl: null,
647
+
targetHandle: "",
648
+
targetEmail: "",
649
+
targetPassword: "",
650
+
inviteCode: "",
651
+
authMethod: "password",
652
+
localAccessToken: null,
653
+
localRefreshToken: null,
654
+
passkeySetupToken: null,
655
+
generatedAppPassword: null,
656
+
generatedAppPasswordName: null,
657
+
emailVerifyToken: "",
658
+
progress: createInitialProgress(),
659
+
error: null,
660
+
plcUpdatedTemporarily: false,
661
+
};
662
+
localServerInfo = null;
663
+
}
664
+
665
+
function tryResume(): boolean {
666
+
const stored = loadOfflineState();
667
+
if (!stored) return false;
668
+
669
+
state.userDid = stored.userDid;
670
+
state.carFileName = stored.carFileName;
671
+
state.carSizeBytes = stored.carSizeBytes;
672
+
state.rotationKeyDidKey = stored.rotationKeyDidKey;
673
+
state.targetHandle = stored.targetHandle;
674
+
state.targetEmail = stored.targetEmail;
675
+
state.authMethod = stored.authMethod ?? "password";
676
+
state.passkeySetupToken = stored.passkeySetupToken ?? null;
677
+
state.oldPdsUrl = stored.oldPdsUrl ?? null;
678
+
state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false;
679
+
state.step = stored.step;
680
+
state.progress.repoExported = stored.progress.accountCreated;
681
+
state.progress.repoImported = stored.progress.repoImported;
682
+
state.progress.plcSigned = stored.progress.plcSigned;
683
+
state.progress.activated = stored.progress.activated;
684
+
state.error = stored.lastError ?? null;
685
+
686
+
if (stored.carFileName && stored.carSizeBytes > 0) {
687
+
state.carNeedsReupload = true;
688
+
}
689
+
690
+
return true;
691
+
}
692
+
693
+
function getLocalSession():
694
+
| { accessJwt: string; did: string; handle: string }
695
+
| null {
696
+
if (!state.localAccessToken) return null;
697
+
return {
698
+
accessJwt: state.localAccessToken,
699
+
did: state.userDid,
700
+
handle: state.targetHandle,
701
+
};
702
+
}
703
+
704
+
return {
705
+
get state() {
706
+
return state;
707
+
},
708
+
getLocalSession,
709
+
setStep,
710
+
setError,
711
+
setProgress,
712
+
loadLocalServerInfo,
713
+
checkHandleAvailability,
714
+
validateRotationKey,
715
+
runMigration,
716
+
submitEmailVerifyToken,
717
+
resendEmailVerification,
718
+
checkEmailVerifiedAndProceed,
719
+
startPasskeyRegistration,
720
+
registerPasskey,
721
+
proceedFromAppPassword,
722
+
reset,
723
+
tryResume,
724
+
clearOfflineState,
725
+
setUserDid(did: string) {
726
+
state.userDid = did;
727
+
saveOfflineState(state);
728
+
},
729
+
setCarFile(file: Uint8Array, fileName: string) {
730
+
state.carFile = file;
731
+
state.carFileName = fileName;
732
+
state.carSizeBytes = file.length;
733
+
state.carNeedsReupload = false;
734
+
saveOfflineState(state);
735
+
},
736
+
setRotationKey(key: string) {
737
+
state.rotationKey = key;
738
+
},
739
+
setTargetHandle(handle: string) {
740
+
state.targetHandle = handle;
741
+
saveOfflineState(state);
742
+
},
743
+
setTargetEmail(email: string) {
744
+
state.targetEmail = email;
745
+
saveOfflineState(state);
746
+
},
747
+
setTargetPassword(password: string) {
748
+
state.targetPassword = password;
749
+
},
750
+
setInviteCode(code: string) {
751
+
state.inviteCode = code;
752
+
},
753
+
setAuthMethod(method: AuthMethod) {
754
+
state.authMethod = method;
755
+
saveOfflineState(state);
756
+
},
757
+
updateField<K extends keyof OfflineInboundMigrationState>(
758
+
field: K,
759
+
value: OfflineInboundMigrationState[K],
760
+
) {
761
+
state[field] = value;
762
+
saveOfflineState(state);
763
+
},
764
+
};
765
+
}
+281
frontend/src/lib/migration/plc-ops.ts
+281
frontend/src/lib/migration/plc-ops.ts
···
···
1
+
import {
2
+
defs,
3
+
type IndexedEntry,
4
+
normalizeOp,
5
+
type Operation,
6
+
} from "@atcute/did-plc";
7
+
import {
8
+
P256PrivateKey,
9
+
parsePrivateMultikey,
10
+
Secp256k1PrivateKey,
11
+
Secp256k1PrivateKeyExportable,
12
+
} from "@atcute/crypto";
13
+
import * as CBOR from "@atcute/cbor";
14
+
import { fromBase16, toBase64Url } from "@atcute/multibase";
15
+
16
+
export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey;
17
+
18
+
export interface KeypairInfo {
19
+
type: "private_key";
20
+
didPublicKey: `did:key:${string}`;
21
+
keypair: PrivateKey;
22
+
}
23
+
24
+
export interface PlcService {
25
+
type: string;
26
+
endpoint: string;
27
+
}
28
+
29
+
export interface PlcOperationData {
30
+
type: "plc_operation";
31
+
prev: string;
32
+
alsoKnownAs: string[];
33
+
rotationKeys: string[];
34
+
services: Record<string, PlcService>;
35
+
verificationMethods: Record<string, string>;
36
+
sig?: string;
37
+
}
38
+
39
+
const jsonToB64Url = (obj: unknown): string => {
40
+
const enc = new TextEncoder();
41
+
const json = JSON.stringify(obj);
42
+
return toBase64Url(enc.encode(json));
43
+
};
44
+
45
+
export class PlcOps {
46
+
private plcDirectoryUrl: string;
47
+
48
+
constructor(plcDirectoryUrl = "https://plc.directory") {
49
+
this.plcDirectoryUrl = plcDirectoryUrl;
50
+
}
51
+
52
+
async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> {
53
+
const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`);
54
+
if (!response.ok) {
55
+
throw new Error(`Failed to fetch PLC audit logs: ${response.status}`);
56
+
}
57
+
const json = await response.json();
58
+
return defs.indexedEntryLog.parse(json);
59
+
}
60
+
61
+
async getLastPlcOpFromPlc(
62
+
did: string,
63
+
): Promise<{ lastOperation: Operation; base: IndexedEntry }> {
64
+
const logs = await this.getPlcAuditLogs(did);
65
+
const lastOp = logs.at(-1);
66
+
if (!lastOp) {
67
+
throw new Error("No PLC operations found for this DID");
68
+
}
69
+
return { lastOperation: normalizeOp(lastOp.operation), base: lastOp };
70
+
}
71
+
72
+
async getCurrentRotationKeysForUser(did: string): Promise<string[]> {
73
+
const { lastOperation } = await this.getLastPlcOpFromPlc(did);
74
+
return lastOperation.rotationKeys || [];
75
+
}
76
+
77
+
async createNewSecp256k1Keypair(): Promise<
78
+
{ privateKey: string; publicKey: `did:key:${string}` }
79
+
> {
80
+
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
81
+
const publicKey = await keypair.exportPublicKey("did");
82
+
const privateKey = await keypair.exportPrivateKey("multikey");
83
+
return { privateKey, publicKey };
84
+
}
85
+
86
+
async getKeyPair(
87
+
privateKeyString: string,
88
+
type: "secp256k1" | "p256" = "secp256k1",
89
+
): Promise<KeypairInfo> {
90
+
const HEX_REGEX = /^[0-9a-f]+$/i;
91
+
const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
92
+
let keypair: PrivateKey | undefined;
93
+
94
+
const trimmed = privateKeyString.trim();
95
+
96
+
if (HEX_REGEX.test(trimmed) && trimmed.length === 64) {
97
+
const privateKeyBytes = fromBase16(trimmed);
98
+
if (type === "p256") {
99
+
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
100
+
} else {
101
+
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
102
+
}
103
+
} else if (MULTIKEY_REGEX.test(trimmed)) {
104
+
const match = parsePrivateMultikey(trimmed);
105
+
const privateKeyBytes = match.privateKeyBytes;
106
+
if (match.type === "p256") {
107
+
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
108
+
} else if (match.type === "secp256k1") {
109
+
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
110
+
} else {
111
+
throw new Error(`Unsupported key type: ${match.type}`);
112
+
}
113
+
} else {
114
+
throw new Error(
115
+
"Invalid key format. Expected 64-char hex or multikey format.",
116
+
);
117
+
}
118
+
119
+
if (!keypair) {
120
+
throw new Error("Failed to parse private key");
121
+
}
122
+
123
+
return {
124
+
type: "private_key",
125
+
didPublicKey: await keypair.exportPublicKey("did"),
126
+
keypair,
127
+
};
128
+
}
129
+
130
+
async signAndPublishNewOp(
131
+
did: string,
132
+
signingRotationKey: PrivateKey,
133
+
alsoKnownAs: string[],
134
+
rotationKeys: string[],
135
+
pds: string,
136
+
verificationKey: string,
137
+
prev: string,
138
+
): Promise<void> {
139
+
const rotationKeysToUse = [...new Set(rotationKeys)];
140
+
if (rotationKeysToUse.length === 0) {
141
+
throw new Error("No rotation keys provided");
142
+
}
143
+
if (rotationKeysToUse.length > 5) {
144
+
throw new Error("Maximum 5 rotation keys allowed");
145
+
}
146
+
147
+
const operation: PlcOperationData = {
148
+
type: "plc_operation",
149
+
prev,
150
+
alsoKnownAs,
151
+
rotationKeys: rotationKeysToUse,
152
+
services: {
153
+
atproto_pds: {
154
+
type: "AtprotoPersonalDataServer",
155
+
endpoint: pds,
156
+
},
157
+
},
158
+
verificationMethods: {
159
+
atproto: verificationKey,
160
+
},
161
+
};
162
+
163
+
const opBytes = CBOR.encode(operation);
164
+
const sigBytes = await signingRotationKey.sign(opBytes);
165
+
const signature = toBase64Url(sigBytes);
166
+
167
+
const signedOperation = {
168
+
...operation,
169
+
sig: signature,
170
+
};
171
+
172
+
await this.pushPlcOperation(did, signedOperation);
173
+
}
174
+
175
+
async pushPlcOperation(
176
+
did: string,
177
+
operation: PlcOperationData,
178
+
): Promise<void> {
179
+
const response = await fetch(`${this.plcDirectoryUrl}/${did}`, {
180
+
method: "POST",
181
+
headers: {
182
+
"Content-Type": "application/json",
183
+
},
184
+
body: JSON.stringify(operation),
185
+
});
186
+
187
+
if (!response.ok) {
188
+
const contentType = response.headers.get("content-type");
189
+
if (contentType?.includes("application/json")) {
190
+
const json = await response.json();
191
+
if (
192
+
typeof json === "object" && json !== null &&
193
+
typeof json.message === "string"
194
+
) {
195
+
throw new Error(json.message);
196
+
}
197
+
}
198
+
throw new Error(`PLC directory returned HTTP ${response.status}`);
199
+
}
200
+
}
201
+
202
+
async createServiceAuthToken(
203
+
iss: string,
204
+
aud: string,
205
+
keypair: PrivateKey,
206
+
lxm: string,
207
+
): Promise<string> {
208
+
const iat = Math.floor(Date.now() / 1000);
209
+
const exp = iat + 60;
210
+
211
+
const jti = (() => {
212
+
const bytes = new Uint8Array(16);
213
+
crypto.getRandomValues(bytes);
214
+
return Array.from(bytes)
215
+
.map((b) => b.toString(16).padStart(2, "0"))
216
+
.join("");
217
+
})();
218
+
219
+
const header = { typ: "JWT", alg: "ES256K" };
220
+
const payload = { iat, iss, aud, exp, lxm, jti };
221
+
222
+
const headerB64 = jsonToB64Url(header);
223
+
const payloadB64 = jsonToB64Url(payload);
224
+
const toSignStr = `${headerB64}.${payloadB64}`;
225
+
226
+
const toSignBytes = new TextEncoder().encode(toSignStr);
227
+
const sigBytes = await keypair.sign(toSignBytes);
228
+
const sigB64 = toBase64Url(sigBytes);
229
+
230
+
return `${toSignStr}.${sigB64}`;
231
+
}
232
+
233
+
async signPlcOperationWithCredentials(
234
+
did: string,
235
+
signingKey: PrivateKey,
236
+
credentials: {
237
+
rotationKeys?: string[];
238
+
alsoKnownAs?: string[];
239
+
verificationMethods?: Record<string, string>;
240
+
services?: Record<string, PlcService>;
241
+
},
242
+
additionalRotationKeys: string[],
243
+
prevCid: string,
244
+
): Promise<void> {
245
+
const rotationKeys = [
246
+
...new Set([
247
+
...(additionalRotationKeys || []),
248
+
...(credentials.rotationKeys || []),
249
+
]),
250
+
];
251
+
252
+
if (rotationKeys.length === 0) {
253
+
throw new Error("No rotation keys provided");
254
+
}
255
+
if (rotationKeys.length > 5) {
256
+
throw new Error("Maximum 5 rotation keys allowed");
257
+
}
258
+
259
+
const operation: PlcOperationData = {
260
+
type: "plc_operation",
261
+
prev: prevCid,
262
+
alsoKnownAs: credentials.alsoKnownAs || [],
263
+
rotationKeys,
264
+
services: credentials.services || {},
265
+
verificationMethods: credentials.verificationMethods || {},
266
+
};
267
+
268
+
const opBytes = CBOR.encode(operation);
269
+
const sigBytes = await signingKey.sign(opBytes);
270
+
const signature = toBase64Url(sigBytes);
271
+
272
+
const signedOperation = {
273
+
...operation,
274
+
sig: signature,
275
+
};
276
+
277
+
await this.pushPlcOperation(did, signedOperation);
278
+
}
279
+
}
280
+
281
+
export const plcOps = new PlcOps();
+35
-21
frontend/src/lib/migration/types.ts
+35
-21
frontend/src/lib/migration/types.ts
···
13
| "success"
14
| "error";
15
16
-
export type AuthMethod = "password" | "passkey";
17
-
18
-
export type OutboundStep =
19
| "welcome"
20
-
| "target-pds"
21
-
| "new-account"
22
| "review"
23
-
| "migrating"
24
-
| "plc-token"
25
| "finalizing"
26
| "success"
27
| "error";
28
29
-
export type MigrationDirection = "inbound" | "outbound";
30
31
export interface MigrationProgress {
32
repoExported: boolean;
···
68
resumeToStep?: InboundStep;
69
}
70
71
-
export interface OutboundMigrationState {
72
-
direction: "outbound";
73
-
step: OutboundStep;
74
-
localDid: string;
75
-
localHandle: string;
76
-
targetPdsUrl: string;
77
-
targetPdsDid: string;
78
targetHandle: string;
79
targetEmail: string;
80
targetPassword: string;
81
inviteCode: string;
82
-
targetAccessToken: string | null;
83
-
targetRefreshToken: string | null;
84
-
serviceAuthToken: string | null;
85
-
plcToken: string;
86
progress: MigrationProgress;
87
error: string | null;
88
-
targetServerInfo: ServerDescription | null;
89
}
90
91
-
export type MigrationState = InboundMigrationState | OutboundMigrationState;
92
93
export interface StoredMigrationState {
94
version: 1;
···
13
| "success"
14
| "error";
15
16
+
export type OfflineInboundStep =
17
| "welcome"
18
+
| "provide-did"
19
+
| "upload-car"
20
+
| "provide-rotation-key"
21
+
| "choose-handle"
22
| "review"
23
+
| "creating"
24
+
| "importing"
25
+
| "migrating-blobs"
26
+
| "plc-signing"
27
+
| "email-verify"
28
+
| "passkey-setup"
29
+
| "app-password"
30
| "finalizing"
31
| "success"
32
| "error";
33
34
+
export type AuthMethod = "password" | "passkey";
35
+
36
+
export type MigrationDirection = "inbound";
37
38
export interface MigrationProgress {
39
repoExported: boolean;
···
75
resumeToStep?: InboundStep;
76
}
77
78
+
export interface OfflineInboundMigrationState {
79
+
direction: "offline-inbound";
80
+
step: OfflineInboundStep;
81
+
userDid: string;
82
+
carFile: Uint8Array | null;
83
+
carFileName: string;
84
+
carSizeBytes: number;
85
+
carNeedsReupload: boolean;
86
+
rotationKey: string;
87
+
rotationKeyDidKey: string;
88
+
oldPdsUrl: string | null;
89
targetHandle: string;
90
targetEmail: string;
91
targetPassword: string;
92
inviteCode: string;
93
+
authMethod: AuthMethod;
94
+
localAccessToken: string | null;
95
+
localRefreshToken: string | null;
96
+
passkeySetupToken: string | null;
97
+
generatedAppPassword: string | null;
98
+
generatedAppPasswordName: string | null;
99
+
emailVerifyToken: string;
100
progress: MigrationProgress;
101
error: string | null;
102
+
plcUpdatedTemporarily: boolean;
103
}
104
105
+
export type MigrationState = InboundMigrationState;
106
107
export interface StoredMigrationState {
108
version: 1;
+152
-98
frontend/src/locales/en.json
+152
-98
frontend/src/locales/en.json
···
17
"dashboard": "Dashboard",
18
"backToDashboard": "← Dashboard",
19
"copied": "Copied!",
20
-
"copyToClipboard": "Copy to Clipboard"
21
},
22
"login": {
23
"title": "Sign In",
···
49
"codeLabel": "Verification Code",
50
"codePlaceholder": "Enter 6-digit code",
51
"verifyButton": "Verify Account",
52
-
"verifying": "Verifying...",
53
-
"resendButton": "Resend Code",
54
-
"resending": "Resending...",
55
-
"resent": "Verification code resent!",
56
-
"backToLogin": "Back to Login"
57
},
58
"register": {
59
"title": "Create Account",
···
124
"inviteCodePlaceholder": "Enter your invite code",
125
"inviteCodeRequired": "required",
126
"createButton": "Create Account",
127
-
"creating": "Creating account...",
128
"alreadyHaveAccount": "Already have an account?",
129
"signIn": "Sign in",
130
"wantPasswordless": "Want passwordless security?",
···
179
"navAdminDesc": "Server stats and admin operations",
180
"navDidDocument": "DID Document",
181
"navDidDocumentDesc": "Manage your DID document for external migrations",
182
"migrated": "Migrated",
183
"migratedTitle": "Account Migrated",
184
"migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
···
208
"serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
209
"currentPds": "Current PDS URL",
210
"save": "Save Changes",
211
-
"saving": "Saving...",
212
"success": "DID document updated successfully",
213
"saveFailed": "Failed to save DID document",
214
"loadFailed": "Failed to load DID document",
···
246
"yourDomain": "Your Domain",
247
"yourDomainPlaceholder": "example.com",
248
"verifyAndUpdate": "Verify & Update Handle",
249
-
"verifying": "Verifying...",
250
"newHandle": "New Handle",
251
"newHandlePlaceholder": "yourhandle",
252
"changeHandleButton": "Change Handle",
···
262
"exportData": "Export Data",
263
"exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.",
264
"downloadRepo": "Download Repository",
265
"exporting": "Exporting...",
266
"deleteAccount": "Delete Account",
267
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
268
"requestDeletion": "Request Account Deletion",
···
291
"deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.",
292
"deletionFailed": "Failed to delete account",
293
"repoExported": "Repository exported successfully",
294
-
"exportFailed": "Failed to export repository",
295
"confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone."
296
}
297
},
···
306
"noPasswords": "No app passwords yet",
307
"revoke": "Revoke",
308
"revoking": "Revoking...",
309
-
"creating": "Creating...",
310
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
311
"saveWarningTitle": "Important: Save this app password!",
312
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
···
354
"used": "Used by @{handle}",
355
"disabled": "Disabled",
356
"usedBy": "Used by",
357
-
"creating": "Creating...",
358
"disableConfirm": "Disable this invite code? It can no longer be used.",
359
"created": "Invite Code Created",
360
"copy": "Copy",
···
482
"verifyButton": "Verify",
483
"verifyCodePlaceholder": "Enter verification code",
484
"submit": "Submit",
485
-
"saving": "Saving...",
486
"savePreferences": "Save Preferences",
487
"preferencesSaved": "Communication preferences saved",
488
"verifiedSuccess": "{channel} verified successfully",
···
521
"noCollectionsYet": "No collections yet. Create your first record to get started.",
522
"loadMore": "Load More",
523
"recordJson": "Record JSON",
524
-
"saving": "Saving...",
525
"updateRecord": "Update Record",
526
"collectionNsid": "Collection (NSID)",
527
"recordKeyOptional": "Record Key (optional)",
528
"autoGenerated": "Auto-generated if empty (TID)",
529
"autoGeneratedHint": "Leave empty to auto-generate a TID-based key",
530
-
"creating": "Creating...",
531
"demoPostText": "Hello from my PDS! This is my first post.",
532
"demoDisplayName": "Your Display Name",
533
"demoBio": "A short bio about yourself."
···
551
"secondaryLight": "Secondary (Light Mode)",
552
"secondaryDark": "Secondary (Dark Mode)",
553
"configSaved": "Server configuration saved",
554
-
"saving": "Saving...",
555
"saveConfig": "Save Configuration",
556
"serverStats": "Server Statistics",
557
"users": "Users",
···
639
"title": "Two-Factor Authentication",
640
"subtitle": "Additional verification is required",
641
"usePasskey": "Use Passkey",
642
-
"useTotp": "Use Authenticator App",
643
-
"verifying": "Verifying..."
644
},
645
"twoFactorCode": {
646
"title": "Two-Factor Authentication",
647
"subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.",
648
"codeLabel": "Verification Code",
649
"codePlaceholder": "Enter 6-digit code",
650
-
"verify": "Verify",
651
-
"verifying": "Verifying...",
652
"errors": {
653
"missingRequestUri": "Missing request_uri parameter",
654
"verificationFailed": "Verification failed",
···
660
"title": "Enter Authenticator Code",
661
"subtitle": "Enter the 6-digit code from your authenticator app",
662
"codePlaceholder": "Enter 6-digit code",
663
-
"verify": "Verify",
664
-
"verifying": "Verifying...",
665
"useBackupCode": "Use backup code instead",
666
"backupCodePlaceholder": "Enter backup code",
667
"trustDevice": "Trust this device for 30 days",
···
691
"codeLabel": "Verification Code",
692
"codeHelp": "Copy the entire code from your message, including dashes",
693
"verifyButton": "Verify Account",
694
-
"verify": "Verify",
695
-
"verifying": "Verifying...",
696
"pleaseWait": "Please wait...",
697
-
"resendCode": "Resend Code",
698
-
"resending": "Resending...",
699
-
"sending": "Sending...",
700
"codeResent": "Verification code resent!",
701
"codeResentDetail": "Verification code sent! Check your inbox.",
702
-
"backToLogin": "Back to Login",
703
-
"backToSettings": "Back to Settings",
704
"verifyingAccount": "Verifying account: @{handle}",
705
"startOver": "Start over with a different account",
706
"noPending": "No pending verification found.",
···
746
"resetButton": "Reset Password",
747
"resetting": "Resetting...",
748
"success": "Password reset successfully!",
749
-
"backToLogin": "Back to Sign In",
750
"requestNewCode": "Request New Code",
751
"passwordsMismatch": "Passwords do not match",
752
"passwordLength": "Password must be at least 8 characters"
···
790
"howItWorks": "How it works",
791
"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.",
792
"sendRecoveryLink": "Send Recovery Link",
793
-
"sending": "Sending...",
794
-
"backToLogin": "Back to Sign In"
795
},
796
"registerPasskey": {
797
"title": "Create Passkey Account",
···
814
"inviteCode": "Invite Code",
815
"inviteCodePlaceholder": "Enter your invite code",
816
"createButton": "Create Account",
817
-
"creating": "Creating...",
818
"continue": "Continue",
819
"back": "Back",
820
"alreadyHaveAccount": "Already have an account?",
···
911
"useTotp": "Use Authenticator",
912
"passwordPlaceholder": "Enter your password",
913
"totpPlaceholder": "Enter 6-digit code",
914
-
"verify": "Verify",
915
-
"verifying": "Verifying...",
916
"authenticating": "Authenticating...",
917
"passkeyPrompt": "Click the button below to authenticate with your passkey.",
918
"cancel": "Cancel"
···
947
"handle": "Handle",
948
"emailOptional": "Email (optional)",
949
"yourAccessLevel": "Your Access Level",
950
-
"creating": "Creating...",
951
"createAccount": "Create Account",
952
"createDelegatedAccountButton": "+ Create Delegated Account",
953
"accountCreated": "Created delegated account: {handle}",
···
1059
"navDesc": "Move your account to or from another PDS",
1060
"migrateHere": "Migrate Here",
1061
"migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.",
1062
-
"migrateAway": "Migrate Away",
1063
-
"migrateAwayDesc": "Move your account from this PDS to another server.",
1064
-
"loginRequired": "Login required",
1065
"bringDid": "Bring your DID and identity",
1066
"transferData": "Transfer all your data",
1067
"keepFollowers": "Keep your followers",
1068
-
"exportRepo": "Export your repository",
1069
-
"transferToPds": "Transfer to new PDS",
1070
-
"updateIdentity": "Update your identity",
1071
"whatIsMigration": "What is account migration?",
1072
"whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.",
1073
"beforeMigrate": "Before you migrate",
···
1077
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1078
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1079
"learnMore": "Learn more about migration risks",
1080
-
"comingSoon": "Coming soon",
1081
"oauthCompleting": "Completing authentication...",
1082
"oauthFailed": "Authentication Failed",
1083
"tryAgain": "Try Again",
···
1086
"incomplete": "You have an incomplete migration in progress:",
1087
"direction": "Direction",
1088
"migratingHere": "Migrating here",
1089
-
"migratingAway": "Migrating away",
1090
"from": "From",
1091
"to": "To",
1092
"progress": "Progress",
···
1229
"error": {
1230
"title": "Migration Error",
1231
"desc": "An error occurred during migration.",
1232
-
"startOver": "Start Over"
1233
},
1234
"common": {
1235
"back": "Back",
···
1247
"warning3": "Your old account will be deactivated after migration"
1248
}
1249
},
1250
-
"outbound": {
1251
"welcome": {
1252
-
"title": "Migrate Away from This PDS",
1253
-
"desc": "Move your account to another Personal Data Server.",
1254
-
"warning": "After migration, your account here will be deactivated.",
1255
-
"didWebNotice": "did:web Migration Notice",
1256
-
"didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.",
1257
-
"understand": "I understand the risks and want to proceed"
1258
},
1259
-
"targetPds": {
1260
-
"title": "Choose Target PDS",
1261
-
"desc": "Enter the URL of the PDS you want to migrate to.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "Validate & Continue",
1265
"validating": "Validating...",
1266
-
"connected": "Connected to {name}",
1267
-
"inviteRequired": "Invite code required",
1268
-
"privacyPolicy": "Privacy Policy",
1269
-
"termsOfService": "Terms of Service"
1270
},
1271
-
"newAccount": {
1272
-
"title": "New Account Details",
1273
-
"desc": "Set up your account on the new PDS.",
1274
-
"handle": "Handle",
1275
-
"availableDomains": "Available domains",
1276
-
"email": "Email",
1277
-
"password": "Password",
1278
-
"confirmPassword": "Confirm Password",
1279
-
"inviteCode": "Invite Code"
1280
},
1281
"review": {
1282
-
"title": "Review Migration",
1283
-
"desc": "Please review and confirm your migration details.",
1284
-
"currentHandle": "Current Handle",
1285
-
"newHandle": "New Handle",
1286
-
"sourcePds": "This PDS",
1287
-
"targetPds": "Target PDS",
1288
-
"confirm": "I confirm I want to migrate my account",
1289
-
"startMigration": "Start Migration"
1290
},
1291
"migrating": {
1292
-
"title": "Migrating Your Account",
1293
-
"desc": "Please wait while we transfer your data..."
1294
-
},
1295
-
"plcToken": {
1296
-
"title": "Verify Your Identity",
1297
-
"desc": "A verification code has been sent to your email."
1298
},
1299
-
"finalizing": {
1300
-
"title": "Finalizing Migration",
1301
-
"desc": "Please wait while we complete the migration...",
1302
-
"updatingForwarding": "Updating DID document forwarding..."
1303
},
1304
"success": {
1305
-
"title": "Migration Complete!",
1306
-
"desc": "Your account has been successfully migrated to your new PDS.",
1307
-
"newHandle": "New Handle",
1308
-
"newPds": "New PDS",
1309
-
"nextSteps": "Next Steps",
1310
-
"nextSteps1": "Sign in to your new PDS",
1311
-
"nextSteps2": "Update any apps with your new credentials",
1312
-
"nextSteps3": "Your followers will automatically see your new location",
1313
-
"loggingOut": "Logging you out in {seconds} seconds..."
1314
}
1315
},
1316
"progress": {
···
17
"dashboard": "Dashboard",
18
"backToDashboard": "← Dashboard",
19
"copied": "Copied!",
20
+
"copyToClipboard": "Copy to Clipboard",
21
+
22
+
"verifying": "Verifying...",
23
+
"saving": "Saving...",
24
+
"creating": "Creating...",
25
+
"updating": "Updating...",
26
+
"sending": "Sending...",
27
+
"authenticating": "Authenticating...",
28
+
"checking": "Checking...",
29
+
"redirecting": "Redirecting...",
30
+
31
+
"signIn": "Sign In",
32
+
"verify": "Verify",
33
+
"remove": "Remove",
34
+
"revoke": "Revoke",
35
+
"resendCode": "Resend Code",
36
+
"startOver": "Start Over",
37
+
"tryAgain": "Try Again",
38
+
39
+
"password": "Password",
40
+
"email": "Email",
41
+
"emailAddress": "Email Address",
42
+
"handle": "Handle",
43
+
"did": "DID",
44
+
"verificationCode": "Verification Code",
45
+
"inviteCode": "Invite Code",
46
+
"newPassword": "New Password",
47
+
"confirmPassword": "Confirm Password",
48
+
49
+
"enterSixDigitCode": "Enter 6-digit code",
50
+
"passwordHint": "At least 8 characters",
51
+
"enterPassword": "Enter your password",
52
+
"emailPlaceholder": "you@example.com",
53
+
54
+
"verified": "Verified",
55
+
"disabled": "Disabled",
56
+
"available": "Available",
57
+
"deactivated": "Deactivated",
58
+
"unverified": "Unverified",
59
+
60
+
"backToLogin": "Back to Login",
61
+
"backToSettings": "Back to Settings",
62
+
"alreadyHaveAccount": "Already have an account?",
63
+
"createAccount": "Create account",
64
+
65
+
"passwordsMismatch": "Passwords do not match",
66
+
"passwordTooShort": "Password must be at least 8 characters"
67
},
68
"login": {
69
"title": "Sign In",
···
95
"codeLabel": "Verification Code",
96
"codePlaceholder": "Enter 6-digit code",
97
"verifyButton": "Verify Account",
98
+
"resent": "Verification code resent!"
99
},
100
"register": {
101
"title": "Create Account",
···
166
"inviteCodePlaceholder": "Enter your invite code",
167
"inviteCodeRequired": "required",
168
"createButton": "Create Account",
169
"alreadyHaveAccount": "Already have an account?",
170
"signIn": "Sign in",
171
"wantPasswordless": "Want passwordless security?",
···
220
"navAdminDesc": "Server stats and admin operations",
221
"navDidDocument": "DID Document",
222
"navDidDocumentDesc": "Manage your DID document for external migrations",
223
+
"navDidDocumentDescActive": "Edit your DID document settings",
224
+
"navBackup": "Download Backup",
225
+
"navBackupDesc": "Download your repository as a CAR file",
226
+
"downloadingBackup": "Downloading...",
227
+
"backupFailed": "Failed to download backup",
228
"migrated": "Migrated",
229
"migratedTitle": "Account Migrated",
230
"migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
···
254
"serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
255
"currentPds": "Current PDS URL",
256
"save": "Save Changes",
257
"success": "DID document updated successfully",
258
"saveFailed": "Failed to save DID document",
259
"loadFailed": "Failed to load DID document",
···
291
"yourDomain": "Your Domain",
292
"yourDomainPlaceholder": "example.com",
293
"verifyAndUpdate": "Verify & Update Handle",
294
"newHandle": "New Handle",
295
"newHandlePlaceholder": "yourhandle",
296
"changeHandleButton": "Change Handle",
···
306
"exportData": "Export Data",
307
"exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.",
308
"downloadRepo": "Download Repository",
309
+
"downloadBlobs": "Download Media",
310
"exporting": "Exporting...",
311
+
"backups": {
312
+
"title": "Backups",
313
+
"description": "Your repository is automatically backed up daily. You can also create manual backups or restore from a previous backup.",
314
+
"enableAutomatic": "Enable automatic backups",
315
+
"enabled": "Automatic backups enabled",
316
+
"disabled": "Automatic backups disabled",
317
+
"toggleFailed": "Failed to update backup setting",
318
+
"noBackups": "No backups available yet.",
319
+
"blocks": "blocks",
320
+
"download": "Download",
321
+
"delete": "Delete",
322
+
"createNow": "Create Backup Now",
323
+
"created": "Backup created successfully",
324
+
"createFailed": "Failed to create backup",
325
+
"downloadFailed": "Failed to download backup",
326
+
"deleted": "Backup deleted",
327
+
"deleteFailed": "Failed to delete backup",
328
+
"restoreTitle": "Restore from Backup",
329
+
"restoreDescription": "Upload a CAR file to restore your repository. This will overwrite your current data.",
330
+
"selectFile": "Select CAR file",
331
+
"selectedFile": "Selected file",
332
+
"restore": "Restore",
333
+
"restoring": "Restoring...",
334
+
"restored": "Repository restored successfully",
335
+
"restoreFailed": "Failed to restore repository"
336
+
},
337
"deleteAccount": "Delete Account",
338
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
339
"requestDeletion": "Request Account Deletion",
···
362
"deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.",
363
"deletionFailed": "Failed to delete account",
364
"repoExported": "Repository exported successfully",
365
+
"blobsExported": "Media files exported successfully",
366
+
"noBlobsToExport": "No media files to export",
367
+
"exportFailed": "Failed to export",
368
"confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone."
369
}
370
},
···
379
"noPasswords": "No app passwords yet",
380
"revoke": "Revoke",
381
"revoking": "Revoking...",
382
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
383
"saveWarningTitle": "Important: Save this app password!",
384
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
···
426
"used": "Used by @{handle}",
427
"disabled": "Disabled",
428
"usedBy": "Used by",
429
"disableConfirm": "Disable this invite code? It can no longer be used.",
430
"created": "Invite Code Created",
431
"copy": "Copy",
···
553
"verifyButton": "Verify",
554
"verifyCodePlaceholder": "Enter verification code",
555
"submit": "Submit",
556
"savePreferences": "Save Preferences",
557
"preferencesSaved": "Communication preferences saved",
558
"verifiedSuccess": "{channel} verified successfully",
···
591
"noCollectionsYet": "No collections yet. Create your first record to get started.",
592
"loadMore": "Load More",
593
"recordJson": "Record JSON",
594
"updateRecord": "Update Record",
595
"collectionNsid": "Collection (NSID)",
596
"recordKeyOptional": "Record Key (optional)",
597
"autoGenerated": "Auto-generated if empty (TID)",
598
"autoGeneratedHint": "Leave empty to auto-generate a TID-based key",
599
"demoPostText": "Hello from my PDS! This is my first post.",
600
"demoDisplayName": "Your Display Name",
601
"demoBio": "A short bio about yourself."
···
619
"secondaryLight": "Secondary (Light Mode)",
620
"secondaryDark": "Secondary (Dark Mode)",
621
"configSaved": "Server configuration saved",
622
"saveConfig": "Save Configuration",
623
"serverStats": "Server Statistics",
624
"users": "Users",
···
706
"title": "Two-Factor Authentication",
707
"subtitle": "Additional verification is required",
708
"usePasskey": "Use Passkey",
709
+
"useTotp": "Use Authenticator App"
710
},
711
"twoFactorCode": {
712
"title": "Two-Factor Authentication",
713
"subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.",
714
"codeLabel": "Verification Code",
715
"codePlaceholder": "Enter 6-digit code",
716
"errors": {
717
"missingRequestUri": "Missing request_uri parameter",
718
"verificationFailed": "Verification failed",
···
724
"title": "Enter Authenticator Code",
725
"subtitle": "Enter the 6-digit code from your authenticator app",
726
"codePlaceholder": "Enter 6-digit code",
727
"useBackupCode": "Use backup code instead",
728
"backupCodePlaceholder": "Enter backup code",
729
"trustDevice": "Trust this device for 30 days",
···
753
"codeLabel": "Verification Code",
754
"codeHelp": "Copy the entire code from your message, including dashes",
755
"verifyButton": "Verify Account",
756
"pleaseWait": "Please wait...",
757
"codeResent": "Verification code resent!",
758
"codeResentDetail": "Verification code sent! Check your inbox.",
759
"verifyingAccount": "Verifying account: @{handle}",
760
"startOver": "Start over with a different account",
761
"noPending": "No pending verification found.",
···
801
"resetButton": "Reset Password",
802
"resetting": "Resetting...",
803
"success": "Password reset successfully!",
804
"requestNewCode": "Request New Code",
805
"passwordsMismatch": "Passwords do not match",
806
"passwordLength": "Password must be at least 8 characters"
···
844
"howItWorks": "How it works",
845
"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.",
846
"sendRecoveryLink": "Send Recovery Link",
847
+
"sending": "Sending..."
848
},
849
"registerPasskey": {
850
"title": "Create Passkey Account",
···
867
"inviteCode": "Invite Code",
868
"inviteCodePlaceholder": "Enter your invite code",
869
"createButton": "Create Account",
870
"continue": "Continue",
871
"back": "Back",
872
"alreadyHaveAccount": "Already have an account?",
···
963
"useTotp": "Use Authenticator",
964
"passwordPlaceholder": "Enter your password",
965
"totpPlaceholder": "Enter 6-digit code",
966
"authenticating": "Authenticating...",
967
"passkeyPrompt": "Click the button below to authenticate with your passkey.",
968
"cancel": "Cancel"
···
997
"handle": "Handle",
998
"emailOptional": "Email (optional)",
999
"yourAccessLevel": "Your Access Level",
1000
"createAccount": "Create Account",
1001
"createDelegatedAccountButton": "+ Create Delegated Account",
1002
"accountCreated": "Created delegated account: {handle}",
···
1108
"navDesc": "Move your account to or from another PDS",
1109
"migrateHere": "Migrate Here",
1110
"migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.",
1111
"bringDid": "Bring your DID and identity",
1112
"transferData": "Transfer all your data",
1113
"keepFollowers": "Keep your followers",
1114
"whatIsMigration": "What is account migration?",
1115
"whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.",
1116
"beforeMigrate": "Before you migrate",
···
1120
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1121
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1122
"learnMore": "Learn more about migration risks",
1123
+
"offlineRestore": "Offline Restore",
1124
+
"offlineRestoreDesc": "Restore from backup when your old PDS is unavailable.",
1125
+
"offlineFeature1": "Use a CAR file backup",
1126
+
"offlineFeature2": "Prove ownership with rotation key",
1127
+
"offlineFeature3": "Recovery for shutdown servers",
1128
"oauthCompleting": "Completing authentication...",
1129
"oauthFailed": "Authentication Failed",
1130
"tryAgain": "Try Again",
···
1133
"incomplete": "You have an incomplete migration in progress:",
1134
"direction": "Direction",
1135
"migratingHere": "Migrating here",
1136
"from": "From",
1137
"to": "To",
1138
"progress": "Progress",
···
1275
"error": {
1276
"title": "Migration Error",
1277
"desc": "An error occurred during migration.",
1278
+
"startOver": "Start Over",
1279
+
"unknown": "An unknown error occurred."
1280
},
1281
"common": {
1282
"back": "Back",
···
1294
"warning3": "Your old account will be deactivated after migration"
1295
}
1296
},
1297
+
"offline": {
1298
"welcome": {
1299
+
"title": "Offline Restore",
1300
+
"desc": "Restore your account when your old PDS is unavailable. This is for disaster recovery when you cannot contact your previous server.",
1301
+
"warningTitle": "Advanced Recovery Method",
1302
+
"warningDesc": "This method requires your rotation key private key. Only use this if your previous PDS has shut down or you cannot access it.",
1303
+
"requirementsTitle": "You will need:",
1304
+
"requirement1": "Your DID (did:plc:...)",
1305
+
"requirement2": "A CAR file backup of your repository",
1306
+
"requirement3": "Your rotation key (private key in hex, base58, or JWK format)",
1307
+
"understand": "I understand this is for offline recovery only"
1308
},
1309
+
"provideDid": {
1310
+
"title": "Enter Your DID",
1311
+
"desc": "Enter the DID of the account you want to restore.",
1312
+
"label": "Your DID",
1313
+
"hint": "Your decentralized identifier (e.g., did:plc:abc123...)"
1314
+
},
1315
+
"uploadCar": {
1316
+
"title": "Upload Repository Backup",
1317
+
"desc": "Upload the CAR file containing your repository data.",
1318
+
"label": "CAR File",
1319
+
"hint": "This should be a .car file from a previous backup of your repository",
1320
+
"reuploadWarningTitle": "CAR File Required",
1321
+
"reuploadWarning": "Your session was restored, but you need to re-upload your CAR file. For security reasons, file contents are not stored between sessions."
1322
+
},
1323
+
"rotationKey": {
1324
+
"title": "Provide Rotation Key",
1325
+
"desc": "Enter your rotation key to prove ownership of this DID.",
1326
+
"securityWarningTitle": "Security Warning",
1327
+
"securityWarning1": "Your rotation key is extremely sensitive - anyone with it can take over your identity",
1328
+
"securityWarning2": "Only enter it on trusted devices and connections",
1329
+
"securityWarning3": "The key will not be stored after migration",
1330
+
"label": "Rotation Key",
1331
+
"placeholder": "Paste your rotation key (hex, base58, or JWK)...",
1332
+
"hint": "Supports 64-character hex, base58, or JWK format",
1333
+
"valid": "Rotation key verified! You have control of this DID.",
1334
+
"invalid": "This key is not a valid rotation key for this DID.",
1335
"validating": "Validating...",
1336
+
"validate": "Validate Key"
1337
},
1338
+
"chooseHandle": {
1339
+
"migratingDid": "Restoring DID"
1340
},
1341
"review": {
1342
+
"desc": "Please confirm the details of your offline restoration.",
1343
+
"carFile": "CAR File",
1344
+
"rotationKey": "Rotation Key",
1345
+
"warning": "After you click \"Start Migration\", your repository will be imported and your DID will be updated to point to this PDS.",
1346
+
"plcWarningTitle": "Point of No Return",
1347
+
"plcWarning": "Once you start, your DID document will be updated to point to this PDS. If something goes wrong, you can use your rotation key to recover, but you should complete the migration to avoid a broken identity state."
1348
},
1349
"migrating": {
1350
+
"title": "Restoring Account",
1351
+
"desc": "Please wait while your account is being restored...",
1352
+
"creating": "Creating account",
1353
+
"importing": "Importing repository",
1354
+
"plcSigning": "Signing identity update",
1355
+
"activating": "Activating account"
1356
},
1357
+
"blobs": {
1358
+
"title": "Migrating Blobs",
1359
+
"desc": "Attempting to recover images and media from your old PDS...",
1360
+
"migrating": "Migrating blobs",
1361
+
"failedTitle": "Some blobs could not be migrated",
1362
+
"failedDesc": "{count} blobs could not be fetched from your old PDS. This may happen if the server is unreachable or the files were deleted.",
1363
+
"sourceUnreachableTitle": "Source PDS Unreachable",
1364
+
"sourceUnreachable": "Could not connect to your old PDS to fetch media files. This is common when migrating from a shut-down server. Your posts will work, but some images may be missing."
1365
},
1366
"success": {
1367
+
"desc": "Your account has been successfully restored to this PDS."
1368
}
1369
},
1370
"progress": {
+154
-100
frontend/src/locales/fi.json
+154
-100
frontend/src/locales/fi.json
···
17
"dashboard": "Hallintapaneeli",
18
"backToDashboard": "← Hallintapaneeli",
19
"copied": "Kopioitu!",
20
-
"copyToClipboard": "Kopioi"
21
},
22
"login": {
23
"title": "Kirjaudu sisään",
···
49
"codeLabel": "Vahvistuskoodi",
50
"codePlaceholder": "Syötä 6-numeroinen koodi",
51
"verifyButton": "Vahvista tili",
52
-
"verifying": "Vahvistetaan...",
53
-
"resendButton": "Lähetä koodi uudelleen",
54
-
"resending": "Lähetetään uudelleen...",
55
-
"resent": "Vahvistuskoodi lähetetty uudelleen!",
56
-
"backToLogin": "Takaisin kirjautumiseen"
57
},
58
"register": {
59
"title": "Luo tili",
···
124
"inviteCodePlaceholder": "Syötä kutsukoodisi",
125
"inviteCodeRequired": "vaaditaan",
126
"createButton": "Luo tili",
127
-
"creating": "Luodaan tiliä...",
128
"alreadyHaveAccount": "Onko sinulla jo tili?",
129
"signIn": "Kirjaudu sisään",
130
"wantPasswordless": "Haluatko salasanattoman turvallisuuden?",
···
179
"navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
180
"navDidDocument": "DID-dokumentti",
181
"navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
182
"migrated": "Siirretty",
183
"migratedTitle": "Tili siirretty",
184
"migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
···
208
"serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
209
"currentPds": "Nykyinen PDS-URL",
210
"save": "Tallenna muutokset",
211
-
"saving": "Tallennetaan...",
212
"success": "DID-dokumentti päivitetty onnistuneesti",
213
"saveFailed": "DID-dokumentin tallennus epäonnistui",
214
"loadFailed": "DID-dokumentin lataus epäonnistui",
···
246
"yourDomain": "Verkkotunnuksesi",
247
"yourDomainPlaceholder": "esimerkki.fi",
248
"verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi",
249
-
"verifying": "Vahvistetaan...",
250
"newHandle": "Uusi käyttäjänimi",
251
"newHandlePlaceholder": "käyttäjänimesi",
252
"changeHandleButton": "Vaihda käyttäjänimi",
···
262
"exportData": "Vie tiedot",
263
"exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.",
264
"downloadRepo": "Lataa tietovarasto",
265
"exporting": "Viedään...",
266
"deleteAccount": "Poista tili",
267
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
268
"requestDeletion": "Pyydä tilin poistoa",
···
291
"deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.",
292
"deletionFailed": "Tilin poisto epäonnistui",
293
"repoExported": "Tietovarasto viety",
294
-
"exportFailed": "Tietovaraston vienti epäonnistui",
295
"confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua."
296
}
297
},
···
306
"noPasswords": "Ei vielä sovellusten salasanoja",
307
"revoke": "Peruuta",
308
"revoking": "Peruutetaan...",
309
-
"creating": "Luodaan...",
310
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
311
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
312
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
···
354
"used": "Käyttänyt @{handle}",
355
"disabled": "Poistettu käytöstä",
356
"usedBy": "Käyttänyt",
357
-
"creating": "Luodaan...",
358
"disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.",
359
"created": "Kutsukoodi luotu",
360
"copy": "Kopioi",
···
482
"verifyButton": "Vahvista",
483
"verifyCodePlaceholder": "Syötä vahvistuskoodi",
484
"submit": "Lähetä",
485
-
"saving": "Tallennetaan...",
486
"savePreferences": "Tallenna asetukset",
487
"preferencesSaved": "Viestintäasetukset tallennettu",
488
"verifiedSuccess": "{channel} vahvistettu",
···
521
"noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.",
522
"loadMore": "Lataa lisää",
523
"recordJson": "Tietueen JSON",
524
-
"saving": "Tallennetaan...",
525
"updateRecord": "Päivitä tietue",
526
"collectionNsid": "Kokoelma (NSID)",
527
"recordKeyOptional": "Tietueavain (valinnainen)",
528
"autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)",
529
"autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti",
530
-
"creating": "Luodaan...",
531
"demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.",
532
"demoDisplayName": "Näyttönimesi",
533
"demoBio": "Lyhyt kuvaus itsestäsi."
···
548
"primaryLight": "Ensisijainen (vaalea tila)",
549
"primaryDark": "Ensisijainen (tumma tila)",
550
"configSaved": "Palvelinasetukset tallennettu",
551
-
"saving": "Tallennetaan...",
552
"saveConfig": "Tallenna asetukset",
553
"serverStats": "Palvelintilastot",
554
"users": "Käyttäjät",
···
639
"title": "Kaksivaiheinen tunnistautuminen",
640
"subtitle": "Lisävahvistus vaaditaan",
641
"usePasskey": "Käytä pääsyavainta",
642
-
"useTotp": "Käytä todentajasovellusta",
643
-
"verifying": "Vahvistetaan..."
644
},
645
"twoFactorCode": {
646
"title": "Kaksivaiheinen tunnistautuminen",
647
"subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.",
648
"codeLabel": "Vahvistuskoodi",
649
"codePlaceholder": "Syötä 6-numeroinen koodi",
650
-
"verify": "Vahvista",
651
-
"verifying": "Vahvistetaan...",
652
"errors": {
653
"missingRequestUri": "Puuttuva request_uri-parametri",
654
"verificationFailed": "Vahvistus epäonnistui",
···
660
"title": "Syötä todentajakoodi",
661
"subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi",
662
"codePlaceholder": "Syötä 6-numeroinen koodi",
663
-
"verify": "Vahvista",
664
-
"verifying": "Vahvistetaan...",
665
"useBackupCode": "Käytä varakoodia sen sijaan",
666
"backupCodePlaceholder": "Syötä varakoodi",
667
"trustDevice": "Luota tähän laitteeseen 30 päivää",
···
691
"codeLabel": "Vahvistuskoodi",
692
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
693
"verifyButton": "Vahvista tili",
694
-
"verify": "Vahvista",
695
-
"verifying": "Vahvistetaan...",
696
"pleaseWait": "Odota...",
697
-
"sending": "Lähetetään...",
698
-
"resendCode": "Lähetä koodi uudelleen",
699
-
"resending": "Lähetetään uudelleen...",
700
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
701
"codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.",
702
"verified": "Vahvistettu!",
···
706
"identifierLabel": "Sähköposti tai tunniste",
707
"identifierPlaceholder": "sinä@esimerkki.fi",
708
"identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin",
709
-
"backToLogin": "Takaisin kirjautumiseen",
710
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
711
"startOver": "Aloita alusta toisella tilillä",
712
"noPending": "Odottavaa vahvistusta ei löytynyt.",
713
"noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.",
714
"createAccount": "Luo tili",
715
"signIn": "Kirjaudu sisään",
716
-
"backToSettings": "Takaisin asetuksiin",
717
"emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi",
718
"emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui",
719
"emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.",
···
746
"resetButton": "Palauta salasana",
747
"resetting": "Palautetaan...",
748
"success": "Salasana palautettu!",
749
-
"backToLogin": "Takaisin kirjautumiseen",
750
"requestNewCode": "Pyydä uusi koodi",
751
"passwordsMismatch": "Salasanat eivät täsmää",
752
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä"
···
790
"howItWorks": "Miten se toimii",
791
"howItWorksDetail": "Lähetämme suojatun linkin rekisteröityyn ilmoituskanavaasi. Klikkaa linkkiä asettaaksesi väliaikaisen salasanan. Sitten voit kirjautua sisään ja lisätä uuden pääsyavaimen.",
792
"sendRecoveryLink": "Lähetä palautuslinkki",
793
-
"sending": "Lähetetään...",
794
-
"backToLogin": "Takaisin kirjautumiseen"
795
},
796
"registerPasskey": {
797
"title": "Luo pääsyavaintili",
···
812
"externalDid": "Sinun did:web",
813
"externalDidPlaceholder": "did:web:verkkotunnuksesi.fi",
814
"createButton": "Luo tili",
815
-
"creating": "Luodaan...",
816
"alreadyHaveAccount": "Onko sinulla jo tili?",
817
"signIn": "Kirjaudu sisään",
818
"wantPassword": "Haluatko käyttää salasanaa?",
···
911
"useTotp": "Käytä todentajaa",
912
"passwordPlaceholder": "Syötä salasanasi",
913
"totpPlaceholder": "Syötä 6-numeroinen koodi",
914
-
"verify": "Vahvista",
915
-
"verifying": "Vahvistetaan...",
916
"authenticating": "Todennetaan...",
917
"passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.",
918
"cancel": "Peruuta"
···
967
"handle": "Käyttäjänimi",
968
"emailOptional": "Sähköposti (valinnainen)",
969
"yourAccessLevel": "Käyttöoikeustasosi",
970
-
"creating": "Luodaan...",
971
"createAccount": "Luo tili",
972
"createDelegatedAccountButton": "+ Luo delegoitu tili",
973
"accountCreated": "Delegoitu tili luotu: {handle}",
···
1059
"navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä",
1060
"migrateHere": "Siirrä tänne",
1061
"migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.",
1062
-
"migrateAway": "Siirrä pois",
1063
-
"migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.",
1064
-
"loginRequired": "Kirjautuminen vaaditaan",
1065
"bringDid": "Tuo DID ja identiteettisi",
1066
"transferData": "Siirrä kaikki tietosi",
1067
"keepFollowers": "Säilytä seuraajasi",
1068
-
"exportRepo": "Vie tietovarastosi",
1069
-
"transferToPds": "Siirrä uuteen PDS:ään",
1070
-
"updateIdentity": "Päivitä identiteettisi",
1071
"whatIsMigration": "Mikä on tilin siirto?",
1072
"whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.",
1073
"beforeMigrate": "Ennen siirtoa",
···
1077
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1078
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1079
"learnMore": "Lue lisää siirron riskeistä",
1080
-
"comingSoon": "Tulossa pian",
1081
"oauthCompleting": "Viimeistellään todennusta...",
1082
"oauthFailed": "Todennus epäonnistui",
1083
"tryAgain": "Yritä uudelleen",
···
1086
"incomplete": "Sinulla on keskeneräinen siirto:",
1087
"direction": "Suunta",
1088
"migratingHere": "Siirretään tänne",
1089
-
"migratingAway": "Siirretään pois",
1090
"from": "Mistä",
1091
"to": "Minne",
1092
"progress": "Edistyminen",
···
1229
"error": {
1230
"title": "Siirtovirhe",
1231
"desc": "Siirron aikana tapahtui virhe.",
1232
-
"startOver": "Aloita alusta"
1233
},
1234
"common": {
1235
"back": "Takaisin",
···
1247
"warning3": "Vanha tilisi deaktivoidaan siirron jälkeen"
1248
}
1249
},
1250
-
"outbound": {
1251
"welcome": {
1252
-
"title": "Siirrä pois tästä PDS:stä",
1253
-
"desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.",
1254
-
"warning": "Siirron jälkeen tilisi täällä deaktivoidaan.",
1255
-
"didWebNotice": "did:web-siirtoilmoitus",
1256
-
"didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.",
1257
-
"understand": "Ymmärrän riskit ja haluan jatkaa"
1258
},
1259
-
"targetPds": {
1260
-
"title": "Valitse kohde-PDS",
1261
-
"desc": "Syötä sen PDS:n URL, johon haluat siirtyä.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "Vahvista ja jatka",
1265
-
"validating": "Vahvistetaan...",
1266
-
"connected": "Yhdistetty: {name}",
1267
-
"inviteRequired": "Kutsukoodi vaaditaan",
1268
-
"privacyPolicy": "Tietosuojakäytäntö",
1269
-
"termsOfService": "Käyttöehdot"
1270
},
1271
-
"newAccount": {
1272
-
"title": "Uuden tilin tiedot",
1273
-
"desc": "Määritä tilisi uudessa PDS:ssä.",
1274
-
"handle": "Käyttäjätunnus",
1275
-
"availableDomains": "Käytettävissä olevat verkkotunnukset",
1276
-
"email": "Sähköposti",
1277
-
"password": "Salasana",
1278
-
"confirmPassword": "Vahvista salasana",
1279
-
"inviteCode": "Kutsukoodi"
1280
},
1281
-
"review": {
1282
-
"title": "Tarkista siirto",
1283
-
"desc": "Tarkista ja vahvista siirtotietosi.",
1284
-
"currentHandle": "Nykyinen käyttäjätunnus",
1285
-
"newHandle": "Uusi käyttäjätunnus",
1286
-
"sourcePds": "Tämä PDS",
1287
-
"targetPds": "Kohde-PDS",
1288
-
"confirm": "Vahvistan haluavani siirtää tilini",
1289
-
"startMigration": "Aloita siirto"
1290
},
1291
-
"migrating": {
1292
-
"title": "Siirretään tiliäsi",
1293
-
"desc": "Odota, kun siirrämme tietojasi..."
1294
},
1295
-
"plcToken": {
1296
-
"title": "Vahvista henkilöllisyytesi",
1297
-
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi."
1298
},
1299
-
"finalizing": {
1300
-
"title": "Viimeistellään siirtoa",
1301
-
"desc": "Odota, kun viimeistelemme siirtoa...",
1302
-
"updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..."
1303
},
1304
"success": {
1305
-
"title": "Siirto valmis!",
1306
-
"desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.",
1307
-
"newHandle": "Uusi käyttäjätunnus",
1308
-
"newPds": "Uusi PDS",
1309
-
"nextSteps": "Seuraavat vaiheet",
1310
-
"nextSteps1": "Kirjaudu uuteen PDS:ääsi",
1311
-
"nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi",
1312
-
"nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi",
1313
-
"loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..."
1314
}
1315
},
1316
"progress": {
···
17
"dashboard": "Hallintapaneeli",
18
"backToDashboard": "← Hallintapaneeli",
19
"copied": "Kopioitu!",
20
+
"copyToClipboard": "Kopioi",
21
+
22
+
"verifying": "Vahvistetaan...",
23
+
"saving": "Tallennetaan...",
24
+
"creating": "Luodaan...",
25
+
"updating": "Päivitetään...",
26
+
"sending": "Lähetetään...",
27
+
"authenticating": "Todennetaan...",
28
+
"checking": "Tarkistetaan...",
29
+
"redirecting": "Ohjataan...",
30
+
31
+
"signIn": "Kirjaudu sisään",
32
+
"verify": "Vahvista",
33
+
"remove": "Poista",
34
+
"revoke": "Peruuta",
35
+
"resendCode": "Lähetä koodi uudelleen",
36
+
"startOver": "Aloita alusta",
37
+
"tryAgain": "Yritä uudelleen",
38
+
39
+
"password": "Salasana",
40
+
"email": "Sähköposti",
41
+
"emailAddress": "Sähköpostiosoite",
42
+
"handle": "Käsittely",
43
+
"did": "DID",
44
+
"verificationCode": "Vahvistuskoodi",
45
+
"inviteCode": "Kutsukoodi",
46
+
"newPassword": "Uusi salasana",
47
+
"confirmPassword": "Vahvista salasana",
48
+
49
+
"enterSixDigitCode": "Syötä 6-numeroinen koodi",
50
+
"passwordHint": "Vähintään 8 merkkiä",
51
+
"enterPassword": "Syötä salasanasi",
52
+
"emailPlaceholder": "sinä@esimerkki.com",
53
+
54
+
"verified": "Vahvistettu",
55
+
"disabled": "Poistettu käytöstä",
56
+
"available": "Saatavilla",
57
+
"deactivated": "Deaktivoitu",
58
+
"unverified": "Vahvistamaton",
59
+
60
+
"backToLogin": "Takaisin kirjautumiseen",
61
+
"backToSettings": "Takaisin asetuksiin",
62
+
"alreadyHaveAccount": "Onko sinulla jo tili?",
63
+
"createAccount": "Luo tili",
64
+
65
+
"passwordsMismatch": "Salasanat eivät täsmää",
66
+
"passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä"
67
},
68
"login": {
69
"title": "Kirjaudu sisään",
···
95
"codeLabel": "Vahvistuskoodi",
96
"codePlaceholder": "Syötä 6-numeroinen koodi",
97
"verifyButton": "Vahvista tili",
98
+
"resent": "Vahvistuskoodi lähetetty uudelleen!"
99
},
100
"register": {
101
"title": "Luo tili",
···
166
"inviteCodePlaceholder": "Syötä kutsukoodisi",
167
"inviteCodeRequired": "vaaditaan",
168
"createButton": "Luo tili",
169
"alreadyHaveAccount": "Onko sinulla jo tili?",
170
"signIn": "Kirjaudu sisään",
171
"wantPasswordless": "Haluatko salasanattoman turvallisuuden?",
···
220
"navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
221
"navDidDocument": "DID-dokumentti",
222
"navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
223
+
"navDidDocumentDescActive": "Muokkaa DID-dokumentin asetuksia",
224
+
"navBackup": "Lataa varmuuskopio",
225
+
"navBackupDesc": "Lataa tietovarastosi CAR-tiedostona",
226
+
"downloadingBackup": "Ladataan...",
227
+
"backupFailed": "Varmuuskopion lataus epäonnistui",
228
"migrated": "Siirretty",
229
"migratedTitle": "Tili siirretty",
230
"migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
···
254
"serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
255
"currentPds": "Nykyinen PDS-URL",
256
"save": "Tallenna muutokset",
257
"success": "DID-dokumentti päivitetty onnistuneesti",
258
"saveFailed": "DID-dokumentin tallennus epäonnistui",
259
"loadFailed": "DID-dokumentin lataus epäonnistui",
···
291
"yourDomain": "Verkkotunnuksesi",
292
"yourDomainPlaceholder": "esimerkki.fi",
293
"verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi",
294
"newHandle": "Uusi käyttäjänimi",
295
"newHandlePlaceholder": "käyttäjänimesi",
296
"changeHandleButton": "Vaihda käyttäjänimi",
···
306
"exportData": "Vie tiedot",
307
"exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.",
308
"downloadRepo": "Lataa tietovarasto",
309
+
"downloadBlobs": "Lataa media",
310
"exporting": "Viedään...",
311
+
"backups": {
312
+
"title": "Varmuuskopiot",
313
+
"description": "Tietovarastosi varmuuskopioidaan automaattisesti päivittäin. Voit myös luoda manuaalisia varmuuskopioita tai palauttaa aiemmasta varmuuskopiosta.",
314
+
"enableAutomatic": "Ota automaattiset varmuuskopiot käyttöön",
315
+
"enabled": "Automaattiset varmuuskopiot käytössä",
316
+
"disabled": "Automaattiset varmuuskopiot pois käytöstä",
317
+
"toggleFailed": "Varmuuskopioasetuksen päivitys epäonnistui",
318
+
"noBackups": "Varmuuskopioita ei ole vielä saatavilla.",
319
+
"blocks": "lohkoa",
320
+
"download": "Lataa",
321
+
"delete": "Poista",
322
+
"createNow": "Luo varmuuskopio nyt",
323
+
"created": "Varmuuskopio luotu onnistuneesti",
324
+
"createFailed": "Varmuuskopion luonti epäonnistui",
325
+
"downloadFailed": "Varmuuskopion lataus epäonnistui",
326
+
"deleted": "Varmuuskopio poistettu",
327
+
"deleteFailed": "Varmuuskopion poisto epäonnistui",
328
+
"restoreTitle": "Palauta varmuuskopiosta",
329
+
"restoreDescription": "Lataa CAR-tiedosto palauttaaksesi tietovarastosi. Tämä korvaa nykyiset tietosi.",
330
+
"selectFile": "Valitse CAR-tiedosto",
331
+
"selectedFile": "Valittu tiedosto",
332
+
"restore": "Palauta",
333
+
"restoring": "Palautetaan...",
334
+
"restored": "Tietovarasto palautettu onnistuneesti",
335
+
"restoreFailed": "Tietovaraston palautus epäonnistui"
336
+
},
337
"deleteAccount": "Poista tili",
338
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
339
"requestDeletion": "Pyydä tilin poistoa",
···
362
"deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.",
363
"deletionFailed": "Tilin poisto epäonnistui",
364
"repoExported": "Tietovarasto viety",
365
+
"blobsExported": "Mediatiedostot viety",
366
+
"noBlobsToExport": "Ei vietäviä mediatiedostoja",
367
+
"exportFailed": "Vienti epäonnistui",
368
"confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua."
369
}
370
},
···
379
"noPasswords": "Ei vielä sovellusten salasanoja",
380
"revoke": "Peruuta",
381
"revoking": "Peruutetaan...",
382
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
383
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
384
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
···
426
"used": "Käyttänyt @{handle}",
427
"disabled": "Poistettu käytöstä",
428
"usedBy": "Käyttänyt",
429
"disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.",
430
"created": "Kutsukoodi luotu",
431
"copy": "Kopioi",
···
553
"verifyButton": "Vahvista",
554
"verifyCodePlaceholder": "Syötä vahvistuskoodi",
555
"submit": "Lähetä",
556
"savePreferences": "Tallenna asetukset",
557
"preferencesSaved": "Viestintäasetukset tallennettu",
558
"verifiedSuccess": "{channel} vahvistettu",
···
591
"noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.",
592
"loadMore": "Lataa lisää",
593
"recordJson": "Tietueen JSON",
594
"updateRecord": "Päivitä tietue",
595
"collectionNsid": "Kokoelma (NSID)",
596
"recordKeyOptional": "Tietueavain (valinnainen)",
597
"autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)",
598
"autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti",
599
"demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.",
600
"demoDisplayName": "Näyttönimesi",
601
"demoBio": "Lyhyt kuvaus itsestäsi."
···
616
"primaryLight": "Ensisijainen (vaalea tila)",
617
"primaryDark": "Ensisijainen (tumma tila)",
618
"configSaved": "Palvelinasetukset tallennettu",
619
"saveConfig": "Tallenna asetukset",
620
"serverStats": "Palvelintilastot",
621
"users": "Käyttäjät",
···
706
"title": "Kaksivaiheinen tunnistautuminen",
707
"subtitle": "Lisävahvistus vaaditaan",
708
"usePasskey": "Käytä pääsyavainta",
709
+
"useTotp": "Käytä todentajasovellusta"
710
},
711
"twoFactorCode": {
712
"title": "Kaksivaiheinen tunnistautuminen",
713
"subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.",
714
"codeLabel": "Vahvistuskoodi",
715
"codePlaceholder": "Syötä 6-numeroinen koodi",
716
"errors": {
717
"missingRequestUri": "Puuttuva request_uri-parametri",
718
"verificationFailed": "Vahvistus epäonnistui",
···
724
"title": "Syötä todentajakoodi",
725
"subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi",
726
"codePlaceholder": "Syötä 6-numeroinen koodi",
727
"useBackupCode": "Käytä varakoodia sen sijaan",
728
"backupCodePlaceholder": "Syötä varakoodi",
729
"trustDevice": "Luota tähän laitteeseen 30 päivää",
···
753
"codeLabel": "Vahvistuskoodi",
754
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
755
"verifyButton": "Vahvista tili",
756
"pleaseWait": "Odota...",
757
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
758
"codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.",
759
"verified": "Vahvistettu!",
···
763
"identifierLabel": "Sähköposti tai tunniste",
764
"identifierPlaceholder": "sinä@esimerkki.fi",
765
"identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin",
766
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
767
"startOver": "Aloita alusta toisella tilillä",
768
"noPending": "Odottavaa vahvistusta ei löytynyt.",
769
"noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.",
770
"createAccount": "Luo tili",
771
"signIn": "Kirjaudu sisään",
772
"emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi",
773
"emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui",
774
"emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.",
···
801
"resetButton": "Palauta salasana",
802
"resetting": "Palautetaan...",
803
"success": "Salasana palautettu!",
804
"requestNewCode": "Pyydä uusi koodi",
805
"passwordsMismatch": "Salasanat eivät täsmää",
806
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä"
···
844
"howItWorks": "Miten se toimii",
845
"howItWorksDetail": "Lähetämme suojatun linkin rekisteröityyn ilmoituskanavaasi. Klikkaa linkkiä asettaaksesi väliaikaisen salasanan. Sitten voit kirjautua sisään ja lisätä uuden pääsyavaimen.",
846
"sendRecoveryLink": "Lähetä palautuslinkki",
847
+
"sending": "Lähetetään..."
848
},
849
"registerPasskey": {
850
"title": "Luo pääsyavaintili",
···
865
"externalDid": "Sinun did:web",
866
"externalDidPlaceholder": "did:web:verkkotunnuksesi.fi",
867
"createButton": "Luo tili",
868
"alreadyHaveAccount": "Onko sinulla jo tili?",
869
"signIn": "Kirjaudu sisään",
870
"wantPassword": "Haluatko käyttää salasanaa?",
···
963
"useTotp": "Käytä todentajaa",
964
"passwordPlaceholder": "Syötä salasanasi",
965
"totpPlaceholder": "Syötä 6-numeroinen koodi",
966
"authenticating": "Todennetaan...",
967
"passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.",
968
"cancel": "Peruuta"
···
1017
"handle": "Käyttäjänimi",
1018
"emailOptional": "Sähköposti (valinnainen)",
1019
"yourAccessLevel": "Käyttöoikeustasosi",
1020
"createAccount": "Luo tili",
1021
"createDelegatedAccountButton": "+ Luo delegoitu tili",
1022
"accountCreated": "Delegoitu tili luotu: {handle}",
···
1108
"navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä",
1109
"migrateHere": "Siirrä tänne",
1110
"migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.",
1111
"bringDid": "Tuo DID ja identiteettisi",
1112
"transferData": "Siirrä kaikki tietosi",
1113
"keepFollowers": "Säilytä seuraajasi",
1114
"whatIsMigration": "Mikä on tilin siirto?",
1115
"whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.",
1116
"beforeMigrate": "Ennen siirtoa",
···
1120
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1121
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1122
"learnMore": "Lue lisää siirron riskeistä",
1123
+
"offlineRestore": "Offline-palautus",
1124
+
"offlineRestoreDesc": "Palauta varmuuskopiosta, kun vanha PDS ei ole käytettävissä.",
1125
+
"offlineFeature1": "Käytä CAR-tiedoston varmuuskopiota",
1126
+
"offlineFeature2": "Todista omistajuus rotaatioavaimella",
1127
+
"offlineFeature3": "Palautus suljetuille palvelimille",
1128
"oauthCompleting": "Viimeistellään todennusta...",
1129
"oauthFailed": "Todennus epäonnistui",
1130
"tryAgain": "Yritä uudelleen",
···
1133
"incomplete": "Sinulla on keskeneräinen siirto:",
1134
"direction": "Suunta",
1135
"migratingHere": "Siirretään tänne",
1136
"from": "Mistä",
1137
"to": "Minne",
1138
"progress": "Edistyminen",
···
1275
"error": {
1276
"title": "Siirtovirhe",
1277
"desc": "Siirron aikana tapahtui virhe.",
1278
+
"startOver": "Aloita alusta",
1279
+
"unknown": "Tuntematon virhe tapahtui."
1280
},
1281
"common": {
1282
"back": "Takaisin",
···
1294
"warning3": "Vanha tilisi deaktivoidaan siirron jälkeen"
1295
}
1296
},
1297
+
"offline": {
1298
"welcome": {
1299
+
"title": "Palauta varmuuskopiosta",
1300
+
"desc": "Palauta tilisi CAR-tiedoston varmuuskopiolla ja rotaatioavaimella. Käytä tätä, kun edellinen PDS ei ole käytettävissä.",
1301
+
"warningTitle": "Milloin käyttää tätä menetelmää",
1302
+
"warningDesc": "Tämä offline-palautus on katastrofipalautukseen, kun vanha PDS on suljettu, tavoittamattomissa tai sinut on lukittu ulos. Jos vanha PDS on edelleen käytettävissä, käytä normaalia siirtoa.",
1303
+
"requirementsTitle": "Tarvitset",
1304
+
"requirement1": "CAR-tiedoston varmuuskopion tietovarastostasi",
1305
+
"requirement2": "Rotaatioavaimesi (DID:n yksityinen avain)",
1306
+
"requirement3": "DID:si (did:plc:xxx)",
1307
+
"understand": "Ymmärrän ja haluan jatkaa"
1308
},
1309
+
"provideDid": {
1310
+
"title": "Syötä DID:si",
1311
+
"desc": "Syötä palautettavan tilin DID.",
1312
+
"label": "DID:si",
1313
+
"hint": "Hajautettu tunnistesi (esim. did:plc:abc123)"
1314
},
1315
+
"uploadCar": {
1316
+
"title": "Lataa CAR-tiedosto",
1317
+
"desc": "Lataa tietovaraston varmuuskopiotiedostosi.",
1318
+
"label": "CAR-tiedosto",
1319
+
"hint": "Valitse .car-tiedosto varmuuskopiostasi",
1320
+
"reuploadWarningTitle": "CAR-tiedosto vaaditaan",
1321
+
"reuploadWarning": "Istuntosi palautettiin, mutta sinun täytyy ladata CAR-tiedostosi uudelleen. Turvallisuussyistä tiedostosisältöä ei tallenneta istuntojen välillä."
1322
},
1323
+
"rotationKey": {
1324
+
"title": "Anna rotaatioavain",
1325
+
"desc": "Anna rotaatioavaimesi todistaaksesi tämän DID:n omistajuuden.",
1326
+
"securityWarningTitle": "Turvallisuusvaroitus",
1327
+
"securityWarning1": "Rotaatioavaimesi on erittäin arkaluonteinen - kohtele sitä kuten pääsalasanaa",
1328
+
"securityWarning2": "Syötä se vain luotetuilla laitteilla ja verkoilla",
1329
+
"securityWarning3": "Tätä avainta ei tallenneta siirron jälkeen",
1330
+
"label": "Rotaatioavain",
1331
+
"placeholder": "Syötä yksityinen avain (hex, base58 tai JWK)",
1332
+
"hint": "Yksityinen avain, joka vastaa yhtä DID-dokumentin rotaatioavaimista",
1333
+
"valid": "Avain on kelvollinen ja vastaa DID:si rotaatioavainta",
1334
+
"invalid": "Avain ei vastaa mitään DID-dokumentin rotaatioavainta",
1335
+
"validating": "Vahvistetaan avainta...",
1336
+
"validate": "Vahvista avain"
1337
},
1338
+
"chooseHandle": {
1339
+
"migratingDid": "Palautetaan DID"
1340
},
1341
+
"review": {
1342
+
"desc": "Tarkista offline-palautuksen tiedot.",
1343
+
"carFile": "CAR-tiedosto",
1344
+
"rotationKey": "Rotaatioavain",
1345
+
"warning": "Kun aloitat palautuksen, identiteettisi päivitetään osoittamaan tähän PDS:ään. Tätä ei voi helposti perua.",
1346
+
"plcWarningTitle": "Ei paluuta",
1347
+
"plcWarning": "Kun aloitat, DID-dokumenttisi päivitetään osoittamaan tähän PDS:ään. Jos jokin menee pieleen, voit käyttää rotaatioavaintasi palautumiseen, mutta sinun tulisi suorittaa siirto loppuun välttääksesi rikkinäisen identiteettitilan."
1348
},
1349
+
"migrating": {
1350
+
"title": "Palautetaan tiliä",
1351
+
"desc": "Odota, tiliäsi palautetaan...",
1352
+
"creating": "Luodaan tili",
1353
+
"importing": "Tuodaan tietovarastoa",
1354
+
"plcSigning": "Päivitetään identiteettiä",
1355
+
"activating": "Aktivoidaan tili"
1356
},
1357
"success": {
1358
+
"desc": "Tilisi on palautettu onnistuneesti tähän PDS:ään."
1359
+
},
1360
+
"blobs": {
1361
+
"title": "Siirretään blob-tiedostoja",
1362
+
"desc": "Yritetään palauttaa kuvia ja mediaa vanhasta PDS:stäsi...",
1363
+
"migrating": "Siirretään blob-tiedostoja",
1364
+
"failedTitle": "Joitain blob-tiedostoja ei voitu siirtää",
1365
+
"failedDesc": "{count} blob-tiedostoa ei voitu hakea vanhasta PDS:stäsi. Tämä voi tapahtua, jos palvelin ei ole tavoitettavissa tai tiedostot on poistettu.",
1366
+
"sourceUnreachableTitle": "Lähde-PDS ei tavoitettavissa",
1367
+
"sourceUnreachable": "Ei voitu yhdistää vanhaan PDS:ääsi mediatiedostojen hakemiseksi. Tämä on yleistä siirrettäessä suljetulta palvelimelta. Julkaisusi toimivat, mutta joitain kuvia saattaa puuttua."
1368
}
1369
},
1370
"progress": {
+147
-100
frontend/src/locales/ja.json
+147
-100
frontend/src/locales/ja.json
···
17
"dashboard": "ダッシュボード",
18
"backToDashboard": "← ダッシュボード",
19
"copied": "コピーしました!",
20
-
"copyToClipboard": "クリップボードにコピー"
21
},
22
"login": {
23
"title": "サインイン",
···
49
"codeLabel": "確認コード",
50
"codePlaceholder": "6桁のコードを入力",
51
"verifyButton": "確認する",
52
-
"verifying": "確認中...",
53
-
"resendButton": "コードを再送信",
54
-
"resending": "送信中...",
55
-
"resent": "確認コードを再送信しました!",
56
-
"backToLogin": "ログインに戻る"
57
},
58
"register": {
59
"title": "アカウント作成",
···
124
"inviteCodePlaceholder": "招待コードを入力",
125
"inviteCodeRequired": "必須",
126
"createButton": "アカウントを作成",
127
-
"creating": "作成中...",
128
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
129
"signIn": "サインイン",
130
"wantPasswordless": "パスワードレスをご希望ですか?",
···
179
"navAdminDesc": "サーバー統計と管理操作",
180
"navDidDocument": "DID ドキュメント",
181
"navDidDocumentDesc": "DID ドキュメントとキーを管理",
182
"migrated": "移行済み",
183
"migratedTitle": "アカウント移行済み",
184
"migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。",
···
208
"serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。",
209
"currentPds": "現在のPDS URL",
210
"save": "変更を保存",
211
-
"saving": "保存中...",
212
"success": "DID ドキュメントを更新しました",
213
"saveFailed": "DIDドキュメントの保存に失敗しました",
214
"loadFailed": "DIDドキュメントの読み込みに失敗しました",
···
246
"yourDomain": "ドメイン",
247
"yourDomainPlaceholder": "example.com",
248
"verifyAndUpdate": "確認してハンドルを更新",
249
-
"verifying": "確認中...",
250
"newHandle": "新しいハンドル",
251
"newHandlePlaceholder": "yourhandle",
252
"changeHandleButton": "ハンドルを変更",
···
262
"exportData": "データエクスポート",
263
"exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。",
264
"downloadRepo": "リポジトリをダウンロード",
265
"exporting": "エクスポート中...",
266
"deleteAccount": "アカウント削除",
267
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
268
"requestDeletion": "アカウント削除をリクエスト",
···
291
"deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。",
292
"deletionFailed": "アカウントの削除に失敗しました",
293
"repoExported": "リポジトリをエクスポートしました",
294
-
"exportFailed": "リポジトリのエクスポートに失敗しました",
295
"confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。"
296
}
297
},
···
306
"noPasswords": "アプリパスワードはまだありません",
307
"revoke": "取り消す",
308
"revoking": "取り消し中...",
309
-
"creating": "作成中...",
310
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
311
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
312
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
···
354
"used": "@{handle} が使用済み",
355
"disabled": "無効",
356
"usedBy": "使用者",
357
-
"creating": "作成中...",
358
"disableConfirm": "この招待コードを無効にしますか?使用できなくなります。",
359
"created": "招待コードを作成しました",
360
"copy": "コピー",
···
482
"verifyButton": "確認",
483
"verifyCodePlaceholder": "確認コードを入力",
484
"submit": "送信",
485
-
"saving": "保存中...",
486
"savePreferences": "設定を保存",
487
"preferencesSaved": "連絡設定を保存しました",
488
"verifiedSuccess": "{channel} を確認しました",
···
521
"noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。",
522
"loadMore": "さらに読み込む",
523
"recordJson": "レコード JSON",
524
-
"saving": "保存中...",
525
"updateRecord": "レコードを更新",
526
"collectionNsid": "コレクション (NSID)",
527
"recordKeyOptional": "レコードキー(任意)",
528
"autoGenerated": "空白で自動生成 (TID)",
529
"autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます",
530
-
"creating": "作成中...",
531
"demoPostText": "こんにちは、私の PDS からの初投稿です!",
532
"demoDisplayName": "表示名",
533
"demoBio": "自己紹介を書いてください。"
···
548
"primaryLight": "プライマリ(ライトモード)",
549
"primaryDark": "プライマリ(ダークモード)",
550
"configSaved": "サーバー設定を保存しました",
551
-
"saving": "保存中...",
552
"saveConfig": "設定を保存",
553
"serverStats": "サーバー統計",
554
"users": "ユーザー",
···
639
"title": "二要素認証",
640
"subtitle": "追加の確認が必要です",
641
"usePasskey": "パスキーを使用",
642
-
"useTotp": "認証アプリを使用",
643
-
"verifying": "確認中..."
644
},
645
"twoFactorCode": {
646
"title": "二要素認証",
647
"subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。",
648
"codeLabel": "確認コード",
649
"codePlaceholder": "6桁のコードを入力",
650
-
"verify": "確認",
651
-
"verifying": "確認中...",
652
"errors": {
653
"missingRequestUri": "request_uri パラメータがありません",
654
"verificationFailed": "確認に失敗しました",
···
660
"title": "認証コードを入力",
661
"subtitle": "認証アプリの6桁のコードを入力",
662
"codePlaceholder": "6桁のコードを入力",
663
-
"verify": "確認",
664
-
"verifying": "確認中...",
665
"useBackupCode": "バックアップコードを使用",
666
"backupCodePlaceholder": "バックアップコードを入力",
667
"trustDevice": "このデバイスを30日間信頼する",
···
691
"codeLabel": "確認コード",
692
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
693
"verifyButton": "アカウントを確認",
694
-
"verify": "確認",
695
-
"verifying": "確認中...",
696
"pleaseWait": "お待ちください...",
697
-
"sending": "送信中...",
698
-
"resendCode": "コードを再送信",
699
-
"resending": "送信中...",
700
"codeResent": "確認コードを再送信しました!",
701
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
702
"verified": "確認完了!",
···
706
"identifierLabel": "メールまたは識別子",
707
"identifierPlaceholder": "you@example.com",
708
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
709
-
"backToLogin": "ログインに戻る",
710
"verifyingAccount": "確認中のアカウント: @{handle}",
711
"startOver": "別のアカウントでやり直す",
712
"noPending": "保留中の確認が見つかりません。",
713
"noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。",
714
"createAccount": "アカウントを作成",
715
"signIn": "サインイン",
716
-
"backToSettings": "設定に戻る",
717
"emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました",
718
"emailUpdateFailed": "メールアドレスの更新に失敗しました",
719
"emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。",
···
746
"resetButton": "パスワードをリセット",
747
"resetting": "リセット中...",
748
"success": "パスワードをリセットしました!",
749
-
"backToLogin": "サインインに戻る",
750
"requestNewCode": "新しいコードをリクエスト",
751
"passwordsMismatch": "パスワードが一致しません",
752
"passwordLength": "パスワードは8文字以上である必要があります"
···
790
"howItWorks": "仕組み",
791
"howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。",
792
"sendRecoveryLink": "復旧リンクを送信",
793
-
"sending": "送信中...",
794
-
"backToLogin": "サインインに戻る"
795
},
796
"registerPasskey": {
797
"title": "パスキーアカウントを作成",
···
812
"externalDid": "あなたの did:web",
813
"externalDidPlaceholder": "did:web:yourdomain.com",
814
"createButton": "アカウントを作成",
815
-
"creating": "作成中...",
816
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
817
"signIn": "サインイン",
818
"wantPassword": "パスワードを使用しますか?",
···
911
"useTotp": "認証アプリを使用",
912
"passwordPlaceholder": "パスワードを入力",
913
"totpPlaceholder": "6桁のコードを入力",
914
-
"verify": "確認",
915
-
"verifying": "確認中...",
916
"authenticating": "認証中...",
917
"passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。",
918
"cancel": "キャンセル"
···
985
"createAccount": "アカウントを作成",
986
"createDelegatedAccount": "委任アカウントを作成",
987
"createDelegatedAccountButton": "+ 委任アカウントを作成",
988
-
"creating": "作成中...",
989
"emailOptional": "メール(任意)",
990
"failedToAddController": "コントローラーの追加に失敗しました",
991
"failedToCreateAccount": "委任アカウントの作成に失敗しました",
···
1059
"navDesc": "別のPDSへ、または別のPDSからアカウントを移動",
1060
"migrateHere": "ここに移行",
1061
"migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。",
1062
-
"migrateAway": "別の場所に移行",
1063
-
"migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。",
1064
-
"loginRequired": "ログインが必要です",
1065
"bringDid": "DIDとアイデンティティを持ち込む",
1066
"transferData": "すべてのデータを転送",
1067
"keepFollowers": "フォロワーを維持",
1068
-
"exportRepo": "リポジトリをエクスポート",
1069
-
"transferToPds": "新しいPDSに転送",
1070
-
"updateIdentity": "アイデンティティを更新",
1071
"whatIsMigration": "アカウント移行とは?",
1072
"whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。",
1073
"beforeMigrate": "移行前の確認事項",
···
1077
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1078
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1079
"learnMore": "移行のリスクについて詳しく",
1080
-
"comingSoon": "近日公開",
1081
"oauthCompleting": "認証を完了しています...",
1082
"oauthFailed": "認証に失敗しました",
1083
"tryAgain": "再試行",
···
1086
"incomplete": "未完了の移行があります:",
1087
"direction": "方向",
1088
"migratingHere": "ここに移行中",
1089
-
"migratingAway": "別の場所に移行中",
1090
"from": "移行元",
1091
"to": "移行先",
1092
"progress": "進行状況",
···
1229
"error": {
1230
"title": "移行エラー",
1231
"desc": "移行中にエラーが発生しました。",
1232
-
"startOver": "最初からやり直す"
1233
},
1234
"common": {
1235
"back": "戻る",
···
1247
"warning3": "移行後、古いアカウントは無効化されます"
1248
}
1249
},
1250
-
"outbound": {
1251
"welcome": {
1252
-
"title": "このPDSから移行",
1253
-
"desc": "アカウントを別のパーソナルデータサーバーに移動します。",
1254
-
"warning": "移行後、ここでのアカウントは無効化されます。",
1255
-
"didWebNotice": "did:web移行のお知らせ",
1256
-
"didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。",
1257
-
"understand": "リスクを理解し、続行します"
1258
},
1259
-
"targetPds": {
1260
-
"title": "移行先PDSを選択",
1261
-
"desc": "移行先のPDSのURLを入力してください。",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "検証して続行",
1265
-
"validating": "検証中...",
1266
-
"connected": "{name}に接続しました",
1267
-
"inviteRequired": "招待コードが必要です",
1268
-
"privacyPolicy": "プライバシーポリシー",
1269
-
"termsOfService": "利用規約"
1270
},
1271
-
"newAccount": {
1272
-
"title": "新しいアカウントの詳細",
1273
-
"desc": "新しいPDSでアカウントを設定します。",
1274
-
"handle": "ハンドル",
1275
-
"availableDomains": "利用可能なドメイン",
1276
-
"email": "メール",
1277
-
"password": "パスワード",
1278
-
"confirmPassword": "パスワードを確認",
1279
-
"inviteCode": "招待コード"
1280
},
1281
-
"review": {
1282
-
"title": "移行の確認",
1283
-
"desc": "移行の詳細を確認してください。",
1284
-
"currentHandle": "現在のハンドル",
1285
-
"newHandle": "新しいハンドル",
1286
-
"sourcePds": "このPDS",
1287
-
"targetPds": "移行先PDS",
1288
-
"confirm": "アカウントを移行することを確認します",
1289
-
"startMigration": "移行を開始"
1290
},
1291
-
"migrating": {
1292
-
"title": "アカウントを移行中",
1293
-
"desc": "データを転送しています..."
1294
},
1295
-
"plcToken": {
1296
-
"title": "本人確認",
1297
-
"desc": "確認コードがメールに送信されました。"
1298
},
1299
-
"finalizing": {
1300
-
"title": "移行を完了中",
1301
-
"desc": "移行を完了しています...",
1302
-
"updatingForwarding": "DIDドキュメントの転送先を更新中..."
1303
},
1304
"success": {
1305
-
"title": "移行完了!",
1306
-
"desc": "アカウントは新しいPDSに正常に移行されました。",
1307
-
"newHandle": "新しいハンドル",
1308
-
"newPds": "新しいPDS",
1309
-
"nextSteps": "次のステップ",
1310
-
"nextSteps1": "新しいPDSにサインイン",
1311
-
"nextSteps2": "アプリの認証情報を更新",
1312
-
"nextSteps3": "フォロワーは自動的に新しい場所を確認できます",
1313
-
"loggingOut": "{seconds}秒後にログアウトします..."
1314
}
1315
},
1316
"progress": {
···
17
"dashboard": "ダッシュボード",
18
"backToDashboard": "← ダッシュボード",
19
"copied": "コピーしました!",
20
+
"copyToClipboard": "クリップボードにコピー",
21
+
"verifying": "確認中...",
22
+
"saving": "保存中...",
23
+
"creating": "作成中...",
24
+
"updating": "更新中...",
25
+
"sending": "送信中...",
26
+
"authenticating": "認証中...",
27
+
"checking": "確認中...",
28
+
"redirecting": "リダイレクト中...",
29
+
"signIn": "サインイン",
30
+
"verify": "確認",
31
+
"remove": "削除",
32
+
"revoke": "取り消し",
33
+
"resendCode": "コードを再送信",
34
+
"startOver": "最初からやり直す",
35
+
"tryAgain": "再試行",
36
+
"password": "パスワード",
37
+
"email": "メール",
38
+
"emailAddress": "メールアドレス",
39
+
"handle": "ハンドル",
40
+
"did": "DID",
41
+
"verificationCode": "確認コード",
42
+
"inviteCode": "招待コード",
43
+
"newPassword": "新しいパスワード",
44
+
"confirmPassword": "パスワードを確認",
45
+
"enterSixDigitCode": "6桁のコードを入力",
46
+
"passwordHint": "8文字以上",
47
+
"enterPassword": "パスワードを入力",
48
+
"emailPlaceholder": "you@example.com",
49
+
"verified": "確認済み",
50
+
"disabled": "無効",
51
+
"available": "利用可能",
52
+
"deactivated": "非アクティブ",
53
+
"unverified": "未確認",
54
+
"backToLogin": "ログインに戻る",
55
+
"backToSettings": "設定に戻る",
56
+
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
57
+
"createAccount": "アカウントを作成",
58
+
"passwordsMismatch": "パスワードが一致しません",
59
+
"passwordTooShort": "パスワードは8文字以上必要です"
60
},
61
"login": {
62
"title": "サインイン",
···
88
"codeLabel": "確認コード",
89
"codePlaceholder": "6桁のコードを入力",
90
"verifyButton": "確認する",
91
+
"resent": "確認コードを再送信しました!"
92
},
93
"register": {
94
"title": "アカウント作成",
···
159
"inviteCodePlaceholder": "招待コードを入力",
160
"inviteCodeRequired": "必須",
161
"createButton": "アカウントを作成",
162
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
163
"signIn": "サインイン",
164
"wantPasswordless": "パスワードレスをご希望ですか?",
···
213
"navAdminDesc": "サーバー統計と管理操作",
214
"navDidDocument": "DID ドキュメント",
215
"navDidDocumentDesc": "DID ドキュメントとキーを管理",
216
+
"navDidDocumentDescActive": "DID ドキュメント設定を編集",
217
+
"navBackup": "バックアップをダウンロード",
218
+
"navBackupDesc": "リポジトリを CAR ファイルとしてダウンロード",
219
+
"downloadingBackup": "ダウンロード中...",
220
+
"backupFailed": "バックアップのダウンロードに失敗しました",
221
"migrated": "移行済み",
222
"migratedTitle": "アカウント移行済み",
223
"migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。",
···
247
"serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。",
248
"currentPds": "現在のPDS URL",
249
"save": "変更を保存",
250
"success": "DID ドキュメントを更新しました",
251
"saveFailed": "DIDドキュメントの保存に失敗しました",
252
"loadFailed": "DIDドキュメントの読み込みに失敗しました",
···
284
"yourDomain": "ドメイン",
285
"yourDomainPlaceholder": "example.com",
286
"verifyAndUpdate": "確認してハンドルを更新",
287
"newHandle": "新しいハンドル",
288
"newHandlePlaceholder": "yourhandle",
289
"changeHandleButton": "ハンドルを変更",
···
299
"exportData": "データエクスポート",
300
"exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。",
301
"downloadRepo": "リポジトリをダウンロード",
302
+
"downloadBlobs": "メディアをダウンロード",
303
"exporting": "エクスポート中...",
304
+
"backups": {
305
+
"title": "バックアップ",
306
+
"description": "リポジトリは毎日自動的にバックアップされます。手動でバックアップを作成したり、以前のバックアップから復元することもできます。",
307
+
"enableAutomatic": "自動バックアップを有効にする",
308
+
"enabled": "自動バックアップが有効です",
309
+
"disabled": "自動バックアップが無効です",
310
+
"toggleFailed": "バックアップ設定の更新に失敗しました",
311
+
"noBackups": "バックアップはまだありません。",
312
+
"blocks": "ブロック",
313
+
"download": "ダウンロード",
314
+
"delete": "削除",
315
+
"createNow": "今すぐバックアップを作成",
316
+
"created": "バックアップが正常に作成されました",
317
+
"createFailed": "バックアップの作成に失敗しました",
318
+
"downloadFailed": "バックアップのダウンロードに失敗しました",
319
+
"deleted": "バックアップが削除されました",
320
+
"deleteFailed": "バックアップの削除に失敗しました",
321
+
"restoreTitle": "バックアップから復元",
322
+
"restoreDescription": "CARファイルをアップロードしてリポジトリを復元します。現在のデータは上書きされます。",
323
+
"selectFile": "CARファイルを選択",
324
+
"selectedFile": "選択されたファイル",
325
+
"restore": "復元",
326
+
"restoring": "復元中...",
327
+
"restored": "リポジトリが正常に復元されました",
328
+
"restoreFailed": "リポジトリの復元に失敗しました"
329
+
},
330
"deleteAccount": "アカウント削除",
331
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
332
"requestDeletion": "アカウント削除をリクエスト",
···
355
"deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。",
356
"deletionFailed": "アカウントの削除に失敗しました",
357
"repoExported": "リポジトリをエクスポートしました",
358
+
"blobsExported": "メディアファイルをエクスポートしました",
359
+
"noBlobsToExport": "エクスポートするメディアファイルがありません",
360
+
"exportFailed": "エクスポートに失敗しました",
361
"confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。"
362
}
363
},
···
372
"noPasswords": "アプリパスワードはまだありません",
373
"revoke": "取り消す",
374
"revoking": "取り消し中...",
375
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
376
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
377
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
···
419
"used": "@{handle} が使用済み",
420
"disabled": "無効",
421
"usedBy": "使用者",
422
"disableConfirm": "この招待コードを無効にしますか?使用できなくなります。",
423
"created": "招待コードを作成しました",
424
"copy": "コピー",
···
546
"verifyButton": "確認",
547
"verifyCodePlaceholder": "確認コードを入力",
548
"submit": "送信",
549
"savePreferences": "設定を保存",
550
"preferencesSaved": "連絡設定を保存しました",
551
"verifiedSuccess": "{channel} を確認しました",
···
584
"noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。",
585
"loadMore": "さらに読み込む",
586
"recordJson": "レコード JSON",
587
"updateRecord": "レコードを更新",
588
"collectionNsid": "コレクション (NSID)",
589
"recordKeyOptional": "レコードキー(任意)",
590
"autoGenerated": "空白で自動生成 (TID)",
591
"autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます",
592
"demoPostText": "こんにちは、私の PDS からの初投稿です!",
593
"demoDisplayName": "表示名",
594
"demoBio": "自己紹介を書いてください。"
···
609
"primaryLight": "プライマリ(ライトモード)",
610
"primaryDark": "プライマリ(ダークモード)",
611
"configSaved": "サーバー設定を保存しました",
612
"saveConfig": "設定を保存",
613
"serverStats": "サーバー統計",
614
"users": "ユーザー",
···
699
"title": "二要素認証",
700
"subtitle": "追加の確認が必要です",
701
"usePasskey": "パスキーを使用",
702
+
"useTotp": "認証アプリを使用"
703
},
704
"twoFactorCode": {
705
"title": "二要素認証",
706
"subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。",
707
"codeLabel": "確認コード",
708
"codePlaceholder": "6桁のコードを入力",
709
"errors": {
710
"missingRequestUri": "request_uri パラメータがありません",
711
"verificationFailed": "確認に失敗しました",
···
717
"title": "認証コードを入力",
718
"subtitle": "認証アプリの6桁のコードを入力",
719
"codePlaceholder": "6桁のコードを入力",
720
"useBackupCode": "バックアップコードを使用",
721
"backupCodePlaceholder": "バックアップコードを入力",
722
"trustDevice": "このデバイスを30日間信頼する",
···
746
"codeLabel": "確認コード",
747
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
748
"verifyButton": "アカウントを確認",
749
"pleaseWait": "お待ちください...",
750
"codeResent": "確認コードを再送信しました!",
751
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
752
"verified": "確認完了!",
···
756
"identifierLabel": "メールまたは識別子",
757
"identifierPlaceholder": "you@example.com",
758
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
759
"verifyingAccount": "確認中のアカウント: @{handle}",
760
"startOver": "別のアカウントでやり直す",
761
"noPending": "保留中の確認が見つかりません。",
762
"noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。",
763
"createAccount": "アカウントを作成",
764
"signIn": "サインイン",
765
"emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました",
766
"emailUpdateFailed": "メールアドレスの更新に失敗しました",
767
"emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。",
···
794
"resetButton": "パスワードをリセット",
795
"resetting": "リセット中...",
796
"success": "パスワードをリセットしました!",
797
"requestNewCode": "新しいコードをリクエスト",
798
"passwordsMismatch": "パスワードが一致しません",
799
"passwordLength": "パスワードは8文字以上である必要があります"
···
837
"howItWorks": "仕組み",
838
"howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。",
839
"sendRecoveryLink": "復旧リンクを送信",
840
+
"sending": "送信中..."
841
},
842
"registerPasskey": {
843
"title": "パスキーアカウントを作成",
···
858
"externalDid": "あなたの did:web",
859
"externalDidPlaceholder": "did:web:yourdomain.com",
860
"createButton": "アカウントを作成",
861
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
862
"signIn": "サインイン",
863
"wantPassword": "パスワードを使用しますか?",
···
956
"useTotp": "認証アプリを使用",
957
"passwordPlaceholder": "パスワードを入力",
958
"totpPlaceholder": "6桁のコードを入力",
959
"authenticating": "認証中...",
960
"passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。",
961
"cancel": "キャンセル"
···
1028
"createAccount": "アカウントを作成",
1029
"createDelegatedAccount": "委任アカウントを作成",
1030
"createDelegatedAccountButton": "+ 委任アカウントを作成",
1031
"emailOptional": "メール(任意)",
1032
"failedToAddController": "コントローラーの追加に失敗しました",
1033
"failedToCreateAccount": "委任アカウントの作成に失敗しました",
···
1101
"navDesc": "別のPDSへ、または別のPDSからアカウントを移動",
1102
"migrateHere": "ここに移行",
1103
"migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。",
1104
"bringDid": "DIDとアイデンティティを持ち込む",
1105
"transferData": "すべてのデータを転送",
1106
"keepFollowers": "フォロワーを維持",
1107
"whatIsMigration": "アカウント移行とは?",
1108
"whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。",
1109
"beforeMigrate": "移行前の確認事項",
···
1113
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1114
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1115
"learnMore": "移行のリスクについて詳しく",
1116
+
"offlineRestore": "オフライン復元",
1117
+
"offlineRestoreDesc": "旧PDSが利用できない場合にバックアップから復元します。",
1118
+
"offlineFeature1": "CARファイルバックアップを使用",
1119
+
"offlineFeature2": "ローテーションキーで所有権を証明",
1120
+
"offlineFeature3": "シャットダウンしたサーバーの復旧",
1121
"oauthCompleting": "認証を完了しています...",
1122
"oauthFailed": "認証に失敗しました",
1123
"tryAgain": "再試行",
···
1126
"incomplete": "未完了の移行があります:",
1127
"direction": "方向",
1128
"migratingHere": "ここに移行中",
1129
"from": "移行元",
1130
"to": "移行先",
1131
"progress": "進行状況",
···
1268
"error": {
1269
"title": "移行エラー",
1270
"desc": "移行中にエラーが発生しました。",
1271
+
"startOver": "最初からやり直す",
1272
+
"unknown": "不明なエラーが発生しました。"
1273
},
1274
"common": {
1275
"back": "戻る",
···
1287
"warning3": "移行後、古いアカウントは無効化されます"
1288
}
1289
},
1290
+
"offline": {
1291
"welcome": {
1292
+
"title": "バックアップから復元",
1293
+
"desc": "CARファイルバックアップとローテーションキーを使用してアカウントを復元します。以前のPDSが利用できない場合に使用してください。",
1294
+
"warningTitle": "この方法を使用するタイミング",
1295
+
"warningDesc": "このオフライン復元は、古いPDSがシャットダウンした、アクセスできない、またはロックアウトされた場合の災害復旧用です。古いPDSがまだ利用可能な場合は、代わりに標準の移行を使用してください。",
1296
+
"requirementsTitle": "必要なもの",
1297
+
"requirement1": "リポジトリのCARファイルバックアップ",
1298
+
"requirement2": "ローテーションキー(DIDの秘密鍵)",
1299
+
"requirement3": "あなたのDID (did:plc:xxx)",
1300
+
"understand": "理解し、続行します"
1301
},
1302
+
"provideDid": {
1303
+
"title": "DIDを入力",
1304
+
"desc": "復元するアカウントのDIDを入力してください。",
1305
+
"label": "あなたのDID",
1306
+
"hint": "分散型識別子(例:did:plc:abc123)"
1307
},
1308
+
"uploadCar": {
1309
+
"title": "CARファイルをアップロード",
1310
+
"desc": "リポジトリバックアップファイルをアップロードしてください。",
1311
+
"label": "CARファイル",
1312
+
"hint": "バックアップから.carファイルを選択",
1313
+
"reuploadWarningTitle": "CARファイルが必要です",
1314
+
"reuploadWarning": "セッションは復元されましたが、CARファイルを再アップロードする必要があります。セキュリティ上の理由から、ファイルの内容はセッション間で保存されません。"
1315
},
1316
+
"rotationKey": {
1317
+
"title": "ローテーションキーを提供",
1318
+
"desc": "このDIDの所有権を証明するためにローテーションキーを入力してください。",
1319
+
"securityWarningTitle": "セキュリティ警告",
1320
+
"securityWarning1": "ローテーションキーは非常に機密性が高いです - マスターパスワードのように扱ってください",
1321
+
"securityWarning2": "信頼できるデバイスとネットワークでのみ入力してください",
1322
+
"securityWarning3": "このキーは移行完了後に保存されません",
1323
+
"label": "ローテーションキー",
1324
+
"placeholder": "秘密鍵を入力(hex、base58、またはJWK)",
1325
+
"hint": "DIDドキュメントのローテーションキーの1つに対応する秘密鍵",
1326
+
"valid": "キーは有効で、DIDのローテーションキーと一致します",
1327
+
"invalid": "キーはDIDドキュメントのどのローテーションキーとも一致しません",
1328
+
"validating": "キーを検証中...",
1329
+
"validate": "キーを検証"
1330
},
1331
+
"chooseHandle": {
1332
+
"migratingDid": "DIDを復元中"
1333
},
1334
+
"review": {
1335
+
"desc": "オフライン復元の詳細を確認してください。",
1336
+
"carFile": "CARファイル",
1337
+
"rotationKey": "ローテーションキー",
1338
+
"warning": "復元を開始すると、アイデンティティがこのPDSを指すように更新されます。これは簡単に元に戻すことができません。",
1339
+
"plcWarningTitle": "引き返せないポイント",
1340
+
"plcWarning": "開始すると、DIDドキュメントがこのPDSを指すように更新されます。問題が発生した場合はローテーションキーを使用して回復できますが、壊れたアイデンティティ状態を避けるために移行を完了する必要があります。"
1341
},
1342
+
"migrating": {
1343
+
"title": "アカウントを復元中",
1344
+
"desc": "アカウントを復元しています...",
1345
+
"creating": "アカウントを作成中",
1346
+
"importing": "リポジトリをインポート中",
1347
+
"plcSigning": "アイデンティティを更新中",
1348
+
"activating": "アカウントをアクティベート中"
1349
},
1350
"success": {
1351
+
"desc": "アカウントはこのPDSに正常に復元されました。"
1352
+
},
1353
+
"blobs": {
1354
+
"title": "Blobを移行中",
1355
+
"desc": "古いPDSから画像とメディアの復元を試みています...",
1356
+
"migrating": "Blobを移行中",
1357
+
"failedTitle": "一部のBlobを移行できませんでした",
1358
+
"failedDesc": "{count}個のBlobを古いPDSから取得できませんでした。サーバーに接続できないか、ファイルが削除された可能性があります。",
1359
+
"sourceUnreachableTitle": "ソースPDSに接続できません",
1360
+
"sourceUnreachable": "古いPDSに接続してメディアファイルを取得できませんでした。シャットダウンしたサーバーからの移行ではよくあることです。投稿は機能しますが、一部の画像が欠落する可能性があります。"
1361
}
1362
},
1363
"progress": {
+147
-100
frontend/src/locales/ko.json
+147
-100
frontend/src/locales/ko.json
···
17
"dashboard": "대시보드",
18
"backToDashboard": "← 대시보드",
19
"copied": "복사됨!",
20
-
"copyToClipboard": "클립보드에 복사"
21
},
22
"login": {
23
"title": "로그인",
···
49
"codeLabel": "인증 코드",
50
"codePlaceholder": "6자리 코드 입력",
51
"verifyButton": "계정 인증",
52
-
"verifying": "인증 중...",
53
-
"resendButton": "코드 다시 보내기",
54
-
"resending": "전송 중...",
55
-
"resent": "인증 코드를 다시 보냈습니다!",
56
-
"backToLogin": "로그인으로 돌아가기"
57
},
58
"register": {
59
"title": "계정 만들기",
···
124
"inviteCodePlaceholder": "초대 코드 입력",
125
"inviteCodeRequired": "필수",
126
"createButton": "계정 만들기",
127
-
"creating": "계정 생성 중...",
128
"alreadyHaveAccount": "이미 계정이 있으신가요?",
129
"signIn": "로그인",
130
"wantPasswordless": "비밀번호 없는 보안을 원하시나요?",
···
179
"navAdminDesc": "서버 통계 및 관리 작업",
180
"navDidDocument": "DID 문서",
181
"navDidDocumentDesc": "DID 문서 및 키 관리",
182
"migrated": "마이그레이션됨",
183
"migratedTitle": "계정 마이그레이션됨",
184
"migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
···
208
"serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.",
209
"currentPds": "현재 PDS URL",
210
"save": "변경사항 저장",
211
-
"saving": "저장 중...",
212
"success": "DID 문서가 업데이트되었습니다",
213
"saveFailed": "DID 문서 저장에 실패했습니다",
214
"loadFailed": "DID 문서 로드에 실패했습니다",
···
246
"yourDomain": "도메인",
247
"yourDomainPlaceholder": "example.com",
248
"verifyAndUpdate": "확인 후 핸들 업데이트",
249
-
"verifying": "확인 중...",
250
"newHandle": "새 핸들",
251
"newHandlePlaceholder": "yourhandle",
252
"changeHandleButton": "핸들 변경",
···
262
"exportData": "데이터 내보내기",
263
"exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.",
264
"downloadRepo": "저장소 다운로드",
265
"exporting": "내보내기 중...",
266
"deleteAccount": "계정 삭제",
267
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
268
"requestDeletion": "계정 삭제 요청",
···
291
"deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
292
"deletionFailed": "계정 삭제에 실패했습니다",
293
"repoExported": "저장소를 내보냈습니다",
294
-
"exportFailed": "저장소 내보내기에 실패했습니다",
295
"confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
296
}
297
},
···
306
"noPasswords": "앱 비밀번호가 아직 없습니다",
307
"revoke": "취소",
308
"revoking": "취소 중...",
309
-
"creating": "생성 중...",
310
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
311
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
312
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
···
354
"used": "@{handle}이(가) 사용함",
355
"disabled": "비활성화됨",
356
"usedBy": "사용자",
357
-
"creating": "생성 중...",
358
"disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.",
359
"created": "초대 코드가 생성되었습니다",
360
"copy": "복사",
···
482
"verifyButton": "인증",
483
"verifyCodePlaceholder": "인증 코드 입력",
484
"submit": "제출",
485
-
"saving": "저장 중...",
486
"savePreferences": "설정 저장",
487
"preferencesSaved": "통신 설정이 저장되었습니다",
488
"verifiedSuccess": "{channel} 인증 완료",
···
521
"noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.",
522
"loadMore": "더 불러오기",
523
"recordJson": "레코드 JSON",
524
-
"saving": "저장 중...",
525
"updateRecord": "레코드 업데이트",
526
"collectionNsid": "컬렉션 (NSID)",
527
"recordKeyOptional": "레코드 키 (선택사항)",
528
"autoGenerated": "비워두면 자동 생성 (TID)",
529
"autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다",
530
-
"creating": "생성 중...",
531
"demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!",
532
"demoDisplayName": "표시 이름",
533
"demoBio": "간단한 자기소개를 작성하세요."
···
548
"primaryLight": "기본 (라이트 모드)",
549
"primaryDark": "기본 (다크 모드)",
550
"configSaved": "서버 설정이 저장되었습니다",
551
-
"saving": "저장 중...",
552
"saveConfig": "설정 저장",
553
"serverStats": "서버 통계",
554
"users": "사용자",
···
639
"title": "2단계 인증",
640
"subtitle": "추가 확인이 필요합니다",
641
"usePasskey": "패스키 사용",
642
-
"useTotp": "인증 앱 사용",
643
-
"verifying": "확인 중..."
644
},
645
"twoFactorCode": {
646
"title": "2단계 인증",
647
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.",
648
"codeLabel": "인증 코드",
649
"codePlaceholder": "6자리 코드 입력",
650
-
"verify": "확인",
651
-
"verifying": "확인 중...",
652
"errors": {
653
"missingRequestUri": "request_uri 매개변수가 없습니다",
654
"verificationFailed": "인증에 실패했습니다",
···
660
"title": "인증 코드 입력",
661
"subtitle": "인증 앱의 6자리 코드를 입력하세요",
662
"codePlaceholder": "6자리 코드 입력",
663
-
"verify": "확인",
664
-
"verifying": "확인 중...",
665
"useBackupCode": "백업 코드 사용",
666
"backupCodePlaceholder": "백업 코드 입력",
667
"trustDevice": "이 기기를 30일간 신뢰",
···
691
"codeLabel": "인증 코드",
692
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
693
"verifyButton": "계정 인증",
694
-
"verify": "인증",
695
-
"verifying": "인증 중...",
696
"pleaseWait": "잠시 기다려 주세요...",
697
-
"sending": "전송 중...",
698
-
"resendCode": "코드 다시 보내기",
699
-
"resending": "전송 중...",
700
"codeResent": "인증 코드를 다시 보냈습니다!",
701
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
702
"verified": "인증 완료!",
···
706
"identifierLabel": "이메일 또는 식별자",
707
"identifierPlaceholder": "you@example.com",
708
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
709
-
"backToLogin": "로그인으로 돌아가기",
710
"verifyingAccount": "인증 중인 계정: @{handle}",
711
"startOver": "다른 계정으로 다시 시작",
712
"noPending": "보류 중인 인증이 없습니다.",
713
"noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.",
714
"createAccount": "계정 만들기",
715
"signIn": "로그인",
716
-
"backToSettings": "설정으로 돌아가기",
717
"emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다",
718
"emailUpdateFailed": "이메일 주소 업데이트 실패",
719
"emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.",
···
746
"resetButton": "비밀번호 재설정",
747
"resetting": "재설정 중...",
748
"success": "비밀번호가 재설정되었습니다!",
749
-
"backToLogin": "로그인으로 돌아가기",
750
"requestNewCode": "새 코드 요청",
751
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
752
"passwordLength": "비밀번호는 8자 이상이어야 합니다"
···
790
"howItWorks": "작동 방식",
791
"howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.",
792
"sendRecoveryLink": "복구 링크 보내기",
793
-
"sending": "전송 중...",
794
-
"backToLogin": "로그인으로 돌아가기"
795
},
796
"registerPasskey": {
797
"title": "패스키 계정 만들기",
···
812
"externalDid": "귀하의 did:web",
813
"externalDidPlaceholder": "did:web:yourdomain.com",
814
"createButton": "계정 만들기",
815
-
"creating": "생성 중...",
816
"alreadyHaveAccount": "이미 계정이 있으신가요?",
817
"signIn": "로그인",
818
"wantPassword": "비밀번호를 사용하시겠습니까?",
···
911
"useTotp": "인증 앱 사용",
912
"passwordPlaceholder": "비밀번호 입력",
913
"totpPlaceholder": "6자리 코드 입력",
914
-
"verify": "확인",
915
-
"verifying": "확인 중...",
916
"authenticating": "인증 중...",
917
"passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.",
918
"cancel": "취소"
···
985
"createAccount": "계정 생성",
986
"createDelegatedAccount": "위임 계정 생성",
987
"createDelegatedAccountButton": "+ 위임 계정 생성",
988
-
"creating": "생성 중...",
989
"emailOptional": "이메일 (선택사항)",
990
"failedToAddController": "컨트롤러 추가에 실패했습니다",
991
"failedToCreateAccount": "위임 계정 생성에 실패했습니다",
···
1059
"navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동",
1060
"migrateHere": "여기로 마이그레이션",
1061
"migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.",
1062
-
"migrateAway": "다른 곳으로 마이그레이션",
1063
-
"migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.",
1064
-
"loginRequired": "로그인 필요",
1065
"bringDid": "DID와 아이덴티티 가져오기",
1066
"transferData": "모든 데이터 전송",
1067
"keepFollowers": "팔로워 유지",
1068
-
"exportRepo": "저장소 내보내기",
1069
-
"transferToPds": "새 PDS로 전송",
1070
-
"updateIdentity": "아이덴티티 업데이트",
1071
"whatIsMigration": "계정 마이그레이션이란?",
1072
"whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.",
1073
"beforeMigrate": "마이그레이션 전 확인사항",
···
1077
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1078
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1079
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1080
-
"comingSoon": "곧 출시 예정",
1081
"oauthCompleting": "인증 완료 중...",
1082
"oauthFailed": "인증 실패",
1083
"tryAgain": "다시 시도",
···
1086
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
1087
"direction": "방향",
1088
"migratingHere": "여기로 마이그레이션 중",
1089
-
"migratingAway": "다른 곳으로 마이그레이션 중",
1090
"from": "출발지",
1091
"to": "목적지",
1092
"progress": "진행 상황",
···
1229
"error": {
1230
"title": "마이그레이션 오류",
1231
"desc": "마이그레이션 중 오류가 발생했습니다.",
1232
-
"startOver": "처음부터 다시 시작"
1233
},
1234
"common": {
1235
"back": "뒤로",
···
1247
"warning3": "마이그레이션 후 이전 계정은 비활성화됩니다"
1248
}
1249
},
1250
-
"outbound": {
1251
"welcome": {
1252
-
"title": "이 PDS에서 마이그레이션",
1253
-
"desc": "계정을 다른 개인 데이터 서버로 이동합니다.",
1254
-
"warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.",
1255
-
"didWebNotice": "did:web 마이그레이션 알림",
1256
-
"didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.",
1257
-
"understand": "위험을 이해하고 계속 진행합니다"
1258
},
1259
-
"targetPds": {
1260
-
"title": "대상 PDS 선택",
1261
-
"desc": "마이그레이션할 PDS의 URL을 입력하세요.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "확인 및 계속",
1265
-
"validating": "확인 중...",
1266
-
"connected": "{name}에 연결됨",
1267
-
"inviteRequired": "초대 코드 필요",
1268
-
"privacyPolicy": "개인정보 처리방침",
1269
-
"termsOfService": "서비스 약관"
1270
},
1271
-
"newAccount": {
1272
-
"title": "새 계정 세부 정보",
1273
-
"desc": "새 PDS에서 계정을 설정합니다.",
1274
-
"handle": "핸들",
1275
-
"availableDomains": "사용 가능한 도메인",
1276
-
"email": "이메일",
1277
-
"password": "비밀번호",
1278
-
"confirmPassword": "비밀번호 확인",
1279
-
"inviteCode": "초대 코드"
1280
},
1281
-
"review": {
1282
-
"title": "마이그레이션 검토",
1283
-
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
1284
-
"currentHandle": "현재 핸들",
1285
-
"newHandle": "새 핸들",
1286
-
"sourcePds": "이 PDS",
1287
-
"targetPds": "대상 PDS",
1288
-
"confirm": "계정 마이그레이션을 확인합니다",
1289
-
"startMigration": "마이그레이션 시작"
1290
},
1291
-
"migrating": {
1292
-
"title": "계정 마이그레이션 중",
1293
-
"desc": "데이터를 전송하는 중입니다..."
1294
},
1295
-
"plcToken": {
1296
-
"title": "신원 확인",
1297
-
"desc": "이메일로 인증 코드가 전송되었습니다."
1298
},
1299
-
"finalizing": {
1300
-
"title": "마이그레이션 완료 중",
1301
-
"desc": "마이그레이션을 완료하는 중입니다...",
1302
-
"updatingForwarding": "DID 문서 포워딩 업데이트 중..."
1303
},
1304
"success": {
1305
-
"title": "마이그레이션 완료!",
1306
-
"desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.",
1307
-
"newHandle": "새 핸들",
1308
-
"newPds": "새 PDS",
1309
-
"nextSteps": "다음 단계",
1310
-
"nextSteps1": "새 PDS에 로그인",
1311
-
"nextSteps2": "새 인증 정보로 앱 업데이트",
1312
-
"nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다",
1313
-
"loggingOut": "{seconds}초 후 로그아웃됩니다..."
1314
}
1315
},
1316
"progress": {
···
17
"dashboard": "대시보드",
18
"backToDashboard": "← 대시보드",
19
"copied": "복사됨!",
20
+
"copyToClipboard": "클립보드에 복사",
21
+
"verifying": "확인 중...",
22
+
"saving": "저장 중...",
23
+
"creating": "생성 중...",
24
+
"updating": "업데이트 중...",
25
+
"sending": "전송 중...",
26
+
"authenticating": "인증 중...",
27
+
"checking": "확인 중...",
28
+
"redirecting": "리디렉션 중...",
29
+
"signIn": "로그인",
30
+
"verify": "확인",
31
+
"remove": "삭제",
32
+
"revoke": "취소",
33
+
"resendCode": "코드 재전송",
34
+
"startOver": "처음부터 다시",
35
+
"tryAgain": "다시 시도",
36
+
"password": "비밀번호",
37
+
"email": "이메일",
38
+
"emailAddress": "이메일 주소",
39
+
"handle": "핸들",
40
+
"did": "DID",
41
+
"verificationCode": "인증 코드",
42
+
"inviteCode": "초대 코드",
43
+
"newPassword": "새 비밀번호",
44
+
"confirmPassword": "비밀번호 확인",
45
+
"enterSixDigitCode": "6자리 코드 입력",
46
+
"passwordHint": "8자 이상",
47
+
"enterPassword": "비밀번호를 입력하세요",
48
+
"emailPlaceholder": "you@example.com",
49
+
"verified": "인증됨",
50
+
"disabled": "비활성화됨",
51
+
"available": "사용 가능",
52
+
"deactivated": "비활성화됨",
53
+
"unverified": "미인증",
54
+
"backToLogin": "로그인으로 돌아가기",
55
+
"backToSettings": "설정으로 돌아가기",
56
+
"alreadyHaveAccount": "이미 계정이 있으신가요?",
57
+
"createAccount": "계정 만들기",
58
+
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
59
+
"passwordTooShort": "비밀번호는 8자 이상이어야 합니다"
60
},
61
"login": {
62
"title": "로그인",
···
88
"codeLabel": "인증 코드",
89
"codePlaceholder": "6자리 코드 입력",
90
"verifyButton": "계정 인증",
91
+
"resent": "인증 코드를 다시 보냈습니다!"
92
},
93
"register": {
94
"title": "계정 만들기",
···
159
"inviteCodePlaceholder": "초대 코드 입력",
160
"inviteCodeRequired": "필수",
161
"createButton": "계정 만들기",
162
"alreadyHaveAccount": "이미 계정이 있으신가요?",
163
"signIn": "로그인",
164
"wantPasswordless": "비밀번호 없는 보안을 원하시나요?",
···
213
"navAdminDesc": "서버 통계 및 관리 작업",
214
"navDidDocument": "DID 문서",
215
"navDidDocumentDesc": "DID 문서 및 키 관리",
216
+
"navDidDocumentDescActive": "DID 문서 설정 편집",
217
+
"navBackup": "백업 다운로드",
218
+
"navBackupDesc": "저장소를 CAR 파일로 다운로드",
219
+
"downloadingBackup": "다운로드 중...",
220
+
"backupFailed": "백업 다운로드 실패",
221
"migrated": "마이그레이션됨",
222
"migratedTitle": "계정 마이그레이션됨",
223
"migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
···
247
"serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.",
248
"currentPds": "현재 PDS URL",
249
"save": "변경사항 저장",
250
"success": "DID 문서가 업데이트되었습니다",
251
"saveFailed": "DID 문서 저장에 실패했습니다",
252
"loadFailed": "DID 문서 로드에 실패했습니다",
···
284
"yourDomain": "도메인",
285
"yourDomainPlaceholder": "example.com",
286
"verifyAndUpdate": "확인 후 핸들 업데이트",
287
"newHandle": "새 핸들",
288
"newHandlePlaceholder": "yourhandle",
289
"changeHandleButton": "핸들 변경",
···
299
"exportData": "데이터 내보내기",
300
"exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.",
301
"downloadRepo": "저장소 다운로드",
302
+
"downloadBlobs": "미디어 다운로드",
303
"exporting": "내보내기 중...",
304
+
"backups": {
305
+
"title": "백업",
306
+
"description": "자동 백업을 관리하고 계정 데이터를 복원하세요. 백업에는 모든 기록과 blob이 포함됩니다.",
307
+
"enableAutomatic": "자동 백업",
308
+
"enabled": "활성화됨",
309
+
"disabled": "비활성화됨",
310
+
"toggleFailed": "백업 설정 변경 실패",
311
+
"noBackups": "아직 백업이 없습니다",
312
+
"blocks": "블록",
313
+
"download": "다운로드",
314
+
"delete": "삭제",
315
+
"createNow": "지금 백업 생성",
316
+
"created": "백업이 생성되었습니다",
317
+
"createFailed": "백업 생성 실패",
318
+
"downloadFailed": "백업 다운로드 실패",
319
+
"deleted": "백업이 삭제되었습니다",
320
+
"deleteFailed": "백업 삭제 실패",
321
+
"restoreTitle": "백업에서 복원",
322
+
"restoreDescription": "이전에 내보낸 CAR 파일에서 계정 데이터를 복원합니다. 이렇게 하면 현재 저장소가 업로드한 백업으로 교체됩니다.",
323
+
"selectFile": "CAR 파일 선택",
324
+
"selectedFile": "선택된 파일",
325
+
"restore": "백업 복원",
326
+
"restoring": "복원 중...",
327
+
"restored": "백업이 성공적으로 복원되었습니다",
328
+
"restoreFailed": "백업 복원 실패"
329
+
},
330
"deleteAccount": "계정 삭제",
331
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
332
"requestDeletion": "계정 삭제 요청",
···
355
"deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
356
"deletionFailed": "계정 삭제에 실패했습니다",
357
"repoExported": "저장소를 내보냈습니다",
358
+
"blobsExported": "미디어 파일을 내보냈습니다",
359
+
"noBlobsToExport": "내보낼 미디어 파일이 없습니다",
360
+
"exportFailed": "내보내기에 실패했습니다",
361
"confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
362
}
363
},
···
372
"noPasswords": "앱 비밀번호가 아직 없습니다",
373
"revoke": "취소",
374
"revoking": "취소 중...",
375
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
376
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
377
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
···
419
"used": "@{handle}이(가) 사용함",
420
"disabled": "비활성화됨",
421
"usedBy": "사용자",
422
"disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.",
423
"created": "초대 코드가 생성되었습니다",
424
"copy": "복사",
···
546
"verifyButton": "인증",
547
"verifyCodePlaceholder": "인증 코드 입력",
548
"submit": "제출",
549
"savePreferences": "설정 저장",
550
"preferencesSaved": "통신 설정이 저장되었습니다",
551
"verifiedSuccess": "{channel} 인증 완료",
···
584
"noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.",
585
"loadMore": "더 불러오기",
586
"recordJson": "레코드 JSON",
587
"updateRecord": "레코드 업데이트",
588
"collectionNsid": "컬렉션 (NSID)",
589
"recordKeyOptional": "레코드 키 (선택사항)",
590
"autoGenerated": "비워두면 자동 생성 (TID)",
591
"autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다",
592
"demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!",
593
"demoDisplayName": "표시 이름",
594
"demoBio": "간단한 자기소개를 작성하세요."
···
609
"primaryLight": "기본 (라이트 모드)",
610
"primaryDark": "기본 (다크 모드)",
611
"configSaved": "서버 설정이 저장되었습니다",
612
"saveConfig": "설정 저장",
613
"serverStats": "서버 통계",
614
"users": "사용자",
···
699
"title": "2단계 인증",
700
"subtitle": "추가 확인이 필요합니다",
701
"usePasskey": "패스키 사용",
702
+
"useTotp": "인증 앱 사용"
703
},
704
"twoFactorCode": {
705
"title": "2단계 인증",
706
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.",
707
"codeLabel": "인증 코드",
708
"codePlaceholder": "6자리 코드 입력",
709
"errors": {
710
"missingRequestUri": "request_uri 매개변수가 없습니다",
711
"verificationFailed": "인증에 실패했습니다",
···
717
"title": "인증 코드 입력",
718
"subtitle": "인증 앱의 6자리 코드를 입력하세요",
719
"codePlaceholder": "6자리 코드 입력",
720
"useBackupCode": "백업 코드 사용",
721
"backupCodePlaceholder": "백업 코드 입력",
722
"trustDevice": "이 기기를 30일간 신뢰",
···
746
"codeLabel": "인증 코드",
747
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
748
"verifyButton": "계정 인증",
749
"pleaseWait": "잠시 기다려 주세요...",
750
"codeResent": "인증 코드를 다시 보냈습니다!",
751
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
752
"verified": "인증 완료!",
···
756
"identifierLabel": "이메일 또는 식별자",
757
"identifierPlaceholder": "you@example.com",
758
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
759
"verifyingAccount": "인증 중인 계정: @{handle}",
760
"startOver": "다른 계정으로 다시 시작",
761
"noPending": "보류 중인 인증이 없습니다.",
762
"noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.",
763
"createAccount": "계정 만들기",
764
"signIn": "로그인",
765
"emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다",
766
"emailUpdateFailed": "이메일 주소 업데이트 실패",
767
"emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.",
···
794
"resetButton": "비밀번호 재설정",
795
"resetting": "재설정 중...",
796
"success": "비밀번호가 재설정되었습니다!",
797
"requestNewCode": "새 코드 요청",
798
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
799
"passwordLength": "비밀번호는 8자 이상이어야 합니다"
···
837
"howItWorks": "작동 방식",
838
"howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.",
839
"sendRecoveryLink": "복구 링크 보내기",
840
+
"sending": "전송 중..."
841
},
842
"registerPasskey": {
843
"title": "패스키 계정 만들기",
···
858
"externalDid": "귀하의 did:web",
859
"externalDidPlaceholder": "did:web:yourdomain.com",
860
"createButton": "계정 만들기",
861
"alreadyHaveAccount": "이미 계정이 있으신가요?",
862
"signIn": "로그인",
863
"wantPassword": "비밀번호를 사용하시겠습니까?",
···
956
"useTotp": "인증 앱 사용",
957
"passwordPlaceholder": "비밀번호 입력",
958
"totpPlaceholder": "6자리 코드 입력",
959
"authenticating": "인증 중...",
960
"passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.",
961
"cancel": "취소"
···
1028
"createAccount": "계정 생성",
1029
"createDelegatedAccount": "위임 계정 생성",
1030
"createDelegatedAccountButton": "+ 위임 계정 생성",
1031
"emailOptional": "이메일 (선택사항)",
1032
"failedToAddController": "컨트롤러 추가에 실패했습니다",
1033
"failedToCreateAccount": "위임 계정 생성에 실패했습니다",
···
1101
"navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동",
1102
"migrateHere": "여기로 마이그레이션",
1103
"migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.",
1104
"bringDid": "DID와 아이덴티티 가져오기",
1105
"transferData": "모든 데이터 전송",
1106
"keepFollowers": "팔로워 유지",
1107
"whatIsMigration": "계정 마이그레이션이란?",
1108
"whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.",
1109
"beforeMigrate": "마이그레이션 전 확인사항",
···
1113
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1114
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1115
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1116
+
"offlineRestore": "오프라인 복원",
1117
+
"offlineRestoreDesc": "이전 PDS를 사용할 수 없을 때 백업에서 복원합니다.",
1118
+
"offlineFeature1": "CAR 파일 백업 사용",
1119
+
"offlineFeature2": "회전 키로 소유권 증명",
1120
+
"offlineFeature3": "종료된 서버 복구",
1121
"oauthCompleting": "인증 완료 중...",
1122
"oauthFailed": "인증 실패",
1123
"tryAgain": "다시 시도",
···
1126
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
1127
"direction": "방향",
1128
"migratingHere": "여기로 마이그레이션 중",
1129
"from": "출발지",
1130
"to": "목적지",
1131
"progress": "진행 상황",
···
1268
"error": {
1269
"title": "마이그레이션 오류",
1270
"desc": "마이그레이션 중 오류가 발생했습니다.",
1271
+
"startOver": "처음부터 다시 시작",
1272
+
"unknown": "알 수 없는 오류가 발생했습니다."
1273
},
1274
"common": {
1275
"back": "뒤로",
···
1287
"warning3": "마이그레이션 후 이전 계정은 비활성화됩니다"
1288
}
1289
},
1290
+
"offline": {
1291
"welcome": {
1292
+
"title": "백업에서 복원",
1293
+
"desc": "CAR 파일 백업과 회전 키를 사용하여 계정을 복원합니다. 이전 PDS를 사용할 수 없을 때 사용하세요.",
1294
+
"warningTitle": "이 방법을 사용해야 할 때",
1295
+
"warningDesc": "이 오프라인 복원은 이전 PDS가 종료되었거나, 접근할 수 없거나, 잠긴 경우의 재해 복구용입니다. 이전 PDS가 여전히 사용 가능하면 표준 마이그레이션을 사용하세요.",
1296
+
"requirementsTitle": "필요한 것",
1297
+
"requirement1": "저장소의 CAR 파일 백업",
1298
+
"requirement2": "회전 키 (DID의 개인 키)",
1299
+
"requirement3": "당신의 DID (did:plc:xxx)",
1300
+
"understand": "이해하고 계속 진행합니다"
1301
},
1302
+
"provideDid": {
1303
+
"title": "DID 입력",
1304
+
"desc": "복원할 계정의 DID를 입력하세요.",
1305
+
"label": "당신의 DID",
1306
+
"hint": "분산 식별자 (예: did:plc:abc123)"
1307
},
1308
+
"uploadCar": {
1309
+
"title": "CAR 파일 업로드",
1310
+
"desc": "저장소 백업 파일을 업로드하세요.",
1311
+
"label": "CAR 파일",
1312
+
"hint": "백업에서 .car 파일을 선택하세요",
1313
+
"reuploadWarningTitle": "CAR 파일 필요",
1314
+
"reuploadWarning": "세션이 복원되었지만 CAR 파일을 다시 업로드해야 합니다. 보안상의 이유로 파일 내용은 세션 간에 저장되지 않습니다."
1315
},
1316
+
"rotationKey": {
1317
+
"title": "회전 키 제공",
1318
+
"desc": "이 DID의 소유권을 증명하기 위해 회전 키를 입력하세요.",
1319
+
"securityWarningTitle": "보안 경고",
1320
+
"securityWarning1": "회전 키는 매우 민감합니다 - 마스터 비밀번호처럼 취급하세요",
1321
+
"securityWarning2": "신뢰할 수 있는 장치와 네트워크에서만 입력하세요",
1322
+
"securityWarning3": "이 키는 마이그레이션 완료 후 저장되지 않습니다",
1323
+
"label": "회전 키",
1324
+
"placeholder": "개인 키 입력 (hex, base58 또는 JWK)",
1325
+
"hint": "DID 문서의 회전 키 중 하나에 해당하는 개인 키",
1326
+
"valid": "키가 유효하고 DID의 회전 키와 일치합니다",
1327
+
"invalid": "키가 DID 문서의 어떤 회전 키와도 일치하지 않습니다",
1328
+
"validating": "키 검증 중...",
1329
+
"validate": "키 검증"
1330
},
1331
+
"chooseHandle": {
1332
+
"migratingDid": "DID 복원 중"
1333
},
1334
+
"review": {
1335
+
"desc": "오프라인 복원 세부 정보를 확인하세요.",
1336
+
"carFile": "CAR 파일",
1337
+
"rotationKey": "회전 키",
1338
+
"warning": "복원을 시작하면 아이덴티티가 이 PDS를 가리키도록 업데이트됩니다. 이것은 쉽게 되돌릴 수 없습니다.",
1339
+
"plcWarningTitle": "되돌릴 수 없는 지점",
1340
+
"plcWarning": "시작하면 DID 문서가 이 PDS를 가리키도록 업데이트됩니다. 문제가 발생하면 회전 키를 사용하여 복구할 수 있지만, 손상된 아이덴티티 상태를 피하려면 마이그레이션을 완료해야 합니다."
1341
},
1342
+
"migrating": {
1343
+
"title": "계정 복원 중",
1344
+
"desc": "계정을 복원하는 중입니다...",
1345
+
"creating": "계정 생성 중",
1346
+
"importing": "저장소 가져오는 중",
1347
+
"plcSigning": "아이덴티티 업데이트 중",
1348
+
"activating": "계정 활성화 중"
1349
},
1350
"success": {
1351
+
"desc": "계정이 이 PDS에 성공적으로 복원되었습니다."
1352
+
},
1353
+
"blobs": {
1354
+
"title": "Blob 마이그레이션 중",
1355
+
"desc": "이전 PDS에서 이미지와 미디어를 복구하는 중...",
1356
+
"migrating": "Blob 마이그레이션 중",
1357
+
"failedTitle": "일부 Blob을 마이그레이션할 수 없음",
1358
+
"failedDesc": "{count}개의 Blob을 이전 PDS에서 가져올 수 없습니다. 서버에 연결할 수 없거나 파일이 삭제되었을 수 있습니다.",
1359
+
"sourceUnreachableTitle": "원본 PDS에 연결할 수 없음",
1360
+
"sourceUnreachable": "이전 PDS에 연결하여 미디어 파일을 가져올 수 없습니다. 종료된 서버에서 마이그레이션할 때 흔히 발생합니다. 게시물은 작동하지만 일부 이미지가 누락될 수 있습니다."
1361
}
1362
},
1363
"progress": {
+147
-100
frontend/src/locales/sv.json
+147
-100
frontend/src/locales/sv.json
···
17
"dashboard": "Kontrollpanel",
18
"backToDashboard": "← Kontrollpanel",
19
"copied": "Kopierat!",
20
-
"copyToClipboard": "Kopiera"
21
},
22
"login": {
23
"title": "Logga in",
···
49
"codeLabel": "Verifieringskod",
50
"codePlaceholder": "Ange 6-siffrig kod",
51
"verifyButton": "Verifiera konto",
52
-
"verifying": "Verifierar...",
53
-
"resendButton": "Skicka kod igen",
54
-
"resending": "Skickar igen...",
55
-
"resent": "Verifieringskod skickad igen!",
56
-
"backToLogin": "Tillbaka till inloggning"
57
},
58
"register": {
59
"title": "Skapa konto",
···
124
"inviteCodePlaceholder": "Ange din inbjudningskod",
125
"inviteCodeRequired": "krävs",
126
"createButton": "Skapa konto",
127
-
"creating": "Skapar konto...",
128
"alreadyHaveAccount": "Har du redan ett konto?",
129
"signIn": "Logga in",
130
"wantPasswordless": "Vill du ha lösenordsfri säkerhet?",
···
179
"navAdminDesc": "Serverstatistik och administratörsoperationer",
180
"navDidDocument": "DID-dokument",
181
"navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
182
"migrated": "Flyttad",
183
"migratedTitle": "Konto flyttat",
184
"migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
···
208
"serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.",
209
"currentPds": "Nuvarande PDS-URL",
210
"save": "Spara ändringar",
211
-
"saving": "Sparar...",
212
"success": "DID-dokumentet har uppdaterats",
213
"saveFailed": "Kunde inte spara DID-dokument",
214
"loadFailed": "Kunde inte ladda DID-dokument",
···
246
"yourDomain": "Din domän",
247
"yourDomainPlaceholder": "exempel.se",
248
"verifyAndUpdate": "Verifiera och uppdatera användarnamn",
249
-
"verifying": "Verifierar...",
250
"newHandle": "Nytt användarnamn",
251
"newHandlePlaceholder": "dittanvändarnamn",
252
"changeHandleButton": "Ändra användarnamn",
···
262
"exportData": "Exportera data",
263
"exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.",
264
"downloadRepo": "Ladda ner arkiv",
265
"exporting": "Exporterar...",
266
"deleteAccount": "Radera konto",
267
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
268
"requestDeletion": "Begär kontoradering",
···
291
"deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.",
292
"deletionFailed": "Kunde inte radera kontot",
293
"repoExported": "Arkiv exporterat",
294
-
"exportFailed": "Kunde inte exportera arkiv",
295
"confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras."
296
}
297
},
···
306
"noPasswords": "Inga applösenord ännu",
307
"revoke": "Återkalla",
308
"revoking": "Återkallar...",
309
-
"creating": "Skapar...",
310
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
311
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
312
"saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
···
354
"used": "Använd av @{handle}",
355
"disabled": "Inaktiverad",
356
"usedBy": "Använd av",
357
-
"creating": "Skapar...",
358
"disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.",
359
"created": "Inbjudningskod skapad",
360
"copy": "Kopiera",
···
482
"verifyButton": "Verifiera",
483
"verifyCodePlaceholder": "Ange verifieringskod",
484
"submit": "Skicka",
485
-
"saving": "Sparar...",
486
"savePreferences": "Spara inställningar",
487
"preferencesSaved": "Kommunikationsinställningar sparade",
488
"verifiedSuccess": "{channel} verifierad",
···
521
"noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.",
522
"loadMore": "Ladda fler",
523
"recordJson": "Post-JSON",
524
-
"saving": "Sparar...",
525
"updateRecord": "Uppdatera post",
526
"collectionNsid": "Samling (NSID)",
527
"recordKeyOptional": "Postnyckel (valfri)",
528
"autoGenerated": "Genereras automatiskt om tom (TID)",
529
"autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel",
530
-
"creating": "Skapar...",
531
"demoPostText": "Hej från min PDS! Detta är mitt första inlägg.",
532
"demoDisplayName": "Ditt visningsnamn",
533
"demoBio": "En kort presentation om dig själv."
···
548
"primaryLight": "Primär (ljust läge)",
549
"primaryDark": "Primär (mörkt läge)",
550
"configSaved": "Serverkonfiguration sparad",
551
-
"saving": "Sparar...",
552
"saveConfig": "Spara konfiguration",
553
"serverStats": "Serverstatistik",
554
"users": "Användare",
···
639
"title": "Tvåfaktorsautentisering",
640
"subtitle": "Ytterligare verifiering krävs",
641
"usePasskey": "Använd nyckel",
642
-
"useTotp": "Använd autentiseringsapp",
643
-
"verifying": "Verifierar..."
644
},
645
"twoFactorCode": {
646
"title": "Tvåfaktorsautentisering",
647
"subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.",
648
"codeLabel": "Verifieringskod",
649
"codePlaceholder": "Ange 6-siffrig kod",
650
-
"verify": "Verifiera",
651
-
"verifying": "Verifierar...",
652
"errors": {
653
"missingRequestUri": "Saknar request_uri-parameter",
654
"verificationFailed": "Verifiering misslyckades",
···
660
"title": "Ange autentiseringskod",
661
"subtitle": "Ange den 6-siffriga koden från din autentiseringsapp",
662
"codePlaceholder": "Ange 6-siffrig kod",
663
-
"verify": "Verifiera",
664
-
"verifying": "Verifierar...",
665
"useBackupCode": "Använd reservkod istället",
666
"backupCodePlaceholder": "Ange reservkod",
667
"trustDevice": "Lita på denna enhet i 30 dagar",
···
691
"codeLabel": "Verifieringskod",
692
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
693
"verifyButton": "Verifiera konto",
694
-
"verify": "Verifiera",
695
-
"verifying": "Verifierar...",
696
"pleaseWait": "Vänta...",
697
-
"sending": "Skickar...",
698
-
"resendCode": "Skicka kod igen",
699
-
"resending": "Skickar igen...",
700
"codeResent": "Verifieringskod skickad igen!",
701
"codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.",
702
"verified": "Verifierad!",
···
706
"identifierLabel": "E-post eller identifierare",
707
"identifierPlaceholder": "du@exempel.se",
708
"identifierHelp": "E-postadressen eller identifieraren koden skickades till",
709
-
"backToLogin": "Tillbaka till inloggning",
710
"verifyingAccount": "Verifierar konto: @{handle}",
711
"startOver": "Börja om med ett annat konto",
712
"noPending": "Ingen väntande verifiering hittades.",
713
"noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.",
714
"createAccount": "Skapa konto",
715
"signIn": "Logga in",
716
-
"backToSettings": "Tillbaka till inställningar",
717
"emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress",
718
"emailUpdateFailed": "Kunde inte uppdatera e-postadress",
719
"emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.",
···
746
"resetButton": "Återställ lösenord",
747
"resetting": "Återställer...",
748
"success": "Lösenord återställt!",
749
-
"backToLogin": "Tillbaka till inloggning",
750
"requestNewCode": "Begär ny kod",
751
"passwordsMismatch": "Lösenorden matchar inte",
752
"passwordLength": "Lösenordet måste vara minst 8 tecken"
···
790
"howItWorks": "Så fungerar det",
791
"howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
792
"sendRecoveryLink": "Skicka återställningslänk",
793
-
"sending": "Skickar...",
794
-
"backToLogin": "Tillbaka till inloggning"
795
},
796
"registerPasskey": {
797
"title": "Skapa nyckelkonto",
···
812
"externalDid": "Din did:web",
813
"externalDidPlaceholder": "did:web:dindomän.se",
814
"createButton": "Skapa konto",
815
-
"creating": "Skapar...",
816
"alreadyHaveAccount": "Har du redan ett konto?",
817
"signIn": "Logga in",
818
"wantPassword": "Vill du använda ett lösenord?",
···
911
"useTotp": "Använd autentiserare",
912
"passwordPlaceholder": "Ange ditt lösenord",
913
"totpPlaceholder": "Ange 6-siffrig kod",
914
-
"verify": "Verifiera",
915
-
"verifying": "Verifierar...",
916
"authenticating": "Autentiserar...",
917
"passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.",
918
"cancel": "Avbryt"
···
985
"createAccount": "Skapa konto",
986
"createDelegatedAccount": "Skapa delegerat konto",
987
"createDelegatedAccountButton": "+ Skapa delegerat konto",
988
-
"creating": "Skapar...",
989
"emailOptional": "E-post (valfritt)",
990
"failedToAddController": "Kunde inte lägga till kontrollant",
991
"failedToCreateAccount": "Kunde inte skapa delegerat konto",
···
1059
"navDesc": "Flytta ditt konto till eller från en annan PDS",
1060
"migrateHere": "Flytta hit",
1061
"migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.",
1062
-
"migrateAway": "Flytta bort",
1063
-
"migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.",
1064
-
"loginRequired": "Inloggning krävs",
1065
"bringDid": "Ta med din DID och identitet",
1066
"transferData": "Överför all din data",
1067
"keepFollowers": "Behåll dina följare",
1068
-
"exportRepo": "Exportera ditt arkiv",
1069
-
"transferToPds": "Överför till ny PDS",
1070
-
"updateIdentity": "Uppdatera din identitet",
1071
"whatIsMigration": "Vad är kontoflyttning?",
1072
"whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.",
1073
"beforeMigrate": "Innan du flyttar",
···
1077
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1078
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1079
"learnMore": "Läs mer om flyttningsrisker",
1080
-
"comingSoon": "Kommer snart",
1081
"oauthCompleting": "Slutför autentisering...",
1082
"oauthFailed": "Autentisering misslyckades",
1083
"tryAgain": "Försök igen",
···
1086
"incomplete": "Du har en ofullständig flytt pågående:",
1087
"direction": "Riktning",
1088
"migratingHere": "Flyttar hit",
1089
-
"migratingAway": "Flyttar bort",
1090
"from": "Från",
1091
"to": "Till",
1092
"progress": "Framsteg",
···
1229
"error": {
1230
"title": "Flyttfel",
1231
"desc": "Ett fel uppstod under flytten.",
1232
-
"startOver": "Börja om"
1233
},
1234
"common": {
1235
"back": "Tillbaka",
···
1247
"warning3": "Ditt gamla konto kommer att inaktiveras efter flytten"
1248
}
1249
},
1250
-
"outbound": {
1251
"welcome": {
1252
-
"title": "Flytta från denna PDS",
1253
-
"desc": "Flytta ditt konto till en annan personlig dataserver.",
1254
-
"warning": "Efter flytten kommer ditt konto här att inaktiveras.",
1255
-
"didWebNotice": "did:web-flyttmeddelande",
1256
-
"didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.",
1257
-
"understand": "Jag förstår riskerna och vill fortsätta"
1258
},
1259
-
"targetPds": {
1260
-
"title": "Välj mål-PDS",
1261
-
"desc": "Ange URL:en för PDS du vill flytta till.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "Validera och fortsätt",
1265
-
"validating": "Validerar...",
1266
-
"connected": "Ansluten till {name}",
1267
-
"inviteRequired": "Inbjudningskod krävs",
1268
-
"privacyPolicy": "Integritetspolicy",
1269
-
"termsOfService": "Användarvillkor"
1270
},
1271
-
"newAccount": {
1272
-
"title": "Nya kontouppgifter",
1273
-
"desc": "Konfigurera ditt konto på den nya PDS.",
1274
-
"handle": "Användarnamn",
1275
-
"availableDomains": "Tillgängliga domäner",
1276
-
"email": "E-post",
1277
-
"password": "Lösenord",
1278
-
"confirmPassword": "Bekräfta lösenord",
1279
-
"inviteCode": "Inbjudningskod"
1280
},
1281
-
"review": {
1282
-
"title": "Granska flytt",
1283
-
"desc": "Granska och bekräfta dina flyttdetaljer.",
1284
-
"currentHandle": "Nuvarande användarnamn",
1285
-
"newHandle": "Nytt användarnamn",
1286
-
"sourcePds": "Denna PDS",
1287
-
"targetPds": "Mål-PDS",
1288
-
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
1289
-
"startMigration": "Starta flytt"
1290
},
1291
-
"migrating": {
1292
-
"title": "Flyttar ditt konto",
1293
-
"desc": "Vänta medan vi överför din data..."
1294
},
1295
-
"plcToken": {
1296
-
"title": "Verifiera din identitet",
1297
-
"desc": "En verifieringskod har skickats till din e-post."
1298
},
1299
-
"finalizing": {
1300
-
"title": "Slutför flytt",
1301
-
"desc": "Vänta medan vi slutför flytten...",
1302
-
"updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..."
1303
},
1304
"success": {
1305
-
"title": "Flytt klar!",
1306
-
"desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.",
1307
-
"newHandle": "Nytt användarnamn",
1308
-
"newPds": "Ny PDS",
1309
-
"nextSteps": "Nästa steg",
1310
-
"nextSteps1": "Logga in på din nya PDS",
1311
-
"nextSteps2": "Uppdatera dina appar med nya uppgifter",
1312
-
"nextSteps3": "Dina följare kommer automatiskt se din nya plats",
1313
-
"loggingOut": "Loggar ut om {seconds} sekunder..."
1314
}
1315
},
1316
"progress": {
···
17
"dashboard": "Kontrollpanel",
18
"backToDashboard": "← Kontrollpanel",
19
"copied": "Kopierat!",
20
+
"copyToClipboard": "Kopiera",
21
+
"verifying": "Verifierar...",
22
+
"saving": "Sparar...",
23
+
"creating": "Skapar...",
24
+
"updating": "Uppdaterar...",
25
+
"sending": "Skickar...",
26
+
"authenticating": "Autentiserar...",
27
+
"checking": "Kontrollerar...",
28
+
"redirecting": "Omdirigerar...",
29
+
"signIn": "Logga in",
30
+
"verify": "Verifiera",
31
+
"remove": "Ta bort",
32
+
"revoke": "Återkalla",
33
+
"resendCode": "Skicka kod igen",
34
+
"startOver": "Börja om",
35
+
"tryAgain": "Försök igen",
36
+
"password": "Lösenord",
37
+
"email": "E-post",
38
+
"emailAddress": "E-postadress",
39
+
"handle": "Användarnamn",
40
+
"did": "DID",
41
+
"verificationCode": "Verifieringskod",
42
+
"inviteCode": "Inbjudningskod",
43
+
"newPassword": "Nytt lösenord",
44
+
"confirmPassword": "Bekräfta lösenord",
45
+
"enterSixDigitCode": "Ange 6-siffrig kod",
46
+
"passwordHint": "Minst 8 tecken",
47
+
"enterPassword": "Ange ditt lösenord",
48
+
"emailPlaceholder": "du@exempel.se",
49
+
"verified": "Verifierad",
50
+
"disabled": "Inaktiverad",
51
+
"available": "Tillgänglig",
52
+
"deactivated": "Avaktiverad",
53
+
"unverified": "Overifierad",
54
+
"backToLogin": "Tillbaka till inloggning",
55
+
"backToSettings": "Tillbaka till inställningar",
56
+
"alreadyHaveAccount": "Har du redan ett konto?",
57
+
"createAccount": "Skapa konto",
58
+
"passwordsMismatch": "Lösenorden matchar inte",
59
+
"passwordTooShort": "Lösenordet måste vara minst 8 tecken"
60
},
61
"login": {
62
"title": "Logga in",
···
88
"codeLabel": "Verifieringskod",
89
"codePlaceholder": "Ange 6-siffrig kod",
90
"verifyButton": "Verifiera konto",
91
+
"resent": "Verifieringskod skickad igen!"
92
},
93
"register": {
94
"title": "Skapa konto",
···
159
"inviteCodePlaceholder": "Ange din inbjudningskod",
160
"inviteCodeRequired": "krävs",
161
"createButton": "Skapa konto",
162
"alreadyHaveAccount": "Har du redan ett konto?",
163
"signIn": "Logga in",
164
"wantPasswordless": "Vill du ha lösenordsfri säkerhet?",
···
213
"navAdminDesc": "Serverstatistik och administratörsoperationer",
214
"navDidDocument": "DID-dokument",
215
"navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
216
+
"navDidDocumentDescActive": "Redigera dina DID-dokumentinställningar",
217
+
"navBackup": "Ladda ner säkerhetskopia",
218
+
"navBackupDesc": "Ladda ner ditt dataförvar som en CAR-fil",
219
+
"downloadingBackup": "Laddar ner...",
220
+
"backupFailed": "Kunde inte ladda ner säkerhetskopia",
221
"migrated": "Flyttad",
222
"migratedTitle": "Konto flyttat",
223
"migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
···
247
"serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.",
248
"currentPds": "Nuvarande PDS-URL",
249
"save": "Spara ändringar",
250
"success": "DID-dokumentet har uppdaterats",
251
"saveFailed": "Kunde inte spara DID-dokument",
252
"loadFailed": "Kunde inte ladda DID-dokument",
···
284
"yourDomain": "Din domän",
285
"yourDomainPlaceholder": "exempel.se",
286
"verifyAndUpdate": "Verifiera och uppdatera användarnamn",
287
"newHandle": "Nytt användarnamn",
288
"newHandlePlaceholder": "dittanvändarnamn",
289
"changeHandleButton": "Ändra användarnamn",
···
299
"exportData": "Exportera data",
300
"exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.",
301
"downloadRepo": "Ladda ner arkiv",
302
+
"downloadBlobs": "Ladda ner media",
303
"exporting": "Exporterar...",
304
+
"backups": {
305
+
"title": "Säkerhetskopior",
306
+
"description": "Hantera automatiska säkerhetskopior och återställ din kontodata. Säkerhetskopior inkluderar alla poster och blobbar.",
307
+
"enableAutomatic": "Automatiska säkerhetskopior",
308
+
"enabled": "Aktiverad",
309
+
"disabled": "Inaktiverad",
310
+
"toggleFailed": "Kunde inte ändra säkerhetskopieringsinställning",
311
+
"noBackups": "Inga säkerhetskopior ännu",
312
+
"blocks": "block",
313
+
"download": "Ladda ner",
314
+
"delete": "Radera",
315
+
"createNow": "Skapa säkerhetskopia nu",
316
+
"created": "Säkerhetskopia skapad",
317
+
"createFailed": "Kunde inte skapa säkerhetskopia",
318
+
"downloadFailed": "Kunde inte ladda ner säkerhetskopia",
319
+
"deleted": "Säkerhetskopia raderad",
320
+
"deleteFailed": "Kunde inte radera säkerhetskopia",
321
+
"restoreTitle": "Återställ från säkerhetskopia",
322
+
"restoreDescription": "Återställ din kontodata från en tidigare exporterad CAR-fil. Detta ersätter ditt nuvarande dataförvar med den uppladdade säkerhetskopian.",
323
+
"selectFile": "Välj CAR-fil",
324
+
"selectedFile": "Vald fil",
325
+
"restore": "Återställ säkerhetskopia",
326
+
"restoring": "Återställer...",
327
+
"restored": "Säkerhetskopia återställd",
328
+
"restoreFailed": "Kunde inte återställa säkerhetskopia"
329
+
},
330
"deleteAccount": "Radera konto",
331
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
332
"requestDeletion": "Begär kontoradering",
···
355
"deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.",
356
"deletionFailed": "Kunde inte radera kontot",
357
"repoExported": "Arkiv exporterat",
358
+
"blobsExported": "Mediafiler exporterade",
359
+
"noBlobsToExport": "Inga mediafiler att exportera",
360
+
"exportFailed": "Export misslyckades",
361
"confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras."
362
}
363
},
···
372
"noPasswords": "Inga applösenord ännu",
373
"revoke": "Återkalla",
374
"revoking": "Återkallar...",
375
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
376
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
377
"saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
···
419
"used": "Använd av @{handle}",
420
"disabled": "Inaktiverad",
421
"usedBy": "Använd av",
422
"disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.",
423
"created": "Inbjudningskod skapad",
424
"copy": "Kopiera",
···
546
"verifyButton": "Verifiera",
547
"verifyCodePlaceholder": "Ange verifieringskod",
548
"submit": "Skicka",
549
"savePreferences": "Spara inställningar",
550
"preferencesSaved": "Kommunikationsinställningar sparade",
551
"verifiedSuccess": "{channel} verifierad",
···
584
"noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.",
585
"loadMore": "Ladda fler",
586
"recordJson": "Post-JSON",
587
"updateRecord": "Uppdatera post",
588
"collectionNsid": "Samling (NSID)",
589
"recordKeyOptional": "Postnyckel (valfri)",
590
"autoGenerated": "Genereras automatiskt om tom (TID)",
591
"autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel",
592
"demoPostText": "Hej från min PDS! Detta är mitt första inlägg.",
593
"demoDisplayName": "Ditt visningsnamn",
594
"demoBio": "En kort presentation om dig själv."
···
609
"primaryLight": "Primär (ljust läge)",
610
"primaryDark": "Primär (mörkt läge)",
611
"configSaved": "Serverkonfiguration sparad",
612
"saveConfig": "Spara konfiguration",
613
"serverStats": "Serverstatistik",
614
"users": "Användare",
···
699
"title": "Tvåfaktorsautentisering",
700
"subtitle": "Ytterligare verifiering krävs",
701
"usePasskey": "Använd nyckel",
702
+
"useTotp": "Använd autentiseringsapp"
703
},
704
"twoFactorCode": {
705
"title": "Tvåfaktorsautentisering",
706
"subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.",
707
"codeLabel": "Verifieringskod",
708
"codePlaceholder": "Ange 6-siffrig kod",
709
"errors": {
710
"missingRequestUri": "Saknar request_uri-parameter",
711
"verificationFailed": "Verifiering misslyckades",
···
717
"title": "Ange autentiseringskod",
718
"subtitle": "Ange den 6-siffriga koden från din autentiseringsapp",
719
"codePlaceholder": "Ange 6-siffrig kod",
720
"useBackupCode": "Använd reservkod istället",
721
"backupCodePlaceholder": "Ange reservkod",
722
"trustDevice": "Lita på denna enhet i 30 dagar",
···
746
"codeLabel": "Verifieringskod",
747
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
748
"verifyButton": "Verifiera konto",
749
"pleaseWait": "Vänta...",
750
"codeResent": "Verifieringskod skickad igen!",
751
"codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.",
752
"verified": "Verifierad!",
···
756
"identifierLabel": "E-post eller identifierare",
757
"identifierPlaceholder": "du@exempel.se",
758
"identifierHelp": "E-postadressen eller identifieraren koden skickades till",
759
"verifyingAccount": "Verifierar konto: @{handle}",
760
"startOver": "Börja om med ett annat konto",
761
"noPending": "Ingen väntande verifiering hittades.",
762
"noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.",
763
"createAccount": "Skapa konto",
764
"signIn": "Logga in",
765
"emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress",
766
"emailUpdateFailed": "Kunde inte uppdatera e-postadress",
767
"emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.",
···
794
"resetButton": "Återställ lösenord",
795
"resetting": "Återställer...",
796
"success": "Lösenord återställt!",
797
"requestNewCode": "Begär ny kod",
798
"passwordsMismatch": "Lösenorden matchar inte",
799
"passwordLength": "Lösenordet måste vara minst 8 tecken"
···
837
"howItWorks": "Så fungerar det",
838
"howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
839
"sendRecoveryLink": "Skicka återställningslänk",
840
+
"sending": "Skickar..."
841
},
842
"registerPasskey": {
843
"title": "Skapa nyckelkonto",
···
858
"externalDid": "Din did:web",
859
"externalDidPlaceholder": "did:web:dindomän.se",
860
"createButton": "Skapa konto",
861
"alreadyHaveAccount": "Har du redan ett konto?",
862
"signIn": "Logga in",
863
"wantPassword": "Vill du använda ett lösenord?",
···
956
"useTotp": "Använd autentiserare",
957
"passwordPlaceholder": "Ange ditt lösenord",
958
"totpPlaceholder": "Ange 6-siffrig kod",
959
"authenticating": "Autentiserar...",
960
"passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.",
961
"cancel": "Avbryt"
···
1028
"createAccount": "Skapa konto",
1029
"createDelegatedAccount": "Skapa delegerat konto",
1030
"createDelegatedAccountButton": "+ Skapa delegerat konto",
1031
"emailOptional": "E-post (valfritt)",
1032
"failedToAddController": "Kunde inte lägga till kontrollant",
1033
"failedToCreateAccount": "Kunde inte skapa delegerat konto",
···
1101
"navDesc": "Flytta ditt konto till eller från en annan PDS",
1102
"migrateHere": "Flytta hit",
1103
"migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.",
1104
"bringDid": "Ta med din DID och identitet",
1105
"transferData": "Överför all din data",
1106
"keepFollowers": "Behåll dina följare",
1107
"whatIsMigration": "Vad är kontoflyttning?",
1108
"whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.",
1109
"beforeMigrate": "Innan du flyttar",
···
1113
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1114
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1115
"learnMore": "Läs mer om flyttningsrisker",
1116
+
"offlineRestore": "Offline-återställning",
1117
+
"offlineRestoreDesc": "Återställ från backup när din gamla PDS inte är tillgänglig.",
1118
+
"offlineFeature1": "Använd en CAR-fil backup",
1119
+
"offlineFeature2": "Bevisa ägande med rotationsnyckel",
1120
+
"offlineFeature3": "Återställning för nedstängda servrar",
1121
"oauthCompleting": "Slutför autentisering...",
1122
"oauthFailed": "Autentisering misslyckades",
1123
"tryAgain": "Försök igen",
···
1126
"incomplete": "Du har en ofullständig flytt pågående:",
1127
"direction": "Riktning",
1128
"migratingHere": "Flyttar hit",
1129
"from": "Från",
1130
"to": "Till",
1131
"progress": "Framsteg",
···
1268
"error": {
1269
"title": "Flyttfel",
1270
"desc": "Ett fel uppstod under flytten.",
1271
+
"startOver": "Börja om",
1272
+
"unknown": "Ett okänt fel uppstod."
1273
},
1274
"common": {
1275
"back": "Tillbaka",
···
1287
"warning3": "Ditt gamla konto kommer att inaktiveras efter flytten"
1288
}
1289
},
1290
+
"offline": {
1291
"welcome": {
1292
+
"title": "Återställ från backup",
1293
+
"desc": "Återställ ditt konto med en CAR-fil backup och rotationsnyckel. Använd detta när din tidigare PDS inte är tillgänglig.",
1294
+
"warningTitle": "När du ska använda denna metod",
1295
+
"warningDesc": "Denna offline-återställning är för katastrofåterställning när din gamla PDS har stängts ner, är oåtkomlig eller du blev utelåst. Om din gamla PDS fortfarande är tillgänglig, använd standardflytten istället.",
1296
+
"requirementsTitle": "Du behöver",
1297
+
"requirement1": "En CAR-fil backup av ditt arkiv",
1298
+
"requirement2": "Din rotationsnyckel (privat nyckel för ditt DID)",
1299
+
"requirement3": "Ditt DID (did:plc:xxx)",
1300
+
"understand": "Jag förstår och vill fortsätta"
1301
},
1302
+
"provideDid": {
1303
+
"title": "Ange ditt DID",
1304
+
"desc": "Ange DID för kontot du vill återställa.",
1305
+
"label": "Ditt DID",
1306
+
"hint": "Din decentraliserade identifierare (t.ex. did:plc:abc123)"
1307
},
1308
+
"uploadCar": {
1309
+
"title": "Ladda upp CAR-fil",
1310
+
"desc": "Ladda upp din arkiv-backupfil.",
1311
+
"label": "CAR-fil",
1312
+
"hint": "Välj .car-filen från din backup",
1313
+
"reuploadWarningTitle": "CAR-fil krävs",
1314
+
"reuploadWarning": "Din session har återställts, men du måste ladda upp din CAR-fil igen. Av säkerhetsskäl lagras inte filinnehåll mellan sessioner."
1315
},
1316
+
"rotationKey": {
1317
+
"title": "Ange rotationsnyckel",
1318
+
"desc": "Ange din rotationsnyckel för att bevisa ägande av detta DID.",
1319
+
"securityWarningTitle": "Säkerhetsvarning",
1320
+
"securityWarning1": "Din rotationsnyckel är extremt känslig - behandla den som ett huvudlösenord",
1321
+
"securityWarning2": "Ange den endast på betrodda enheter och nätverk",
1322
+
"securityWarning3": "Denna nyckel kommer inte att lagras efter att flytten slutförts",
1323
+
"label": "Rotationsnyckel",
1324
+
"placeholder": "Ange privat nyckel (hex, base58 eller JWK)",
1325
+
"hint": "Den privata nyckeln som motsvarar en av rotationsnycklarna i ditt DID-dokument",
1326
+
"valid": "Nyckeln är giltig och matchar en rotationsnyckel i ditt DID",
1327
+
"invalid": "Nyckeln matchar inte någon rotationsnyckel i ditt DID-dokument",
1328
+
"validating": "Validerar nyckel...",
1329
+
"validate": "Validera nyckel"
1330
},
1331
+
"chooseHandle": {
1332
+
"migratingDid": "Återställer DID"
1333
},
1334
+
"review": {
1335
+
"desc": "Granska dina offline-återställningsuppgifter.",
1336
+
"carFile": "CAR-fil",
1337
+
"rotationKey": "Rotationsnyckel",
1338
+
"warning": "När du startar återställningen kommer din identitet att uppdateras för att peka på denna PDS. Detta kan inte enkelt ångras.",
1339
+
"plcWarningTitle": "Ingen återvändo",
1340
+
"plcWarning": "När du startar kommer ditt DID-dokument att uppdateras för att peka på denna PDS. Om något går fel kan du använda din rotationsnyckel för att återställa, men du bör slutföra flytten för att undvika ett trasigt identitetstillstånd."
1341
},
1342
+
"migrating": {
1343
+
"title": "Återställer konto",
1344
+
"desc": "Vänta medan ditt konto återställs...",
1345
+
"creating": "Skapar konto",
1346
+
"importing": "Importerar arkiv",
1347
+
"plcSigning": "Uppdaterar identitet",
1348
+
"activating": "Aktiverar konto"
1349
},
1350
"success": {
1351
+
"desc": "Ditt konto har framgångsrikt återställts till denna PDS."
1352
+
},
1353
+
"blobs": {
1354
+
"title": "Flyttar blobbar",
1355
+
"desc": "Försöker återställa bilder och media från din gamla PDS...",
1356
+
"migrating": "Flyttar blobbar",
1357
+
"failedTitle": "Vissa blobbar kunde inte flyttas",
1358
+
"failedDesc": "{count} blobbar kunde inte hämtas från din gamla PDS. Detta kan hända om servern är otillgänglig eller om filerna raderades.",
1359
+
"sourceUnreachableTitle": "Käll-PDS otillgänglig",
1360
+
"sourceUnreachable": "Kunde inte ansluta till din gamla PDS för att hämta mediafiler. Detta är vanligt vid flytt från en nedstängd server. Dina inlägg kommer att fungera, men vissa bilder kan saknas."
1361
}
1362
},
1363
"progress": {
+147
-100
frontend/src/locales/zh.json
+147
-100
frontend/src/locales/zh.json
···
17
"dashboard": "控制台",
18
"backToDashboard": "← 返回控制台",
19
"copied": "已复制!",
20
-
"copyToClipboard": "复制"
21
},
22
"login": {
23
"title": "登录",
···
49
"codeLabel": "验证码",
50
"codePlaceholder": "输入6位验证码",
51
"verifyButton": "验证账户",
52
-
"verifying": "验证中...",
53
-
"resendButton": "重新发送验证码",
54
-
"resending": "发送中...",
55
-
"resent": "验证码已重新发送!",
56
-
"backToLogin": "返回登录"
57
},
58
"register": {
59
"title": "创建账户",
···
124
"inviteCodePlaceholder": "输入您的邀请码",
125
"inviteCodeRequired": "必填",
126
"createButton": "创建账户",
127
-
"creating": "正在创建...",
128
"alreadyHaveAccount": "已有账户?",
129
"signIn": "立即登录",
130
"wantPasswordless": "想要无密码登录?",
···
179
"navAdminDesc": "服务器统计和管理操作",
180
"navDidDocument": "DID 文档",
181
"navDidDocumentDesc": "管理您的 DID 文档和密钥",
182
"migrated": "已迁移",
183
"migratedTitle": "账户已迁移",
184
"migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。",
···
208
"serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。",
209
"currentPds": "当前 PDS URL",
210
"save": "保存更改",
211
-
"saving": "保存中...",
212
"success": "DID 文档已更新",
213
"saveFailed": "保存 DID 文档失败",
214
"loadFailed": "加载 DID 文档失败",
···
246
"yourDomain": "您的域名",
247
"yourDomainPlaceholder": "example.com",
248
"verifyAndUpdate": "验证并更新用户名",
249
-
"verifying": "验证中...",
250
"newHandle": "新用户名",
251
"newHandlePlaceholder": "yourhandle",
252
"changeHandleButton": "更改用户名",
···
262
"exportData": "导出数据",
263
"exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。",
264
"downloadRepo": "下载数据",
265
"exporting": "导出中...",
266
"deleteAccount": "删除账户",
267
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
268
"requestDeletion": "请求删除账户",
···
291
"deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。",
292
"deletionFailed": "账户删除失败",
293
"repoExported": "数据导出成功",
294
-
"exportFailed": "数据导出失败",
295
"confirmDelete": "您确定要删除账户吗?此操作无法撤销。"
296
}
297
},
···
306
"noPasswords": "暂无应用专用密码",
307
"revoke": "撤销",
308
"revoking": "撤销中...",
309
-
"creating": "创建中...",
310
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
311
"saveWarningTitle": "重要:请保存此应用专用密码!",
312
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
···
354
"used": "已被 @{handle} 使用",
355
"disabled": "已禁用",
356
"usedBy": "使用者",
357
-
"creating": "创建中...",
358
"disableConfirm": "禁用此邀请码?它将无法再被使用。",
359
"created": "邀请码已创建",
360
"copy": "复制",
···
482
"verifyButton": "验证",
483
"verifyCodePlaceholder": "输入验证码",
484
"submit": "提交",
485
-
"saving": "保存中...",
486
"savePreferences": "保存偏好设置",
487
"preferencesSaved": "通讯偏好已保存",
488
"verifiedSuccess": "{channel} 验证成功",
···
521
"noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。",
522
"loadMore": "加载更多",
523
"recordJson": "记录 JSON",
524
-
"saving": "保存中...",
525
"updateRecord": "更新记录",
526
"collectionNsid": "集合 (NSID)",
527
"recordKeyOptional": "记录键(可选)",
528
"autoGenerated": "留空自动生成 (TID)",
529
"autoGeneratedHint": "留空将自动生成基于 TID 的键",
530
-
"creating": "创建中...",
531
"demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。",
532
"demoDisplayName": "你的显示名称",
533
"demoBio": "写一段简短的自我介绍。"
···
551
"secondaryLight": "副色(浅色模式)",
552
"secondaryDark": "副色(深色模式)",
553
"configSaved": "服务器配置已保存",
554
-
"saving": "保存中...",
555
"saveConfig": "保存配置",
556
"serverStats": "服务器统计",
557
"users": "用户",
···
639
"title": "双重身份验证",
640
"subtitle": "需要额外验证",
641
"usePasskey": "使用通行密钥",
642
-
"useTotp": "使用身份验证器",
643
-
"verifying": "验证中..."
644
},
645
"twoFactorCode": {
646
"title": "双重身份验证",
647
"subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。",
648
"codeLabel": "验证码",
649
"codePlaceholder": "输入6位验证码",
650
-
"verify": "验证",
651
-
"verifying": "验证中...",
652
"errors": {
653
"missingRequestUri": "缺少 request_uri 参数",
654
"verificationFailed": "验证失败",
···
660
"title": "输入验证码",
661
"subtitle": "请输入身份验证器应用中的6位验证码",
662
"codePlaceholder": "输入6位验证码",
663
-
"verify": "验证",
664
-
"verifying": "验证中...",
665
"useBackupCode": "使用备用验证码",
666
"backupCodePlaceholder": "输入备用验证码",
667
"trustDevice": "信任此设备30天",
···
691
"codeLabel": "验证码",
692
"codeHelp": "复制消息中的完整验证码,包括横线",
693
"verifyButton": "验证账户",
694
-
"verify": "验证",
695
-
"verifying": "验证中...",
696
"pleaseWait": "请稍候...",
697
-
"resendCode": "重新发送验证码",
698
-
"resending": "发送中...",
699
-
"sending": "发送中...",
700
"codeResent": "验证码已重新发送!",
701
"codeResentDetail": "验证码已发送!请查收。",
702
-
"backToLogin": "返回登录",
703
"verifyingAccount": "正在验证账户:@{handle}",
704
"startOver": "使用其他账户重新开始",
705
"noPending": "未找到待验证的账户",
···
713
"identifierLabel": "邮箱或标识符",
714
"identifierPlaceholder": "you@example.com",
715
"identifierHelp": "接收验证码的邮箱地址或标识符",
716
-
"backToSettings": "返回设置",
717
"emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址",
718
"emailUpdateFailed": "更新邮箱地址失败",
719
"emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。",
···
746
"resetButton": "重置密码",
747
"resetting": "重置中...",
748
"success": "密码重置成功!",
749
-
"backToLogin": "返回登录",
750
"requestNewCode": "重新获取验证码",
751
"passwordsMismatch": "两次输入的密码不一致",
752
"passwordLength": "密码至少需要8位字符"
···
790
"howItWorks": "如何恢复",
791
"howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。",
792
"sendRecoveryLink": "发送恢复链接",
793
-
"sending": "发送中...",
794
-
"backToLogin": "返回登录"
795
},
796
"registerPasskey": {
797
"title": "创建通行密钥账户",
···
814
"inviteCode": "邀请码",
815
"inviteCodePlaceholder": "输入您的邀请码",
816
"createButton": "创建账户",
817
-
"creating": "创建中...",
818
"continue": "继续",
819
"back": "返回",
820
"alreadyHaveAccount": "已有账户?",
···
911
"useTotp": "使用身份验证器",
912
"passwordPlaceholder": "输入您的密码",
913
"totpPlaceholder": "输入6位验证码",
914
-
"verify": "验证",
915
-
"verifying": "验证中...",
916
"authenticating": "正在验证...",
917
"passkeyPrompt": "点击下方按钮使用通行密钥进行验证。",
918
"cancel": "取消"
···
986
"createAccount": "创建账户",
987
"createDelegatedAccount": "创建委托账户",
988
"createDelegatedAccountButton": "+ 创建委托账户",
989
-
"creating": "创建中...",
990
"emailOptional": "邮箱(可选)",
991
"failedToAddController": "添加控制者失败",
992
"failedToCreateAccount": "创建委托账户失败",
···
1059
"navDesc": "将您的账户移至其他PDS或从其他PDS移入",
1060
"migrateHere": "迁移到此处",
1061
"migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。",
1062
-
"migrateAway": "迁移离开",
1063
-
"migrateAwayDesc": "将您的账户从此PDS移至其他服务器。",
1064
-
"loginRequired": "需要登录",
1065
"bringDid": "携带您的DID和身份",
1066
"transferData": "转移所有数据",
1067
"keepFollowers": "保留您的关注者",
1068
-
"exportRepo": "导出您的存储库",
1069
-
"transferToPds": "转移到新PDS",
1070
-
"updateIdentity": "更新您的身份",
1071
"whatIsMigration": "什么是账户迁移?",
1072
"whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。",
1073
"beforeMigrate": "迁移前须知",
···
1077
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1078
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1079
"learnMore": "了解更多迁移风险",
1080
-
"comingSoon": "即将推出",
1081
"oauthCompleting": "正在完成身份验证...",
1082
"oauthFailed": "身份验证失败",
1083
"tryAgain": "重试",
···
1086
"incomplete": "您有一个未完成的迁移:",
1087
"direction": "方向",
1088
"migratingHere": "正在迁移到此处",
1089
-
"migratingAway": "正在迁移离开",
1090
"from": "从",
1091
"to": "到",
1092
"progress": "进度",
···
1229
"error": {
1230
"title": "迁移错误",
1231
"desc": "迁移过程中发生错误。",
1232
-
"startOver": "重新开始"
1233
},
1234
"common": {
1235
"back": "返回",
···
1247
"warning3": "迁移后您的旧账户将被停用"
1248
}
1249
},
1250
-
"outbound": {
1251
"welcome": {
1252
-
"title": "从此PDS迁移离开",
1253
-
"desc": "将您的账户移至另一个个人数据服务器。",
1254
-
"warning": "迁移后,您在此处的账户将被停用。",
1255
-
"didWebNotice": "did:web迁移通知",
1256
-
"didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。",
1257
-
"understand": "我了解风险并希望继续"
1258
},
1259
-
"targetPds": {
1260
-
"title": "选择目标PDS",
1261
-
"desc": "输入您要迁移到的PDS的URL。",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "验证并继续",
1265
-
"validating": "验证中...",
1266
-
"connected": "已连接到 {name}",
1267
-
"inviteRequired": "需要邀请码",
1268
-
"privacyPolicy": "隐私政策",
1269
-
"termsOfService": "服务条款"
1270
},
1271
-
"newAccount": {
1272
-
"title": "新账户详情",
1273
-
"desc": "在新PDS上设置您的账户。",
1274
-
"handle": "用户名",
1275
-
"availableDomains": "可用域名",
1276
-
"email": "邮箱",
1277
-
"password": "密码",
1278
-
"confirmPassword": "确认密码",
1279
-
"inviteCode": "邀请码"
1280
},
1281
-
"review": {
1282
-
"title": "检查迁移",
1283
-
"desc": "请检查并确认您的迁移详情。",
1284
-
"currentHandle": "当前用户名",
1285
-
"newHandle": "新用户名",
1286
-
"sourcePds": "此PDS",
1287
-
"targetPds": "目标PDS",
1288
-
"confirm": "我确认要迁移我的账户",
1289
-
"startMigration": "开始迁移"
1290
},
1291
-
"migrating": {
1292
-
"title": "正在迁移您的账户",
1293
-
"desc": "请稍候,正在转移您的数据..."
1294
},
1295
-
"plcToken": {
1296
-
"title": "验证您的身份",
1297
-
"desc": "验证码已发送到您的邮箱。"
1298
},
1299
-
"finalizing": {
1300
-
"title": "正在完成迁移",
1301
-
"desc": "请稍候,正在完成迁移...",
1302
-
"updatingForwarding": "正在更新DID文档转发..."
1303
},
1304
"success": {
1305
-
"title": "迁移完成!",
1306
-
"desc": "您的账户已成功迁移到新PDS。",
1307
-
"newHandle": "新用户名",
1308
-
"newPds": "新PDS",
1309
-
"nextSteps": "后续步骤",
1310
-
"nextSteps1": "登录到您的新PDS",
1311
-
"nextSteps2": "使用新凭据更新您的应用",
1312
-
"nextSteps3": "您的关注者将自动看到您的新位置",
1313
-
"loggingOut": "{seconds}秒后退出登录..."
1314
}
1315
},
1316
"progress": {
···
17
"dashboard": "控制台",
18
"backToDashboard": "← 返回控制台",
19
"copied": "已复制!",
20
+
"copyToClipboard": "复制",
21
+
"verifying": "验证中...",
22
+
"saving": "保存中...",
23
+
"creating": "创建中...",
24
+
"updating": "更新中...",
25
+
"sending": "发送中...",
26
+
"authenticating": "认证中...",
27
+
"checking": "检查中...",
28
+
"redirecting": "跳转中...",
29
+
"signIn": "登录",
30
+
"verify": "验证",
31
+
"remove": "移除",
32
+
"revoke": "撤销",
33
+
"resendCode": "重新发送验证码",
34
+
"startOver": "重新开始",
35
+
"tryAgain": "重试",
36
+
"password": "密码",
37
+
"email": "邮箱",
38
+
"emailAddress": "邮箱地址",
39
+
"handle": "用户名",
40
+
"did": "DID",
41
+
"verificationCode": "验证码",
42
+
"inviteCode": "邀请码",
43
+
"newPassword": "新密码",
44
+
"confirmPassword": "确认密码",
45
+
"enterSixDigitCode": "输入6位验证码",
46
+
"passwordHint": "至少8个字符",
47
+
"enterPassword": "请输入密码",
48
+
"emailPlaceholder": "you@example.com",
49
+
"verified": "已验证",
50
+
"disabled": "已禁用",
51
+
"available": "可用",
52
+
"deactivated": "已停用",
53
+
"unverified": "未验证",
54
+
"backToLogin": "返回登录",
55
+
"backToSettings": "返回设置",
56
+
"alreadyHaveAccount": "已有账户?",
57
+
"createAccount": "立即注册",
58
+
"passwordsMismatch": "密码不匹配",
59
+
"passwordTooShort": "密码至少需要8个字符"
60
},
61
"login": {
62
"title": "登录",
···
88
"codeLabel": "验证码",
89
"codePlaceholder": "输入6位验证码",
90
"verifyButton": "验证账户",
91
+
"resent": "验证码已重新发送!"
92
},
93
"register": {
94
"title": "创建账户",
···
159
"inviteCodePlaceholder": "输入您的邀请码",
160
"inviteCodeRequired": "必填",
161
"createButton": "创建账户",
162
"alreadyHaveAccount": "已有账户?",
163
"signIn": "立即登录",
164
"wantPasswordless": "想要无密码登录?",
···
213
"navAdminDesc": "服务器统计和管理操作",
214
"navDidDocument": "DID 文档",
215
"navDidDocumentDesc": "管理您的 DID 文档和密钥",
216
+
"navDidDocumentDescActive": "编辑您的 DID 文档设置",
217
+
"navBackup": "下载备份",
218
+
"navBackupDesc": "将您的存储库下载为 CAR 文件",
219
+
"downloadingBackup": "下载中...",
220
+
"backupFailed": "下载备份失败",
221
"migrated": "已迁移",
222
"migratedTitle": "账户已迁移",
223
"migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。",
···
247
"serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。",
248
"currentPds": "当前 PDS URL",
249
"save": "保存更改",
250
"success": "DID 文档已更新",
251
"saveFailed": "保存 DID 文档失败",
252
"loadFailed": "加载 DID 文档失败",
···
284
"yourDomain": "您的域名",
285
"yourDomainPlaceholder": "example.com",
286
"verifyAndUpdate": "验证并更新用户名",
287
"newHandle": "新用户名",
288
"newHandlePlaceholder": "yourhandle",
289
"changeHandleButton": "更改用户名",
···
299
"exportData": "导出数据",
300
"exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。",
301
"downloadRepo": "下载数据",
302
+
"downloadBlobs": "下载媒体文件",
303
"exporting": "导出中...",
304
+
"backups": {
305
+
"title": "备份",
306
+
"description": "管理自动备份并恢复账户数据。备份包括所有记录和文件。",
307
+
"enableAutomatic": "自动备份",
308
+
"enabled": "已启用",
309
+
"disabled": "已禁用",
310
+
"toggleFailed": "更改备份设置失败",
311
+
"noBackups": "暂无备份",
312
+
"blocks": "块",
313
+
"download": "下载",
314
+
"delete": "删除",
315
+
"createNow": "立即创建备份",
316
+
"created": "备份已创建",
317
+
"createFailed": "创建备份失败",
318
+
"downloadFailed": "下载备份失败",
319
+
"deleted": "备份已删除",
320
+
"deleteFailed": "删除备份失败",
321
+
"restoreTitle": "从备份恢复",
322
+
"restoreDescription": "从之前导出的 CAR 文件恢复账户数据。这将用上传的备份替换当前的存储库。",
323
+
"selectFile": "选择 CAR 文件",
324
+
"selectedFile": "已选文件",
325
+
"restore": "恢复备份",
326
+
"restoring": "恢复中...",
327
+
"restored": "备份恢复成功",
328
+
"restoreFailed": "备份恢复失败"
329
+
},
330
"deleteAccount": "删除账户",
331
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
332
"requestDeletion": "请求删除账户",
···
355
"deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。",
356
"deletionFailed": "账户删除失败",
357
"repoExported": "数据导出成功",
358
+
"blobsExported": "媒体文件导出成功",
359
+
"noBlobsToExport": "没有可导出的媒体文件",
360
+
"exportFailed": "导出失败",
361
"confirmDelete": "您确定要删除账户吗?此操作无法撤销。"
362
}
363
},
···
372
"noPasswords": "暂无应用专用密码",
373
"revoke": "撤销",
374
"revoking": "撤销中...",
375
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
376
"saveWarningTitle": "重要:请保存此应用专用密码!",
377
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
···
419
"used": "已被 @{handle} 使用",
420
"disabled": "已禁用",
421
"usedBy": "使用者",
422
"disableConfirm": "禁用此邀请码?它将无法再被使用。",
423
"created": "邀请码已创建",
424
"copy": "复制",
···
546
"verifyButton": "验证",
547
"verifyCodePlaceholder": "输入验证码",
548
"submit": "提交",
549
"savePreferences": "保存偏好设置",
550
"preferencesSaved": "通讯偏好已保存",
551
"verifiedSuccess": "{channel} 验证成功",
···
584
"noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。",
585
"loadMore": "加载更多",
586
"recordJson": "记录 JSON",
587
"updateRecord": "更新记录",
588
"collectionNsid": "集合 (NSID)",
589
"recordKeyOptional": "记录键(可选)",
590
"autoGenerated": "留空自动生成 (TID)",
591
"autoGeneratedHint": "留空将自动生成基于 TID 的键",
592
"demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。",
593
"demoDisplayName": "你的显示名称",
594
"demoBio": "写一段简短的自我介绍。"
···
612
"secondaryLight": "副色(浅色模式)",
613
"secondaryDark": "副色(深色模式)",
614
"configSaved": "服务器配置已保存",
615
"saveConfig": "保存配置",
616
"serverStats": "服务器统计",
617
"users": "用户",
···
699
"title": "双重身份验证",
700
"subtitle": "需要额外验证",
701
"usePasskey": "使用通行密钥",
702
+
"useTotp": "使用身份验证器"
703
},
704
"twoFactorCode": {
705
"title": "双重身份验证",
706
"subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。",
707
"codeLabel": "验证码",
708
"codePlaceholder": "输入6位验证码",
709
"errors": {
710
"missingRequestUri": "缺少 request_uri 参数",
711
"verificationFailed": "验证失败",
···
717
"title": "输入验证码",
718
"subtitle": "请输入身份验证器应用中的6位验证码",
719
"codePlaceholder": "输入6位验证码",
720
"useBackupCode": "使用备用验证码",
721
"backupCodePlaceholder": "输入备用验证码",
722
"trustDevice": "信任此设备30天",
···
746
"codeLabel": "验证码",
747
"codeHelp": "复制消息中的完整验证码,包括横线",
748
"verifyButton": "验证账户",
749
"pleaseWait": "请稍候...",
750
"codeResent": "验证码已重新发送!",
751
"codeResentDetail": "验证码已发送!请查收。",
752
"verifyingAccount": "正在验证账户:@{handle}",
753
"startOver": "使用其他账户重新开始",
754
"noPending": "未找到待验证的账户",
···
762
"identifierLabel": "邮箱或标识符",
763
"identifierPlaceholder": "you@example.com",
764
"identifierHelp": "接收验证码的邮箱地址或标识符",
765
"emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址",
766
"emailUpdateFailed": "更新邮箱地址失败",
767
"emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。",
···
794
"resetButton": "重置密码",
795
"resetting": "重置中...",
796
"success": "密码重置成功!",
797
"requestNewCode": "重新获取验证码",
798
"passwordsMismatch": "两次输入的密码不一致",
799
"passwordLength": "密码至少需要8位字符"
···
837
"howItWorks": "如何恢复",
838
"howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。",
839
"sendRecoveryLink": "发送恢复链接",
840
+
"sending": "发送中..."
841
},
842
"registerPasskey": {
843
"title": "创建通行密钥账户",
···
860
"inviteCode": "邀请码",
861
"inviteCodePlaceholder": "输入您的邀请码",
862
"createButton": "创建账户",
863
"continue": "继续",
864
"back": "返回",
865
"alreadyHaveAccount": "已有账户?",
···
956
"useTotp": "使用身份验证器",
957
"passwordPlaceholder": "输入您的密码",
958
"totpPlaceholder": "输入6位验证码",
959
"authenticating": "正在验证...",
960
"passkeyPrompt": "点击下方按钮使用通行密钥进行验证。",
961
"cancel": "取消"
···
1029
"createAccount": "创建账户",
1030
"createDelegatedAccount": "创建委托账户",
1031
"createDelegatedAccountButton": "+ 创建委托账户",
1032
"emailOptional": "邮箱(可选)",
1033
"failedToAddController": "添加控制者失败",
1034
"failedToCreateAccount": "创建委托账户失败",
···
1101
"navDesc": "将您的账户移至其他PDS或从其他PDS移入",
1102
"migrateHere": "迁移到此处",
1103
"migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。",
1104
"bringDid": "携带您的DID和身份",
1105
"transferData": "转移所有数据",
1106
"keepFollowers": "保留您的关注者",
1107
"whatIsMigration": "什么是账户迁移?",
1108
"whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。",
1109
"beforeMigrate": "迁移前须知",
···
1113
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1114
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1115
"learnMore": "了解更多迁移风险",
1116
+
"offlineRestore": "离线恢复",
1117
+
"offlineRestoreDesc": "当旧 PDS 不可用时从备份恢复。",
1118
+
"offlineFeature1": "使用 CAR 文件备份",
1119
+
"offlineFeature2": "使用轮换密钥证明所有权",
1120
+
"offlineFeature3": "用于已关闭服务器的恢复",
1121
"oauthCompleting": "正在完成身份验证...",
1122
"oauthFailed": "身份验证失败",
1123
"tryAgain": "重试",
···
1126
"incomplete": "您有一个未完成的迁移:",
1127
"direction": "方向",
1128
"migratingHere": "正在迁移到此处",
1129
"from": "从",
1130
"to": "到",
1131
"progress": "进度",
···
1268
"error": {
1269
"title": "迁移错误",
1270
"desc": "迁移过程中发生错误。",
1271
+
"startOver": "重新开始",
1272
+
"unknown": "发生未知错误。"
1273
},
1274
"common": {
1275
"back": "返回",
···
1287
"warning3": "迁移后您的旧账户将被停用"
1288
}
1289
},
1290
+
"offline": {
1291
"welcome": {
1292
+
"title": "从备份恢复",
1293
+
"desc": "使用 CAR 文件备份和轮换密钥恢复您的账户。当您的旧 PDS 不可用时使用此方法。",
1294
+
"warningTitle": "何时使用此方法",
1295
+
"warningDesc": "此离线恢复用于灾难恢复,当您的旧 PDS 已关闭、无法访问或您被锁定时使用。如果您的旧 PDS 仍然可用,请使用标准迁移。",
1296
+
"requirementsTitle": "您需要",
1297
+
"requirement1": "您的存储库的 CAR 文件备份",
1298
+
"requirement2": "您的轮换密钥(DID 的私钥)",
1299
+
"requirement3": "您的 DID (did:plc:xxx)",
1300
+
"understand": "我了解并希望继续"
1301
},
1302
+
"provideDid": {
1303
+
"title": "输入您的 DID",
1304
+
"desc": "输入您要恢复的账户的 DID。",
1305
+
"label": "您的 DID",
1306
+
"hint": "您的去中心化标识符(例如 did:plc:abc123)"
1307
},
1308
+
"uploadCar": {
1309
+
"title": "上传 CAR 文件",
1310
+
"desc": "上传您的存储库备份文件。",
1311
+
"label": "CAR 文件",
1312
+
"hint": "从您的备份中选择 .car 文件",
1313
+
"reuploadWarningTitle": "需要 CAR 文件",
1314
+
"reuploadWarning": "您的会话已恢复,但您需要重新上传 CAR 文件。出于安全原因,文件内容不会在会话之间保存。"
1315
},
1316
+
"rotationKey": {
1317
+
"title": "提供轮换密钥",
1318
+
"desc": "输入您的轮换密钥以证明此 DID 的所有权。",
1319
+
"securityWarningTitle": "安全警告",
1320
+
"securityWarning1": "您的轮换密钥极为敏感 - 请像对待主密码一样对待它",
1321
+
"securityWarning2": "仅在受信任的设备和网络上输入",
1322
+
"securityWarning3": "迁移完成后此密钥不会被存储",
1323
+
"label": "轮换密钥",
1324
+
"placeholder": "输入私钥(hex、base58 或 JWK)",
1325
+
"hint": "与您的 DID 文档中的轮换密钥之一对应的私钥",
1326
+
"valid": "密钥有效并匹配您的 DID 中的轮换密钥",
1327
+
"invalid": "密钥与您的 DID 文档中的任何轮换密钥都不匹配",
1328
+
"validating": "验证密钥...",
1329
+
"validate": "验证密钥"
1330
},
1331
+
"chooseHandle": {
1332
+
"migratingDid": "恢复 DID"
1333
},
1334
+
"review": {
1335
+
"desc": "检查您的离线恢复详情。",
1336
+
"carFile": "CAR 文件",
1337
+
"rotationKey": "轮换密钥",
1338
+
"warning": "开始恢复后,您的身份将更新为指向此 PDS。此操作无法轻易撤销。",
1339
+
"plcWarningTitle": "不可逆转点",
1340
+
"plcWarning": "一旦开始,您的 DID 文档将更新为指向此 PDS。如果出现问题,您可以使用轮换密钥恢复,但您应该完成迁移以避免身份状态损坏。"
1341
},
1342
+
"migrating": {
1343
+
"title": "恢复账户",
1344
+
"desc": "请稍候,正在恢复您的账户...",
1345
+
"creating": "创建账户",
1346
+
"importing": "导入存储库",
1347
+
"plcSigning": "更新身份",
1348
+
"activating": "激活账户"
1349
},
1350
"success": {
1351
+
"desc": "您的账户已成功恢复到此 PDS。"
1352
+
},
1353
+
"blobs": {
1354
+
"title": "迁移 Blob",
1355
+
"desc": "正在尝试从您的旧 PDS 恢复图片和媒体...",
1356
+
"migrating": "正在迁移 blob",
1357
+
"failedTitle": "部分 blob 无法迁移",
1358
+
"failedDesc": "{count} 个 blob 无法从您的旧 PDS 获取。这可能是因为服务器无法访问或文件已被删除。",
1359
+
"sourceUnreachableTitle": "源 PDS 无法访问",
1360
+
"sourceUnreachable": "无法连接到您的旧 PDS 来获取媒体文件。从已关闭的服务器迁移时这很常见。您的帖子将正常工作,但部分图片可能会丢失。"
1361
}
1362
},
1363
"progress": {
+1
-1
frontend/src/routes/ActAs.svelte
+1
-1
frontend/src/routes/ActAs.svelte
+1
-1
frontend/src/routes/Admin.svelte
+1
-1
frontend/src/routes/Admin.svelte
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/Comms.svelte
+1
-1
frontend/src/routes/Comms.svelte
+7
-7
frontend/src/routes/Controllers.svelte
+7
-7
frontend/src/routes/Controllers.svelte
···
75
async function loadControllers() {
76
if (!auth.session) return
77
try {
78
-
const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', {
79
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
80
})
81
if (response.ok) {
···
90
async function loadControlledAccounts() {
91
if (!auth.session) return
92
try {
93
-
const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', {
94
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
95
})
96
if (response.ok) {
···
104
105
async function loadScopePresets() {
106
try {
107
-
const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets')
108
if (response.ok) {
109
const data = await response.json()
110
scopePresets = data.presets || []
···
121
success = null
122
123
try {
124
-
const response = await fetch('/xrpc/com.tranquil.delegation.addController', {
125
method: 'POST',
126
headers: {
127
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
159
success = null
160
161
try {
162
-
const response = await fetch('/xrpc/com.tranquil.delegation.removeController', {
163
method: 'POST',
164
headers: {
165
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
188
success = null
189
190
try {
191
-
const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', {
192
method: 'POST',
193
headers: {
194
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
407
{$_('common.cancel')}
408
</button>
409
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
410
-
{creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')}
411
</button>
412
</div>
413
</div>
···
75
async function loadControllers() {
76
if (!auth.session) return
77
try {
78
+
const response = await fetch('/xrpc/_delegation.listControllers', {
79
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
80
})
81
if (response.ok) {
···
90
async function loadControlledAccounts() {
91
if (!auth.session) return
92
try {
93
+
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
94
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
95
})
96
if (response.ok) {
···
104
105
async function loadScopePresets() {
106
try {
107
+
const response = await fetch('/xrpc/_delegation.getScopePresets')
108
if (response.ok) {
109
const data = await response.json()
110
scopePresets = data.presets || []
···
121
success = null
122
123
try {
124
+
const response = await fetch('/xrpc/_delegation.addController', {
125
method: 'POST',
126
headers: {
127
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
159
success = null
160
161
try {
162
+
const response = await fetch('/xrpc/_delegation.removeController', {
163
method: 'POST',
164
headers: {
165
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
188
success = null
189
190
try {
191
+
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
192
method: 'POST',
193
headers: {
194
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
407
{$_('common.cancel')}
408
</button>
409
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
410
+
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
411
</button>
412
</div>
413
</div>
+21
frontend/src/routes/Dashboard.svelte
+21
frontend/src/routes/Dashboard.svelte
···
10
let switching = $state(false)
11
let inviteCodesEnabled = $state(false)
12
13
onMount(async () => {
14
try {
15
const serverInfo = await api.describeServer()
···
176
<h3>{$_('dashboard.navSecurity')}</h3>
177
<p>{$_('dashboard.navSecurityDesc')}</p>
178
</a>
179
<a href="#/migrate" class="nav-card">
180
<h3>{$_('dashboard.navMigrateAgain')}</h3>
181
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
···
215
<h3>{$_('dashboard.navDelegation')}</h3>
216
<p>{$_('dashboard.navDelegationDesc')}</p>
217
</a>
218
<a href="#/migrate" class="nav-card">
219
<h3>{$_('migration.navTitle')}</h3>
220
<p>{$_('migration.navDesc')}</p>
···
503
504
.nav-card.migrated-card h3 {
505
color: var(--info-text, #0369a1);
506
}
507
</style>
···
10
let switching = $state(false)
11
let inviteCodesEnabled = $state(false)
12
13
+
const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false)
14
+
15
onMount(async () => {
16
try {
17
const serverInfo = await api.describeServer()
···
178
<h3>{$_('dashboard.navSecurity')}</h3>
179
<p>{$_('dashboard.navSecurityDesc')}</p>
180
</a>
181
+
<a href="#/settings" class="nav-card">
182
+
<h3>{$_('dashboard.navSettings')}</h3>
183
+
<p>{$_('dashboard.navSettingsDesc')}</p>
184
+
</a>
185
<a href="#/migrate" class="nav-card">
186
<h3>{$_('dashboard.navMigrateAgain')}</h3>
187
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
···
221
<h3>{$_('dashboard.navDelegation')}</h3>
222
<p>{$_('dashboard.navDelegationDesc')}</p>
223
</a>
224
+
{#if isDidWeb}
225
+
<a href="#/did-document" class="nav-card did-web-card">
226
+
<h3>{$_('dashboard.navDidDocument')}</h3>
227
+
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
228
+
</a>
229
+
{/if}
230
<a href="#/migrate" class="nav-card">
231
<h3>{$_('migration.navTitle')}</h3>
232
<p>{$_('migration.navDesc')}</p>
···
515
516
.nav-card.migrated-card h3 {
517
color: var(--info-text, #0369a1);
518
+
}
519
+
520
+
.nav-card.did-web-card {
521
+
border-color: var(--accent);
522
+
background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%);
523
+
}
524
+
525
+
.nav-card.did-web-card:hover {
526
+
box-shadow: 0 2px 12px var(--accent-muted);
527
}
528
</style>
+1
-1
frontend/src/routes/DelegationAudit.svelte
+1
-1
frontend/src/routes/DelegationAudit.svelte
+1
-1
frontend/src/routes/DidDocumentEditor.svelte
+1
-1
frontend/src/routes/DidDocumentEditor.svelte
+5
frontend/src/routes/Home.svelte
+5
frontend/src/routes/Home.svelte
···
183
<h3>Delegate without sharing passwords</h3>
184
<p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p>
185
</div>
186
+
187
+
<div class="feature">
188
+
<h3>Automatic backups</h3>
189
+
<p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p>
190
+
</div>
191
</div>
192
193
<h2>Everything in one place</h2>
+1
-1
frontend/src/routes/InviteCodes.svelte
+1
-1
frontend/src/routes/InviteCodes.svelte
+3
-3
frontend/src/routes/Login.svelte
+3
-3
frontend/src/routes/Login.svelte
···
107
</div>
108
<div class="actions">
109
<button type="submit" disabled={submitting || !verificationCode.trim()}>
110
-
{submitting ? $_('verification.verifying') : $_('verification.verifyButton')}
111
</button>
112
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
113
-
{resendingCode ? $_('verification.resending') : $_('verification.resendButton')}
114
</button>
115
<button type="button" class="tertiary" onclick={backToLogin}>
116
-
{$_('verification.backToLogin')}
117
</button>
118
</div>
119
</form>
···
107
</div>
108
<div class="actions">
109
<button type="submit" disabled={submitting || !verificationCode.trim()}>
110
+
{submitting ? $_('common.verifying') : $_('common.verify')}
111
</button>
112
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
113
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
114
</button>
115
<button type="button" class="tertiary" onclick={backToLogin}>
116
+
{$_('common.backToLogin')}
117
</button>
118
</div>
119
</form>
+63
-69
frontend/src/routes/Migration.svelte
+63
-69
frontend/src/routes/Migration.svelte
···
1
<script lang="ts">
2
-
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import {
6
createInboundMigrationFlow,
7
-
createOutboundMigrationFlow,
8
hasPendingMigration,
9
getResumeInfo,
10
clearMigrationState,
11
loadMigrationState,
12
} from '../lib/migration'
13
import InboundWizard from '../components/migration/InboundWizard.svelte'
14
-
import OutboundWizard from '../components/migration/OutboundWizard.svelte'
15
16
-
const auth = getAuthState()
17
-
18
-
type Direction = 'select' | 'inbound' | 'outbound'
19
let direction = $state<Direction>('select')
20
let showResumeModal = $state(false)
21
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
···
23
let oauthLoading = $state(false)
24
25
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
26
-
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
27
let oauthCallbackProcessed = $state(false)
28
29
$effect(() => {
···
66
const urlParams = new URLSearchParams(window.location.search)
67
const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
68
69
-
if (!hasOAuthCallback && hasPendingMigration()) {
70
-
resumeInfo = getResumeInfo()
71
-
if (resumeInfo) {
72
-
const stored = loadMigrationState()
73
-
if (stored) {
74
-
if (stored.direction === 'inbound') {
75
-
direction = 'inbound'
76
-
inboundFlow = createInboundMigrationFlow()
77
-
inboundFlow.resumeFromState(stored)
78
} else {
79
-
direction = 'outbound'
80
-
outboundFlow = createOutboundMigrationFlow()
81
}
82
}
83
}
84
}
85
···
88
inboundFlow = createInboundMigrationFlow()
89
}
90
91
-
function selectOutbound() {
92
-
if (!auth.session) {
93
-
navigate('/login')
94
-
return
95
-
}
96
-
direction = 'outbound'
97
-
outboundFlow = createOutboundMigrationFlow()
98
-
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
99
}
100
101
function handleResume() {
···
108
direction = 'inbound'
109
inboundFlow = createInboundMigrationFlow()
110
inboundFlow.resumeFromState(stored)
111
-
} else {
112
-
if (!auth.session) {
113
-
navigate('/login')
114
-
return
115
-
}
116
-
direction = 'outbound'
117
-
outboundFlow = createOutboundMigrationFlow()
118
-
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
119
}
120
}
121
···
130
inboundFlow.reset()
131
inboundFlow = null
132
}
133
-
if (outboundFlow) {
134
-
outboundFlow.reset()
135
-
outboundFlow = null
136
}
137
direction = 'select'
138
}
···
150
navigate('/dashboard')
151
}
152
153
-
async function handleOutboundComplete() {
154
-
await logout()
155
-
navigate('/login')
156
}
157
</script>
158
···
165
<div class="resume-details">
166
<div class="detail-row">
167
<span class="label">{$_('migration.resume.direction')}:</span>
168
-
<span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span>
169
</div>
170
{#if resumeInfo.sourceHandle}
171
<div class="detail-row">
···
212
213
<div class="direction-cards">
214
<button class="direction-card ghost" onclick={selectInbound}>
215
-
<div class="card-icon">↓</div>
216
<h2>{$_('migration.migrateHere')}</h2>
217
<p>{$_('migration.migrateHereDesc')}</p>
218
<ul class="features">
···
222
</ul>
223
</button>
224
225
-
<button class="direction-card ghost" onclick={selectOutbound} disabled>
226
-
<div class="card-icon">↑</div>
227
-
<h2>{$_('migration.migrateAway')}</h2>
228
-
<p>{$_('migration.migrateAwayDesc')}</p>
229
<ul class="features">
230
-
<li>{$_('migration.exportRepo')}</li>
231
-
<li>{$_('migration.transferToPds')}</li>
232
-
<li>{$_('migration.updateIdentity')}</li>
233
</ul>
234
-
<p class="login-required">{$_('migration.comingSoon')}</p>
235
</button>
236
</div>
237
···
263
onComplete={handleInboundComplete}
264
/>
265
266
-
{:else if direction === 'outbound' && outboundFlow}
267
-
<OutboundWizard
268
-
flow={outboundFlow}
269
onBack={handleBack}
270
-
onComplete={handleOutboundComplete}
271
/>
272
{/if}
273
</div>
···
302
}
303
304
.direction-card {
305
background: var(--bg-secondary);
306
border: 1px solid var(--border);
307
border-radius: var(--radius-xl);
···
322
cursor: not-allowed;
323
}
324
325
-
.card-icon {
326
-
font-size: var(--text-3xl);
327
-
margin-bottom: var(--space-4);
328
-
color: var(--accent);
329
-
}
330
-
331
.direction-card h2 {
332
margin: 0 0 var(--space-3) 0;
333
font-size: var(--text-xl);
···
349
350
.features li {
351
margin-bottom: var(--space-2);
352
-
}
353
-
354
-
.login-required {
355
-
color: var(--warning-text);
356
-
font-weight: var(--font-medium);
357
-
margin-top: var(--space-4);
358
}
359
360
.info-section {
···
402
}
403
404
.warning-box a {
405
-
display: block;
406
-
margin-top: var(--space-3);
407
-
color: var(--accent);
408
}
409
410
.modal-overlay {
···
1
<script lang="ts">
2
+
import { setSession } from '../lib/auth.svelte'
3
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import {
6
createInboundMigrationFlow,
7
+
createOfflineInboundMigrationFlow,
8
hasPendingMigration,
9
+
hasPendingOfflineMigration,
10
getResumeInfo,
11
+
getOfflineResumeInfo,
12
clearMigrationState,
13
+
clearOfflineState,
14
loadMigrationState,
15
} from '../lib/migration'
16
import InboundWizard from '../components/migration/InboundWizard.svelte'
17
+
import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte'
18
19
+
type Direction = 'select' | 'inbound' | 'offline-inbound'
20
let direction = $state<Direction>('select')
21
let showResumeModal = $state(false)
22
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
···
24
let oauthLoading = $state(false)
25
26
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
27
+
let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null)
28
let oauthCallbackProcessed = $state(false)
29
30
$effect(() => {
···
67
const urlParams = new URLSearchParams(window.location.search)
68
const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
69
70
+
if (!hasOAuthCallback) {
71
+
if (hasPendingMigration()) {
72
+
resumeInfo = getResumeInfo()
73
+
if (resumeInfo) {
74
+
if (resumeInfo.step === 'success') {
75
+
clearMigrationState()
76
+
resumeInfo = null
77
} else {
78
+
const stored = loadMigrationState()
79
+
if (stored && stored.direction === 'inbound') {
80
+
direction = 'inbound'
81
+
inboundFlow = createInboundMigrationFlow()
82
+
inboundFlow.resumeFromState(stored)
83
+
}
84
}
85
}
86
+
} else if (hasPendingOfflineMigration()) {
87
+
const offlineInfo = getOfflineResumeInfo()
88
+
if (offlineInfo && offlineInfo.step === 'success') {
89
+
clearOfflineState()
90
+
} else {
91
+
direction = 'offline-inbound'
92
+
offlineFlow = createOfflineInboundMigrationFlow()
93
+
offlineFlow.tryResume()
94
+
}
95
}
96
}
97
···
100
inboundFlow = createInboundMigrationFlow()
101
}
102
103
+
function selectOfflineInbound() {
104
+
direction = 'offline-inbound'
105
+
offlineFlow = createOfflineInboundMigrationFlow()
106
}
107
108
function handleResume() {
···
115
direction = 'inbound'
116
inboundFlow = createInboundMigrationFlow()
117
inboundFlow.resumeFromState(stored)
118
}
119
}
120
···
129
inboundFlow.reset()
130
inboundFlow = null
131
}
132
+
if (offlineFlow) {
133
+
offlineFlow.reset()
134
+
offlineFlow = null
135
}
136
direction = 'select'
137
}
···
149
navigate('/dashboard')
150
}
151
152
+
function handleOfflineComplete() {
153
+
const session = offlineFlow?.getLocalSession()
154
+
if (session) {
155
+
setSession({
156
+
did: session.did,
157
+
handle: session.handle,
158
+
accessJwt: session.accessJwt,
159
+
refreshJwt: '',
160
+
})
161
+
}
162
+
navigate('/dashboard')
163
}
164
</script>
165
···
172
<div class="resume-details">
173
<div class="detail-row">
174
<span class="label">{$_('migration.resume.direction')}:</span>
175
+
<span class="value">{$_('migration.resume.migratingHere')}</span>
176
</div>
177
{#if resumeInfo.sourceHandle}
178
<div class="detail-row">
···
219
220
<div class="direction-cards">
221
<button class="direction-card ghost" onclick={selectInbound}>
222
<h2>{$_('migration.migrateHere')}</h2>
223
<p>{$_('migration.migrateHereDesc')}</p>
224
<ul class="features">
···
228
</ul>
229
</button>
230
231
+
<button class="direction-card ghost offline-card" onclick={selectOfflineInbound}>
232
+
<h2>{$_('migration.offlineRestore')}</h2>
233
+
<p>{$_('migration.offlineRestoreDesc')}</p>
234
<ul class="features">
235
+
<li>{$_('migration.offlineFeature1')}</li>
236
+
<li>{$_('migration.offlineFeature2')}</li>
237
+
<li>{$_('migration.offlineFeature3')}</li>
238
</ul>
239
</button>
240
</div>
241
···
267
onComplete={handleInboundComplete}
268
/>
269
270
+
{:else if direction === 'offline-inbound' && offlineFlow}
271
+
<OfflineInboundWizard
272
+
flow={offlineFlow}
273
onBack={handleBack}
274
+
onComplete={handleOfflineComplete}
275
/>
276
{/if}
277
</div>
···
306
}
307
308
.direction-card {
309
+
display: flex;
310
+
flex-direction: column;
311
+
align-items: stretch;
312
background: var(--bg-secondary);
313
border: 1px solid var(--border);
314
border-radius: var(--radius-xl);
···
329
cursor: not-allowed;
330
}
331
332
.direction-card h2 {
333
margin: 0 0 var(--space-3) 0;
334
font-size: var(--text-xl);
···
350
351
.features li {
352
margin-bottom: var(--space-2);
353
}
354
355
.info-section {
···
397
}
398
399
.warning-box a {
400
+
display: inline;
401
+
margin-top: var(--space-2);
402
}
403
404
.modal-overlay {
+1
-1
frontend/src/routes/OAuth2FA.svelte
+1
-1
frontend/src/routes/OAuth2FA.svelte
+1
-1
frontend/src/routes/OAuthConsent.svelte
+1
-1
frontend/src/routes/OAuthConsent.svelte
+1
-1
frontend/src/routes/OAuthTotp.svelte
+1
-1
frontend/src/routes/OAuthTotp.svelte
+3
-3
frontend/src/routes/Register.svelte
+3
-3
frontend/src/routes/Register.svelte
···
145
case 'info': return $_('register.subtitle')
146
case 'key-choice': return $_('register.subtitleKeyChoice')
147
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
148
-
case 'creating': return $_('register.creating')
149
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
150
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
151
case 'activating': return $_('register.subtitleActivating')
···
375
{/if}
376
377
<button type="submit" disabled={flow.state.submitting}>
378
-
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
379
</button>
380
</form>
381
···
413
/>
414
415
{:else if flow.state.step === 'creating'}
416
-
<p class="loading">{$_('register.creating')}</p>
417
418
{:else if flow.state.step === 'verify'}
419
<VerificationStep {flow} />
···
145
case 'info': return $_('register.subtitle')
146
case 'key-choice': return $_('register.subtitleKeyChoice')
147
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
148
+
case 'creating': return $_('common.creating')
149
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
150
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
151
case 'activating': return $_('register.subtitleActivating')
···
375
{/if}
376
377
<button type="submit" disabled={flow.state.submitting}>
378
+
{flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
379
</button>
380
</form>
381
···
413
/>
414
415
{:else if flow.state.step === 'creating'}
416
+
<p class="loading">{$_('common.creating')}</p>
417
418
{:else if flow.state.step === 'verify'}
419
<VerificationStep {flow} />
+1
-1
frontend/src/routes/RegisterPasskey.svelte
+1
-1
frontend/src/routes/RegisterPasskey.svelte
+2
-2
frontend/src/routes/RepoExplorer.svelte
+2
-2
frontend/src/routes/RepoExplorer.svelte
···
417
</div>
418
<div class="actions">
419
<button type="submit" class="primary" disabled={saving || !!jsonError}>
420
-
{saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')}
421
</button>
422
<button type="button" class="danger" onclick={handleDelete} disabled={saving}>
423
{$_('common.delete')}
···
464
</div>
465
<div class="actions">
466
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
467
-
{saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')}
468
</button>
469
<button type="button" class="secondary" onclick={goBack}>
470
{$_('common.cancel')}
···
417
</div>
418
<div class="actions">
419
<button type="submit" class="primary" disabled={saving || !!jsonError}>
420
+
{saving ? $_('common.saving') : $_('repoExplorer.updateRecord')}
421
</button>
422
<button type="button" class="danger" onclick={handleDelete} disabled={saving}>
423
{$_('common.delete')}
···
464
</div>
465
<div class="actions">
466
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
467
+
{saving ? $_('common.creating') : $_('repoExplorer.createRecord')}
468
</button>
469
<button type="button" class="secondary" onclick={goBack}>
470
{$_('common.cancel')}
+2
-2
frontend/src/routes/RequestPasskeyRecovery.svelte
+2
-2
frontend/src/routes/RequestPasskeyRecovery.svelte
···
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>
40
</div>
41
{:else}
42
<h1>{$_('requestPasskeyRecovery.title')}</h1>
···
71
{/if}
72
73
<p class="link-text">
74
-
<a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a>
75
</p>
76
</div>
77
···
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')}>{$_('common.backToLogin')}</button>
40
</div>
41
{:else}
42
<h1>{$_('requestPasskeyRecovery.title')}</h1>
···
71
{/if}
72
73
<p class="link-text">
74
+
<a href="#/login">{$_('common.backToLogin')}</a>
75
</p>
76
</div>
77
+1
-1
frontend/src/routes/ResetPassword.svelte
+1
-1
frontend/src/routes/ResetPassword.svelte
+341
-3
frontend/src/routes/Settings.svelte
+341
-3
frontend/src/routes/Settings.svelte
···
40
let deleteToken = $state('')
41
let deleteTokenSent = $state(false)
42
let exportLoading = $state(false)
43
let passwordLoading = $state(false)
44
let currentPassword = $state('')
45
let newPassword = $state('')
···
173
exportLoading = false
174
}
175
}
176
async function handleChangePassword(e: Event) {
177
e.preventDefault()
178
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
···
323
/>
324
</div>
325
<button type="submit" disabled={handleLoading || !newHandle}>
326
-
{handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')}
327
</button>
328
</form>
329
</div>
···
394
<section>
395
<h2>{$_('settings.exportData')}</h2>
396
<p class="description">{$_('settings.exportDataDescription')}</p>
397
-
<button onclick={handleExportRepo} disabled={exportLoading}>
398
-
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
399
</button>
400
</section>
401
</div>
402
<section class="danger-zone">
···
658
white-space: nowrap;
659
border-left: 1px solid var(--border-color);
660
background: var(--bg-card);
661
}
662
</style>
···
40
let deleteToken = $state('')
41
let deleteTokenSent = $state(false)
42
let exportLoading = $state(false)
43
+
let exportBlobsLoading = $state(false)
44
let passwordLoading = $state(false)
45
let currentPassword = $state('')
46
let newPassword = $state('')
···
174
exportLoading = false
175
}
176
}
177
+
async function handleExportBlobs() {
178
+
if (!auth.session) return
179
+
exportBlobsLoading = true
180
+
message = null
181
+
try {
182
+
const response = await fetch('/xrpc/_backup.exportBlobs', {
183
+
headers: {
184
+
'Authorization': `Bearer ${auth.session.accessJwt}`
185
+
}
186
+
})
187
+
if (!response.ok) {
188
+
const err = await response.json().catch(() => ({ message: 'Export failed' }))
189
+
throw new Error(err.message || 'Export failed')
190
+
}
191
+
const blob = await response.blob()
192
+
if (blob.size === 0) {
193
+
showMessage('success', $_('settings.messages.noBlobsToExport'))
194
+
return
195
+
}
196
+
const url = URL.createObjectURL(blob)
197
+
const a = document.createElement('a')
198
+
a.href = url
199
+
a.download = `${auth.session.handle}-blobs.zip`
200
+
document.body.appendChild(a)
201
+
a.click()
202
+
document.body.removeChild(a)
203
+
URL.revokeObjectURL(url)
204
+
showMessage('success', $_('settings.messages.blobsExported'))
205
+
} catch (e) {
206
+
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
207
+
} finally {
208
+
exportBlobsLoading = false
209
+
}
210
+
}
211
+
212
+
interface BackupInfo {
213
+
id: string
214
+
repoRev: string
215
+
repoRootCid: string
216
+
blockCount: number
217
+
sizeBytes: number
218
+
createdAt: string
219
+
}
220
+
let backups = $state<BackupInfo[]>([])
221
+
let backupEnabled = $state(true)
222
+
let backupsLoading = $state(false)
223
+
let createBackupLoading = $state(false)
224
+
let restoreFile = $state<File | null>(null)
225
+
let restoreLoading = $state(false)
226
+
227
+
async function loadBackups() {
228
+
if (!auth.session) return
229
+
backupsLoading = true
230
+
try {
231
+
const result = await api.listBackups(auth.session.accessJwt)
232
+
backups = result.backups
233
+
backupEnabled = result.backupEnabled
234
+
} catch (e) {
235
+
console.error('Failed to load backups:', e)
236
+
} finally {
237
+
backupsLoading = false
238
+
}
239
+
}
240
+
241
+
onMount(() => {
242
+
loadBackups()
243
+
})
244
+
245
+
async function handleToggleBackup() {
246
+
if (!auth.session) return
247
+
const newEnabled = !backupEnabled
248
+
backupsLoading = true
249
+
try {
250
+
await api.setBackupEnabled(auth.session.accessJwt, newEnabled)
251
+
backupEnabled = newEnabled
252
+
showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
253
+
} catch (e) {
254
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
255
+
} finally {
256
+
backupsLoading = false
257
+
}
258
+
}
259
+
260
+
async function handleCreateBackup() {
261
+
if (!auth.session) return
262
+
createBackupLoading = true
263
+
message = null
264
+
try {
265
+
await api.createBackup(auth.session.accessJwt)
266
+
await loadBackups()
267
+
showMessage('success', $_('settings.backups.created'))
268
+
} catch (e) {
269
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
270
+
} finally {
271
+
createBackupLoading = false
272
+
}
273
+
}
274
+
275
+
async function handleDownloadBackup(id: string, rev: string) {
276
+
if (!auth.session) return
277
+
try {
278
+
const blob = await api.getBackup(auth.session.accessJwt, id)
279
+
const url = URL.createObjectURL(blob)
280
+
const a = document.createElement('a')
281
+
a.href = url
282
+
a.download = `${auth.session.handle}-${rev}.car`
283
+
document.body.appendChild(a)
284
+
a.click()
285
+
document.body.removeChild(a)
286
+
URL.revokeObjectURL(url)
287
+
} catch (e) {
288
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
289
+
}
290
+
}
291
+
292
+
async function handleDeleteBackup(id: string) {
293
+
if (!auth.session) return
294
+
try {
295
+
await api.deleteBackup(auth.session.accessJwt, id)
296
+
await loadBackups()
297
+
showMessage('success', $_('settings.backups.deleted'))
298
+
} catch (e) {
299
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
300
+
}
301
+
}
302
+
303
+
function handleFileSelect(e: Event) {
304
+
const input = e.target as HTMLInputElement
305
+
if (input.files && input.files.length > 0) {
306
+
restoreFile = input.files[0]
307
+
}
308
+
}
309
+
310
+
async function handleRestore() {
311
+
if (!auth.session || !restoreFile) return
312
+
restoreLoading = true
313
+
message = null
314
+
try {
315
+
const buffer = await restoreFile.arrayBuffer()
316
+
const car = new Uint8Array(buffer)
317
+
await api.importRepo(auth.session.accessJwt, car)
318
+
showMessage('success', $_('settings.backups.restored'))
319
+
restoreFile = null
320
+
} catch (e) {
321
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
322
+
} finally {
323
+
restoreLoading = false
324
+
}
325
+
}
326
+
327
+
function formatBytes(bytes: number): string {
328
+
if (bytes < 1024) return `${bytes} B`
329
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
330
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
331
+
}
332
+
333
+
function formatDate(iso: string): string {
334
+
return new Date(iso).toLocaleDateString(undefined, {
335
+
year: 'numeric',
336
+
month: 'short',
337
+
day: 'numeric',
338
+
hour: '2-digit',
339
+
minute: '2-digit'
340
+
})
341
+
}
342
+
343
async function handleChangePassword(e: Event) {
344
e.preventDefault()
345
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
···
490
/>
491
</div>
492
<button type="submit" disabled={handleLoading || !newHandle}>
493
+
{handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')}
494
</button>
495
</form>
496
</div>
···
561
<section>
562
<h2>{$_('settings.exportData')}</h2>
563
<p class="description">{$_('settings.exportDataDescription')}</p>
564
+
<div class="export-buttons">
565
+
<button onclick={handleExportRepo} disabled={exportLoading}>
566
+
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
567
+
</button>
568
+
<button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary">
569
+
{exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')}
570
+
</button>
571
+
</div>
572
+
</section>
573
+
<section class="backups-section">
574
+
<h2>{$_('settings.backups.title')}</h2>
575
+
<p class="description">{$_('settings.backups.description')}</p>
576
+
577
+
<label class="checkbox-label">
578
+
<input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} />
579
+
<span>{$_('settings.backups.enableAutomatic')}</span>
580
+
</label>
581
+
582
+
{#if backupsLoading}
583
+
<p class="loading">{$_('common.loading')}</p>
584
+
{:else if backups.length > 0}
585
+
<ul class="backup-list">
586
+
{#each backups as backup}
587
+
<li class="backup-item">
588
+
<div class="backup-info">
589
+
<span class="backup-date">{formatDate(backup.createdAt)}</span>
590
+
<span class="backup-size">{formatBytes(backup.sizeBytes)}</span>
591
+
<span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span>
592
+
</div>
593
+
<div class="backup-actions">
594
+
<button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}>
595
+
{$_('settings.backups.download')}
596
+
</button>
597
+
<button class="small danger" onclick={() => handleDeleteBackup(backup.id)}>
598
+
{$_('settings.backups.delete')}
599
+
</button>
600
+
</div>
601
+
</li>
602
+
{/each}
603
+
</ul>
604
+
{:else}
605
+
<p class="empty">{$_('settings.backups.noBackups')}</p>
606
+
{/if}
607
+
608
+
<button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}>
609
+
{createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')}
610
</button>
611
+
</section>
612
+
<section class="restore-section">
613
+
<h2>{$_('settings.backups.restoreTitle')}</h2>
614
+
<p class="description">{$_('settings.backups.restoreDescription')}</p>
615
+
616
+
<div class="field">
617
+
<label for="restore-file">{$_('settings.backups.selectFile')}</label>
618
+
<input
619
+
id="restore-file"
620
+
type="file"
621
+
accept=".car"
622
+
onchange={handleFileSelect}
623
+
disabled={restoreLoading}
624
+
/>
625
+
</div>
626
+
627
+
{#if restoreFile}
628
+
<div class="restore-preview">
629
+
<p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p>
630
+
<button onclick={handleRestore} disabled={restoreLoading} class="danger">
631
+
{restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')}
632
+
</button>
633
+
</div>
634
+
{/if}
635
</section>
636
</div>
637
<section class="danger-zone">
···
893
white-space: nowrap;
894
border-left: 1px solid var(--border-color);
895
background: var(--bg-card);
896
+
}
897
+
898
+
.checkbox-label {
899
+
display: flex;
900
+
align-items: center;
901
+
gap: var(--space-2);
902
+
cursor: pointer;
903
+
margin-bottom: var(--space-4);
904
+
}
905
+
906
+
.checkbox-label input[type="checkbox"] {
907
+
width: 18px;
908
+
height: 18px;
909
+
cursor: pointer;
910
+
}
911
+
912
+
.backup-list {
913
+
list-style: none;
914
+
padding: 0;
915
+
margin: 0 0 var(--space-4) 0;
916
+
display: flex;
917
+
flex-direction: column;
918
+
gap: var(--space-2);
919
+
}
920
+
921
+
.backup-item {
922
+
display: flex;
923
+
justify-content: space-between;
924
+
align-items: center;
925
+
padding: var(--space-3);
926
+
background: var(--bg-card);
927
+
border: 1px solid var(--border-color);
928
+
border-radius: var(--radius-md);
929
+
gap: var(--space-4);
930
+
}
931
+
932
+
.backup-info {
933
+
display: flex;
934
+
gap: var(--space-4);
935
+
font-size: var(--text-sm);
936
+
flex-wrap: wrap;
937
+
}
938
+
939
+
.backup-date {
940
+
font-weight: 500;
941
+
}
942
+
943
+
.backup-size,
944
+
.backup-blocks {
945
+
color: var(--text-secondary);
946
+
}
947
+
948
+
.backup-actions {
949
+
display: flex;
950
+
gap: var(--space-2);
951
+
flex-shrink: 0;
952
+
}
953
+
954
+
button.small {
955
+
padding: var(--space-1) var(--space-2);
956
+
font-size: var(--text-xs);
957
+
}
958
+
959
+
.empty,
960
+
.loading {
961
+
color: var(--text-secondary);
962
+
font-size: var(--text-sm);
963
+
margin-bottom: var(--space-4);
964
+
}
965
+
966
+
.restore-preview {
967
+
background: var(--bg-card);
968
+
border: 1px solid var(--border-color);
969
+
border-radius: var(--radius-md);
970
+
padding: var(--space-4);
971
+
margin-top: var(--space-3);
972
+
}
973
+
974
+
.restore-preview p {
975
+
margin: 0 0 var(--space-3) 0;
976
+
font-size: var(--text-sm);
977
+
}
978
+
979
+
.export-buttons {
980
+
display: flex;
981
+
gap: var(--space-2);
982
+
flex-wrap: wrap;
983
+
}
984
+
985
+
@media (max-width: 640px) {
986
+
.backup-item {
987
+
flex-direction: column;
988
+
align-items: flex-start;
989
+
}
990
+
991
+
.backup-actions {
992
+
width: 100%;
993
+
margin-top: var(--space-2);
994
+
}
995
+
996
+
.backup-actions button {
997
+
flex: 1;
998
+
}
999
}
1000
</style>
+8
-8
frontend/src/routes/Verify.svelte
+8
-8
frontend/src/routes/Verify.svelte
···
225
<div class="verify-page">
226
{#if autoSubmitting}
227
<div class="loading-container">
228
-
<h1>{$_('verify.verifying')}</h1>
229
<p class="subtitle">{$_('verify.pleaseWait')}</p>
230
</div>
231
{:else if success}
···
235
<p class="subtitle">{$_('verify.emailUpdated')}</p>
236
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
237
<div class="actions">
238
-
<a href="#/settings" class="btn">{$_('verify.backToSettings')}</a>
239
</div>
240
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
241
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
···
301
</form>
302
303
<p class="link-text">
304
-
<a href="#/settings">{$_('verify.backToSettings')}</a>
305
</p>
306
{/if}
307
{:else if mode === 'token'}
···
347
</div>
348
349
<button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
350
-
{submitting ? $_('verify.verifying') : $_('verify.verify')}
351
</button>
352
353
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
354
-
{resendingCode ? $_('verify.sending') : $_('verify.resendCode')}
355
</button>
356
</form>
357
358
<p class="link-text">
359
-
<a href="#/login">{$_('verify.backToLogin')}</a>
360
</p>
361
{:else if pendingVerification}
362
<h1>{$_('verify.title')}</h1>
···
390
</div>
391
392
<button type="submit" disabled={submitting || !verificationCode.trim()}>
393
-
{submitting ? $_('verify.verifying') : $_('verify.verifyButton')}
394
</button>
395
396
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
397
-
{resendingCode ? $_('verify.resending') : $_('verify.resendCode')}
398
</button>
399
</form>
400
···
225
<div class="verify-page">
226
{#if autoSubmitting}
227
<div class="loading-container">
228
+
<h1>{$_('common.verifying')}</h1>
229
<p class="subtitle">{$_('verify.pleaseWait')}</p>
230
</div>
231
{:else if success}
···
235
<p class="subtitle">{$_('verify.emailUpdated')}</p>
236
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
237
<div class="actions">
238
+
<a href="#/settings" class="btn">{$_('common.backToSettings')}</a>
239
</div>
240
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
241
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
···
301
</form>
302
303
<p class="link-text">
304
+
<a href="#/settings">{$_('common.backToSettings')}</a>
305
</p>
306
{/if}
307
{:else if mode === 'token'}
···
347
</div>
348
349
<button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
350
+
{submitting ? $_('common.verifying') : $_('common.verify')}
351
</button>
352
353
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
354
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
355
</button>
356
</form>
357
358
<p class="link-text">
359
+
<a href="#/login">{$_('common.backToLogin')}</a>
360
</p>
361
{:else if pendingVerification}
362
<h1>{$_('verify.title')}</h1>
···
390
</div>
391
392
<button type="submit" disabled={submitting || !verificationCode.trim()}>
393
+
{submitting ? $_('common.verifying') : $_('common.verify')}
394
</button>
395
396
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
397
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
398
</button>
399
</form>
400
+5
-5
frontend/src/styles/base.css
+5
-5
frontend/src/styles/base.css
···
54
}
55
56
a {
57
-
color: var(--secondary);
58
-
text-decoration: none;
59
-
transition: color 0.3s ease;
60
}
61
62
a:hover {
63
-
color: var(--secondary-hover);
64
-
text-decoration: none;
65
}
66
67
::selection {
···
372
color: var(--text-secondary);
373
font-size: var(--text-sm);
374
margin-bottom: var(--space-3);
375
}
376
377
.back-link:hover {
···
54
}
55
56
a {
57
+
color: var(--accent);
58
+
text-decoration: underline;
59
+
text-underline-offset: 2px;
60
}
61
62
a:hover {
63
+
color: var(--accent-hover);
64
}
65
66
::selection {
···
371
color: var(--text-secondary);
372
font-size: var(--text-sm);
373
margin-bottom: var(--space-3);
374
+
text-decoration: none;
375
}
376
377
.back-link:hover {
+90
frontend/src/styles/migration.css
+90
frontend/src/styles/migration.css
···
190
191
.current-info .value {
192
font-weight: var(--font-medium);
193
+
word-break: break-all;
194
+
}
195
+
196
+
.current-info .value.mono {
197
+
font-family: var(--font-mono);
198
+
font-size: var(--text-sm);
199
}
200
201
.review-card {
···
274
text-align: center;
275
color: var(--text-secondary);
276
font-size: var(--text-sm);
277
+
}
278
+
279
+
.blob-progress {
280
+
margin: var(--space-4) 0;
281
+
}
282
+
283
+
.blob-progress-bar {
284
+
height: 8px;
285
+
background: var(--bg-primary);
286
+
border-radius: var(--radius-md);
287
+
overflow: hidden;
288
+
margin-bottom: var(--space-2);
289
+
}
290
+
291
+
.blob-progress-fill {
292
+
height: 100%;
293
+
background: var(--accent);
294
+
transition: width var(--transition-slow);
295
+
}
296
+
297
+
.blob-progress-text {
298
+
text-align: center;
299
+
color: var(--text-secondary);
300
+
font-size: var(--text-sm);
301
+
margin: 0;
302
}
303
304
.success-content {
···
598
font-size: var(--text-sm);
599
font-style: italic;
600
}
601
+
602
+
.file-input-container {
603
+
display: flex;
604
+
flex-direction: column;
605
+
gap: var(--space-3);
606
+
}
607
+
608
+
.file-info {
609
+
display: flex;
610
+
gap: var(--space-2);
611
+
align-items: center;
612
+
padding: var(--space-3);
613
+
background: var(--bg-primary);
614
+
border-radius: var(--radius-md);
615
+
}
616
+
617
+
.file-name {
618
+
font-weight: var(--font-medium);
619
+
}
620
+
621
+
.file-size {
622
+
color: var(--text-secondary);
623
+
font-size: var(--text-sm);
624
+
}
625
+
626
+
.step-content textarea {
627
+
width: 100%;
628
+
font-family: var(--font-mono);
629
+
font-size: var(--text-sm);
630
+
padding: var(--space-3);
631
+
border: 1px solid var(--border-color);
632
+
border-radius: var(--radius-md);
633
+
background: var(--bg-input);
634
+
color: var(--text-primary);
635
+
resize: vertical;
636
+
}
637
+
638
+
.step-content textarea:focus {
639
+
outline: none;
640
+
border-color: var(--accent);
641
+
}
642
+
643
+
.message {
644
+
padding: var(--space-4);
645
+
border-radius: var(--radius-lg);
646
+
margin-bottom: var(--space-4);
647
+
}
648
+
649
+
.message.success {
650
+
background: var(--success-bg);
651
+
color: var(--success-text);
652
+
border: 1px solid var(--success-border);
653
+
}
654
+
655
+
.message.error {
656
+
background: var(--error-bg);
657
+
color: var(--error-text);
658
+
border: 1px solid var(--error-border);
659
+
}
+35
-35
frontend/src/tests/Comms.test.ts
+35
-35
frontend/src/tests/Comms.test.ts
···
29
beforeEach(() => {
30
setupAuthenticatedUser();
31
mockEndpoint(
32
-
"com.tranquil.account.getNotificationPrefs",
33
() => jsonResponse(mockData.notificationPrefs()),
34
);
35
mockEndpoint(
···
37
() => jsonResponse(mockData.describeServer()),
38
);
39
mockEndpoint(
40
-
"com.tranquil.account.getNotificationHistory",
41
() => jsonResponse({ notifications: [] }),
42
);
43
});
···
67
() => jsonResponse(mockData.describeServer()),
68
);
69
mockEndpoint(
70
-
"com.tranquil.account.getNotificationHistory",
71
() => jsonResponse({ notifications: [] }),
72
);
73
});
74
it("shows loading text while fetching preferences", async () => {
75
-
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
76
await new Promise((resolve) => setTimeout(resolve, 100));
77
return jsonResponse(mockData.notificationPrefs());
78
});
···
88
() => jsonResponse(mockData.describeServer()),
89
);
90
mockEndpoint(
91
-
"com.tranquil.account.getNotificationHistory",
92
() => jsonResponse({ notifications: [] }),
93
);
94
});
95
it("displays all four channel options", async () => {
96
mockEndpoint(
97
-
"com.tranquil.account.getNotificationPrefs",
98
() => jsonResponse(mockData.notificationPrefs()),
99
);
100
render(Comms);
···
111
});
112
it("email channel is always selectable", async () => {
113
mockEndpoint(
114
-
"com.tranquil.account.getNotificationPrefs",
115
() => jsonResponse(mockData.notificationPrefs()),
116
);
117
render(Comms);
···
122
});
123
it("discord channel is disabled when not configured", async () => {
124
mockEndpoint(
125
-
"com.tranquil.account.getNotificationPrefs",
126
() => jsonResponse(mockData.notificationPrefs({ discordId: null })),
127
);
128
render(Comms);
···
133
});
134
it("discord channel is enabled when configured", async () => {
135
mockEndpoint(
136
-
"com.tranquil.account.getNotificationPrefs",
137
() =>
138
jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
139
);
···
145
});
146
it("shows hint for disabled channels", async () => {
147
mockEndpoint(
148
-
"com.tranquil.account.getNotificationPrefs",
149
() => jsonResponse(mockData.notificationPrefs()),
150
);
151
render(Comms);
···
156
});
157
it("selects current preferred channel", async () => {
158
mockEndpoint(
159
-
"com.tranquil.account.getNotificationPrefs",
160
() =>
161
jsonResponse(
162
mockData.notificationPrefs({ preferredChannel: "email" }),
···
179
() => jsonResponse(mockData.describeServer()),
180
);
181
mockEndpoint(
182
-
"com.tranquil.account.getNotificationHistory",
183
() => jsonResponse({ notifications: [] }),
184
);
185
});
186
it("displays email as readonly with current value", async () => {
187
mockEndpoint(
188
-
"com.tranquil.account.getNotificationPrefs",
189
() => jsonResponse(mockData.notificationPrefs()),
190
);
191
render(Comms);
···
199
});
200
it("displays all channel inputs with current values", async () => {
201
mockEndpoint(
202
-
"com.tranquil.account.getNotificationPrefs",
203
() =>
204
jsonResponse(mockData.notificationPrefs({
205
discordId: "123456789",
···
231
() => jsonResponse(mockData.describeServer()),
232
);
233
mockEndpoint(
234
-
"com.tranquil.account.getNotificationHistory",
235
() => jsonResponse({ notifications: [] }),
236
);
237
});
238
it("shows Primary badge for email", async () => {
239
mockEndpoint(
240
-
"com.tranquil.account.getNotificationPrefs",
241
() => jsonResponse(mockData.notificationPrefs()),
242
);
243
render(Comms);
···
247
});
248
it("shows Verified badge for verified discord", async () => {
249
mockEndpoint(
250
-
"com.tranquil.account.getNotificationPrefs",
251
() =>
252
jsonResponse(mockData.notificationPrefs({
253
discordId: "123456789",
···
262
});
263
it("shows Not verified badge for unverified discord", async () => {
264
mockEndpoint(
265
-
"com.tranquil.account.getNotificationPrefs",
266
() =>
267
jsonResponse(mockData.notificationPrefs({
268
discordId: "123456789",
···
276
});
277
it("does not show badge when channel not configured", async () => {
278
mockEndpoint(
279
-
"com.tranquil.account.getNotificationPrefs",
280
() => jsonResponse(mockData.notificationPrefs()),
281
);
282
render(Comms);
···
294
() => jsonResponse(mockData.describeServer()),
295
);
296
mockEndpoint(
297
-
"com.tranquil.account.getNotificationHistory",
298
() => jsonResponse({ notifications: [] }),
299
);
300
});
301
it("calls updateNotificationPrefs with correct data", async () => {
302
let capturedBody: Record<string, unknown> | null = null;
303
mockEndpoint(
304
-
"com.tranquil.account.getNotificationPrefs",
305
() => jsonResponse(mockData.notificationPrefs()),
306
);
307
mockEndpoint(
308
-
"com.tranquil.account.updateNotificationPrefs",
309
(_url, options) => {
310
capturedBody = JSON.parse((options?.body as string) || "{}");
311
return jsonResponse({ success: true });
···
329
});
330
it("shows loading state while saving", async () => {
331
mockEndpoint(
332
-
"com.tranquil.account.getNotificationPrefs",
333
() => jsonResponse(mockData.notificationPrefs()),
334
);
335
-
mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => {
336
await new Promise((resolve) => setTimeout(resolve, 100));
337
return jsonResponse({ success: true });
338
});
···
350
});
351
it("shows success message after saving", async () => {
352
mockEndpoint(
353
-
"com.tranquil.account.getNotificationPrefs",
354
() => jsonResponse(mockData.notificationPrefs()),
355
);
356
mockEndpoint(
357
-
"com.tranquil.account.updateNotificationPrefs",
358
() => jsonResponse({ success: true }),
359
);
360
render(Comms);
···
372
});
373
it("shows error when save fails", async () => {
374
mockEndpoint(
375
-
"com.tranquil.account.getNotificationPrefs",
376
() => jsonResponse(mockData.notificationPrefs()),
377
);
378
mockEndpoint(
379
-
"com.tranquil.account.updateNotificationPrefs",
380
() =>
381
errorResponse("InvalidRequest", "Invalid channel configuration", 400),
382
);
···
400
});
401
it("reloads preferences after successful save", async () => {
402
let loadCount = 0;
403
-
mockEndpoint("com.tranquil.account.getNotificationPrefs", () => {
404
loadCount++;
405
return jsonResponse(mockData.notificationPrefs());
406
});
407
mockEndpoint(
408
-
"com.tranquil.account.updateNotificationPrefs",
409
() => jsonResponse({ success: true }),
410
);
411
render(Comms);
···
430
() => jsonResponse(mockData.describeServer()),
431
);
432
mockEndpoint(
433
-
"com.tranquil.account.getNotificationHistory",
434
() => jsonResponse({ notifications: [] }),
435
);
436
});
437
it("enables discord channel after entering discord ID", async () => {
438
mockEndpoint(
439
-
"com.tranquil.account.getNotificationPrefs",
440
() => jsonResponse(mockData.notificationPrefs()),
441
);
442
render(Comms);
···
453
});
454
it("allows selecting a configured channel", async () => {
455
mockEndpoint(
456
-
"com.tranquil.account.getNotificationPrefs",
457
() =>
458
jsonResponse(mockData.notificationPrefs({
459
discordId: "123456789",
···
480
() => jsonResponse(mockData.describeServer()),
481
);
482
mockEndpoint(
483
-
"com.tranquil.account.getNotificationHistory",
484
() => jsonResponse({ notifications: [] }),
485
);
486
});
487
it("shows error when loading preferences fails", async () => {
488
mockEndpoint(
489
-
"com.tranquil.account.getNotificationPrefs",
490
() => errorResponse("InternalError", "Database connection failed", 500),
491
);
492
render(Comms);
···
29
beforeEach(() => {
30
setupAuthenticatedUser();
31
mockEndpoint(
32
+
"_account.getNotificationPrefs",
33
() => jsonResponse(mockData.notificationPrefs()),
34
);
35
mockEndpoint(
···
37
() => jsonResponse(mockData.describeServer()),
38
);
39
mockEndpoint(
40
+
"_account.getNotificationHistory",
41
() => jsonResponse({ notifications: [] }),
42
);
43
});
···
67
() => jsonResponse(mockData.describeServer()),
68
);
69
mockEndpoint(
70
+
"_account.getNotificationHistory",
71
() => jsonResponse({ notifications: [] }),
72
);
73
});
74
it("shows loading text while fetching preferences", async () => {
75
+
mockEndpoint("_account.getNotificationPrefs", async () => {
76
await new Promise((resolve) => setTimeout(resolve, 100));
77
return jsonResponse(mockData.notificationPrefs());
78
});
···
88
() => jsonResponse(mockData.describeServer()),
89
);
90
mockEndpoint(
91
+
"_account.getNotificationHistory",
92
() => jsonResponse({ notifications: [] }),
93
);
94
});
95
it("displays all four channel options", async () => {
96
mockEndpoint(
97
+
"_account.getNotificationPrefs",
98
() => jsonResponse(mockData.notificationPrefs()),
99
);
100
render(Comms);
···
111
});
112
it("email channel is always selectable", async () => {
113
mockEndpoint(
114
+
"_account.getNotificationPrefs",
115
() => jsonResponse(mockData.notificationPrefs()),
116
);
117
render(Comms);
···
122
});
123
it("discord channel is disabled when not configured", async () => {
124
mockEndpoint(
125
+
"_account.getNotificationPrefs",
126
() => jsonResponse(mockData.notificationPrefs({ discordId: null })),
127
);
128
render(Comms);
···
133
});
134
it("discord channel is enabled when configured", async () => {
135
mockEndpoint(
136
+
"_account.getNotificationPrefs",
137
() =>
138
jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
139
);
···
145
});
146
it("shows hint for disabled channels", async () => {
147
mockEndpoint(
148
+
"_account.getNotificationPrefs",
149
() => jsonResponse(mockData.notificationPrefs()),
150
);
151
render(Comms);
···
156
});
157
it("selects current preferred channel", async () => {
158
mockEndpoint(
159
+
"_account.getNotificationPrefs",
160
() =>
161
jsonResponse(
162
mockData.notificationPrefs({ preferredChannel: "email" }),
···
179
() => jsonResponse(mockData.describeServer()),
180
);
181
mockEndpoint(
182
+
"_account.getNotificationHistory",
183
() => jsonResponse({ notifications: [] }),
184
);
185
});
186
it("displays email as readonly with current value", async () => {
187
mockEndpoint(
188
+
"_account.getNotificationPrefs",
189
() => jsonResponse(mockData.notificationPrefs()),
190
);
191
render(Comms);
···
199
});
200
it("displays all channel inputs with current values", async () => {
201
mockEndpoint(
202
+
"_account.getNotificationPrefs",
203
() =>
204
jsonResponse(mockData.notificationPrefs({
205
discordId: "123456789",
···
231
() => jsonResponse(mockData.describeServer()),
232
);
233
mockEndpoint(
234
+
"_account.getNotificationHistory",
235
() => jsonResponse({ notifications: [] }),
236
);
237
});
238
it("shows Primary badge for email", async () => {
239
mockEndpoint(
240
+
"_account.getNotificationPrefs",
241
() => jsonResponse(mockData.notificationPrefs()),
242
);
243
render(Comms);
···
247
});
248
it("shows Verified badge for verified discord", async () => {
249
mockEndpoint(
250
+
"_account.getNotificationPrefs",
251
() =>
252
jsonResponse(mockData.notificationPrefs({
253
discordId: "123456789",
···
262
});
263
it("shows Not verified badge for unverified discord", async () => {
264
mockEndpoint(
265
+
"_account.getNotificationPrefs",
266
() =>
267
jsonResponse(mockData.notificationPrefs({
268
discordId: "123456789",
···
276
});
277
it("does not show badge when channel not configured", async () => {
278
mockEndpoint(
279
+
"_account.getNotificationPrefs",
280
() => jsonResponse(mockData.notificationPrefs()),
281
);
282
render(Comms);
···
294
() => jsonResponse(mockData.describeServer()),
295
);
296
mockEndpoint(
297
+
"_account.getNotificationHistory",
298
() => jsonResponse({ notifications: [] }),
299
);
300
});
301
it("calls updateNotificationPrefs with correct data", async () => {
302
let capturedBody: Record<string, unknown> | null = null;
303
mockEndpoint(
304
+
"_account.getNotificationPrefs",
305
() => jsonResponse(mockData.notificationPrefs()),
306
);
307
mockEndpoint(
308
+
"_account.updateNotificationPrefs",
309
(_url, options) => {
310
capturedBody = JSON.parse((options?.body as string) || "{}");
311
return jsonResponse({ success: true });
···
329
});
330
it("shows loading state while saving", async () => {
331
mockEndpoint(
332
+
"_account.getNotificationPrefs",
333
() => jsonResponse(mockData.notificationPrefs()),
334
);
335
+
mockEndpoint("_account.updateNotificationPrefs", async () => {
336
await new Promise((resolve) => setTimeout(resolve, 100));
337
return jsonResponse({ success: true });
338
});
···
350
});
351
it("shows success message after saving", async () => {
352
mockEndpoint(
353
+
"_account.getNotificationPrefs",
354
() => jsonResponse(mockData.notificationPrefs()),
355
);
356
mockEndpoint(
357
+
"_account.updateNotificationPrefs",
358
() => jsonResponse({ success: true }),
359
);
360
render(Comms);
···
372
});
373
it("shows error when save fails", async () => {
374
mockEndpoint(
375
+
"_account.getNotificationPrefs",
376
() => jsonResponse(mockData.notificationPrefs()),
377
);
378
mockEndpoint(
379
+
"_account.updateNotificationPrefs",
380
() =>
381
errorResponse("InvalidRequest", "Invalid channel configuration", 400),
382
);
···
400
});
401
it("reloads preferences after successful save", async () => {
402
let loadCount = 0;
403
+
mockEndpoint("_account.getNotificationPrefs", () => {
404
loadCount++;
405
return jsonResponse(mockData.notificationPrefs());
406
});
407
mockEndpoint(
408
+
"_account.updateNotificationPrefs",
409
() => jsonResponse({ success: true }),
410
);
411
render(Comms);
···
430
() => jsonResponse(mockData.describeServer()),
431
);
432
mockEndpoint(
433
+
"_account.getNotificationHistory",
434
() => jsonResponse({ notifications: [] }),
435
);
436
});
437
it("enables discord channel after entering discord ID", async () => {
438
mockEndpoint(
439
+
"_account.getNotificationPrefs",
440
() => jsonResponse(mockData.notificationPrefs()),
441
);
442
render(Comms);
···
453
});
454
it("allows selecting a configured channel", async () => {
455
mockEndpoint(
456
+
"_account.getNotificationPrefs",
457
() =>
458
jsonResponse(mockData.notificationPrefs({
459
discordId: "123456789",
···
480
() => jsonResponse(mockData.describeServer()),
481
);
482
mockEndpoint(
483
+
"_account.getNotificationHistory",
484
() => jsonResponse({ notifications: [] }),
485
);
486
});
487
it("shows error when loading preferences fails", async () => {
488
mockEndpoint(
489
+
"_account.getNotificationPrefs",
490
() => errorResponse("InternalError", "Database connection failed", 500),
491
);
492
render(Comms);
+2
-2
frontend/src/tests/Settings.test.ts
+2
-2
frontend/src/tests/Settings.test.ts
···
8
mockData,
9
mockEndpoint,
10
setupAuthenticatedUser,
11
-
setupFetchMock,
12
setupUnauthenticatedUser,
13
} from "./mocks";
14
describe("Settings", () => {
15
beforeEach(() => {
16
clearMocks();
17
-
setupFetchMock();
18
globalThis.confirm = vi.fn(() => true);
19
});
20
describe("authentication guard", () => {
···
8
mockData,
9
mockEndpoint,
10
setupAuthenticatedUser,
11
+
setupDefaultMocks,
12
setupUnauthenticatedUser,
13
} from "./mocks";
14
describe("Settings", () => {
15
beforeEach(() => {
16
clearMocks();
17
+
setupDefaultMocks();
18
globalThis.confirm = vi.fn(() => true);
19
});
20
describe("authentication guard", () => {
+491
frontend/src/tests/migration/offline-flow.test.ts
+491
frontend/src/tests/migration/offline-flow.test.ts
···
···
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte";
3
+
4
+
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
5
+
6
+
describe("migration/offline-flow", () => {
7
+
beforeEach(() => {
8
+
localStorage.removeItem(OFFLINE_STORAGE_KEY);
9
+
vi.restoreAllMocks();
10
+
});
11
+
12
+
describe("createOfflineInboundMigrationFlow", () => {
13
+
it("creates flow with initial state", () => {
14
+
const flow = createOfflineInboundMigrationFlow();
15
+
16
+
expect(flow.state.direction).toBe("offline-inbound");
17
+
expect(flow.state.step).toBe("welcome");
18
+
expect(flow.state.userDid).toBe("");
19
+
expect(flow.state.carFile).toBeNull();
20
+
expect(flow.state.carFileName).toBe("");
21
+
expect(flow.state.carSizeBytes).toBe(0);
22
+
expect(flow.state.rotationKey).toBe("");
23
+
expect(flow.state.rotationKeyDidKey).toBe("");
24
+
expect(flow.state.targetHandle).toBe("");
25
+
expect(flow.state.targetEmail).toBe("");
26
+
expect(flow.state.targetPassword).toBe("");
27
+
expect(flow.state.inviteCode).toBe("");
28
+
expect(flow.state.localAccessToken).toBeNull();
29
+
expect(flow.state.localRefreshToken).toBeNull();
30
+
expect(flow.state.error).toBeNull();
31
+
});
32
+
33
+
it("initializes progress correctly", () => {
34
+
const flow = createOfflineInboundMigrationFlow();
35
+
36
+
expect(flow.state.progress.repoExported).toBe(false);
37
+
expect(flow.state.progress.repoImported).toBe(false);
38
+
expect(flow.state.progress.blobsTotal).toBe(0);
39
+
expect(flow.state.progress.blobsMigrated).toBe(0);
40
+
expect(flow.state.progress.blobsFailed).toEqual([]);
41
+
expect(flow.state.progress.prefsMigrated).toBe(false);
42
+
expect(flow.state.progress.plcSigned).toBe(false);
43
+
expect(flow.state.progress.activated).toBe(false);
44
+
expect(flow.state.progress.deactivated).toBe(false);
45
+
expect(flow.state.progress.currentOperation).toBe("");
46
+
});
47
+
});
48
+
49
+
describe("setUserDid", () => {
50
+
it("sets the user DID", () => {
51
+
const flow = createOfflineInboundMigrationFlow();
52
+
53
+
flow.setUserDid("did:plc:abc123");
54
+
55
+
expect(flow.state.userDid).toBe("did:plc:abc123");
56
+
});
57
+
58
+
it("saves state to localStorage", () => {
59
+
const flow = createOfflineInboundMigrationFlow();
60
+
61
+
flow.setUserDid("did:plc:xyz789");
62
+
63
+
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
64
+
expect(stored.userDid).toBe("did:plc:xyz789");
65
+
});
66
+
});
67
+
68
+
describe("setCarFile", () => {
69
+
it("sets CAR file data", () => {
70
+
const flow = createOfflineInboundMigrationFlow();
71
+
const carData = new Uint8Array([1, 2, 3, 4, 5]);
72
+
73
+
flow.setCarFile(carData, "repo.car");
74
+
75
+
expect(flow.state.carFile).toEqual(carData);
76
+
expect(flow.state.carFileName).toBe("repo.car");
77
+
expect(flow.state.carSizeBytes).toBe(5);
78
+
});
79
+
80
+
it("saves file metadata to localStorage (not file content)", () => {
81
+
const flow = createOfflineInboundMigrationFlow();
82
+
const carData = new Uint8Array([1, 2, 3, 4, 5]);
83
+
84
+
flow.setCarFile(carData, "backup.car");
85
+
86
+
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
87
+
expect(stored.carFileName).toBe("backup.car");
88
+
expect(stored.carSizeBytes).toBe(5);
89
+
});
90
+
});
91
+
92
+
describe("setRotationKey", () => {
93
+
it("sets the rotation key", () => {
94
+
const flow = createOfflineInboundMigrationFlow();
95
+
96
+
flow.setRotationKey("abc123privatekey");
97
+
98
+
expect(flow.state.rotationKey).toBe("abc123privatekey");
99
+
});
100
+
101
+
it("does not save rotation key to localStorage (security)", () => {
102
+
const flow = createOfflineInboundMigrationFlow();
103
+
104
+
flow.setRotationKey("supersecretkey");
105
+
106
+
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
107
+
if (stored) {
108
+
const parsed = JSON.parse(stored);
109
+
expect(parsed.rotationKey).toBeUndefined();
110
+
}
111
+
});
112
+
});
113
+
114
+
describe("setTargetHandle", () => {
115
+
it("sets the target handle", () => {
116
+
const flow = createOfflineInboundMigrationFlow();
117
+
118
+
flow.setTargetHandle("alice.example.com");
119
+
120
+
expect(flow.state.targetHandle).toBe("alice.example.com");
121
+
});
122
+
123
+
it("saves to localStorage", () => {
124
+
const flow = createOfflineInboundMigrationFlow();
125
+
126
+
flow.setTargetHandle("bob.example.com");
127
+
128
+
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
129
+
expect(stored.targetHandle).toBe("bob.example.com");
130
+
});
131
+
});
132
+
133
+
describe("setTargetEmail", () => {
134
+
it("sets the target email", () => {
135
+
const flow = createOfflineInboundMigrationFlow();
136
+
137
+
flow.setTargetEmail("alice@example.com");
138
+
139
+
expect(flow.state.targetEmail).toBe("alice@example.com");
140
+
});
141
+
142
+
it("saves to localStorage", () => {
143
+
const flow = createOfflineInboundMigrationFlow();
144
+
145
+
flow.setTargetEmail("bob@example.com");
146
+
147
+
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
148
+
expect(stored.targetEmail).toBe("bob@example.com");
149
+
});
150
+
});
151
+
152
+
describe("setTargetPassword", () => {
153
+
it("sets the target password", () => {
154
+
const flow = createOfflineInboundMigrationFlow();
155
+
156
+
flow.setTargetPassword("securepassword123");
157
+
158
+
expect(flow.state.targetPassword).toBe("securepassword123");
159
+
});
160
+
161
+
it("does not save password to localStorage (security)", () => {
162
+
const flow = createOfflineInboundMigrationFlow();
163
+
flow.setUserDid("did:plc:test");
164
+
165
+
flow.setTargetPassword("mypassword");
166
+
167
+
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
168
+
if (stored) {
169
+
const parsed = JSON.parse(stored);
170
+
expect(parsed.targetPassword).toBeUndefined();
171
+
}
172
+
});
173
+
});
174
+
175
+
describe("setInviteCode", () => {
176
+
it("sets the invite code", () => {
177
+
const flow = createOfflineInboundMigrationFlow();
178
+
179
+
flow.setInviteCode("invite-abc123");
180
+
181
+
expect(flow.state.inviteCode).toBe("invite-abc123");
182
+
});
183
+
});
184
+
185
+
describe("setStep", () => {
186
+
it("changes the current step", () => {
187
+
const flow = createOfflineInboundMigrationFlow();
188
+
189
+
flow.setStep("provide-did");
190
+
191
+
expect(flow.state.step).toBe("provide-did");
192
+
});
193
+
194
+
it("clears error when changing step", () => {
195
+
const flow = createOfflineInboundMigrationFlow();
196
+
flow.setError("Previous error");
197
+
198
+
flow.setStep("upload-car");
199
+
200
+
expect(flow.state.error).toBeNull();
201
+
});
202
+
203
+
it("saves step to localStorage", () => {
204
+
const flow = createOfflineInboundMigrationFlow();
205
+
206
+
flow.setStep("provide-rotation-key");
207
+
208
+
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
209
+
expect(stored.step).toBe("provide-rotation-key");
210
+
});
211
+
});
212
+
213
+
describe("setError", () => {
214
+
it("sets the error message", () => {
215
+
const flow = createOfflineInboundMigrationFlow();
216
+
217
+
flow.setError("Something went wrong");
218
+
219
+
expect(flow.state.error).toBe("Something went wrong");
220
+
});
221
+
222
+
it("saves error to localStorage", () => {
223
+
const flow = createOfflineInboundMigrationFlow();
224
+
225
+
flow.setError("Connection failed");
226
+
227
+
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
228
+
expect(stored.lastError).toBe("Connection failed");
229
+
});
230
+
});
231
+
232
+
describe("setProgress", () => {
233
+
it("updates progress fields", () => {
234
+
const flow = createOfflineInboundMigrationFlow();
235
+
236
+
flow.setProgress({
237
+
repoImported: true,
238
+
currentOperation: "Importing...",
239
+
});
240
+
241
+
expect(flow.state.progress.repoImported).toBe(true);
242
+
expect(flow.state.progress.currentOperation).toBe("Importing...");
243
+
});
244
+
245
+
it("preserves other progress fields", () => {
246
+
const flow = createOfflineInboundMigrationFlow();
247
+
flow.setProgress({ repoExported: true });
248
+
249
+
flow.setProgress({ repoImported: true });
250
+
251
+
expect(flow.state.progress.repoExported).toBe(true);
252
+
expect(flow.state.progress.repoImported).toBe(true);
253
+
});
254
+
});
255
+
256
+
describe("reset", () => {
257
+
it("resets state to initial values", () => {
258
+
const flow = createOfflineInboundMigrationFlow();
259
+
flow.setUserDid("did:plc:abc123");
260
+
flow.setTargetHandle("alice.example.com");
261
+
flow.setStep("review");
262
+
263
+
flow.reset();
264
+
265
+
expect(flow.state.step).toBe("welcome");
266
+
expect(flow.state.userDid).toBe("");
267
+
expect(flow.state.targetHandle).toBe("");
268
+
});
269
+
270
+
it("clears localStorage", () => {
271
+
const flow = createOfflineInboundMigrationFlow();
272
+
flow.setUserDid("did:plc:abc123");
273
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
274
+
275
+
flow.reset();
276
+
277
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
278
+
});
279
+
});
280
+
281
+
describe("clearOfflineState", () => {
282
+
it("removes state from localStorage", () => {
283
+
const flow = createOfflineInboundMigrationFlow();
284
+
flow.setUserDid("did:plc:abc123");
285
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
286
+
287
+
flow.clearOfflineState();
288
+
289
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
290
+
});
291
+
});
292
+
293
+
describe("tryResume", () => {
294
+
it("returns false when no stored state", () => {
295
+
const flow = createOfflineInboundMigrationFlow();
296
+
297
+
const result = flow.tryResume();
298
+
299
+
expect(result).toBe(false);
300
+
});
301
+
302
+
it("restores state from localStorage", () => {
303
+
const storedState = {
304
+
version: 1,
305
+
step: "choose-handle",
306
+
startedAt: new Date().toISOString(),
307
+
userDid: "did:plc:restored123",
308
+
carFileName: "backup.car",
309
+
carSizeBytes: 12345,
310
+
rotationKeyDidKey: "did:key:z123abc",
311
+
targetHandle: "restored.example.com",
312
+
targetEmail: "restored@example.com",
313
+
progress: {
314
+
accountCreated: true,
315
+
repoImported: false,
316
+
plcSigned: false,
317
+
activated: false,
318
+
},
319
+
};
320
+
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
321
+
322
+
const flow = createOfflineInboundMigrationFlow();
323
+
const result = flow.tryResume();
324
+
325
+
expect(result).toBe(true);
326
+
expect(flow.state.step).toBe("choose-handle");
327
+
expect(flow.state.userDid).toBe("did:plc:restored123");
328
+
expect(flow.state.carFileName).toBe("backup.car");
329
+
expect(flow.state.carSizeBytes).toBe(12345);
330
+
expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc");
331
+
expect(flow.state.targetHandle).toBe("restored.example.com");
332
+
expect(flow.state.targetEmail).toBe("restored@example.com");
333
+
expect(flow.state.progress.repoExported).toBe(true);
334
+
});
335
+
336
+
it("restores error from stored state", () => {
337
+
const storedState = {
338
+
version: 1,
339
+
step: "error",
340
+
startedAt: new Date().toISOString(),
341
+
userDid: "did:plc:abc",
342
+
carFileName: "",
343
+
carSizeBytes: 0,
344
+
rotationKeyDidKey: "",
345
+
targetHandle: "",
346
+
targetEmail: "",
347
+
progress: {
348
+
accountCreated: false,
349
+
repoImported: false,
350
+
plcSigned: false,
351
+
activated: false,
352
+
},
353
+
lastError: "Previous migration failed",
354
+
};
355
+
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
356
+
357
+
const flow = createOfflineInboundMigrationFlow();
358
+
flow.tryResume();
359
+
360
+
expect(flow.state.error).toBe("Previous migration failed");
361
+
});
362
+
363
+
it("returns false and clears for incompatible version", () => {
364
+
const storedState = {
365
+
version: 999,
366
+
step: "review",
367
+
userDid: "did:plc:abc",
368
+
};
369
+
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
370
+
371
+
const flow = createOfflineInboundMigrationFlow();
372
+
const result = flow.tryResume();
373
+
374
+
expect(result).toBe(false);
375
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
376
+
});
377
+
378
+
it("returns false and clears for expired state (> 24 hours)", () => {
379
+
const expiredState = {
380
+
version: 1,
381
+
step: "review",
382
+
startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
383
+
userDid: "did:plc:expired",
384
+
carFileName: "old.car",
385
+
carSizeBytes: 100,
386
+
rotationKeyDidKey: "",
387
+
targetHandle: "old.example.com",
388
+
targetEmail: "old@example.com",
389
+
progress: {
390
+
accountCreated: false,
391
+
repoImported: false,
392
+
plcSigned: false,
393
+
activated: false,
394
+
},
395
+
};
396
+
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState));
397
+
398
+
const flow = createOfflineInboundMigrationFlow();
399
+
const result = flow.tryResume();
400
+
401
+
expect(result).toBe(false);
402
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
403
+
});
404
+
405
+
it("returns false and clears for invalid JSON", () => {
406
+
localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json");
407
+
408
+
const flow = createOfflineInboundMigrationFlow();
409
+
const result = flow.tryResume();
410
+
411
+
expect(result).toBe(false);
412
+
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
413
+
});
414
+
415
+
it("accepts state within 24 hours", () => {
416
+
const recentState = {
417
+
version: 1,
418
+
step: "review",
419
+
startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(),
420
+
userDid: "did:plc:recent",
421
+
carFileName: "recent.car",
422
+
carSizeBytes: 500,
423
+
rotationKeyDidKey: "did:key:zRecent",
424
+
targetHandle: "recent.example.com",
425
+
targetEmail: "recent@example.com",
426
+
progress: {
427
+
accountCreated: true,
428
+
repoImported: true,
429
+
plcSigned: false,
430
+
activated: false,
431
+
},
432
+
};
433
+
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState));
434
+
435
+
const flow = createOfflineInboundMigrationFlow();
436
+
const result = flow.tryResume();
437
+
438
+
expect(result).toBe(true);
439
+
expect(flow.state.userDid).toBe("did:plc:recent");
440
+
});
441
+
});
442
+
443
+
describe("loadLocalServerInfo", () => {
444
+
function createMockResponse(data: unknown) {
445
+
const jsonStr = JSON.stringify(data);
446
+
return new Response(jsonStr, {
447
+
status: 200,
448
+
headers: { "Content-Type": "application/json" },
449
+
});
450
+
}
451
+
452
+
it("fetches server description", async () => {
453
+
const mockServerInfo = {
454
+
did: "did:web:example.com",
455
+
availableUserDomains: ["example.com"],
456
+
inviteCodeRequired: false,
457
+
};
458
+
459
+
globalThis.fetch = vi.fn().mockResolvedValue(
460
+
createMockResponse(mockServerInfo),
461
+
);
462
+
463
+
const flow = createOfflineInboundMigrationFlow();
464
+
const result = await flow.loadLocalServerInfo();
465
+
466
+
expect(result).toEqual(mockServerInfo);
467
+
expect(fetch).toHaveBeenCalledWith(
468
+
expect.stringContaining("com.atproto.server.describeServer"),
469
+
expect.any(Object),
470
+
);
471
+
});
472
+
473
+
it("caches server info", async () => {
474
+
const mockServerInfo = {
475
+
did: "did:web:example.com",
476
+
availableUserDomains: ["example.com"],
477
+
inviteCodeRequired: false,
478
+
};
479
+
480
+
globalThis.fetch = vi.fn().mockResolvedValue(
481
+
createMockResponse(mockServerInfo),
482
+
);
483
+
484
+
const flow = createOfflineInboundMigrationFlow();
485
+
await flow.loadLocalServerInfo();
486
+
await flow.loadLocalServerInfo();
487
+
488
+
expect(fetch).toHaveBeenCalledTimes(1);
489
+
});
490
+
});
491
+
});
+333
frontend/src/tests/migration/plc-ops.test.ts
+333
frontend/src/tests/migration/plc-ops.test.ts
···
···
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { PlcOps, plcOps } from "../../lib/migration/plc-ops";
3
+
4
+
describe("migration/plc-ops", () => {
5
+
beforeEach(() => {
6
+
vi.restoreAllMocks();
7
+
});
8
+
9
+
describe("PlcOps class", () => {
10
+
it("uses default PLC directory URL", () => {
11
+
const ops = new PlcOps();
12
+
expect(ops).toBeDefined();
13
+
});
14
+
15
+
it("accepts custom PLC directory URL", () => {
16
+
const ops = new PlcOps("https://custom-plc.example.com");
17
+
expect(ops).toBeDefined();
18
+
});
19
+
});
20
+
21
+
describe("plcOps singleton", () => {
22
+
it("exports a singleton instance", () => {
23
+
expect(plcOps).toBeInstanceOf(PlcOps);
24
+
});
25
+
});
26
+
27
+
describe("getPlcAuditLogs", () => {
28
+
it("throws on HTTP error", async () => {
29
+
globalThis.fetch = vi.fn().mockResolvedValue({
30
+
ok: false,
31
+
status: 404,
32
+
});
33
+
34
+
await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow(
35
+
"Failed to fetch PLC audit logs: 404",
36
+
);
37
+
});
38
+
});
39
+
40
+
describe("getLastPlcOpFromPlc", () => {
41
+
it("throws when empty array returned", async () => {
42
+
globalThis.fetch = vi.fn().mockResolvedValue({
43
+
ok: true,
44
+
json: () => Promise.resolve([]),
45
+
});
46
+
47
+
await expect(
48
+
plcOps.getLastPlcOpFromPlc("did:plc:empty"),
49
+
).rejects.toThrow();
50
+
});
51
+
});
52
+
53
+
describe("createNewSecp256k1Keypair", () => {
54
+
it("generates a keypair with private and public keys", async () => {
55
+
const result = await plcOps.createNewSecp256k1Keypair();
56
+
57
+
expect(result.privateKey).toBeDefined();
58
+
expect(result.publicKey).toBeDefined();
59
+
expect(result.publicKey.startsWith("did:key:")).toBe(true);
60
+
});
61
+
62
+
it("generates different keypairs each time", async () => {
63
+
const result1 = await plcOps.createNewSecp256k1Keypair();
64
+
const result2 = await plcOps.createNewSecp256k1Keypair();
65
+
66
+
expect(result1.privateKey).not.toBe(result2.privateKey);
67
+
expect(result1.publicKey).not.toBe(result2.publicKey);
68
+
});
69
+
});
70
+
71
+
describe("getKeyPair", () => {
72
+
it("parses 64-character hex private key", async () => {
73
+
const hexKey = "a".repeat(64);
74
+
75
+
const result = await plcOps.getKeyPair(hexKey);
76
+
77
+
expect(result.type).toBe("private_key");
78
+
expect(result.didPublicKey.startsWith("did:key:")).toBe(true);
79
+
expect(result.keypair).toBeDefined();
80
+
});
81
+
82
+
it("handles whitespace in key input", async () => {
83
+
const hexKey = " " + "b".repeat(64) + " ";
84
+
85
+
const result = await plcOps.getKeyPair(hexKey);
86
+
87
+
expect(result.type).toBe("private_key");
88
+
});
89
+
90
+
it("throws for invalid key format", async () => {
91
+
await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow(
92
+
"Invalid key format",
93
+
);
94
+
});
95
+
96
+
it("throws for hex key with wrong length", async () => {
97
+
await expect(plcOps.getKeyPair("abc123")).rejects.toThrow(
98
+
"Invalid key format",
99
+
);
100
+
});
101
+
});
102
+
103
+
describe("pushPlcOperation", () => {
104
+
it("posts operation to PLC directory", async () => {
105
+
globalThis.fetch = vi.fn().mockResolvedValue({
106
+
ok: true,
107
+
});
108
+
109
+
const operation = {
110
+
type: "plc_operation" as const,
111
+
prev: "bafyreiabc",
112
+
alsoKnownAs: ["at://alice.example.com"],
113
+
rotationKeys: ["did:key:z123"],
114
+
services: {
115
+
atproto_pds: {
116
+
type: "AtprotoPersonalDataServer",
117
+
endpoint: "https://pds.example.com",
118
+
},
119
+
},
120
+
verificationMethods: {
121
+
atproto: "did:key:z456",
122
+
},
123
+
sig: "test-signature",
124
+
};
125
+
126
+
await plcOps.pushPlcOperation("did:plc:abc123", operation);
127
+
128
+
expect(fetch).toHaveBeenCalledWith(
129
+
"https://plc.directory/did:plc:abc123",
130
+
expect.objectContaining({
131
+
method: "POST",
132
+
headers: { "Content-Type": "application/json" },
133
+
body: JSON.stringify(operation),
134
+
}),
135
+
);
136
+
});
137
+
138
+
it("throws with error message from PLC directory", async () => {
139
+
globalThis.fetch = vi.fn().mockResolvedValue({
140
+
ok: false,
141
+
status: 400,
142
+
headers: new Map([["content-type", "application/json"]]),
143
+
json: () => Promise.resolve({ message: "Invalid signature" }),
144
+
});
145
+
146
+
const operation = {
147
+
type: "plc_operation" as const,
148
+
prev: "bafyreiabc",
149
+
alsoKnownAs: [],
150
+
rotationKeys: ["did:key:z123"],
151
+
services: {},
152
+
verificationMethods: {},
153
+
sig: "bad-sig",
154
+
};
155
+
156
+
await expect(
157
+
plcOps.pushPlcOperation("did:plc:abc123", operation),
158
+
).rejects.toThrow("Invalid signature");
159
+
});
160
+
161
+
it("throws generic error when no message in response", async () => {
162
+
globalThis.fetch = vi.fn().mockResolvedValue({
163
+
ok: false,
164
+
status: 500,
165
+
headers: new Map([["content-type", "text/plain"]]),
166
+
});
167
+
168
+
const operation = {
169
+
type: "plc_operation" as const,
170
+
prev: null,
171
+
alsoKnownAs: [],
172
+
rotationKeys: [],
173
+
services: {},
174
+
verificationMethods: {},
175
+
};
176
+
177
+
await expect(
178
+
plcOps.pushPlcOperation("did:plc:abc123", operation),
179
+
).rejects.toThrow("PLC directory returned HTTP 500");
180
+
});
181
+
});
182
+
183
+
describe("createServiceAuthToken", () => {
184
+
it("creates a valid JWT", async () => {
185
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
186
+
const keypair = await plcOps.getKeyPair(privateKey);
187
+
188
+
const token = await plcOps.createServiceAuthToken(
189
+
"did:plc:issuer",
190
+
"did:web:audience.example.com",
191
+
keypair.keypair,
192
+
"com.atproto.server.createAccount",
193
+
);
194
+
195
+
expect(token).toBeDefined();
196
+
const parts = token.split(".");
197
+
expect(parts).toHaveLength(3);
198
+
});
199
+
200
+
it("includes correct header", async () => {
201
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
202
+
const keypair = await plcOps.getKeyPair(privateKey);
203
+
204
+
const token = await plcOps.createServiceAuthToken(
205
+
"did:plc:issuer",
206
+
"did:web:audience",
207
+
keypair.keypair,
208
+
"com.atproto.server.createAccount",
209
+
);
210
+
211
+
const headerB64 = token.split(".")[0];
212
+
const header = JSON.parse(
213
+
atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")),
214
+
);
215
+
expect(header.typ).toBe("JWT");
216
+
expect(header.alg).toBe("ES256K");
217
+
});
218
+
219
+
it("includes correct payload claims", async () => {
220
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
221
+
const keypair = await plcOps.getKeyPair(privateKey);
222
+
223
+
const before = Math.floor(Date.now() / 1000);
224
+
const token = await plcOps.createServiceAuthToken(
225
+
"did:plc:myissuer",
226
+
"did:web:myaudience.com",
227
+
keypair.keypair,
228
+
"com.atproto.sync.getRepo",
229
+
);
230
+
const after = Math.floor(Date.now() / 1000);
231
+
232
+
const payloadB64 = token.split(".")[1];
233
+
const payload = JSON.parse(
234
+
atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")),
235
+
);
236
+
237
+
expect(payload.iss).toBe("did:plc:myissuer");
238
+
expect(payload.aud).toBe("did:web:myaudience.com");
239
+
expect(payload.lxm).toBe("com.atproto.sync.getRepo");
240
+
expect(payload.iat).toBeGreaterThanOrEqual(before);
241
+
expect(payload.iat).toBeLessThanOrEqual(after);
242
+
expect(payload.exp).toBe(payload.iat + 60);
243
+
expect(payload.jti).toBeDefined();
244
+
});
245
+
});
246
+
247
+
describe("signAndPublishNewOp", () => {
248
+
it("throws when no rotation keys provided", async () => {
249
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
250
+
const keypair = await plcOps.getKeyPair(privateKey);
251
+
252
+
await expect(
253
+
plcOps.signAndPublishNewOp(
254
+
"did:plc:test",
255
+
keypair.keypair,
256
+
["at://alice.example.com"],
257
+
[],
258
+
"https://pds.example.com",
259
+
"did:key:zVerify",
260
+
"bafyreiprev",
261
+
),
262
+
).rejects.toThrow("No rotation keys provided");
263
+
});
264
+
265
+
it("throws when more than 5 unique rotation keys provided", async () => {
266
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
267
+
const keypair = await plcOps.getKeyPair(privateKey);
268
+
269
+
const tooManyKeys = [
270
+
"did:key:z1",
271
+
"did:key:z2",
272
+
"did:key:z3",
273
+
"did:key:z4",
274
+
"did:key:z5",
275
+
"did:key:z6",
276
+
];
277
+
278
+
await expect(
279
+
plcOps.signAndPublishNewOp(
280
+
"did:plc:test",
281
+
keypair.keypair,
282
+
[],
283
+
tooManyKeys,
284
+
"https://pds.example.com",
285
+
"did:key:zVerify",
286
+
"bafyreiprev",
287
+
),
288
+
).rejects.toThrow("Maximum 5 rotation keys allowed");
289
+
});
290
+
});
291
+
292
+
describe("signPlcOperationWithCredentials", () => {
293
+
it("throws when no rotation keys provided", async () => {
294
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
295
+
const keypair = await plcOps.getKeyPair(privateKey);
296
+
297
+
await expect(
298
+
plcOps.signPlcOperationWithCredentials(
299
+
"did:plc:test",
300
+
keypair.keypair,
301
+
{
302
+
rotationKeys: [],
303
+
alsoKnownAs: [],
304
+
verificationMethods: {},
305
+
services: {},
306
+
},
307
+
[],
308
+
"bafyreiprev",
309
+
),
310
+
).rejects.toThrow("No rotation keys provided");
311
+
});
312
+
313
+
it("throws when more than 5 rotation keys provided", async () => {
314
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
315
+
const keypair = await plcOps.getKeyPair(privateKey);
316
+
317
+
await expect(
318
+
plcOps.signPlcOperationWithCredentials(
319
+
"did:plc:test",
320
+
keypair.keypair,
321
+
{
322
+
rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"],
323
+
alsoKnownAs: [],
324
+
verificationMethods: {},
325
+
services: {},
326
+
},
327
+
["did:key:z4", "did:key:z5", "did:key:z6"],
328
+
"bafyreiprev",
329
+
),
330
+
).rejects.toThrow("Maximum 5 rotation keys allowed");
331
+
});
332
+
});
333
+
});
+7
-3
frontend/src/tests/mocks.ts
+7
-3
frontend/src/tests/mocks.ts
···
206
() => jsonResponse({ code: "new-invite-" + Date.now() }),
207
);
208
mockEndpoint(
209
-
"com.tranquil.account.getNotificationPrefs",
210
() => jsonResponse(mockData.notificationPrefs()),
211
);
212
mockEndpoint(
213
-
"com.tranquil.account.updateNotificationPrefs",
214
() => jsonResponse({ success: true }),
215
);
216
mockEndpoint(
217
-
"com.tranquil.account.getNotificationHistory",
218
() => jsonResponse({ notifications: [] }),
219
);
220
mockEndpoint(
···
240
mockEndpoint(
241
"com.atproto.repo.listRecords",
242
() => jsonResponse({ records: [] }),
243
);
244
}
245
export function setupAuthenticatedUser(
···
206
() => jsonResponse({ code: "new-invite-" + Date.now() }),
207
);
208
mockEndpoint(
209
+
"_account.getNotificationPrefs",
210
() => jsonResponse(mockData.notificationPrefs()),
211
);
212
mockEndpoint(
213
+
"_account.updateNotificationPrefs",
214
() => jsonResponse({ success: true }),
215
);
216
mockEndpoint(
217
+
"_account.getNotificationHistory",
218
() => jsonResponse({ notifications: [] }),
219
);
220
mockEndpoint(
···
240
mockEndpoint(
241
"com.atproto.repo.listRecords",
242
() => jsonResponse({ records: [] }),
243
+
);
244
+
mockEndpoint(
245
+
"_backup.listBackups",
246
+
() => jsonResponse({ backups: [] }),
247
);
248
}
249
export function setupAuthenticatedUser(
+15
migrations/20260101_account_backups.sql
+15
migrations/20260101_account_backups.sql
···
···
1
+
ALTER TABLE users ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT TRUE;
2
+
3
+
CREATE TABLE account_backups (
4
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6
+
storage_key TEXT NOT NULL,
7
+
repo_root_cid TEXT NOT NULL,
8
+
repo_rev TEXT NOT NULL,
9
+
block_count INT NOT NULL,
10
+
size_bytes BIGINT NOT NULL,
11
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12
+
);
13
+
14
+
CREATE INDEX idx_account_backups_user_id ON account_backups(user_id);
15
+
CREATE INDEX idx_account_backups_created_at ON account_backups(created_at);
+6
-3
scripts/install-debian.sh
+6
-3
scripts/install-debian.sh
···
44
sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true
45
sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true
46
47
-
log_info "Removing minio bucket..."
48
if command -v mc &>/dev/null; then
49
mc rb local/pds-blobs --force 2>/dev/null || true
50
mc alias remove local 2>/dev/null || true
51
fi
52
systemctl stop minio 2>/dev/null || true
···
78
echo " - PostgreSQL database 'pds' and all data"
79
echo " - All Tranquil PDS configuration and credentials"
80
echo " - All source code in /opt/tranquil-pds"
81
-
echo " - MinIO bucket 'pds-blobs' and all blobs"
82
echo ""
83
read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE
84
if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then
···
274
mc alias remove local 2>/dev/null || true
275
mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4
276
mc mb local/pds-blobs --ignore-existing
277
-
log_success "minio bucket created"
278
279
log_info "Installing rust..."
280
if [[ -f "$HOME/.cargo/env" ]]; then
···
382
S3_ENDPOINT=http://localhost:9000
383
AWS_REGION=us-east-1
384
S3_BUCKET=pds-blobs
385
AWS_ACCESS_KEY_ID=minioadmin
386
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
387
VALKEY_URL=redis://localhost:6379
···
44
sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true
45
sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true
46
47
+
log_info "Removing minio buckets..."
48
if command -v mc &>/dev/null; then
49
mc rb local/pds-blobs --force 2>/dev/null || true
50
+
mc rb local/pds-backups --force 2>/dev/null || true
51
mc alias remove local 2>/dev/null || true
52
fi
53
systemctl stop minio 2>/dev/null || true
···
79
echo " - PostgreSQL database 'pds' and all data"
80
echo " - All Tranquil PDS configuration and credentials"
81
echo " - All source code in /opt/tranquil-pds"
82
+
echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data"
83
echo ""
84
read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE
85
if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then
···
275
mc alias remove local 2>/dev/null || true
276
mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4
277
mc mb local/pds-blobs --ignore-existing
278
+
mc mb local/pds-backups --ignore-existing
279
+
log_success "minio buckets created"
280
281
log_info "Installing rust..."
282
if [[ -f "$HOME/.cargo/env" ]]; then
···
384
S3_ENDPOINT=http://localhost:9000
385
AWS_REGION=us-east-1
386
S3_BUCKET=pds-blobs
387
+
BACKUP_S3_BUCKET=pds-backups
388
AWS_ACCESS_KEY_ID=minioadmin
389
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
390
VALKEY_URL=redis://localhost:6379
+5
-1
scripts/test-infra.sh
+5
-1
scripts/test-infra.sh
···
83
echo "Waiting for Valkey... ($i/30)"
84
sleep 1
85
done
86
-
echo "Creating MinIO bucket..."
87
$CONTAINER_CMD run --rm --network host \
88
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
89
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
90
cat > "$INFRA_FILE" << EOF
91
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
92
export TEST_DB_PORT="${PG_PORT}"
93
export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}"
94
export S3_BUCKET="test-bucket"
95
export AWS_ACCESS_KEY_ID="minioadmin"
96
export AWS_SECRET_ACCESS_KEY="minioadmin"
97
export AWS_REGION="us-east-1"
···
83
echo "Waiting for Valkey... ($i/30)"
84
sleep 1
85
done
86
+
echo "Creating MinIO buckets..."
87
$CONTAINER_CMD run --rm --network host \
88
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
89
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
90
+
$CONTAINER_CMD run --rm --network host \
91
+
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
92
+
minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true
93
cat > "$INFRA_FILE" << EOF
94
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
95
export TEST_DB_PORT="${PG_PORT}"
96
export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}"
97
export S3_BUCKET="test-bucket"
98
+
export BACKUP_S3_BUCKET="test-backups"
99
export AWS_ACCESS_KEY_ID="minioadmin"
100
export AWS_SECRET_ACCESS_KEY="minioadmin"
101
export AWS_REGION="us-east-1"
+930
src/api/backup.rs
+930
src/api/backup.rs
···
···
1
+
use crate::auth::BearerAuth;
2
+
use crate::scheduled::generate_full_backup;
3
+
use crate::state::AppState;
4
+
use crate::storage::BackupStorage;
5
+
use axum::{
6
+
Json,
7
+
extract::{Query, State},
8
+
http::StatusCode,
9
+
response::{IntoResponse, Response},
10
+
};
11
+
use cid::Cid;
12
+
use serde::{Deserialize, Serialize};
13
+
use serde_json::json;
14
+
use std::str::FromStr;
15
+
use tracing::{error, info, warn};
16
+
17
+
#[derive(Serialize)]
18
+
#[serde(rename_all = "camelCase")]
19
+
pub struct BackupInfo {
20
+
pub id: String,
21
+
pub repo_rev: String,
22
+
pub repo_root_cid: String,
23
+
pub block_count: i32,
24
+
pub size_bytes: i64,
25
+
pub created_at: String,
26
+
}
27
+
28
+
#[derive(Serialize)]
29
+
#[serde(rename_all = "camelCase")]
30
+
pub struct ListBackupsOutput {
31
+
pub backups: Vec<BackupInfo>,
32
+
pub backup_enabled: bool,
33
+
}
34
+
35
+
pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response {
36
+
let user = match sqlx::query!(
37
+
"SELECT id, backup_enabled FROM users WHERE did = $1",
38
+
auth.0.did
39
+
)
40
+
.fetch_optional(&state.db)
41
+
.await
42
+
{
43
+
Ok(Some(u)) => u,
44
+
Ok(None) => {
45
+
return (
46
+
StatusCode::NOT_FOUND,
47
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
48
+
)
49
+
.into_response();
50
+
}
51
+
Err(e) => {
52
+
error!("DB error fetching user: {:?}", e);
53
+
return (
54
+
StatusCode::INTERNAL_SERVER_ERROR,
55
+
Json(json!({"error": "InternalError", "message": "Database error"})),
56
+
)
57
+
.into_response();
58
+
}
59
+
};
60
+
61
+
let backups = match sqlx::query!(
62
+
r#"
63
+
SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at
64
+
FROM account_backups
65
+
WHERE user_id = $1
66
+
ORDER BY created_at DESC
67
+
"#,
68
+
user.id
69
+
)
70
+
.fetch_all(&state.db)
71
+
.await
72
+
{
73
+
Ok(rows) => rows,
74
+
Err(e) => {
75
+
error!("DB error fetching backups: {:?}", e);
76
+
return (
77
+
StatusCode::INTERNAL_SERVER_ERROR,
78
+
Json(json!({"error": "InternalError", "message": "Database error"})),
79
+
)
80
+
.into_response();
81
+
}
82
+
};
83
+
84
+
let backup_list: Vec<BackupInfo> = backups
85
+
.into_iter()
86
+
.map(|b| BackupInfo {
87
+
id: b.id.to_string(),
88
+
repo_rev: b.repo_rev,
89
+
repo_root_cid: b.repo_root_cid,
90
+
block_count: b.block_count,
91
+
size_bytes: b.size_bytes,
92
+
created_at: b.created_at.to_rfc3339(),
93
+
})
94
+
.collect();
95
+
96
+
(
97
+
StatusCode::OK,
98
+
Json(ListBackupsOutput {
99
+
backups: backup_list,
100
+
backup_enabled: user.backup_enabled,
101
+
}),
102
+
)
103
+
.into_response()
104
+
}
105
+
106
+
#[derive(Deserialize)]
107
+
pub struct GetBackupQuery {
108
+
pub id: String,
109
+
}
110
+
111
+
pub async fn get_backup(
112
+
State(state): State<AppState>,
113
+
auth: BearerAuth,
114
+
Query(query): Query<GetBackupQuery>,
115
+
) -> Response {
116
+
let backup_id = match uuid::Uuid::parse_str(&query.id) {
117
+
Ok(id) => id,
118
+
Err(_) => {
119
+
return (
120
+
StatusCode::BAD_REQUEST,
121
+
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
122
+
)
123
+
.into_response();
124
+
}
125
+
};
126
+
127
+
let backup = match sqlx::query!(
128
+
r#"
129
+
SELECT ab.storage_key, ab.repo_rev
130
+
FROM account_backups ab
131
+
JOIN users u ON u.id = ab.user_id
132
+
WHERE ab.id = $1 AND u.did = $2
133
+
"#,
134
+
backup_id,
135
+
auth.0.did
136
+
)
137
+
.fetch_optional(&state.db)
138
+
.await
139
+
{
140
+
Ok(Some(b)) => b,
141
+
Ok(None) => {
142
+
return (
143
+
StatusCode::NOT_FOUND,
144
+
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
145
+
)
146
+
.into_response();
147
+
}
148
+
Err(e) => {
149
+
error!("DB error fetching backup: {:?}", e);
150
+
return (
151
+
StatusCode::INTERNAL_SERVER_ERROR,
152
+
Json(json!({"error": "InternalError", "message": "Database error"})),
153
+
)
154
+
.into_response();
155
+
}
156
+
};
157
+
158
+
let backup_storage = match state.backup_storage.as_ref() {
159
+
Some(storage) => storage,
160
+
None => {
161
+
return (
162
+
StatusCode::SERVICE_UNAVAILABLE,
163
+
Json(
164
+
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
165
+
),
166
+
)
167
+
.into_response();
168
+
}
169
+
};
170
+
171
+
let car_bytes = match backup_storage.get_backup(&backup.storage_key).await {
172
+
Ok(bytes) => bytes,
173
+
Err(e) => {
174
+
error!("Failed to fetch backup from storage: {:?}", e);
175
+
return (
176
+
StatusCode::INTERNAL_SERVER_ERROR,
177
+
Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})),
178
+
)
179
+
.into_response();
180
+
}
181
+
};
182
+
183
+
(
184
+
StatusCode::OK,
185
+
[
186
+
(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"),
187
+
(
188
+
axum::http::header::CONTENT_DISPOSITION,
189
+
&format!("attachment; filename=\"{}.car\"", backup.repo_rev),
190
+
),
191
+
],
192
+
car_bytes,
193
+
)
194
+
.into_response()
195
+
}
196
+
197
+
#[derive(Serialize)]
198
+
#[serde(rename_all = "camelCase")]
199
+
pub struct CreateBackupOutput {
200
+
pub id: String,
201
+
pub repo_rev: String,
202
+
pub size_bytes: i64,
203
+
pub block_count: i32,
204
+
}
205
+
206
+
pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response {
207
+
let backup_storage = match state.backup_storage.as_ref() {
208
+
Some(storage) => storage,
209
+
None => {
210
+
return (
211
+
StatusCode::SERVICE_UNAVAILABLE,
212
+
Json(
213
+
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
214
+
),
215
+
)
216
+
.into_response();
217
+
}
218
+
};
219
+
220
+
let user = match sqlx::query!(
221
+
r#"
222
+
SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev
223
+
FROM users u
224
+
JOIN repos r ON r.user_id = u.id
225
+
WHERE u.did = $1
226
+
"#,
227
+
auth.0.did
228
+
)
229
+
.fetch_optional(&state.db)
230
+
.await
231
+
{
232
+
Ok(Some(u)) => u,
233
+
Ok(None) => {
234
+
return (
235
+
StatusCode::NOT_FOUND,
236
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
237
+
)
238
+
.into_response();
239
+
}
240
+
Err(e) => {
241
+
error!("DB error fetching user: {:?}", e);
242
+
return (
243
+
StatusCode::INTERNAL_SERVER_ERROR,
244
+
Json(json!({"error": "InternalError", "message": "Database error"})),
245
+
)
246
+
.into_response();
247
+
}
248
+
};
249
+
250
+
if user.deactivated_at.is_some() {
251
+
return (
252
+
StatusCode::BAD_REQUEST,
253
+
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
254
+
)
255
+
.into_response();
256
+
}
257
+
258
+
let repo_rev = match &user.repo_rev {
259
+
Some(rev) => rev.clone(),
260
+
None => {
261
+
return (
262
+
StatusCode::BAD_REQUEST,
263
+
Json(
264
+
json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}),
265
+
),
266
+
)
267
+
.into_response();
268
+
}
269
+
};
270
+
271
+
let head_cid = match Cid::from_str(&user.repo_root_cid) {
272
+
Ok(c) => c,
273
+
Err(_) => {
274
+
return (
275
+
StatusCode::INTERNAL_SERVER_ERROR,
276
+
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
277
+
)
278
+
.into_response();
279
+
}
280
+
};
281
+
282
+
let car_bytes = match generate_full_backup(&state.block_store, &head_cid).await {
283
+
Ok(bytes) => bytes,
284
+
Err(e) => {
285
+
error!("Failed to generate CAR: {:?}", e);
286
+
return (
287
+
StatusCode::INTERNAL_SERVER_ERROR,
288
+
Json(json!({"error": "InternalError", "message": "Failed to generate backup"})),
289
+
)
290
+
.into_response();
291
+
}
292
+
};
293
+
294
+
let block_count = crate::scheduled::count_car_blocks(&car_bytes);
295
+
let size_bytes = car_bytes.len() as i64;
296
+
297
+
let storage_key = match backup_storage
298
+
.put_backup(&user.did, &repo_rev, &car_bytes)
299
+
.await
300
+
{
301
+
Ok(key) => key,
302
+
Err(e) => {
303
+
error!("Failed to upload backup: {:?}", e);
304
+
return (
305
+
StatusCode::INTERNAL_SERVER_ERROR,
306
+
Json(json!({"error": "InternalError", "message": "Failed to store backup"})),
307
+
)
308
+
.into_response();
309
+
}
310
+
};
311
+
312
+
let backup_id = match sqlx::query_scalar!(
313
+
r#"
314
+
INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)
315
+
VALUES ($1, $2, $3, $4, $5, $6)
316
+
RETURNING id
317
+
"#,
318
+
user.id,
319
+
storage_key,
320
+
user.repo_root_cid,
321
+
repo_rev,
322
+
block_count,
323
+
size_bytes
324
+
)
325
+
.fetch_one(&state.db)
326
+
.await
327
+
{
328
+
Ok(id) => id,
329
+
Err(e) => {
330
+
error!("DB error inserting backup: {:?}", e);
331
+
if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await {
332
+
error!(
333
+
storage_key = %storage_key,
334
+
error = %rollback_err,
335
+
"Failed to rollback orphaned backup from S3"
336
+
);
337
+
}
338
+
return (
339
+
StatusCode::INTERNAL_SERVER_ERROR,
340
+
Json(json!({"error": "InternalError", "message": "Failed to record backup"})),
341
+
)
342
+
.into_response();
343
+
}
344
+
};
345
+
346
+
info!(
347
+
did = %user.did,
348
+
rev = %repo_rev,
349
+
size_bytes,
350
+
"Created manual backup"
351
+
);
352
+
353
+
let retention = BackupStorage::retention_count();
354
+
if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await {
355
+
warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup");
356
+
}
357
+
358
+
(
359
+
StatusCode::OK,
360
+
Json(CreateBackupOutput {
361
+
id: backup_id.to_string(),
362
+
repo_rev,
363
+
size_bytes,
364
+
block_count,
365
+
}),
366
+
)
367
+
.into_response()
368
+
}
369
+
370
+
async fn cleanup_old_backups(
371
+
db: &sqlx::PgPool,
372
+
backup_storage: &BackupStorage,
373
+
user_id: uuid::Uuid,
374
+
retention_count: u32,
375
+
) -> Result<(), String> {
376
+
let old_backups = sqlx::query!(
377
+
r#"
378
+
SELECT id, storage_key
379
+
FROM account_backups
380
+
WHERE user_id = $1
381
+
ORDER BY created_at DESC
382
+
OFFSET $2
383
+
"#,
384
+
user_id,
385
+
retention_count as i64
386
+
)
387
+
.fetch_all(db)
388
+
.await
389
+
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
390
+
391
+
for backup in old_backups {
392
+
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
393
+
warn!(
394
+
storage_key = %backup.storage_key,
395
+
error = %e,
396
+
"Failed to delete old backup from storage, skipping DB cleanup to avoid orphan"
397
+
);
398
+
continue;
399
+
}
400
+
401
+
sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
402
+
.execute(db)
403
+
.await
404
+
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
405
+
}
406
+
407
+
Ok(())
408
+
}
409
+
410
+
#[derive(Deserialize)]
411
+
pub struct DeleteBackupQuery {
412
+
pub id: String,
413
+
}
414
+
415
+
pub async fn delete_backup(
416
+
State(state): State<AppState>,
417
+
auth: BearerAuth,
418
+
Query(query): Query<DeleteBackupQuery>,
419
+
) -> Response {
420
+
let backup_id = match uuid::Uuid::parse_str(&query.id) {
421
+
Ok(id) => id,
422
+
Err(_) => {
423
+
return (
424
+
StatusCode::BAD_REQUEST,
425
+
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
426
+
)
427
+
.into_response();
428
+
}
429
+
};
430
+
431
+
let backup = match sqlx::query!(
432
+
r#"
433
+
SELECT ab.id, ab.storage_key, u.deactivated_at
434
+
FROM account_backups ab
435
+
JOIN users u ON u.id = ab.user_id
436
+
WHERE ab.id = $1 AND u.did = $2
437
+
"#,
438
+
backup_id,
439
+
auth.0.did
440
+
)
441
+
.fetch_optional(&state.db)
442
+
.await
443
+
{
444
+
Ok(Some(b)) => b,
445
+
Ok(None) => {
446
+
return (
447
+
StatusCode::NOT_FOUND,
448
+
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
449
+
)
450
+
.into_response();
451
+
}
452
+
Err(e) => {
453
+
error!("DB error fetching backup: {:?}", e);
454
+
return (
455
+
StatusCode::INTERNAL_SERVER_ERROR,
456
+
Json(json!({"error": "InternalError", "message": "Database error"})),
457
+
)
458
+
.into_response();
459
+
}
460
+
};
461
+
462
+
if backup.deactivated_at.is_some() {
463
+
return (
464
+
StatusCode::BAD_REQUEST,
465
+
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
466
+
)
467
+
.into_response();
468
+
}
469
+
470
+
if let Some(backup_storage) = state.backup_storage.as_ref()
471
+
&& let Err(e) = backup_storage.delete_backup(&backup.storage_key).await
472
+
{
473
+
warn!(
474
+
storage_key = %backup.storage_key,
475
+
error = %e,
476
+
"Failed to delete backup from storage (continuing anyway)"
477
+
);
478
+
}
479
+
480
+
if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
481
+
.execute(&state.db)
482
+
.await
483
+
{
484
+
error!("DB error deleting backup: {:?}", e);
485
+
return (
486
+
StatusCode::INTERNAL_SERVER_ERROR,
487
+
Json(json!({"error": "InternalError", "message": "Failed to delete backup"})),
488
+
)
489
+
.into_response();
490
+
}
491
+
492
+
info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup");
493
+
494
+
(StatusCode::OK, Json(json!({}))).into_response()
495
+
}
496
+
497
+
#[derive(Deserialize)]
498
+
#[serde(rename_all = "camelCase")]
499
+
pub struct SetBackupEnabledInput {
500
+
pub enabled: bool,
501
+
}
502
+
503
+
pub async fn set_backup_enabled(
504
+
State(state): State<AppState>,
505
+
auth: BearerAuth,
506
+
Json(input): Json<SetBackupEnabledInput>,
507
+
) -> Response {
508
+
let user = match sqlx::query!(
509
+
"SELECT deactivated_at FROM users WHERE did = $1",
510
+
auth.0.did
511
+
)
512
+
.fetch_optional(&state.db)
513
+
.await
514
+
{
515
+
Ok(Some(u)) => u,
516
+
Ok(None) => {
517
+
return (
518
+
StatusCode::NOT_FOUND,
519
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
520
+
)
521
+
.into_response();
522
+
}
523
+
Err(e) => {
524
+
error!("DB error fetching user: {:?}", e);
525
+
return (
526
+
StatusCode::INTERNAL_SERVER_ERROR,
527
+
Json(json!({"error": "InternalError", "message": "Database error"})),
528
+
)
529
+
.into_response();
530
+
}
531
+
};
532
+
533
+
if user.deactivated_at.is_some() {
534
+
return (
535
+
StatusCode::BAD_REQUEST,
536
+
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
537
+
)
538
+
.into_response();
539
+
}
540
+
541
+
if let Err(e) = sqlx::query!(
542
+
"UPDATE users SET backup_enabled = $1 WHERE did = $2",
543
+
input.enabled,
544
+
auth.0.did
545
+
)
546
+
.execute(&state.db)
547
+
.await
548
+
{
549
+
error!("DB error updating backup_enabled: {:?}", e);
550
+
return (
551
+
StatusCode::INTERNAL_SERVER_ERROR,
552
+
Json(json!({"error": "InternalError", "message": "Failed to update setting"})),
553
+
)
554
+
.into_response();
555
+
}
556
+
557
+
info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting");
558
+
559
+
(StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response()
560
+
}
561
+
562
+
pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response {
563
+
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did)
564
+
.fetch_optional(&state.db)
565
+
.await
566
+
{
567
+
Ok(Some(u)) => u,
568
+
Ok(None) => {
569
+
return (
570
+
StatusCode::NOT_FOUND,
571
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
572
+
)
573
+
.into_response();
574
+
}
575
+
Err(e) => {
576
+
error!("DB error fetching user: {:?}", e);
577
+
return (
578
+
StatusCode::INTERNAL_SERVER_ERROR,
579
+
Json(json!({"error": "InternalError", "message": "Database error"})),
580
+
)
581
+
.into_response();
582
+
}
583
+
};
584
+
585
+
let blobs = match sqlx::query!(
586
+
r#"
587
+
SELECT DISTINCT b.cid, b.storage_key, b.mime_type
588
+
FROM blobs b
589
+
JOIN record_blobs rb ON rb.blob_cid = b.cid
590
+
WHERE rb.repo_id = $1
591
+
"#,
592
+
user.id
593
+
)
594
+
.fetch_all(&state.db)
595
+
.await
596
+
{
597
+
Ok(rows) => rows,
598
+
Err(e) => {
599
+
error!("DB error fetching blobs: {:?}", e);
600
+
return (
601
+
StatusCode::INTERNAL_SERVER_ERROR,
602
+
Json(json!({"error": "InternalError", "message": "Database error"})),
603
+
)
604
+
.into_response();
605
+
}
606
+
};
607
+
608
+
if blobs.is_empty() {
609
+
return (
610
+
StatusCode::OK,
611
+
[
612
+
(axum::http::header::CONTENT_TYPE, "application/zip"),
613
+
(
614
+
axum::http::header::CONTENT_DISPOSITION,
615
+
"attachment; filename=\"blobs.zip\"",
616
+
),
617
+
],
618
+
Vec::<u8>::new(),
619
+
)
620
+
.into_response();
621
+
}
622
+
623
+
let mut zip_buffer = std::io::Cursor::new(Vec::new());
624
+
{
625
+
let mut zip = zip::ZipWriter::new(&mut zip_buffer);
626
+
627
+
let options = zip::write::SimpleFileOptions::default()
628
+
.compression_method(zip::CompressionMethod::Deflated);
629
+
630
+
let mut exported: Vec<serde_json::Value> = Vec::new();
631
+
let mut skipped: Vec<serde_json::Value> = Vec::new();
632
+
633
+
for blob in &blobs {
634
+
let blob_data = match state.blob_store.get(&blob.storage_key).await {
635
+
Ok(data) => data,
636
+
Err(e) => {
637
+
warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping");
638
+
skipped.push(json!({
639
+
"cid": blob.cid,
640
+
"mimeType": blob.mime_type,
641
+
"reason": "fetch_failed"
642
+
}));
643
+
continue;
644
+
}
645
+
};
646
+
647
+
let extension = mime_to_extension(&blob.mime_type);
648
+
let filename = format!("{}{}", blob.cid, extension);
649
+
650
+
if let Err(e) = zip.start_file(&filename, options) {
651
+
warn!(filename = %filename, error = %e, "Failed to start zip file entry");
652
+
skipped.push(json!({
653
+
"cid": blob.cid,
654
+
"mimeType": blob.mime_type,
655
+
"reason": "zip_entry_failed"
656
+
}));
657
+
continue;
658
+
}
659
+
660
+
if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) {
661
+
warn!(filename = %filename, error = %e, "Failed to write blob to zip");
662
+
skipped.push(json!({
663
+
"cid": blob.cid,
664
+
"mimeType": blob.mime_type,
665
+
"reason": "write_failed"
666
+
}));
667
+
continue;
668
+
}
669
+
670
+
exported.push(json!({
671
+
"cid": blob.cid,
672
+
"filename": filename,
673
+
"mimeType": blob.mime_type,
674
+
"sizeBytes": blob_data.len()
675
+
}));
676
+
}
677
+
678
+
let manifest = json!({
679
+
"exportedAt": chrono::Utc::now().to_rfc3339(),
680
+
"totalBlobs": blobs.len(),
681
+
"exportedCount": exported.len(),
682
+
"skippedCount": skipped.len(),
683
+
"exported": exported,
684
+
"skipped": skipped
685
+
});
686
+
687
+
if zip.start_file("manifest.json", options).is_ok() {
688
+
let _ = std::io::Write::write_all(
689
+
&mut zip,
690
+
serde_json::to_string_pretty(&manifest)
691
+
.unwrap_or_else(|_| "{}".to_string())
692
+
.as_bytes(),
693
+
);
694
+
}
695
+
696
+
if let Err(e) = zip.finish() {
697
+
error!("Failed to finish zip: {:?}", e);
698
+
return (
699
+
StatusCode::INTERNAL_SERVER_ERROR,
700
+
Json(json!({"error": "InternalError", "message": "Failed to create zip file"})),
701
+
)
702
+
.into_response();
703
+
}
704
+
}
705
+
706
+
let zip_bytes = zip_buffer.into_inner();
707
+
708
+
info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs");
709
+
710
+
(
711
+
StatusCode::OK,
712
+
[
713
+
(axum::http::header::CONTENT_TYPE, "application/zip"),
714
+
(
715
+
axum::http::header::CONTENT_DISPOSITION,
716
+
"attachment; filename=\"blobs.zip\"",
717
+
),
718
+
],
719
+
zip_bytes,
720
+
)
721
+
.into_response()
722
+
}
723
+
724
+
fn mime_to_extension(mime_type: &str) -> &'static str {
725
+
match mime_type {
726
+
"application/font-sfnt" => ".otf",
727
+
"application/font-tdpfr" => ".pfr",
728
+
"application/font-woff" => ".woff",
729
+
"application/gzip" => ".gz",
730
+
"application/json" => ".json",
731
+
"application/json5" => ".json5",
732
+
"application/jsonml+json" => ".jsonml",
733
+
"application/octet-stream" => ".bin",
734
+
"application/pdf" => ".pdf",
735
+
"application/zip" => ".zip",
736
+
"audio/aac" => ".aac",
737
+
"audio/ac3" => ".ac3",
738
+
"audio/aiff" => ".aiff",
739
+
"audio/annodex" => ".axa",
740
+
"audio/audible" => ".aa",
741
+
"audio/basic" => ".au",
742
+
"audio/flac" => ".flac",
743
+
"audio/m4a" => ".m4a",
744
+
"audio/m4b" => ".m4b",
745
+
"audio/m4p" => ".m4p",
746
+
"audio/mid" => ".mid",
747
+
"audio/midi" => ".midi",
748
+
"audio/mp4" => ".mp4a",
749
+
"audio/mpeg" => ".mp3",
750
+
"audio/ogg" => ".ogg",
751
+
"audio/s3m" => ".s3m",
752
+
"audio/scpls" => ".pls",
753
+
"audio/silk" => ".sil",
754
+
"audio/vnd.audible.aax" => ".aax",
755
+
"audio/vnd.dece.audio" => ".uva",
756
+
"audio/vnd.digital-winds" => ".eol",
757
+
"audio/vnd.dlna.adts" => ".adt",
758
+
"audio/vnd.dra" => ".dra",
759
+
"audio/vnd.dts" => ".dts",
760
+
"audio/vnd.dts.hd" => ".dtshd",
761
+
"audio/vnd.lucent.voice" => ".lvp",
762
+
"audio/vnd.ms-playready.media.pya" => ".pya",
763
+
"audio/vnd.nuera.ecelp4800" => ".ecelp4800",
764
+
"audio/vnd.nuera.ecelp7470" => ".ecelp7470",
765
+
"audio/vnd.nuera.ecelp9600" => ".ecelp9600",
766
+
"audio/vnd.rip" => ".rip",
767
+
"audio/wav" => ".wav",
768
+
"audio/webm" => ".weba",
769
+
"audio/x-caf" => ".caf",
770
+
"audio/x-gsm" => ".gsm",
771
+
"audio/x-m4r" => ".m4r",
772
+
"audio/x-matroska" => ".mka",
773
+
"audio/x-mpegurl" => ".m3u",
774
+
"audio/x-ms-wax" => ".wax",
775
+
"audio/x-ms-wma" => ".wma",
776
+
"audio/x-pn-realaudio" => ".ra",
777
+
"audio/x-pn-realaudio-plugin" => ".rpm",
778
+
"audio/x-sd2" => ".sd2",
779
+
"audio/x-smd" => ".smd",
780
+
"audio/xm" => ".xm",
781
+
"font/collection" => ".ttc",
782
+
"font/ttf" => ".ttf",
783
+
"font/woff" => ".woff",
784
+
"font/woff2" => ".woff2",
785
+
"image/apng" => ".apng",
786
+
"image/avif" => ".avif",
787
+
"image/avif-sequence" => ".avifs",
788
+
"image/bmp" => ".bmp",
789
+
"image/cgm" => ".cgm",
790
+
"image/cis-cod" => ".cod",
791
+
"image/g3fax" => ".g3",
792
+
"image/gif" => ".gif",
793
+
"image/heic" => ".heic",
794
+
"image/heic-sequence" => ".heics",
795
+
"image/heif" => ".heif",
796
+
"image/heif-sequence" => ".heifs",
797
+
"image/ief" => ".ief",
798
+
"image/jp2" => ".jp2",
799
+
"image/jpeg" => ".jpg",
800
+
"image/jpm" => ".jpm",
801
+
"image/jpx" => ".jpf",
802
+
"image/jxl" => ".jxl",
803
+
"image/ktx" => ".ktx",
804
+
"image/pict" => ".pct",
805
+
"image/png" => ".png",
806
+
"image/prs.btif" => ".btif",
807
+
"image/qoi" => ".qoi",
808
+
"image/sgi" => ".sgi",
809
+
"image/svg+xml" => ".svg",
810
+
"image/tiff" => ".tiff",
811
+
"image/vnd.dece.graphic" => ".uvg",
812
+
"image/vnd.djvu" => ".djv",
813
+
"image/vnd.fastbidsheet" => ".fbs",
814
+
"image/vnd.fpx" => ".fpx",
815
+
"image/vnd.fst" => ".fst",
816
+
"image/vnd.fujixerox.edmics-mmr" => ".mmr",
817
+
"image/vnd.fujixerox.edmics-rlc" => ".rlc",
818
+
"image/vnd.ms-modi" => ".mdi",
819
+
"image/vnd.ms-photo" => ".wdp",
820
+
"image/vnd.net-fpx" => ".npx",
821
+
"image/vnd.radiance" => ".hdr",
822
+
"image/vnd.rn-realflash" => ".rf",
823
+
"image/vnd.wap.wbmp" => ".wbmp",
824
+
"image/vnd.xiff" => ".xif",
825
+
"image/webp" => ".webp",
826
+
"image/x-3ds" => ".3ds",
827
+
"image/x-adobe-dng" => ".dng",
828
+
"image/x-canon-cr2" => ".cr2",
829
+
"image/x-canon-cr3" => ".cr3",
830
+
"image/x-canon-crw" => ".crw",
831
+
"image/x-cmu-raster" => ".ras",
832
+
"image/x-cmx" => ".cmx",
833
+
"image/x-epson-erf" => ".erf",
834
+
"image/x-freehand" => ".fh",
835
+
"image/x-fuji-raf" => ".raf",
836
+
"image/x-icon" => ".ico",
837
+
"image/x-jg" => ".art",
838
+
"image/x-jng" => ".jng",
839
+
"image/x-kodak-dcr" => ".dcr",
840
+
"image/x-kodak-k25" => ".k25",
841
+
"image/x-kodak-kdc" => ".kdc",
842
+
"image/x-macpaint" => ".mac",
843
+
"image/x-minolta-mrw" => ".mrw",
844
+
"image/x-mrsid-image" => ".sid",
845
+
"image/x-nikon-nef" => ".nef",
846
+
"image/x-nikon-nrw" => ".nrw",
847
+
"image/x-olympus-orf" => ".orf",
848
+
"image/x-panasonic-rw" => ".raw",
849
+
"image/x-panasonic-rw2" => ".rw2",
850
+
"image/x-pentax-pef" => ".pef",
851
+
"image/x-portable-anymap" => ".pnm",
852
+
"image/x-portable-bitmap" => ".pbm",
853
+
"image/x-portable-graymap" => ".pgm",
854
+
"image/x-portable-pixmap" => ".ppm",
855
+
"image/x-qoi" => ".qoi",
856
+
"image/x-quicktime" => ".qti",
857
+
"image/x-rgb" => ".rgb",
858
+
"image/x-sigma-x3f" => ".x3f",
859
+
"image/x-sony-arw" => ".arw",
860
+
"image/x-sony-sr2" => ".sr2",
861
+
"image/x-sony-srf" => ".srf",
862
+
"image/x-tga" => ".tga",
863
+
"image/x-xbitmap" => ".xbm",
864
+
"image/x-xcf" => ".xcf",
865
+
"image/x-xpixmap" => ".xpm",
866
+
"image/x-xwindowdump" => ".xwd",
867
+
"model/gltf+json" => ".gltf",
868
+
"model/gltf-binary" => ".glb",
869
+
"model/iges" => ".igs",
870
+
"model/mesh" => ".msh",
871
+
"model/vnd.collada+xml" => ".dae",
872
+
"model/vnd.gdl" => ".gdl",
873
+
"model/vnd.gtw" => ".gtw",
874
+
"model/vnd.vtu" => ".vtu",
875
+
"model/vrml" => ".vrml",
876
+
"model/x3d+binary" => ".x3db",
877
+
"model/x3d+vrml" => ".x3dv",
878
+
"model/x3d+xml" => ".x3d",
879
+
"text/css" => ".css",
880
+
"text/html" => ".html",
881
+
"text/plain" => ".txt",
882
+
"video/3gpp" => ".3gp",
883
+
"video/3gpp2" => ".3g2",
884
+
"video/annodex" => ".axv",
885
+
"video/divx" => ".divx",
886
+
"video/h261" => ".h261",
887
+
"video/h263" => ".h263",
888
+
"video/h264" => ".h264",
889
+
"video/jpeg" => ".jpgv",
890
+
"video/jpm" => ".jpgm",
891
+
"video/mj2" => ".mj2",
892
+
"video/mp4" => ".mp4",
893
+
"video/mpeg" => ".mpg",
894
+
"video/ogg" => ".ogv",
895
+
"video/quicktime" => ".mov",
896
+
"video/vnd.dece.hd" => ".uvh",
897
+
"video/vnd.dece.mobile" => ".uvm",
898
+
"video/vnd.dece.pd" => ".uvp",
899
+
"video/vnd.dece.sd" => ".uvs",
900
+
"video/vnd.dece.video" => ".uvv",
901
+
"video/vnd.dlna.mpeg-tts" => ".ts",
902
+
"video/vnd.dvb.file" => ".dvb",
903
+
"video/vnd.fvt" => ".fvt",
904
+
"video/vnd.mpegurl" => ".m4u",
905
+
"video/vnd.ms-playready.media.pyv" => ".pyv",
906
+
"video/vnd.uvvu.mp4" => ".uvu",
907
+
"video/vnd.vivo" => ".viv",
908
+
"video/webm" => ".webm",
909
+
"video/x-dv" => ".dv",
910
+
"video/x-f4v" => ".f4v",
911
+
"video/x-fli" => ".fli",
912
+
"video/x-flv" => ".flv",
913
+
"video/x-ivf" => ".ivf",
914
+
"video/x-la-asf" => ".lsf",
915
+
"video/x-m4v" => ".m4v",
916
+
"video/x-matroska" => ".mkv",
917
+
"video/x-mng" => ".mng",
918
+
"video/x-ms-asf" => ".asf",
919
+
"video/x-ms-vob" => ".vob",
920
+
"video/x-ms-wm" => ".wm",
921
+
"video/x-ms-wmp" => ".wmp",
922
+
"video/x-ms-wmv" => ".wmv",
923
+
"video/x-ms-wmx" => ".wmx",
924
+
"video/x-ms-wvx" => ".wvx",
925
+
"video/x-msvideo" => ".avi",
926
+
"video/x-sgi-movie" => ".movie",
927
+
"video/x-smv" => ".smv",
928
+
_ => ".bin",
929
+
}
930
+
}
+1
src/api/mod.rs
+1
src/api/mod.rs
+26
-7
src/api/notification_prefs.rs
+26
-7
src/api/notification_prefs.rs
···
182
.into_response(),
183
};
184
185
let notifications = rows
186
.iter()
187
-
.map(|row| NotificationHistoryEntry {
188
-
created_at: row.created_at.to_rfc3339(),
189
-
channel: row.channel.clone(),
190
-
comms_type: row.comms_type.clone(),
191
-
status: row.status.clone(),
192
-
subject: row.subject.clone(),
193
-
body: row.body.clone(),
194
})
195
.collect();
196
···
182
.into_response(),
183
};
184
185
+
let sensitive_types = [
186
+
"email_verification",
187
+
"password_reset",
188
+
"email_update",
189
+
"two_factor_code",
190
+
"passkey_recovery",
191
+
"migration_verification",
192
+
"plc_operation",
193
+
"channel_verification",
194
+
"signup_verification",
195
+
];
196
+
197
let notifications = rows
198
.iter()
199
+
.map(|row| {
200
+
let body = if sensitive_types.contains(&row.comms_type.as_str()) {
201
+
"[Code redacted for security]".to_string()
202
+
} else {
203
+
row.body.clone()
204
+
};
205
+
NotificationHistoryEntry {
206
+
created_at: row.created_at.to_rfc3339(),
207
+
channel: row.channel.clone(),
208
+
comms_type: row.comms_type.clone(),
209
+
status: row.status.clone(),
210
+
subject: row.subject.clone(),
211
+
body,
212
+
}
213
})
214
.collect();
215
+1
-1
src/api/repo/blob.rs
+1
-1
src/api/repo/blob.rs
+4
-2
src/api/repo/record/batch.rs
+4
-2
src/api/repo/record/batch.rs
···
345
let rkey = rkey
346
.clone()
347
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
348
let mut record_bytes = Vec::new();
349
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
350
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
351
}
352
let record_cid = match tracking_store.put(&record_bytes).await {
···
409
}
410
};
411
all_blob_cids.extend(extract_blob_cids(value));
412
let mut record_bytes = Vec::new();
413
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
414
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
415
}
416
let record_cid = match tracking_store.put(&record_bytes).await {
···
345
let rkey = rkey
346
.clone()
347
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
348
+
let record_ipld = crate::util::json_to_ipld(value);
349
let mut record_bytes = Vec::new();
350
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
351
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
352
}
353
let record_cid = match tracking_store.put(&record_bytes).await {
···
410
}
411
};
412
all_blob_cids.extend(extract_blob_cids(value));
413
+
let record_ipld = crate::util::json_to_ipld(value);
414
let mut record_bytes = Vec::new();
415
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
416
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
417
}
418
let record_cid = match tracking_store.put(&record_bytes).await {
+2
-1
src/api/repo/record/utils.rs
+2
-1
src/api/repo/record/utils.rs
···
382
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
383
.map_err(|e| format!("Failed to parse commit: {:?}", e))?;
384
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
385
let mut record_bytes = Vec::new();
386
-
serde_ipld_dagcbor::to_writer(&mut record_bytes, record)
387
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
388
let record_cid = tracking_store
389
.put(&record_bytes)
···
382
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
383
.map_err(|e| format!("Failed to parse commit: {:?}", e))?;
384
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
385
+
let record_ipld = crate::util::json_to_ipld(record);
386
let mut record_bytes = Vec::new();
387
+
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
388
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
389
let record_cid = tracking_store
390
.put(&record_bytes)
+4
-2
src/api/repo/record/write.rs
+4
-2
src/api/repo/record/write.rs
···
297
let rkey = input
298
.rkey
299
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
300
let mut record_bytes = Vec::new();
301
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
302
return (
303
StatusCode::BAD_REQUEST,
304
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
···
550
}
551
}
552
let existing_cid = mst.get(&key).await.ok().flatten();
553
let mut record_bytes = Vec::new();
554
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
555
return (
556
StatusCode::BAD_REQUEST,
557
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
···
297
let rkey = input
298
.rkey
299
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
300
+
let record_ipld = crate::util::json_to_ipld(&input.record);
301
let mut record_bytes = Vec::new();
302
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
303
return (
304
StatusCode::BAD_REQUEST,
305
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
···
551
}
552
}
553
let existing_cid = mst.get(&key).await.ok().flatten();
554
+
let record_ipld = crate::util::json_to_ipld(&input.record);
555
let mut record_bytes = Vec::new();
556
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
557
return (
558
StatusCode::BAD_REQUEST,
559
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+15
-44
src/api/server/account_status.rs
+15
-44
src/api/server/account_status.rs
···
567
#[serde(rename_all = "camelCase")]
568
pub struct DeactivateAccountInput {
569
pub delete_after: Option<String>,
570
-
pub migrating_to: Option<String>,
571
}
572
573
pub async fn deactivate_account(
···
618
619
let did = auth_user.did;
620
621
-
let migrating_to = if let Some(ref url) = input.migrating_to {
622
-
let url = url.trim().trim_end_matches('/');
623
-
if url.is_empty() || !did.starts_with("did:web:") {
624
-
None
625
-
} else {
626
-
if !url.starts_with("https://") {
627
-
return ApiError::InvalidRequest("migratingTo must start with https://".into())
628
-
.into_response();
629
-
}
630
-
Some(url.to_string())
631
-
}
632
-
} else {
633
-
None
634
-
};
635
-
636
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
637
.fetch_optional(&state.db)
638
.await
639
.ok()
640
.flatten();
641
642
-
let result = if let Some(ref pds_url) = migrating_to {
643
-
sqlx::query!(
644
-
"UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
645
-
did,
646
-
delete_after,
647
-
pds_url
648
-
)
649
-
.execute(&state.db)
650
-
.await
651
-
} else {
652
-
sqlx::query!(
653
-
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
654
-
did,
655
-
delete_after
656
-
)
657
-
.execute(&state.db)
658
-
.await
659
-
};
660
-
661
-
let status = if migrating_to.is_some() {
662
-
"migrated"
663
-
} else {
664
-
"deactivated"
665
-
};
666
667
match result {
668
Ok(_) => {
669
if let Some(ref h) = handle {
670
let _ = state.cache.delete(&format!("handle:{}", h)).await;
671
}
672
-
if let Err(e) =
673
-
crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status))
674
-
.await
675
{
676
-
warn!("Failed to sequence account {} event: {}", status, e);
677
}
678
(StatusCode::OK, Json(json!({}))).into_response()
679
}
···
567
#[serde(rename_all = "camelCase")]
568
pub struct DeactivateAccountInput {
569
pub delete_after: Option<String>,
570
}
571
572
pub async fn deactivate_account(
···
617
618
let did = auth_user.did;
619
620
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
621
.fetch_optional(&state.db)
622
.await
623
.ok()
624
.flatten();
625
626
+
let result = sqlx::query!(
627
+
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
628
+
did,
629
+
delete_after
630
+
)
631
+
.execute(&state.db)
632
+
.await;
633
634
match result {
635
Ok(_) => {
636
if let Some(ref h) = handle {
637
let _ = state.cache.delete(&format!("handle:{}", h)).await;
638
}
639
+
if let Err(e) = crate::api::repo::record::sequence_account_event(
640
+
&state,
641
+
&did,
642
+
false,
643
+
Some("deactivated"),
644
+
)
645
+
.await
646
{
647
+
warn!("Failed to sequence account deactivated event: {}", e);
648
}
649
(StatusCode::OK, Json(json!({}))).into_response()
650
}
+54
src/api/server/email.rs
+54
src/api/server/email.rs
···
476
info!("Email updated for user {}", user_id);
477
(StatusCode::OK, Json(json!({}))).into_response()
478
}
479
+
480
+
#[derive(Deserialize)]
481
+
pub struct CheckEmailVerifiedInput {
482
+
pub identifier: String,
483
+
}
484
+
485
+
pub async fn check_email_verified(
486
+
State(state): State<AppState>,
487
+
headers: axum::http::HeaderMap,
488
+
Json(input): Json<CheckEmailVerifiedInput>,
489
+
) -> Response {
490
+
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
491
+
if !state
492
+
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
493
+
.await
494
+
{
495
+
return (
496
+
StatusCode::TOO_MANY_REQUESTS,
497
+
Json(json!({
498
+
"error": "RateLimitExceeded",
499
+
"message": "Too many requests. Please try again later."
500
+
})),
501
+
)
502
+
.into_response();
503
+
}
504
+
505
+
let user = sqlx::query!(
506
+
"SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
507
+
input.identifier
508
+
)
509
+
.fetch_optional(&state.db)
510
+
.await;
511
+
512
+
match user {
513
+
Ok(Some(row)) => (
514
+
StatusCode::OK,
515
+
Json(json!({ "verified": row.email_verified })),
516
+
)
517
+
.into_response(),
518
+
Ok(None) => (
519
+
StatusCode::NOT_FOUND,
520
+
Json(json!({ "error": "AccountNotFound", "message": "Account not found" })),
521
+
)
522
+
.into_response(),
523
+
Err(e) => {
524
+
error!("DB error checking email verified: {:?}", e);
525
+
(
526
+
StatusCode::INTERNAL_SERVER_ERROR,
527
+
Json(json!({ "error": "InternalError" })),
528
+
)
529
+
.into_response()
530
+
}
531
+
}
532
+
}
+6
-241
src/api/server/migration.rs
+6
-241
src/api/server/migration.rs
···
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
-
use chrono::{DateTime, Utc};
10
use serde::{Deserialize, Serialize};
11
use serde_json::json;
12
13
-
#[derive(Serialize)]
14
-
#[serde(rename_all = "camelCase")]
15
-
pub struct GetMigrationStatusOutput {
16
-
pub did: String,
17
-
pub did_type: String,
18
-
pub migrated: bool,
19
-
#[serde(skip_serializing_if = "Option::is_none")]
20
-
pub migrated_to_pds: Option<String>,
21
-
#[serde(skip_serializing_if = "Option::is_none")]
22
-
pub migrated_at: Option<DateTime<Utc>>,
23
-
}
24
-
25
-
pub async fn get_migration_status(
26
-
State(state): State<AppState>,
27
-
headers: axum::http::HeaderMap,
28
-
) -> Response {
29
-
let extracted = match crate::auth::extract_auth_token_from_header(
30
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
31
-
) {
32
-
Some(t) => t,
33
-
None => return ApiError::AuthenticationRequired.into_response(),
34
-
};
35
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
36
-
let http_uri = format!(
37
-
"https://{}/xrpc/com.tranquil.account.getMigrationStatus",
38
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
39
-
);
40
-
let auth_user = match crate::auth::validate_token_with_dpop(
41
-
&state.db,
42
-
&extracted.token,
43
-
extracted.is_dpop,
44
-
dpop_proof,
45
-
"GET",
46
-
&http_uri,
47
-
true,
48
-
)
49
-
.await
50
-
{
51
-
Ok(user) => user,
52
-
Err(e) => return ApiError::from(e).into_response(),
53
-
};
54
-
let user = match sqlx::query!(
55
-
"SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
56
-
auth_user.did
57
-
)
58
-
.fetch_optional(&state.db)
59
-
.await
60
-
{
61
-
Ok(Some(row)) => row,
62
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
63
-
Err(e) => {
64
-
tracing::error!("DB error getting migration status: {:?}", e);
65
-
return ApiError::InternalError.into_response();
66
-
}
67
-
};
68
-
let did_type = if user.did.starts_with("did:plc:") {
69
-
"plc"
70
-
} else if user.did.starts_with("did:web:") {
71
-
"web"
72
-
} else {
73
-
"unknown"
74
-
};
75
-
let migrated = user.migrated_to_pds.is_some();
76
-
(
77
-
StatusCode::OK,
78
-
Json(GetMigrationStatusOutput {
79
-
did: user.did,
80
-
did_type: did_type.to_string(),
81
-
migrated,
82
-
migrated_to_pds: user.migrated_to_pds,
83
-
migrated_at: user.migrated_at,
84
-
}),
85
-
)
86
-
.into_response()
87
-
}
88
-
89
-
#[derive(Deserialize)]
90
-
#[serde(rename_all = "camelCase")]
91
-
pub struct UpdateMigrationForwardingInput {
92
-
pub pds_url: String,
93
-
}
94
-
95
-
#[derive(Serialize)]
96
-
#[serde(rename_all = "camelCase")]
97
-
pub struct UpdateMigrationForwardingOutput {
98
-
pub success: bool,
99
-
pub migrated_to_pds: String,
100
-
pub migrated_at: DateTime<Utc>,
101
-
}
102
-
103
-
pub async fn update_migration_forwarding(
104
-
State(state): State<AppState>,
105
-
headers: axum::http::HeaderMap,
106
-
Json(input): Json<UpdateMigrationForwardingInput>,
107
-
) -> Response {
108
-
let extracted = match crate::auth::extract_auth_token_from_header(
109
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
110
-
) {
111
-
Some(t) => t,
112
-
None => return ApiError::AuthenticationRequired.into_response(),
113
-
};
114
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
115
-
let http_uri = format!(
116
-
"https://{}/xrpc/com.tranquil.account.updateMigrationForwarding",
117
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
118
-
);
119
-
let auth_user = match crate::auth::validate_token_with_dpop(
120
-
&state.db,
121
-
&extracted.token,
122
-
extracted.is_dpop,
123
-
dpop_proof,
124
-
"POST",
125
-
&http_uri,
126
-
true,
127
-
)
128
-
.await
129
-
{
130
-
Ok(user) => user,
131
-
Err(e) => return ApiError::from(e).into_response(),
132
-
};
133
-
if !auth_user.did.starts_with("did:web:") {
134
-
return (
135
-
StatusCode::BAD_REQUEST,
136
-
Json(json!({
137
-
"error": "InvalidRequest",
138
-
"message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates."
139
-
})),
140
-
)
141
-
.into_response();
142
-
}
143
-
let pds_url = input.pds_url.trim();
144
-
if pds_url.is_empty() {
145
-
return ApiError::InvalidRequest("pds_url is required".into()).into_response();
146
-
}
147
-
if !pds_url.starts_with("https://") {
148
-
return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response();
149
-
}
150
-
let pds_url_clean = pds_url.trim_end_matches('/');
151
-
let now = Utc::now();
152
-
let result = sqlx::query!(
153
-
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
154
-
pds_url_clean,
155
-
now,
156
-
auth_user.did
157
-
)
158
-
.execute(&state.db)
159
-
.await;
160
-
match result {
161
-
Ok(_) => {
162
-
tracing::info!(
163
-
"Updated migration forwarding for {} to {}",
164
-
auth_user.did,
165
-
pds_url_clean
166
-
);
167
-
(
168
-
StatusCode::OK,
169
-
Json(UpdateMigrationForwardingOutput {
170
-
success: true,
171
-
migrated_to_pds: pds_url_clean.to_string(),
172
-
migrated_at: now,
173
-
}),
174
-
)
175
-
.into_response()
176
-
}
177
-
Err(e) => {
178
-
tracing::error!("DB error updating migration forwarding: {:?}", e);
179
-
ApiError::InternalError.into_response()
180
-
}
181
-
}
182
-
}
183
-
184
-
pub async fn clear_migration_forwarding(
185
-
State(state): State<AppState>,
186
-
headers: axum::http::HeaderMap,
187
-
) -> Response {
188
-
let extracted = match crate::auth::extract_auth_token_from_header(
189
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
190
-
) {
191
-
Some(t) => t,
192
-
None => return ApiError::AuthenticationRequired.into_response(),
193
-
};
194
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
195
-
let http_uri = format!(
196
-
"https://{}/xrpc/com.tranquil.account.clearMigrationForwarding",
197
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
198
-
);
199
-
let auth_user = match crate::auth::validate_token_with_dpop(
200
-
&state.db,
201
-
&extracted.token,
202
-
extracted.is_dpop,
203
-
dpop_proof,
204
-
"POST",
205
-
&http_uri,
206
-
true,
207
-
)
208
-
.await
209
-
{
210
-
Ok(user) => user,
211
-
Err(e) => return ApiError::from(e).into_response(),
212
-
};
213
-
if !auth_user.did.starts_with("did:web:") {
214
-
return (
215
-
StatusCode::BAD_REQUEST,
216
-
Json(json!({
217
-
"error": "InvalidRequest",
218
-
"message": "Migration forwarding is only available for did:web accounts"
219
-
})),
220
-
)
221
-
.into_response();
222
-
}
223
-
let result = sqlx::query!(
224
-
"UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
225
-
auth_user.did
226
-
)
227
-
.execute(&state.db)
228
-
.await;
229
-
match result {
230
-
Ok(_) => {
231
-
tracing::info!("Cleared migration forwarding for {}", auth_user.did);
232
-
(StatusCode::OK, Json(json!({ "success": true }))).into_response()
233
-
}
234
-
Err(e) => {
235
-
tracing::error!("DB error clearing migration forwarding: {:?}", e);
236
-
ApiError::InternalError.into_response()
237
-
}
238
-
}
239
-
}
240
-
241
#[derive(Debug, Clone, Serialize, Deserialize)]
242
#[serde(rename_all = "camelCase")]
243
pub struct VerificationMethod {
···
275
};
276
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
277
let http_uri = format!(
278
-
"https://{}/xrpc/com.tranquil.account.updateDidDocument",
279
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
280
);
281
let auth_user = match crate::auth::validate_token_with_dpop(
···
305
}
306
307
let user = match sqlx::query!(
308
-
"SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
309
auth_user.did
310
)
311
.fetch_optional(&state.db)
···
319
}
320
};
321
322
-
if user.migrated_to_pds.is_none() {
323
-
return (
324
-
StatusCode::BAD_REQUEST,
325
-
Json(json!({
326
-
"error": "InvalidRequest",
327
-
"message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
328
-
})),
329
-
)
330
-
.into_response();
331
}
332
333
if let Some(ref methods) = input.verification_methods {
···
452
};
453
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
454
let http_uri = format!(
455
-
"https://{}/xrpc/com.tranquil.account.getDidDocument",
456
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
457
);
458
let auth_user = match crate::auth::validate_token_with_dpop(
···
6
http::StatusCode,
7
response::{IntoResponse, Response},
8
};
9
+
use chrono::Utc;
10
use serde::{Deserialize, Serialize};
11
use serde_json::json;
12
13
#[derive(Debug, Clone, Serialize, Deserialize)]
14
#[serde(rename_all = "camelCase")]
15
pub struct VerificationMethod {
···
47
};
48
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
49
let http_uri = format!(
50
+
"https://{}/xrpc/_account.updateDidDocument",
51
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
52
);
53
let auth_user = match crate::auth::validate_token_with_dpop(
···
77
}
78
79
let user = match sqlx::query!(
80
+
"SELECT id, handle, deactivated_at FROM users WHERE did = $1",
81
auth_user.did
82
)
83
.fetch_optional(&state.db)
···
91
}
92
};
93
94
+
if user.deactivated_at.is_some() {
95
+
return ApiError::AccountDeactivated.into_response();
96
}
97
98
if let Some(ref methods) = input.verification_methods {
···
217
};
218
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
219
let http_uri = format!(
220
+
"https://{}/xrpc/_account.getDidDocument",
221
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
222
);
223
let auth_user = match crate::auth::validate_token_with_dpop(
+2
-5
src/api/server/mod.rs
+2
-5
src/api/server/mod.rs
···
22
request_account_delete,
23
};
24
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
25
-
pub use email::{confirm_email, request_email_update, update_email};
26
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
27
pub use logo::get_logo;
28
pub use meta::{describe_server, health, robots_txt};
29
-
pub use migration::{
30
-
clear_migration_forwarding, get_did_document, get_migration_status, update_did_document,
31
-
update_migration_forwarding,
32
-
};
33
pub use passkey_account::{
34
complete_passkey_setup, create_passkey_account, recover_passkey_account,
35
request_passkey_recovery, start_passkey_registration_for_setup,
···
22
request_account_delete,
23
};
24
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
25
+
pub use email::{check_email_verified, confirm_email, request_email_update, update_email};
26
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
27
pub use logo::get_logo;
28
pub use meta::{describe_server, health, robots_txt};
29
+
pub use migration::{get_did_document, update_did_document};
30
pub use passkey_account::{
31
complete_passkey_setup, create_passkey_account, recover_passkey_account,
32
request_passkey_recovery, start_passkey_registration_for_setup,
+59
-55
src/lib.rs
+59
-55
src/lib.rs
···
57
get(api::server::get_session),
58
)
59
.route(
60
-
"/xrpc/com.tranquil.account.listSessions",
61
get(api::server::list_sessions),
62
)
63
.route(
64
-
"/xrpc/com.tranquil.account.revokeSession",
65
post(api::server::revoke_session),
66
)
67
.route(
68
-
"/xrpc/com.tranquil.account.revokeAllSessions",
69
post(api::server::revoke_all_sessions),
70
)
71
.route(
···
208
post(api::server::reset_password),
209
)
210
.route(
211
-
"/xrpc/com.tranquil.account.changePassword",
212
post(api::server::change_password),
213
)
214
.route(
215
-
"/xrpc/com.tranquil.account.removePassword",
216
post(api::server::remove_password),
217
)
218
.route(
219
-
"/xrpc/com.tranquil.account.getPasswordStatus",
220
get(api::server::get_password_status),
221
)
222
.route(
223
-
"/xrpc/com.tranquil.account.getReauthStatus",
224
get(api::server::get_reauth_status),
225
)
226
.route(
227
-
"/xrpc/com.tranquil.account.reauthPassword",
228
post(api::server::reauth_password),
229
)
230
-
.route(
231
-
"/xrpc/com.tranquil.account.reauthTotp",
232
-
post(api::server::reauth_totp),
233
-
)
234
.route(
235
-
"/xrpc/com.tranquil.account.reauthPasskeyStart",
236
post(api::server::reauth_passkey_start),
237
)
238
.route(
239
-
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
240
post(api::server::reauth_passkey_finish),
241
)
242
.route(
243
-
"/xrpc/com.tranquil.account.getLegacyLoginPreference",
244
get(api::server::get_legacy_login_preference),
245
)
246
.route(
247
-
"/xrpc/com.tranquil.account.updateLegacyLoginPreference",
248
post(api::server::update_legacy_login_preference),
249
)
250
.route(
251
-
"/xrpc/com.tranquil.account.updateLocale",
252
post(api::server::update_locale),
253
)
254
.route(
255
-
"/xrpc/com.tranquil.account.listTrustedDevices",
256
get(api::server::list_trusted_devices),
257
)
258
.route(
259
-
"/xrpc/com.tranquil.account.revokeTrustedDevice",
260
post(api::server::revoke_trusted_device),
261
)
262
.route(
263
-
"/xrpc/com.tranquil.account.updateTrustedDevice",
264
post(api::server::update_trusted_device),
265
)
266
.route(
267
-
"/xrpc/com.tranquil.account.createPasskeyAccount",
268
post(api::server::create_passkey_account),
269
)
270
.route(
271
-
"/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup",
272
post(api::server::start_passkey_registration_for_setup),
273
)
274
.route(
275
-
"/xrpc/com.tranquil.account.completePasskeySetup",
276
post(api::server::complete_passkey_setup),
277
)
278
.route(
279
-
"/xrpc/com.tranquil.account.requestPasskeyRecovery",
280
post(api::server::request_passkey_recovery),
281
)
282
.route(
283
-
"/xrpc/com.tranquil.account.recoverPasskeyAccount",
284
post(api::server::recover_passkey_account),
285
)
286
.route(
287
-
"/xrpc/com.tranquil.account.getMigrationStatus",
288
-
get(api::server::get_migration_status),
289
-
)
290
-
.route(
291
-
"/xrpc/com.tranquil.account.updateMigrationForwarding",
292
-
post(api::server::update_migration_forwarding),
293
-
)
294
-
.route(
295
-
"/xrpc/com.tranquil.account.clearMigrationForwarding",
296
-
post(api::server::clear_migration_forwarding),
297
-
)
298
-
.route(
299
-
"/xrpc/com.tranquil.account.updateDidDocument",
300
post(api::server::update_did_document),
301
)
302
.route(
303
-
"/xrpc/com.tranquil.account.getDidDocument",
304
get(api::server::get_did_document),
305
)
306
.route(
307
"/xrpc/com.atproto.server.requestEmailUpdate",
308
post(api::server::request_email_update),
309
)
310
.route(
311
"/xrpc/com.atproto.server.confirmEmail",
···
432
get(api::admin::get_invite_codes),
433
)
434
.route(
435
-
"/xrpc/com.tranquil.admin.getServerStats",
436
get(api::admin::get_server_stats),
437
)
438
.route(
439
-
"/xrpc/com.tranquil.server.getConfig",
440
get(api::admin::get_server_config),
441
)
442
.route(
443
-
"/xrpc/com.tranquil.admin.updateServerConfig",
444
post(api::admin::update_server_config),
445
)
446
.route(
···
575
post(api::temp::dereference_scope),
576
)
577
.route(
578
-
"/xrpc/com.tranquil.account.getNotificationPrefs",
579
get(api::notification_prefs::get_notification_prefs),
580
)
581
.route(
582
-
"/xrpc/com.tranquil.account.updateNotificationPrefs",
583
post(api::notification_prefs::update_notification_prefs),
584
)
585
.route(
586
-
"/xrpc/com.tranquil.account.getNotificationHistory",
587
get(api::notification_prefs::get_notification_history),
588
)
589
.route(
590
-
"/xrpc/com.tranquil.account.confirmChannelVerification",
591
post(api::verification::confirm_channel_verification),
592
)
593
.route(
594
-
"/xrpc/com.tranquil.account.verifyToken",
595
post(api::server::verify_token),
596
)
597
.route(
598
-
"/xrpc/com.tranquil.delegation.listControllers",
599
get(api::delegation::list_controllers),
600
)
601
.route(
602
-
"/xrpc/com.tranquil.delegation.addController",
603
post(api::delegation::add_controller),
604
)
605
.route(
606
-
"/xrpc/com.tranquil.delegation.removeController",
607
post(api::delegation::remove_controller),
608
)
609
.route(
610
-
"/xrpc/com.tranquil.delegation.updateControllerScopes",
611
post(api::delegation::update_controller_scopes),
612
)
613
.route(
614
-
"/xrpc/com.tranquil.delegation.listControlledAccounts",
615
get(api::delegation::list_controlled_accounts),
616
)
617
.route(
618
-
"/xrpc/com.tranquil.delegation.getAuditLog",
619
get(api::delegation::get_audit_log),
620
)
621
.route(
622
-
"/xrpc/com.tranquil.delegation.getScopePresets",
623
get(api::delegation::get_scope_presets),
624
)
625
.route(
626
-
"/xrpc/com.tranquil.delegation.createDelegatedAccount",
627
post(api::delegation::create_delegated_account),
628
)
629
.route(
630
"/xrpc/app.bsky.ageassurance.getState",
631
get(api::age_assurance::get_state),
···
57
get(api::server::get_session),
58
)
59
.route(
60
+
"/xrpc/_account.listSessions",
61
get(api::server::list_sessions),
62
)
63
.route(
64
+
"/xrpc/_account.revokeSession",
65
post(api::server::revoke_session),
66
)
67
.route(
68
+
"/xrpc/_account.revokeAllSessions",
69
post(api::server::revoke_all_sessions),
70
)
71
.route(
···
208
post(api::server::reset_password),
209
)
210
.route(
211
+
"/xrpc/_account.changePassword",
212
post(api::server::change_password),
213
)
214
.route(
215
+
"/xrpc/_account.removePassword",
216
post(api::server::remove_password),
217
)
218
.route(
219
+
"/xrpc/_account.getPasswordStatus",
220
get(api::server::get_password_status),
221
)
222
.route(
223
+
"/xrpc/_account.getReauthStatus",
224
get(api::server::get_reauth_status),
225
)
226
.route(
227
+
"/xrpc/_account.reauthPassword",
228
post(api::server::reauth_password),
229
)
230
+
.route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp))
231
.route(
232
+
"/xrpc/_account.reauthPasskeyStart",
233
post(api::server::reauth_passkey_start),
234
)
235
.route(
236
+
"/xrpc/_account.reauthPasskeyFinish",
237
post(api::server::reauth_passkey_finish),
238
)
239
.route(
240
+
"/xrpc/_account.getLegacyLoginPreference",
241
get(api::server::get_legacy_login_preference),
242
)
243
.route(
244
+
"/xrpc/_account.updateLegacyLoginPreference",
245
post(api::server::update_legacy_login_preference),
246
)
247
.route(
248
+
"/xrpc/_account.updateLocale",
249
post(api::server::update_locale),
250
)
251
.route(
252
+
"/xrpc/_account.listTrustedDevices",
253
get(api::server::list_trusted_devices),
254
)
255
.route(
256
+
"/xrpc/_account.revokeTrustedDevice",
257
post(api::server::revoke_trusted_device),
258
)
259
.route(
260
+
"/xrpc/_account.updateTrustedDevice",
261
post(api::server::update_trusted_device),
262
)
263
.route(
264
+
"/xrpc/_account.createPasskeyAccount",
265
post(api::server::create_passkey_account),
266
)
267
.route(
268
+
"/xrpc/_account.startPasskeyRegistrationForSetup",
269
post(api::server::start_passkey_registration_for_setup),
270
)
271
.route(
272
+
"/xrpc/_account.completePasskeySetup",
273
post(api::server::complete_passkey_setup),
274
)
275
.route(
276
+
"/xrpc/_account.requestPasskeyRecovery",
277
post(api::server::request_passkey_recovery),
278
)
279
.route(
280
+
"/xrpc/_account.recoverPasskeyAccount",
281
post(api::server::recover_passkey_account),
282
)
283
.route(
284
+
"/xrpc/_account.updateDidDocument",
285
post(api::server::update_did_document),
286
)
287
.route(
288
+
"/xrpc/_account.getDidDocument",
289
get(api::server::get_did_document),
290
)
291
.route(
292
"/xrpc/com.atproto.server.requestEmailUpdate",
293
post(api::server::request_email_update),
294
+
)
295
+
.route(
296
+
"/xrpc/_checkEmailVerified",
297
+
post(api::server::check_email_verified),
298
)
299
.route(
300
"/xrpc/com.atproto.server.confirmEmail",
···
421
get(api::admin::get_invite_codes),
422
)
423
.route(
424
+
"/xrpc/_admin.getServerStats",
425
get(api::admin::get_server_stats),
426
)
427
.route(
428
+
"/xrpc/_server.getConfig",
429
get(api::admin::get_server_config),
430
)
431
.route(
432
+
"/xrpc/_admin.updateServerConfig",
433
post(api::admin::update_server_config),
434
)
435
.route(
···
564
post(api::temp::dereference_scope),
565
)
566
.route(
567
+
"/xrpc/_account.getNotificationPrefs",
568
get(api::notification_prefs::get_notification_prefs),
569
)
570
.route(
571
+
"/xrpc/_account.updateNotificationPrefs",
572
post(api::notification_prefs::update_notification_prefs),
573
)
574
.route(
575
+
"/xrpc/_account.getNotificationHistory",
576
get(api::notification_prefs::get_notification_history),
577
)
578
.route(
579
+
"/xrpc/_account.confirmChannelVerification",
580
post(api::verification::confirm_channel_verification),
581
)
582
.route(
583
+
"/xrpc/_account.verifyToken",
584
post(api::server::verify_token),
585
)
586
.route(
587
+
"/xrpc/_delegation.listControllers",
588
get(api::delegation::list_controllers),
589
)
590
.route(
591
+
"/xrpc/_delegation.addController",
592
post(api::delegation::add_controller),
593
)
594
.route(
595
+
"/xrpc/_delegation.removeController",
596
post(api::delegation::remove_controller),
597
)
598
.route(
599
+
"/xrpc/_delegation.updateControllerScopes",
600
post(api::delegation::update_controller_scopes),
601
)
602
.route(
603
+
"/xrpc/_delegation.listControlledAccounts",
604
get(api::delegation::list_controlled_accounts),
605
)
606
.route(
607
+
"/xrpc/_delegation.getAuditLog",
608
get(api::delegation::get_audit_log),
609
)
610
.route(
611
+
"/xrpc/_delegation.getScopePresets",
612
get(api::delegation::get_scope_presets),
613
)
614
.route(
615
+
"/xrpc/_delegation.createDelegatedAccount",
616
post(api::delegation::create_delegated_account),
617
)
618
+
.route("/xrpc/_backup.listBackups", get(api::backup::list_backups))
619
+
.route("/xrpc/_backup.getBackup", get(api::backup::get_backup))
620
+
.route(
621
+
"/xrpc/_backup.createBackup",
622
+
post(api::backup::create_backup),
623
+
)
624
+
.route(
625
+
"/xrpc/_backup.deleteBackup",
626
+
post(api::backup::delete_backup),
627
+
)
628
+
.route(
629
+
"/xrpc/_backup.setEnabled",
630
+
post(api::backup::set_backup_enabled),
631
+
)
632
+
.route("/xrpc/_backup.exportBlobs", get(api::backup::export_blobs))
633
.route(
634
"/xrpc/app.bsky.ageassurance.getState",
635
get(api::age_assurance::get_state),
+18
-1
src/main.rs
+18
-1
src/main.rs
···
7
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
8
use tranquil_pds::scheduled::{
9
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
10
-
start_scheduled_tasks,
11
};
12
use tranquil_pds::state::AppState;
13
···
83
None
84
};
85
86
let scheduled_handle = tokio::spawn(start_scheduled_tasks(
87
state.db.clone(),
88
state.blob_store.clone(),
···
114
comms_handle.await.ok();
115
116
if let Some(handle) = crawlers_handle {
117
handle.await.ok();
118
}
119
···
7
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
8
use tranquil_pds::scheduled::{
9
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
10
+
start_backup_tasks, start_scheduled_tasks,
11
};
12
use tranquil_pds::state::AppState;
13
···
83
None
84
};
85
86
+
let backup_handle = if let Some(backup_storage) = state.backup_storage.clone() {
87
+
info!("Backup service enabled");
88
+
Some(tokio::spawn(start_backup_tasks(
89
+
state.db.clone(),
90
+
state.block_store.clone(),
91
+
backup_storage,
92
+
shutdown_rx.clone(),
93
+
)))
94
+
} else {
95
+
warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)");
96
+
None
97
+
};
98
+
99
let scheduled_handle = tokio::spawn(start_scheduled_tasks(
100
state.db.clone(),
101
state.blob_store.clone(),
···
127
comms_handle.await.ok();
128
129
if let Some(handle) = crawlers_handle {
130
+
handle.await.ok();
131
+
}
132
+
133
+
if let Some(handle) = backup_handle {
134
handle.await.ok();
135
}
136
+4
src/rate_limit.rs
+4
src/rate_limit.rs
···
32
pub totp_verify: Arc<KeyedRateLimiter>,
33
pub handle_update: Arc<KeyedRateLimiter>,
34
pub handle_update_daily: Arc<KeyedRateLimiter>,
35
+
pub verification_check: Arc<KeyedRateLimiter>,
36
}
37
38
impl Default for RateLimiters {
···
92
.unwrap()
93
.allow_burst(NonZeroU32::new(50).unwrap()),
94
)),
95
+
verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute(
96
+
NonZeroU32::new(60).unwrap(),
97
+
))),
98
}
99
}
100
+311
-1
src/scheduled.rs
+311
-1
src/scheduled.rs
···
11
use tracing::{debug, error, info, warn};
12
13
use crate::repo::PostgresBlockStore;
14
-
use crate::storage::BlobStorage;
15
16
pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) {
17
let broken_genesis_commits = match sqlx::query!(
···
563
564
Ok(())
565
}
···
11
use tracing::{debug, error, info, warn};
12
13
use crate::repo::PostgresBlockStore;
14
+
use crate::storage::{BackupStorage, BlobStorage};
15
+
use crate::sync::car::encode_car_header;
16
17
pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) {
18
let broken_genesis_commits = match sqlx::query!(
···
564
565
Ok(())
566
}
567
+
568
+
pub async fn start_backup_tasks(
569
+
db: PgPool,
570
+
block_store: PostgresBlockStore,
571
+
backup_storage: Arc<BackupStorage>,
572
+
mut shutdown_rx: watch::Receiver<bool>,
573
+
) {
574
+
let backup_interval = Duration::from_secs(BackupStorage::interval_secs());
575
+
576
+
info!(
577
+
interval_secs = backup_interval.as_secs(),
578
+
retention_count = BackupStorage::retention_count(),
579
+
"Starting backup service"
580
+
);
581
+
582
+
let mut ticker = interval(backup_interval);
583
+
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
584
+
585
+
loop {
586
+
tokio::select! {
587
+
_ = shutdown_rx.changed() => {
588
+
if *shutdown_rx.borrow() {
589
+
info!("Backup service shutting down");
590
+
break;
591
+
}
592
+
}
593
+
_ = ticker.tick() => {
594
+
if let Err(e) = process_scheduled_backups(&db, &block_store, &backup_storage).await {
595
+
error!("Error processing scheduled backups: {}", e);
596
+
}
597
+
}
598
+
}
599
+
}
600
+
}
601
+
602
+
async fn process_scheduled_backups(
603
+
db: &PgPool,
604
+
block_store: &PostgresBlockStore,
605
+
backup_storage: &BackupStorage,
606
+
) -> Result<(), String> {
607
+
let backup_interval_secs = BackupStorage::interval_secs() as i64;
608
+
let retention_count = BackupStorage::retention_count();
609
+
610
+
let users_needing_backup = sqlx::query!(
611
+
r#"
612
+
SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev
613
+
FROM users u
614
+
JOIN repos r ON r.user_id = u.id
615
+
WHERE u.backup_enabled = true
616
+
AND u.deactivated_at IS NULL
617
+
AND (
618
+
NOT EXISTS (
619
+
SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id
620
+
)
621
+
OR (
622
+
SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id
623
+
) < NOW() - make_interval(secs => $1)
624
+
)
625
+
LIMIT 50
626
+
"#,
627
+
backup_interval_secs as f64
628
+
)
629
+
.fetch_all(db)
630
+
.await
631
+
.map_err(|e| format!("DB error fetching users for backup: {}", e))?;
632
+
633
+
if users_needing_backup.is_empty() {
634
+
debug!("No accounts need backup");
635
+
return Ok(());
636
+
}
637
+
638
+
info!(
639
+
count = users_needing_backup.len(),
640
+
"Processing scheduled backups"
641
+
);
642
+
643
+
for user in users_needing_backup {
644
+
let repo_root_cid = user.repo_root_cid.clone();
645
+
646
+
let repo_rev = match &user.repo_rev {
647
+
Some(rev) => rev.clone(),
648
+
None => {
649
+
warn!(did = %user.did, "User has no repo_rev, skipping backup");
650
+
continue;
651
+
}
652
+
};
653
+
654
+
let head_cid = match Cid::from_str(&repo_root_cid) {
655
+
Ok(c) => c,
656
+
Err(e) => {
657
+
warn!(did = %user.did, error = %e, "Invalid repo_root_cid, skipping backup");
658
+
continue;
659
+
}
660
+
};
661
+
662
+
let car_result = generate_full_backup(block_store, &head_cid).await;
663
+
let car_bytes = match car_result {
664
+
Ok(bytes) => bytes,
665
+
Err(e) => {
666
+
warn!(did = %user.did, error = %e, "Failed to generate CAR for backup");
667
+
continue;
668
+
}
669
+
};
670
+
671
+
let block_count = count_car_blocks(&car_bytes);
672
+
let size_bytes = car_bytes.len() as i64;
673
+
674
+
let storage_key = match backup_storage
675
+
.put_backup(&user.did, &repo_rev, &car_bytes)
676
+
.await
677
+
{
678
+
Ok(key) => key,
679
+
Err(e) => {
680
+
warn!(did = %user.did, error = %e, "Failed to upload backup to storage");
681
+
continue;
682
+
}
683
+
};
684
+
685
+
if let Err(e) = sqlx::query!(
686
+
r#"
687
+
INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)
688
+
VALUES ($1, $2, $3, $4, $5, $6)
689
+
"#,
690
+
user.user_id,
691
+
storage_key,
692
+
repo_root_cid,
693
+
repo_rev,
694
+
block_count,
695
+
size_bytes
696
+
)
697
+
.execute(db)
698
+
.await
699
+
{
700
+
warn!(did = %user.did, error = %e, "Failed to insert backup record, rolling back S3 upload");
701
+
if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await {
702
+
error!(
703
+
did = %user.did,
704
+
storage_key = %storage_key,
705
+
error = %rollback_err,
706
+
"Failed to rollback orphaned backup from S3"
707
+
);
708
+
}
709
+
continue;
710
+
}
711
+
712
+
info!(
713
+
did = %user.did,
714
+
rev = %repo_rev,
715
+
size_bytes,
716
+
block_count,
717
+
"Created backup"
718
+
);
719
+
720
+
if let Err(e) = cleanup_old_backups(db, backup_storage, user.user_id, retention_count).await
721
+
{
722
+
warn!(did = %user.did, error = %e, "Failed to cleanup old backups");
723
+
}
724
+
}
725
+
726
+
Ok(())
727
+
}
728
+
729
+
pub async fn generate_repo_car(
730
+
block_store: &PostgresBlockStore,
731
+
head_cid: &Cid,
732
+
) -> Result<Vec<u8>, String> {
733
+
use jacquard_repo::storage::BlockStore;
734
+
use std::io::Write;
735
+
736
+
let mut car_bytes =
737
+
encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?;
738
+
739
+
let mut stack = vec![*head_cid];
740
+
let mut visited = std::collections::HashSet::new();
741
+
742
+
while let Some(cid) = stack.pop() {
743
+
if visited.contains(&cid) {
744
+
continue;
745
+
}
746
+
visited.insert(cid);
747
+
748
+
if let Ok(Some(block)) = block_store.get(&cid).await {
749
+
let cid_bytes = cid.to_bytes();
750
+
let total_len = cid_bytes.len() + block.len();
751
+
let mut writer = Vec::new();
752
+
crate::sync::car::write_varint(&mut writer, total_len as u64)
753
+
.expect("Writing to Vec<u8> should never fail");
754
+
writer
755
+
.write_all(&cid_bytes)
756
+
.expect("Writing to Vec<u8> should never fail");
757
+
writer
758
+
.write_all(&block)
759
+
.expect("Writing to Vec<u8> should never fail");
760
+
car_bytes.extend_from_slice(&writer);
761
+
762
+
if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
763
+
extract_links(&value, &mut stack);
764
+
}
765
+
}
766
+
}
767
+
768
+
Ok(car_bytes)
769
+
}
770
+
771
+
pub async fn generate_full_backup(
772
+
block_store: &PostgresBlockStore,
773
+
head_cid: &Cid,
774
+
) -> Result<Vec<u8>, String> {
775
+
generate_repo_car(block_store, head_cid).await
776
+
}
777
+
778
+
fn extract_links(value: &Ipld, stack: &mut Vec<Cid>) {
779
+
match value {
780
+
Ipld::Link(cid) => {
781
+
stack.push(*cid);
782
+
}
783
+
Ipld::Map(map) => {
784
+
for v in map.values() {
785
+
extract_links(v, stack);
786
+
}
787
+
}
788
+
Ipld::List(arr) => {
789
+
for v in arr {
790
+
extract_links(v, stack);
791
+
}
792
+
}
793
+
_ => {}
794
+
}
795
+
}
796
+
797
+
pub fn count_car_blocks(car_bytes: &[u8]) -> i32 {
798
+
let mut count = 0;
799
+
let mut pos = 0;
800
+
801
+
if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) {
802
+
pos += header_varint_len + header_len as usize;
803
+
} else {
804
+
return 0;
805
+
}
806
+
807
+
while pos < car_bytes.len() {
808
+
if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) {
809
+
pos += varint_len + block_len as usize;
810
+
count += 1;
811
+
} else {
812
+
break;
813
+
}
814
+
}
815
+
816
+
count
817
+
}
818
+
819
+
fn read_varint(data: &[u8]) -> Option<(u64, usize)> {
820
+
let mut value: u64 = 0;
821
+
let mut shift = 0;
822
+
let mut pos = 0;
823
+
824
+
while pos < data.len() && pos < 10 {
825
+
let byte = data[pos];
826
+
value |= ((byte & 0x7f) as u64) << shift;
827
+
pos += 1;
828
+
if byte & 0x80 == 0 {
829
+
return Some((value, pos));
830
+
}
831
+
shift += 7;
832
+
}
833
+
834
+
None
835
+
}
836
+
837
+
async fn cleanup_old_backups(
838
+
db: &PgPool,
839
+
backup_storage: &BackupStorage,
840
+
user_id: uuid::Uuid,
841
+
retention_count: u32,
842
+
) -> Result<(), String> {
843
+
let old_backups = sqlx::query!(
844
+
r#"
845
+
SELECT id, storage_key
846
+
FROM account_backups
847
+
WHERE user_id = $1
848
+
ORDER BY created_at DESC
849
+
OFFSET $2
850
+
"#,
851
+
user_id,
852
+
retention_count as i64
853
+
)
854
+
.fetch_all(db)
855
+
.await
856
+
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
857
+
858
+
for backup in old_backups {
859
+
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
860
+
warn!(
861
+
storage_key = %backup.storage_key,
862
+
error = %e,
863
+
"Failed to delete old backup from storage, skipping DB cleanup to avoid orphan"
864
+
);
865
+
continue;
866
+
}
867
+
868
+
sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
869
+
.execute(db)
870
+
.await
871
+
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
872
+
}
873
+
874
+
Ok(())
875
+
}
+8
-1
src/state.rs
+8
-1
src/state.rs
···
4
use crate::config::AuthConfig;
5
use crate::rate_limit::RateLimiters;
6
use crate::repo::PostgresBlockStore;
7
-
use crate::storage::{BlobStorage, S3BlobStorage};
8
use crate::sync::firehose::SequencedEvent;
9
use sqlx::PgPool;
10
use std::error::Error;
···
16
pub db: PgPool,
17
pub block_store: PostgresBlockStore,
18
pub blob_store: Arc<dyn BlobStorage>,
19
pub firehose_tx: broadcast::Sender<SequencedEvent>,
20
pub rate_limiters: Arc<RateLimiters>,
21
pub circuit_breakers: Arc<CircuitBreakers>,
···
39
TotpVerify,
40
HandleUpdate,
41
HandleUpdateDaily,
42
}
43
44
impl RateLimitKind {
···
58
Self::TotpVerify => "totp_verify",
59
Self::HandleUpdate => "handle_update",
60
Self::HandleUpdateDaily => "handle_update_daily",
61
}
62
}
63
···
77
Self::TotpVerify => (5, 300_000),
78
Self::HandleUpdate => (10, 300_000),
79
Self::HandleUpdateDaily => (50, 86_400_000),
80
}
81
}
82
}
···
131
132
let block_store = PostgresBlockStore::new(db.clone());
133
let blob_store = S3BlobStorage::new().await;
134
135
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
136
.ok()
···
147
db,
148
block_store,
149
blob_store: Arc::new(blob_store),
150
firehose_tx,
151
rate_limiters,
152
circuit_breakers,
···
199
RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify,
200
RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update,
201
RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily,
202
};
203
204
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
···
4
use crate::config::AuthConfig;
5
use crate::rate_limit::RateLimiters;
6
use crate::repo::PostgresBlockStore;
7
+
use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage};
8
use crate::sync::firehose::SequencedEvent;
9
use sqlx::PgPool;
10
use std::error::Error;
···
16
pub db: PgPool,
17
pub block_store: PostgresBlockStore,
18
pub blob_store: Arc<dyn BlobStorage>,
19
+
pub backup_storage: Option<Arc<BackupStorage>>,
20
pub firehose_tx: broadcast::Sender<SequencedEvent>,
21
pub rate_limiters: Arc<RateLimiters>,
22
pub circuit_breakers: Arc<CircuitBreakers>,
···
40
TotpVerify,
41
HandleUpdate,
42
HandleUpdateDaily,
43
+
VerificationCheck,
44
}
45
46
impl RateLimitKind {
···
60
Self::TotpVerify => "totp_verify",
61
Self::HandleUpdate => "handle_update",
62
Self::HandleUpdateDaily => "handle_update_daily",
63
+
Self::VerificationCheck => "verification_check",
64
}
65
}
66
···
80
Self::TotpVerify => (5, 300_000),
81
Self::HandleUpdate => (10, 300_000),
82
Self::HandleUpdateDaily => (50, 86_400_000),
83
+
Self::VerificationCheck => (60, 60_000),
84
}
85
}
86
}
···
135
136
let block_store = PostgresBlockStore::new(db.clone());
137
let blob_store = S3BlobStorage::new().await;
138
+
let backup_storage = BackupStorage::new().await.map(Arc::new);
139
140
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
141
.ok()
···
152
db,
153
block_store,
154
blob_store: Arc::new(blob_store),
155
+
backup_storage,
156
firehose_tx,
157
rate_limiters,
158
circuit_breakers,
···
205
RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify,
206
RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update,
207
RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily,
208
+
RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check,
209
};
210
211
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+119
-16
src/storage/mod.rs
+119
-16
src/storage/mod.rs
···
32
33
impl S3BlobStorage {
34
pub async fn new() -> Self {
35
-
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
36
37
-
let config = aws_config::defaults(BehaviorVersion::latest())
38
-
.region(region_provider)
39
-
.load()
40
-
.await;
41
42
-
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
43
44
-
let client = if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
45
-
let s3_config = aws_sdk_s3::config::Builder::from(&config)
46
-
.endpoint_url(endpoint)
47
-
.force_path_style(true)
48
-
.build();
49
-
Client::from_conf(s3_config)
50
-
} else {
51
-
Client::new(&config)
52
-
};
53
54
-
Self { client, bucket }
55
}
56
}
57
···
32
33
impl S3BlobStorage {
34
pub async fn new() -> Self {
35
+
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
36
+
let client = create_s3_client().await;
37
+
Self { client, bucket }
38
+
}
39
+
}
40
+
41
+
async fn create_s3_client() -> Client {
42
+
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
43
+
44
+
let config = aws_config::defaults(BehaviorVersion::latest())
45
+
.region(region_provider)
46
+
.load()
47
+
.await;
48
+
49
+
if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
50
+
let s3_config = aws_sdk_s3::config::Builder::from(&config)
51
+
.endpoint_url(endpoint)
52
+
.force_path_style(true)
53
+
.build();
54
+
Client::from_conf(s3_config)
55
+
} else {
56
+
Client::new(&config)
57
+
}
58
+
}
59
+
60
+
pub struct BackupStorage {
61
+
client: Client,
62
+
bucket: String,
63
+
}
64
+
65
+
impl BackupStorage {
66
+
pub async fn new() -> Option<Self> {
67
+
let backup_enabled = std::env::var("BACKUP_ENABLED")
68
+
.map(|v| v != "false" && v != "0")
69
+
.unwrap_or(true);
70
+
71
+
if !backup_enabled {
72
+
return None;
73
+
}
74
+
75
+
let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?;
76
+
let client = create_s3_client().await;
77
+
Some(Self { client, bucket })
78
+
}
79
+
80
+
pub fn retention_count() -> u32 {
81
+
std::env::var("BACKUP_RETENTION_COUNT")
82
+
.ok()
83
+
.and_then(|v| v.parse().ok())
84
+
.unwrap_or(7)
85
+
}
86
+
87
+
pub fn interval_secs() -> u64 {
88
+
std::env::var("BACKUP_INTERVAL_SECS")
89
+
.ok()
90
+
.and_then(|v| v.parse().ok())
91
+
.unwrap_or(86400)
92
+
}
93
+
94
+
pub async fn put_backup(
95
+
&self,
96
+
did: &str,
97
+
rev: &str,
98
+
data: &[u8],
99
+
) -> Result<String, StorageError> {
100
+
let key = format!("{}/{}.car", did, rev);
101
+
self.client
102
+
.put_object()
103
+
.bucket(&self.bucket)
104
+
.key(&key)
105
+
.body(ByteStream::from(Bytes::copy_from_slice(data)))
106
+
.send()
107
+
.await
108
+
.map_err(|e| {
109
+
crate::metrics::record_s3_operation("backup_put", "error");
110
+
StorageError::S3(e.to_string())
111
+
})?;
112
113
+
crate::metrics::record_s3_operation("backup_put", "success");
114
+
Ok(key)
115
+
}
116
+
117
+
pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> {
118
+
let resp = self
119
+
.client
120
+
.get_object()
121
+
.bucket(&self.bucket)
122
+
.key(storage_key)
123
+
.send()
124
+
.await
125
+
.map_err(|e| {
126
+
crate::metrics::record_s3_operation("backup_get", "error");
127
+
StorageError::S3(e.to_string())
128
+
})?;
129
+
130
+
let data = resp
131
+
.body
132
+
.collect()
133
+
.await
134
+
.map_err(|e| {
135
+
crate::metrics::record_s3_operation("backup_get", "error");
136
+
StorageError::S3(e.to_string())
137
+
})?
138
+
.into_bytes();
139
140
+
crate::metrics::record_s3_operation("backup_get", "success");
141
+
Ok(data)
142
+
}
143
144
+
pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> {
145
+
self.client
146
+
.delete_object()
147
+
.bucket(&self.bucket)
148
+
.key(storage_key)
149
+
.send()
150
+
.await
151
+
.map_err(|e| {
152
+
crate::metrics::record_s3_operation("backup_delete", "error");
153
+
StorageError::S3(e.to_string())
154
+
})?;
155
156
+
crate::metrics::record_s3_operation("backup_delete", "success");
157
+
Ok(())
158
}
159
}
160
+23
-12
src/sync/import.rs
+23
-12
src/sync/import.rs
···
77
Ipld::Map(obj) => {
78
if let Some(Ipld::String(type_str)) = obj.get("$type")
79
&& type_str == "blob"
80
-
&& let Some(Ipld::Link(link_cid)) = obj.get("ref")
81
{
82
-
let mime = obj.get("mimeType").and_then(|v| {
83
-
if let Ipld::String(s) = v {
84
-
Some(s.clone())
85
-
} else {
86
-
None
87
-
}
88
-
});
89
-
return vec![BlobRef {
90
-
cid: link_cid.to_string(),
91
-
mime_type: mime,
92
-
}];
93
}
94
obj.values()
95
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
···
77
Ipld::Map(obj) => {
78
if let Some(Ipld::String(type_str)) = obj.get("$type")
79
&& type_str == "blob"
80
{
81
+
let cid_str = if let Some(Ipld::Link(link_cid)) = obj.get("ref") {
82
+
Some(link_cid.to_string())
83
+
} else if let Some(Ipld::Map(ref_obj)) = obj.get("ref")
84
+
&& let Some(Ipld::String(link)) = ref_obj.get("$link")
85
+
{
86
+
Some(link.clone())
87
+
} else {
88
+
None
89
+
};
90
+
91
+
if let Some(cid) = cid_str {
92
+
let mime = obj.get("mimeType").and_then(|v| {
93
+
if let Ipld::String(s) = v {
94
+
Some(s.clone())
95
+
} else {
96
+
None
97
+
}
98
+
});
99
+
return vec![BlobRef {
100
+
cid,
101
+
mime_type: mime,
102
+
}];
103
+
}
104
}
105
obj.values()
106
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
+129
src/util.rs
+129
src/util.rs
···
1
use axum::http::HeaderMap;
2
use rand::Rng;
3
use sqlx::PgPool;
4
use std::sync::OnceLock;
5
use uuid::Uuid;
6
···
150
format!("{}{}", pds_public_url(), path)
151
}
152
153
#[cfg(test)]
154
mod tests {
155
use super::*;
···
223
for part in parts {
224
assert_eq!(part.len(), 4);
225
}
226
}
227
}
···
1
use axum::http::HeaderMap;
2
+
use cid::Cid;
3
+
use ipld_core::ipld::Ipld;
4
use rand::Rng;
5
+
use serde_json::Value as JsonValue;
6
use sqlx::PgPool;
7
+
use std::collections::BTreeMap;
8
+
use std::str::FromStr;
9
use std::sync::OnceLock;
10
use uuid::Uuid;
11
···
155
format!("{}{}", pds_public_url(), path)
156
}
157
158
+
pub fn json_to_ipld(value: &JsonValue) -> Ipld {
159
+
match value {
160
+
JsonValue::Null => Ipld::Null,
161
+
JsonValue::Bool(b) => Ipld::Bool(*b),
162
+
JsonValue::Number(n) => {
163
+
if let Some(i) = n.as_i64() {
164
+
Ipld::Integer(i as i128)
165
+
} else if let Some(f) = n.as_f64() {
166
+
Ipld::Float(f)
167
+
} else {
168
+
Ipld::Null
169
+
}
170
+
}
171
+
JsonValue::String(s) => Ipld::String(s.clone()),
172
+
JsonValue::Array(arr) => Ipld::List(arr.iter().map(json_to_ipld).collect()),
173
+
JsonValue::Object(obj) => {
174
+
if let Some(JsonValue::String(link)) = obj.get("$link")
175
+
&& obj.len() == 1
176
+
&& let Ok(cid) = Cid::from_str(link)
177
+
{
178
+
return Ipld::Link(cid);
179
+
}
180
+
let map: BTreeMap<String, Ipld> = obj
181
+
.iter()
182
+
.map(|(k, v)| (k.clone(), json_to_ipld(v)))
183
+
.collect();
184
+
Ipld::Map(map)
185
+
}
186
+
}
187
+
}
188
+
189
#[cfg(test)]
190
mod tests {
191
use super::*;
···
259
for part in parts {
260
assert_eq!(part.len(), 4);
261
}
262
+
}
263
+
264
+
#[test]
265
+
fn test_json_to_ipld_cid_link() {
266
+
let json = serde_json::json!({
267
+
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
268
+
});
269
+
let ipld = json_to_ipld(&json);
270
+
match ipld {
271
+
Ipld::Link(cid) => {
272
+
assert_eq!(
273
+
cid.to_string(),
274
+
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
275
+
);
276
+
}
277
+
_ => panic!("Expected Ipld::Link, got {:?}", ipld),
278
+
}
279
+
}
280
+
281
+
#[test]
282
+
fn test_json_to_ipld_blob_ref() {
283
+
let json = serde_json::json!({
284
+
"$type": "blob",
285
+
"ref": {
286
+
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
287
+
},
288
+
"mimeType": "image/jpeg",
289
+
"size": 12345
290
+
});
291
+
let ipld = json_to_ipld(&json);
292
+
match ipld {
293
+
Ipld::Map(map) => {
294
+
assert_eq!(map.get("$type"), Some(&Ipld::String("blob".to_string())));
295
+
assert_eq!(
296
+
map.get("mimeType"),
297
+
Some(&Ipld::String("image/jpeg".to_string()))
298
+
);
299
+
assert_eq!(map.get("size"), Some(&Ipld::Integer(12345)));
300
+
match map.get("ref") {
301
+
Some(Ipld::Link(cid)) => {
302
+
assert_eq!(
303
+
cid.to_string(),
304
+
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
305
+
);
306
+
}
307
+
_ => panic!("Expected Ipld::Link in ref field, got {:?}", map.get("ref")),
308
+
}
309
+
}
310
+
_ => panic!("Expected Ipld::Map, got {:?}", ipld),
311
+
}
312
+
}
313
+
314
+
#[test]
315
+
fn test_json_to_ipld_nested_blob_refs_serializes_correctly() {
316
+
let record = serde_json::json!({
317
+
"$type": "app.bsky.feed.post",
318
+
"text": "Hello world",
319
+
"embed": {
320
+
"$type": "app.bsky.embed.images",
321
+
"images": [
322
+
{
323
+
"alt": "Test image",
324
+
"image": {
325
+
"$type": "blob",
326
+
"ref": {
327
+
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
328
+
},
329
+
"mimeType": "image/jpeg",
330
+
"size": 12345
331
+
}
332
+
}
333
+
]
334
+
}
335
+
});
336
+
let ipld = json_to_ipld(&record);
337
+
let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed");
338
+
assert!(!cbor_bytes.is_empty());
339
+
let parsed: Ipld =
340
+
serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed");
341
+
if let Ipld::Map(map) = &parsed
342
+
&& let Some(Ipld::Map(embed)) = map.get("embed")
343
+
&& let Some(Ipld::List(images)) = embed.get("images")
344
+
&& let Some(Ipld::Map(img)) = images.first()
345
+
&& let Some(Ipld::Map(blob)) = img.get("image")
346
+
&& let Some(Ipld::Link(cid)) = blob.get("ref")
347
+
{
348
+
assert_eq!(
349
+
cid.to_string(),
350
+
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
351
+
);
352
+
return;
353
+
}
354
+
panic!("Failed to find CID link in parsed CBOR");
355
}
356
}
+10
-40
tests/account_notifications.rs
+10
-40
tests/account_notifications.rs
···
27
}
28
29
let resp = client
30
-
.get(format!(
31
-
"{}/xrpc/com.tranquil.account.getNotificationHistory",
32
-
base
33
-
))
34
.header("Authorization", format!("Bearer {}", token))
35
.send()
36
.await
···
56
"discordId": "123456789"
57
});
58
let resp = client
59
-
.post(format!(
60
-
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
61
-
base
62
-
))
63
.header("Authorization", format!("Bearer {}", token))
64
.json(&prefs)
65
.send()
···
101
"code": code
102
});
103
let resp = client
104
-
.post(format!(
105
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
106
-
base
107
-
))
108
.header("Authorization", format!("Bearer {}", token))
109
.json(&input)
110
.send()
···
113
assert_eq!(resp.status(), 200);
114
115
let resp = client
116
-
.get(format!(
117
-
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
118
-
base
119
-
))
120
.header("Authorization", format!("Bearer {}", token))
121
.send()
122
.await
···
136
"telegramUsername": "testuser"
137
});
138
let resp = client
139
-
.post(format!(
140
-
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
141
-
base
142
-
))
143
.header("Authorization", format!("Bearer {}", token))
144
.json(&prefs)
145
.send()
···
153
"code": "XXXX-XXXX-XXXX-XXXX"
154
});
155
let resp = client
156
-
.post(format!(
157
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
158
-
base
159
-
))
160
.header("Authorization", format!("Bearer {}", token))
161
.json(&input)
162
.send()
···
181
"code": "XXXX-XXXX-XXXX-XXXX"
182
});
183
let resp = client
184
-
.post(format!(
185
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
186
-
base
187
-
))
188
.header("Authorization", format!("Bearer {}", token))
189
.json(&input)
190
.send()
···
209
"email": unique_email
210
});
211
let resp = client
212
-
.post(format!(
213
-
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
214
-
base
215
-
))
216
.header("Authorization", format!("Bearer {}", token))
217
.json(&prefs)
218
.send()
···
263
"code": code
264
});
265
let resp = client
266
-
.post(format!(
267
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
268
-
base
269
-
))
270
.header("Authorization", format!("Bearer {}", token))
271
.json(&input)
272
.send()
···
275
assert_eq!(resp.status(), 200);
276
277
let resp = client
278
-
.get(format!(
279
-
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
280
-
base
281
-
))
282
.header("Authorization", format!("Bearer {}", token))
283
.send()
284
.await
···
27
}
28
29
let resp = client
30
+
.get(format!("{}/xrpc/_account.getNotificationHistory", base))
31
.header("Authorization", format!("Bearer {}", token))
32
.send()
33
.await
···
53
"discordId": "123456789"
54
});
55
let resp = client
56
+
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
57
.header("Authorization", format!("Bearer {}", token))
58
.json(&prefs)
59
.send()
···
95
"code": code
96
});
97
let resp = client
98
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
99
.header("Authorization", format!("Bearer {}", token))
100
.json(&input)
101
.send()
···
104
assert_eq!(resp.status(), 200);
105
106
let resp = client
107
+
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
108
.header("Authorization", format!("Bearer {}", token))
109
.send()
110
.await
···
124
"telegramUsername": "testuser"
125
});
126
let resp = client
127
+
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
128
.header("Authorization", format!("Bearer {}", token))
129
.json(&prefs)
130
.send()
···
138
"code": "XXXX-XXXX-XXXX-XXXX"
139
});
140
let resp = client
141
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
142
.header("Authorization", format!("Bearer {}", token))
143
.json(&input)
144
.send()
···
163
"code": "XXXX-XXXX-XXXX-XXXX"
164
});
165
let resp = client
166
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
167
.header("Authorization", format!("Bearer {}", token))
168
.json(&input)
169
.send()
···
188
"email": unique_email
189
});
190
let resp = client
191
+
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
192
.header("Authorization", format!("Bearer {}", token))
193
.json(&prefs)
194
.send()
···
239
"code": code
240
});
241
let resp = client
242
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
243
.header("Authorization", format!("Bearer {}", token))
244
.json(&input)
245
.send()
···
248
assert_eq!(resp.status(), 200);
249
250
let resp = client
251
+
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
252
.header("Authorization", format!("Bearer {}", token))
253
.send()
254
.await
+2
-2
tests/admin_stats.rs
+2
-2
tests/admin_stats.rs
···
11
let (_, _) = create_admin_account_and_login(&client).await;
12
13
let resp = client
14
-
.get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base))
15
.header("Authorization", format!("Bearer {}", token1))
16
.send()
17
.await
···
33
let client = client();
34
let base = base_url().await;
35
let resp = client
36
-
.get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base))
37
.send()
38
.await
39
.unwrap();
···
11
let (_, _) = create_admin_account_and_login(&client).await;
12
13
let resp = client
14
+
.get(format!("{}/xrpc/_admin.getServerStats", base))
15
.header("Authorization", format!("Bearer {}", token1))
16
.send()
17
.await
···
33
let client = client();
34
let base = base_url().await;
35
let resp = client
36
+
.get(format!("{}/xrpc/_admin.getServerStats", base))
37
.send()
38
.await
39
.unwrap();
+325
tests/backup.rs
+325
tests/backup.rs
···
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use common::*;
5
+
use reqwest::{StatusCode, header};
6
+
use serde_json::{Value, json};
7
+
8
+
#[tokio::test]
9
+
async fn test_list_backups_empty() {
10
+
let client = client();
11
+
let (token, _did) = create_account_and_login(&client).await;
12
+
13
+
let res = client
14
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
15
+
.bearer_auth(&token)
16
+
.send()
17
+
.await
18
+
.expect("listBackups request failed");
19
+
20
+
assert_eq!(res.status(), StatusCode::OK);
21
+
let body: Value = res.json().await.expect("Invalid JSON");
22
+
assert!(body["backups"].is_array());
23
+
assert_eq!(body["backups"].as_array().unwrap().len(), 0);
24
+
assert!(body["backupEnabled"].as_bool().unwrap_or(false));
25
+
}
26
+
27
+
#[tokio::test]
28
+
async fn test_create_and_list_backup() {
29
+
let client = client();
30
+
let (token, _did) = create_account_and_login(&client).await;
31
+
32
+
let create_res = client
33
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
34
+
.bearer_auth(&token)
35
+
.send()
36
+
.await
37
+
.expect("createBackup request failed");
38
+
39
+
assert_eq!(create_res.status(), StatusCode::OK, "createBackup failed");
40
+
let create_body: Value = create_res.json().await.expect("Invalid JSON");
41
+
assert!(create_body["id"].is_string());
42
+
assert!(create_body["repoRev"].is_string());
43
+
assert!(create_body["sizeBytes"].is_i64());
44
+
assert!(create_body["blockCount"].is_i64());
45
+
46
+
let list_res = client
47
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
48
+
.bearer_auth(&token)
49
+
.send()
50
+
.await
51
+
.expect("listBackups request failed");
52
+
53
+
assert_eq!(list_res.status(), StatusCode::OK);
54
+
let list_body: Value = list_res.json().await.expect("Invalid JSON");
55
+
let backups = list_body["backups"].as_array().unwrap();
56
+
assert!(backups.len() >= 1);
57
+
}
58
+
59
+
#[tokio::test]
60
+
async fn test_download_backup() {
61
+
let client = client();
62
+
let (token, _did) = create_account_and_login(&client).await;
63
+
64
+
let create_res = client
65
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
66
+
.bearer_auth(&token)
67
+
.send()
68
+
.await
69
+
.expect("createBackup request failed");
70
+
71
+
assert_eq!(create_res.status(), StatusCode::OK);
72
+
let create_body: Value = create_res.json().await.expect("Invalid JSON");
73
+
let backup_id = create_body["id"].as_str().unwrap();
74
+
75
+
let get_res = client
76
+
.get(format!(
77
+
"{}/xrpc/_backup.getBackup?id={}",
78
+
base_url().await,
79
+
backup_id
80
+
))
81
+
.bearer_auth(&token)
82
+
.send()
83
+
.await
84
+
.expect("getBackup request failed");
85
+
86
+
assert_eq!(get_res.status(), StatusCode::OK);
87
+
let content_type = get_res.headers().get(header::CONTENT_TYPE).unwrap();
88
+
assert_eq!(content_type, "application/vnd.ipld.car");
89
+
90
+
let bytes = get_res.bytes().await.expect("Failed to read body");
91
+
assert!(bytes.len() > 100, "CAR file should have content");
92
+
assert_eq!(
93
+
bytes[1], 0xa2,
94
+
"CAR file should have valid header structure"
95
+
);
96
+
}
97
+
98
+
#[tokio::test]
99
+
async fn test_delete_backup() {
100
+
let client = client();
101
+
let (token, _did) = create_account_and_login(&client).await;
102
+
103
+
let create_res = client
104
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
105
+
.bearer_auth(&token)
106
+
.send()
107
+
.await
108
+
.expect("createBackup request failed");
109
+
110
+
assert_eq!(create_res.status(), StatusCode::OK);
111
+
let create_body: Value = create_res.json().await.expect("Invalid JSON");
112
+
let backup_id = create_body["id"].as_str().unwrap();
113
+
114
+
let delete_res = client
115
+
.post(format!(
116
+
"{}/xrpc/_backup.deleteBackup?id={}",
117
+
base_url().await,
118
+
backup_id
119
+
))
120
+
.bearer_auth(&token)
121
+
.send()
122
+
.await
123
+
.expect("deleteBackup request failed");
124
+
125
+
assert_eq!(delete_res.status(), StatusCode::OK);
126
+
127
+
let get_res = client
128
+
.get(format!(
129
+
"{}/xrpc/_backup.getBackup?id={}",
130
+
base_url().await,
131
+
backup_id
132
+
))
133
+
.bearer_auth(&token)
134
+
.send()
135
+
.await
136
+
.expect("getBackup request failed");
137
+
138
+
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
139
+
}
140
+
141
+
#[tokio::test]
142
+
async fn test_toggle_backup_enabled() {
143
+
let client = client();
144
+
let (token, _did) = create_account_and_login(&client).await;
145
+
146
+
let list_res = client
147
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
148
+
.bearer_auth(&token)
149
+
.send()
150
+
.await
151
+
.expect("listBackups request failed");
152
+
153
+
assert_eq!(list_res.status(), StatusCode::OK);
154
+
let list_body: Value = list_res.json().await.expect("Invalid JSON");
155
+
assert!(list_body["backupEnabled"].as_bool().unwrap());
156
+
157
+
let disable_res = client
158
+
.post(format!("{}/xrpc/_backup.setEnabled", base_url().await))
159
+
.bearer_auth(&token)
160
+
.json(&json!({"enabled": false}))
161
+
.send()
162
+
.await
163
+
.expect("setEnabled request failed");
164
+
165
+
assert_eq!(disable_res.status(), StatusCode::OK);
166
+
let disable_body: Value = disable_res.json().await.expect("Invalid JSON");
167
+
assert!(!disable_body["enabled"].as_bool().unwrap());
168
+
169
+
let list_res2 = client
170
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
171
+
.bearer_auth(&token)
172
+
.send()
173
+
.await
174
+
.expect("listBackups request failed");
175
+
176
+
let list_body2: Value = list_res2.json().await.expect("Invalid JSON");
177
+
assert!(!list_body2["backupEnabled"].as_bool().unwrap());
178
+
179
+
let enable_res = client
180
+
.post(format!("{}/xrpc/_backup.setEnabled", base_url().await))
181
+
.bearer_auth(&token)
182
+
.json(&json!({"enabled": true}))
183
+
.send()
184
+
.await
185
+
.expect("setEnabled request failed");
186
+
187
+
assert_eq!(enable_res.status(), StatusCode::OK);
188
+
}
189
+
190
+
#[tokio::test]
191
+
async fn test_backup_includes_blobs() {
192
+
let client = client();
193
+
let (token, did) = create_account_and_login(&client).await;
194
+
195
+
let blob_data = b"Hello, this is test blob data for backup testing!";
196
+
let upload_res = client
197
+
.post(format!(
198
+
"{}/xrpc/com.atproto.repo.uploadBlob",
199
+
base_url().await
200
+
))
201
+
.header(header::CONTENT_TYPE, "text/plain")
202
+
.bearer_auth(&token)
203
+
.body(blob_data.to_vec())
204
+
.send()
205
+
.await
206
+
.expect("uploadBlob request failed");
207
+
208
+
assert_eq!(upload_res.status(), StatusCode::OK);
209
+
let upload_body: Value = upload_res.json().await.expect("Invalid JSON");
210
+
let blob = &upload_body["blob"];
211
+
212
+
let record = json!({
213
+
"$type": "app.bsky.feed.post",
214
+
"text": "Test post with blob",
215
+
"createdAt": chrono::Utc::now().to_rfc3339(),
216
+
"embed": {
217
+
"$type": "app.bsky.embed.images",
218
+
"images": [{
219
+
"alt": "test image",
220
+
"image": blob
221
+
}]
222
+
}
223
+
});
224
+
225
+
let create_record_res = client
226
+
.post(format!(
227
+
"{}/xrpc/com.atproto.repo.createRecord",
228
+
base_url().await
229
+
))
230
+
.bearer_auth(&token)
231
+
.json(&json!({
232
+
"repo": did,
233
+
"collection": "app.bsky.feed.post",
234
+
"record": record
235
+
}))
236
+
.send()
237
+
.await
238
+
.expect("createRecord request failed");
239
+
240
+
assert_eq!(create_record_res.status(), StatusCode::OK);
241
+
242
+
let create_backup_res = client
243
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
244
+
.bearer_auth(&token)
245
+
.send()
246
+
.await
247
+
.expect("createBackup request failed");
248
+
249
+
assert_eq!(create_backup_res.status(), StatusCode::OK);
250
+
let backup_body: Value = create_backup_res.json().await.expect("Invalid JSON");
251
+
let backup_id = backup_body["id"].as_str().unwrap();
252
+
253
+
let get_backup_res = client
254
+
.get(format!(
255
+
"{}/xrpc/_backup.getBackup?id={}",
256
+
base_url().await,
257
+
backup_id
258
+
))
259
+
.bearer_auth(&token)
260
+
.send()
261
+
.await
262
+
.expect("getBackup request failed");
263
+
264
+
assert_eq!(get_backup_res.status(), StatusCode::OK);
265
+
let car_bytes = get_backup_res.bytes().await.expect("Failed to read body");
266
+
267
+
let blob_cid = blob["ref"]["$link"].as_str().unwrap();
268
+
let blob_found = String::from_utf8_lossy(&car_bytes).contains("Hello, this is test blob data");
269
+
assert!(
270
+
blob_found || car_bytes.len() > 500,
271
+
"Backup should contain blob data (cid: {})",
272
+
blob_cid
273
+
);
274
+
}
275
+
276
+
#[tokio::test]
277
+
async fn test_backup_unauthorized() {
278
+
let client = client();
279
+
280
+
let res = client
281
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
282
+
.send()
283
+
.await
284
+
.expect("listBackups request failed");
285
+
286
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
287
+
}
288
+
289
+
#[tokio::test]
290
+
async fn test_get_nonexistent_backup() {
291
+
let client = client();
292
+
let (token, _did) = create_account_and_login(&client).await;
293
+
294
+
let fake_id = uuid::Uuid::new_v4();
295
+
let res = client
296
+
.get(format!(
297
+
"{}/xrpc/_backup.getBackup?id={}",
298
+
base_url().await,
299
+
fake_id
300
+
))
301
+
.bearer_auth(&token)
302
+
.send()
303
+
.await
304
+
.expect("getBackup request failed");
305
+
306
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
307
+
}
308
+
309
+
#[tokio::test]
310
+
async fn test_backup_invalid_id() {
311
+
let client = client();
312
+
let (token, _did) = create_account_and_login(&client).await;
313
+
314
+
let res = client
315
+
.get(format!(
316
+
"{}/xrpc/_backup.getBackup?id=not-a-uuid",
317
+
base_url().await
318
+
))
319
+
.bearer_auth(&token)
320
+
.send()
321
+
.await
322
+
.expect("getBackup request failed");
323
+
324
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
325
+
}
+6
-24
tests/change_password.rs
+6
-24
tests/change_password.rs
···
32
let did = create_body["did"].as_str().unwrap();
33
let jwt = verify_new_account(&client, did).await;
34
let change_res = client
35
-
.post(format!(
36
-
"{}/xrpc/com.tranquil.account.changePassword",
37
-
base_url().await
38
-
))
39
.bearer_auth(&jwt)
40
.json(&json!({
41
"currentPassword": old_password,
···
86
let client = client();
87
let (_, jwt) = setup_new_user("change-pw-wrong").await;
88
let res = client
89
-
.post(format!(
90
-
"{}/xrpc/com.tranquil.account.changePassword",
91
-
base_url().await
92
-
))
93
.bearer_auth(&jwt)
94
.json(&json!({
95
"currentPassword": "Wrongpass999!",
···
129
let did = create_body["did"].as_str().unwrap();
130
let jwt = verify_new_account(&client, did).await;
131
let res = client
132
-
.post(format!(
133
-
"{}/xrpc/com.tranquil.account.changePassword",
134
-
base_url().await
135
-
))
136
.bearer_auth(&jwt)
137
.json(&json!({
138
"currentPassword": password,
···
151
let client = client();
152
let (_, jwt) = setup_new_user("change-pw-empty").await;
153
let res = client
154
-
.post(format!(
155
-
"{}/xrpc/com.tranquil.account.changePassword",
156
-
base_url().await
157
-
))
158
.bearer_auth(&jwt)
159
.json(&json!({
160
"currentPassword": "",
···
171
let client = client();
172
let (_, jwt) = setup_new_user("change-pw-emptynew").await;
173
let res = client
174
-
.post(format!(
175
-
"{}/xrpc/com.tranquil.account.changePassword",
176
-
base_url().await
177
-
))
178
.bearer_auth(&jwt)
179
.json(&json!({
180
"currentPassword": "E2epass123!",
···
190
async fn test_change_password_requires_auth() {
191
let client = client();
192
let res = client
193
-
.post(format!(
194
-
"{}/xrpc/com.tranquil.account.changePassword",
195
-
base_url().await
196
-
))
197
.json(&json!({
198
"currentPassword": "Oldpass123!",
199
"newPassword": "Newpass123!"
···
32
let did = create_body["did"].as_str().unwrap();
33
let jwt = verify_new_account(&client, did).await;
34
let change_res = client
35
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
36
.bearer_auth(&jwt)
37
.json(&json!({
38
"currentPassword": old_password,
···
83
let client = client();
84
let (_, jwt) = setup_new_user("change-pw-wrong").await;
85
let res = client
86
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
87
.bearer_auth(&jwt)
88
.json(&json!({
89
"currentPassword": "Wrongpass999!",
···
123
let did = create_body["did"].as_str().unwrap();
124
let jwt = verify_new_account(&client, did).await;
125
let res = client
126
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
127
.bearer_auth(&jwt)
128
.json(&json!({
129
"currentPassword": password,
···
142
let client = client();
143
let (_, jwt) = setup_new_user("change-pw-empty").await;
144
let res = client
145
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
146
.bearer_auth(&jwt)
147
.json(&json!({
148
"currentPassword": "",
···
159
let client = client();
160
let (_, jwt) = setup_new_user("change-pw-emptynew").await;
161
let res = client
162
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
163
.bearer_auth(&jwt)
164
.json(&json!({
165
"currentPassword": "E2epass123!",
···
175
async fn test_change_password_requires_auth() {
176
let client = client();
177
let res = client
178
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
179
.json(&json!({
180
"currentPassword": "Oldpass123!",
181
"newPassword": "Newpass123!"
+28
-247
tests/did_web.rs
+28
-247
tests/did_web.rs
···
547
}
548
549
#[tokio::test]
550
-
async fn test_deactivate_with_migrating_to() {
551
let client = client();
552
let base = base_url().await;
553
-
let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
554
let payload = json!({
555
"handle": handle,
556
"email": format!("{}@example.com", handle),
···
567
let body: Value = res.json().await.expect("Response was not JSON");
568
let did = body["did"].as_str().expect("No DID").to_string();
569
let jwt = verify_new_account(&client, &did).await;
570
-
let target_pds = "https://pds2.example.com";
571
let res = client
572
-
.post(format!(
573
-
"{}/xrpc/com.atproto.server.deactivateAccount",
574
-
base
575
-
))
576
.bearer_auth(&jwt)
577
-
.json(&json!({ "migratingTo": target_pds }))
578
-
.send()
579
-
.await
580
-
.expect("Failed to send request");
581
-
assert_eq!(res.status(), StatusCode::OK);
582
-
let pool = get_test_db_pool().await;
583
-
let row = sqlx::query!(
584
-
r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
585
-
&did
586
-
)
587
-
.fetch_one(pool)
588
-
.await
589
-
.expect("Failed to query user");
590
-
assert_eq!(
591
-
row.migrated_to_pds.as_deref(),
592
-
Some(target_pds),
593
-
"migrated_to_pds should be set to target PDS"
594
-
);
595
-
assert!(
596
-
row.deactivated_at.is_some(),
597
-
"deactivated_at should be set for migrated account"
598
-
);
599
-
}
600
-
601
-
#[tokio::test]
602
-
async fn test_migrated_account_blocked_from_repo_ops() {
603
-
let client = client();
604
-
let base = base_url().await;
605
-
let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
606
-
let payload = json!({
607
-
"handle": handle,
608
-
"email": format!("{}@example.com", handle),
609
-
"password": "Testpass123!",
610
-
"didType": "web"
611
-
});
612
-
let res = client
613
-
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
614
-
.json(&payload)
615
.send()
616
.await
617
.expect("Failed to send request");
618
assert_eq!(res.status(), StatusCode::OK);
619
let body: Value = res.json().await.expect("Response was not JSON");
620
-
let did = body["did"].as_str().expect("No DID").to_string();
621
-
let jwt = verify_new_account(&client, &did).await;
622
-
let res = client
623
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
624
-
.bearer_auth(&jwt)
625
-
.json(&json!({
626
-
"repo": did,
627
-
"collection": "app.bsky.feed.post",
628
-
"record": {
629
-
"$type": "app.bsky.feed.post",
630
-
"text": "Pre-migration post",
631
-
"createdAt": chrono::Utc::now().to_rfc3339()
632
-
}
633
-
}))
634
-
.send()
635
-
.await
636
-
.expect("Failed to send request");
637
-
assert_eq!(res.status(), StatusCode::OK);
638
-
let res = client
639
-
.post(format!(
640
-
"{}/xrpc/com.atproto.server.deactivateAccount",
641
-
base
642
-
))
643
-
.bearer_auth(&jwt)
644
-
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
645
-
.send()
646
-
.await
647
-
.expect("Failed to send request");
648
-
assert_eq!(res.status(), StatusCode::OK);
649
-
let res = client
650
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
651
-
.bearer_auth(&jwt)
652
-
.json(&json!({
653
-
"repo": did,
654
-
"collection": "app.bsky.feed.post",
655
-
"record": {
656
-
"$type": "app.bsky.feed.post",
657
-
"text": "Post-migration post - should fail",
658
-
"createdAt": chrono::Utc::now().to_rfc3339()
659
-
}
660
-
}))
661
-
.send()
662
-
.await
663
-
.expect("Failed to send request");
664
assert!(
665
-
res.status().is_client_error(),
666
-
"createRecord should fail for migrated account: {}",
667
-
res.status()
668
);
669
-
let res = client
670
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
671
-
.bearer_auth(&jwt)
672
-
.json(&json!({
673
-
"repo": did,
674
-
"collection": "app.bsky.actor.profile",
675
-
"rkey": "self",
676
-
"record": {
677
-
"$type": "app.bsky.actor.profile",
678
-
"displayName": "Test"
679
-
}
680
-
}))
681
-
.send()
682
-
.await
683
-
.expect("Failed to send request");
684
-
assert!(
685
-
res.status().is_client_error(),
686
-
"putRecord should fail for migrated account: {}",
687
-
res.status()
688
);
689
let res = client
690
-
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
691
.bearer_auth(&jwt)
692
.json(&json!({
693
-
"repo": did,
694
-
"collection": "app.bsky.feed.post",
695
-
"rkey": "test123"
696
}))
697
.send()
698
.await
699
.expect("Failed to send request");
700
-
assert!(
701
-
res.status().is_client_error(),
702
-
"deleteRecord should fail for migrated account: {}",
703
-
res.status()
704
);
705
-
let res = client
706
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
707
-
.bearer_auth(&jwt)
708
-
.json(&json!({
709
-
"repo": did,
710
-
"writes": [{
711
-
"$type": "com.atproto.repo.applyWrites#create",
712
-
"collection": "app.bsky.feed.post",
713
-
"value": {
714
-
"$type": "app.bsky.feed.post",
715
-
"text": "Batch post",
716
-
"createdAt": chrono::Utc::now().to_rfc3339()
717
-
}
718
-
}]
719
-
}))
720
-
.send()
721
-
.await
722
-
.expect("Failed to send request");
723
assert!(
724
-
res.status().is_client_error(),
725
-
"applyWrites should fail for migrated account: {}",
726
-
res.status()
727
-
);
728
-
let res = client
729
-
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
730
-
.bearer_auth(&jwt)
731
-
.header("Content-Type", "text/plain")
732
-
.body("test blob content")
733
-
.send()
734
-
.await
735
-
.expect("Failed to send request");
736
-
assert!(
737
-
res.status().is_client_error(),
738
-
"uploadBlob should fail for migrated account: {}",
739
-
res.status()
740
);
741
}
742
743
#[tokio::test]
744
-
async fn test_migrated_session_status() {
745
let client = client();
746
let base = base_url().await;
747
-
let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
748
let payload = json!({
749
"handle": handle,
750
"email": format!("{}@example.com", handle),
···
762
let did = body["did"].as_str().expect("No DID").to_string();
763
let jwt = verify_new_account(&client, &did).await;
764
let res = client
765
-
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
766
-
.bearer_auth(&jwt)
767
-
.send()
768
-
.await
769
-
.expect("Failed to send request");
770
-
assert_eq!(res.status(), StatusCode::OK);
771
-
let body: Value = res.json().await.expect("Response was not JSON");
772
-
assert_eq!(body["active"], true);
773
-
assert!(
774
-
body["status"].is_null() || body["status"] == "active",
775
-
"Status should be null or 'active' for normal accounts"
776
-
);
777
-
let target_pds = "https://pds3.example.com";
778
-
let res = client
779
.post(format!(
780
"{}/xrpc/com.atproto.server.deactivateAccount",
781
base
782
))
783
.bearer_auth(&jwt)
784
-
.json(&json!({ "migratingTo": target_pds }))
785
.send()
786
.await
787
.expect("Failed to send request");
···
794
.expect("Failed to send request");
795
assert_eq!(res.status(), StatusCode::OK);
796
let body: Value = res.json().await.expect("Response was not JSON");
797
-
assert_eq!(
798
-
body["active"], false,
799
-
"Migrated account should not be active"
800
-
);
801
-
assert_eq!(
802
-
body["status"], "migrated",
803
-
"Status should be 'migrated' after migration"
804
-
);
805
-
assert_eq!(
806
-
body["migratedToPds"], target_pds,
807
-
"migratedToPds should be set to target PDS"
808
-
);
809
-
}
810
-
811
-
#[tokio::test]
812
-
async fn test_migrating_to_ignored_for_did_plc() {
813
-
let client = client();
814
-
let base = base_url().await;
815
-
let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
816
-
let payload = json!({
817
-
"handle": handle,
818
-
"email": format!("{}@example.com", handle),
819
-
"password": "Testpass123!",
820
-
"didType": "plc"
821
-
});
822
-
let res = client
823
-
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
824
-
.json(&payload)
825
-
.send()
826
-
.await
827
-
.expect("Failed to send request");
828
-
assert_eq!(res.status(), StatusCode::OK);
829
-
let body: Value = res.json().await.expect("Response was not JSON");
830
-
let did = body["did"].as_str().expect("No DID").to_string();
831
-
assert!(did.starts_with("did:plc:"), "Should be did:plc account");
832
-
let jwt = verify_new_account(&client, &did).await;
833
-
let res = client
834
-
.post(format!(
835
-
"{}/xrpc/com.atproto.server.deactivateAccount",
836
-
base
837
-
))
838
-
.bearer_auth(&jwt)
839
-
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
840
-
.send()
841
-
.await
842
-
.expect("Failed to send request");
843
-
assert_eq!(res.status(), StatusCode::OK);
844
-
let pool = get_test_db_pool().await;
845
-
let row = sqlx::query!(
846
-
r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
847
-
&did
848
-
)
849
-
.fetch_one(pool)
850
-
.await
851
-
.expect("Failed to query user");
852
-
assert!(
853
-
row.migrated_to_pds.is_none(),
854
-
"migrated_to_pds should NOT be set for did:plc accounts"
855
-
);
856
-
assert!(
857
-
row.deactivated_at.is_some(),
858
-
"deactivated_at should still be set"
859
-
);
860
-
let res = client
861
-
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
862
-
.bearer_auth(&jwt)
863
-
.send()
864
-
.await
865
-
.expect("Failed to send request");
866
-
assert_eq!(res.status(), StatusCode::OK);
867
-
let body: Value = res.json().await.expect("Response was not JSON");
868
-
assert_eq!(body["active"], false);
869
assert_eq!(
870
body["status"], "deactivated",
871
-
"Status should be 'deactivated' not 'migrated' for did:plc"
872
-
);
873
-
assert!(
874
-
body["migratedToPds"].is_null(),
875
-
"migratedToPds should not be set for did:plc accounts"
876
);
877
}
···
547
}
548
549
#[tokio::test]
550
+
async fn test_did_web_can_edit_did_document() {
551
let client = client();
552
let base = base_url().await;
553
+
let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
554
let payload = json!({
555
"handle": handle,
556
"email": format!("{}@example.com", handle),
···
567
let body: Value = res.json().await.expect("Response was not JSON");
568
let did = body["did"].as_str().expect("No DID").to_string();
569
let jwt = verify_new_account(&client, &did).await;
570
let res = client
571
+
.get(format!("{}/xrpc/_account.getDidDocument", base))
572
.bearer_auth(&jwt)
573
.send()
574
.await
575
.expect("Failed to send request");
576
assert_eq!(res.status(), StatusCode::OK);
577
let body: Value = res.json().await.expect("Response was not JSON");
578
assert!(
579
+
body["didDocument"].is_object(),
580
+
"Should return DID document"
581
);
582
+
assert_eq!(
583
+
body["didDocument"]["id"], did,
584
+
"DID document should have correct id"
585
);
586
let res = client
587
+
.post(format!("{}/xrpc/_account.updateDidDocument", base))
588
.bearer_auth(&jwt)
589
.json(&json!({
590
+
"alsoKnownAs": ["at://custom.handle.test"]
591
}))
592
.send()
593
.await
594
.expect("Failed to send request");
595
+
assert_eq!(
596
+
res.status(),
597
+
StatusCode::OK,
598
+
"Non-migrated did:web user should be able to update DID document"
599
);
600
+
let body: Value = res.json().await.expect("Response was not JSON");
601
+
assert!(body["success"].as_bool().unwrap_or(false));
602
+
let also_known_as = body["didDocument"]["alsoKnownAs"]
603
+
.as_array()
604
+
.expect("alsoKnownAs should be array");
605
assert!(
606
+
also_known_as
607
+
.iter()
608
+
.any(|v| v.as_str() == Some("at://custom.handle.test")),
609
+
"alsoKnownAs should contain custom entry"
610
);
611
}
612
613
#[tokio::test]
614
+
async fn test_deactivate_account_basic() {
615
let client = client();
616
let base = base_url().await;
617
+
let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
618
let payload = json!({
619
"handle": handle,
620
"email": format!("{}@example.com", handle),
···
632
let did = body["did"].as_str().expect("No DID").to_string();
633
let jwt = verify_new_account(&client, &did).await;
634
let res = client
635
.post(format!(
636
"{}/xrpc/com.atproto.server.deactivateAccount",
637
base
638
))
639
.bearer_auth(&jwt)
640
+
.json(&json!({}))
641
.send()
642
.await
643
.expect("Failed to send request");
···
650
.expect("Failed to send request");
651
assert_eq!(res.status(), StatusCode::OK);
652
let body: Value = res.json().await.expect("Response was not JSON");
653
+
assert_eq!(body["active"], false, "Account should be deactivated");
654
assert_eq!(
655
body["status"], "deactivated",
656
+
"Status should be 'deactivated'"
657
);
658
}
-1
tests/oauth.rs
-1
tests/oauth.rs
+1
-4
tests/oauth_security.rs
+1
-4
tests/oauth_security.rs
+9
-36
tests/session_management.rs
+9
-36
tests/session_management.rs
···
10
let client = client();
11
let (did, jwt) = setup_new_user("list-sessions").await;
12
let res = client
13
-
.get(format!(
14
-
"{}/xrpc/com.tranquil.account.listSessions",
15
-
base_url().await
16
-
))
17
.bearer_auth(&jwt)
18
.send()
19
.await
···
83
let login_body: Value = login_res.json().await.unwrap();
84
let jwt2 = login_body["accessJwt"].as_str().unwrap();
85
let list_res = client
86
-
.get(format!(
87
-
"{}/xrpc/com.tranquil.account.listSessions",
88
-
base_url().await
89
-
))
90
.bearer_auth(jwt2)
91
.send()
92
.await
···
106
async fn test_list_sessions_requires_auth() {
107
let client = client();
108
let res = client
109
-
.get(format!(
110
-
"{}/xrpc/com.tranquil.account.listSessions",
111
-
base_url().await
112
-
))
113
.send()
114
.await
115
.expect("Failed to send request");
···
158
let login_body: Value = login_res.json().await.unwrap();
159
let jwt2 = login_body["accessJwt"].as_str().unwrap();
160
let list_res = client
161
-
.get(format!(
162
-
"{}/xrpc/com.tranquil.account.listSessions",
163
-
base_url().await
164
-
))
165
.bearer_auth(jwt2)
166
.send()
167
.await
···
177
);
178
let session_id = other_session.unwrap()["id"].as_str().unwrap();
179
let revoke_res = client
180
-
.post(format!(
181
-
"{}/xrpc/com.tranquil.account.revokeSession",
182
-
base_url().await
183
-
))
184
.bearer_auth(jwt2)
185
.json(&json!({"sessionId": session_id}))
186
.send()
···
188
.expect("Failed to revoke session");
189
assert_eq!(revoke_res.status(), StatusCode::OK);
190
let list_after_res = client
191
-
.get(format!(
192
-
"{}/xrpc/com.tranquil.account.listSessions",
193
-
base_url().await
194
-
))
195
.bearer_auth(jwt2)
196
.send()
197
.await
···
213
let client = client();
214
let (_, jwt) = setup_new_user("revoke-invalid").await;
215
let res = client
216
-
.post(format!(
217
-
"{}/xrpc/com.tranquil.account.revokeSession",
218
-
base_url().await
219
-
))
220
.bearer_auth(&jwt)
221
.json(&json!({"sessionId": "not-a-number"}))
222
.send()
···
230
let client = client();
231
let (_, jwt) = setup_new_user("revoke-notfound").await;
232
let res = client
233
-
.post(format!(
234
-
"{}/xrpc/com.tranquil.account.revokeSession",
235
-
base_url().await
236
-
))
237
.bearer_auth(&jwt)
238
.json(&json!({"sessionId": "jwt:999999999"}))
239
.send()
···
246
async fn test_revoke_session_requires_auth() {
247
let client = client();
248
let res = client
249
-
.post(format!(
250
-
"{}/xrpc/com.tranquil.account.revokeSession",
251
-
base_url().await
252
-
))
253
.json(&json!({"sessionId": "1"}))
254
.send()
255
.await
···
10
let client = client();
11
let (did, jwt) = setup_new_user("list-sessions").await;
12
let res = client
13
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
14
.bearer_auth(&jwt)
15
.send()
16
.await
···
80
let login_body: Value = login_res.json().await.unwrap();
81
let jwt2 = login_body["accessJwt"].as_str().unwrap();
82
let list_res = client
83
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
84
.bearer_auth(jwt2)
85
.send()
86
.await
···
100
async fn test_list_sessions_requires_auth() {
101
let client = client();
102
let res = client
103
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
104
.send()
105
.await
106
.expect("Failed to send request");
···
149
let login_body: Value = login_res.json().await.unwrap();
150
let jwt2 = login_body["accessJwt"].as_str().unwrap();
151
let list_res = client
152
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
153
.bearer_auth(jwt2)
154
.send()
155
.await
···
165
);
166
let session_id = other_session.unwrap()["id"].as_str().unwrap();
167
let revoke_res = client
168
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
169
.bearer_auth(jwt2)
170
.json(&json!({"sessionId": session_id}))
171
.send()
···
173
.expect("Failed to revoke session");
174
assert_eq!(revoke_res.status(), StatusCode::OK);
175
let list_after_res = client
176
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
177
.bearer_auth(jwt2)
178
.send()
179
.await
···
195
let client = client();
196
let (_, jwt) = setup_new_user("revoke-invalid").await;
197
let res = client
198
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
199
.bearer_auth(&jwt)
200
.json(&json!({"sessionId": "not-a-number"}))
201
.send()
···
209
let client = client();
210
let (_, jwt) = setup_new_user("revoke-notfound").await;
211
let res = client
212
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
213
.bearer_auth(&jwt)
214
.json(&json!({"sessionId": "jwt:999999999"}))
215
.send()
···
222
async fn test_revoke_session_requires_auth() {
223
let client = client();
224
let res = client
225
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
226
.json(&json!({"sessionId": "1"}))
227
.send()
228
.await