Tranquil PDS! Moved to https://tangled.org/tranquil.farm/tranquil-pds

Fix replying to other users not in the same PDS, add PAR for oauth and fix minor bugs #10

closed opened by scanash.com targeting main from [deleted fork]: main
Labels

None yet.

assignee

None yet.

Participants 3
AT URI
at://did:plc:3i6uzuatdyk7rwfkrybynf5j/sh.tangled.repo.pull/3mbekljkj5d22
+7619 -2898
Interdiff #1 โ†’ #2
+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
··· 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
··· 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
··· 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
··· 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 + }
-34
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, migrated_to_pds, handle 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": "migrated_to_pds", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "handle", 19 - "type_info": "Text" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - true, 30 - false 31 - ] 32 - }, 33 - "hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073" 34 - }
-30
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.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 - { 7 - "ordinal": 0, 8 - "name": "blob_cid", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "record_uri", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid", 20 - "Text", 21 - "Int8" 22 - ] 23 - }, 24 - "nullable": [ 25 - false, 26 - false 27 - ] 28 - }, 29 - "hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115" 30 - }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 + }
+30
.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\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 + { 7 + "ordinal": 0, 8 + "name": "blob_cid", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "record_uri", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Text", 21 + "Int8" 22 + ] 23 + }, 24 + "nullable": [ 25 + false, 26 + false 27 + ] 28 + }, 29 + "hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf" 30 + }
+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
··· 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
··· 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
··· 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
··· 111 111 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 112 112 113 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]] 114 123 name = "arc-swap" 115 124 version = "1.7.1" 116 125 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1621 1630 ] 1622 1631 1623 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]] 1624 1644 name = "derive_more" 1625 1645 version = "1.0.0" 1626 1646 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1973 1993 checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 1974 1994 dependencies = [ 1975 1995 "crc32fast", 1996 + "libz-rs-sys", 1976 1997 "miniz_oxide", 1977 1998 ] 1978 1999 ··· 3457 3478 dependencies = [ 3458 3479 "pkg-config", 3459 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", 3460 3490 ] 3461 3491 3462 3492 [[package]] ··· 6286 6316 "ed25519-dalek", 6287 6317 "futures", 6288 6318 "governor", 6319 + "hex", 6289 6320 "hickory-resolver", 6290 6321 "hkdf", 6291 6322 "hmac", ··· 6329 6360 "webauthn-rs", 6330 6361 "webauthn-rs-proto", 6331 6362 "wiremock", 6363 + "zip", 6332 6364 ] 6333 6365 6334 6366 [[package]] ··· 7289 7321 "proc-macro2", 7290 7322 "quote", 7291 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", 7292 7356 ] 7293 7357 7294 7358 [[package]]
+2
Cargo.toml
··· 19 19 dotenvy = "0.15.7" 20 20 futures = "0.3.30" 21 21 governor = "0.10" 22 + hex = "0.4" 22 23 hkdf = "0.12" 23 24 hmac = "0.12" 24 25 aes-gcm = "0.10" ··· 62 63 totp-rs = { version = "5", features = ["qr"] } 63 64 webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 64 65 webauthn-rs-proto = "0.5.4" 66 + zip = { version = "7.0.0", default-features = false, features = ["deflate"] } 65 67 [features] 66 68 external-infra = [] 67 69 [dev-dependencies]
Dockerfile

This file has not been changed.

+1 -1
README.md
··· 14 14 15 15 This software isn't an afterthought by a company with limited resources. 16 16 17 + It is a superset of the reference PDS, including: 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. 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 18 19 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20 20
+2 -15
TODO.md
··· 2 2 3 3 ## Active development 4 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 5 ### Plugin system 21 6 Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 22 7 ··· 69 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. 70 55 71 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
··· 1 1 { 2 2 "version": "5", 3 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", 4 8 "npm:@noble/secp256k1@^2.1.0": "2.3.0", 5 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", 6 10 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", ··· 30 34 "lru-cache" 31 35 ] 32 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 + }, 33 111 "@babel/code-frame@7.27.1": { 34 112 "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 35 113 "dependencies": [ ··· 43 121 }, 44 122 "@babel/runtime@7.28.4": { 45 123 "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" 124 + }, 125 + "@badrap/valita@0.4.6": { 126 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 46 127 }, 47 128 "@csstools/color-helpers@5.1.0": { 48 129 "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" ··· 498 579 "@noble/secp256k1@2.3.0": { 499 580 "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 500 581 }, 582 + "@noble/secp256k1@3.0.0": { 583 + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" 584 + }, 501 585 "@rollup/rollup-android-arm-eabi@4.53.3": { 502 586 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", 503 587 "os": ["android"], ··· 607 691 "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", 608 692 "os": ["win32"], 609 693 "cpu": ["x64"] 694 + }, 695 + "@standard-schema/spec@1.1.0": { 696 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" 610 697 }, 611 698 "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { 612 699 "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", ··· 1545 1632 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1546 1633 "bin": true 1547 1634 }, 1635 + "unicode-segmenter@0.14.4": { 1636 + "integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==" 1637 + }, 1548 1638 "vite-node@2.1.9": { 1549 1639 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1550 1640 "dependencies": [ ··· 1671 1761 "workspace": { 1672 1762 "packageJson": { 1673 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", 1674 1768 "npm:@noble/secp256k1@^2.1.0", 1675 1769 "npm:@sveltejs/vite-plugin-svelte@5", 1676 1770 "npm:@testing-library/jest-dom@^6.6.3",
+4
frontend/package.json
··· 12 12 "test:coverage": "vitest run --coverage" 13 13 }, 14 14 "dependencies": { 15 + "@atcute/cbor": "^2.2.8", 16 + "@atcute/crypto": "^2.3.0", 17 + "@atcute/did-plc": "^0.3.1", 18 + "@atcute/multibase": "^1.1.6", 15 19 "@noble/secp256k1": "^2.1.0", 16 20 "multiformats": "^13.3.1", 17 21 "svelte-i18n": "^4.0.1"
+2 -2
frontend/src/components/ReauthModal.svelte
··· 228 228 /> 229 229 </div> 230 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 + {loading ? $_('common.verifying') : $_('common.verify')} 231 - {loading ? $_('reauth.verifying') : $_('reauth.verify')} 232 232 </button> 233 233 </form> 234 234 {:else if activeMethod === 'totp'} ··· 247 247 /> 248 248 </div> 249 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 + {loading ? $_('common.verifying') : $_('common.verify')} 250 - {loading ? $_('reauth.verifying') : $_('reauth.verify')} 251 251 </button> 252 252 </form> 253 253 {:else if activeMethod === 'passkey'}
+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
··· 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
··· 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
··· 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
··· 5 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 6 import { _ } from '../../lib/i18n' 7 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' 8 14 9 15 interface ResumeInfo { 16 + direction: 'inbound' 10 - direction: 'inbound' | 'outbound' 11 17 sourceHandle: string 12 18 targetHandle: string 13 19 sourcePdsUrl: string ··· 37 43 let checkingHandle = $state(false) 38 44 let selectedAuthMethod = $state<AuthMethod>('password') 39 45 let passkeyName = $state('') 40 - let appPasswordCopied = $state(false) 41 - let appPasswordAcknowledged = $state(false) 42 46 43 47 const isResuming = $derived(flow.state.needsReauth === true) 44 48 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) ··· 234 238 } 235 239 } 236 240 237 - function copyAppPassword() { 238 - if (flow.state.generatedAppPassword) { 239 - navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 - appPasswordCopied = true 241 - } 242 - } 243 - 244 241 async function handleProceedFromAppPassword() { 245 242 loading = true 246 243 try { ··· 352 349 </label> 353 350 354 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')}> 355 - <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 - <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 357 354 {$_('migration.inbound.common.continue')} 358 355 </button> 359 356 </div> ··· 409 406 </div> 410 407 411 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 + /> 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 432 538 433 {:else if flow.state.step === 'review'} 539 434 <div class="step-content"> ··· 620 515 </div> 621 516 622 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 + /> 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 525 653 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 + /> 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 533 684 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 + /> 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 544 726 545 {:else if flow.state.step === 'plc-token'} 727 546 <div class="step-content"> ··· 837 656 </div> 838 657 839 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> 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 668 865 669 {:else if flow.state.step === 'error'} 670 + <ErrorStep error={flow.state.error} onStartOver={onBack} /> 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 671 {/if} 879 672 </div> 880 673 881 674 <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 675 .resume-info { 918 676 margin-bottom: var(--space-5); 919 677 }
+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
··· 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
··· 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
··· 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
··· 205 205 return data; 206 206 }, 207 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 + 208 240 async confirmSignup( 209 241 did: string, 210 242 verificationCode: string, ··· 226 258 return xrpc("com.atproto.server.createSession", { 227 259 method: "POST", 228 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 }, 229 268 }); 230 269 }, 231 270 ··· 379 418 signalNumber: string | null; 380 419 signalVerified: boolean; 381 420 }> { 421 + return xrpc("_account.getNotificationPrefs", { token }); 382 - return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 383 422 }, 384 423 385 424 async updateNotificationPrefs(token: string, prefs: { ··· 388 427 telegramUsername?: string; 389 428 signalNumber?: string; 390 429 }): Promise<{ success: boolean }> { 430 + return xrpc("_account.updateNotificationPrefs", { 391 - return xrpc("com.tranquil.account.updateNotificationPrefs", { 392 431 method: "POST", 393 432 token, 394 433 body: prefs, ··· 401 440 identifier: string, 402 441 code: string, 403 442 ): Promise<{ success: boolean }> { 443 + return xrpc("_account.confirmChannelVerification", { 404 - return xrpc("com.tranquil.account.confirmChannelVerification", { 405 444 method: "POST", 406 445 token, 407 446 body: { channel, identifier, code }, ··· 418 457 body: string; 419 458 }>; 420 459 }> { 460 + return xrpc("_account.getNotificationHistory", { token }); 421 - return xrpc("com.tranquil.account.getNotificationHistory", { token }); 422 461 }, 423 462 424 463 async getServerStats(token: string): Promise<{ ··· 427 466 recordCount: number; 428 467 blobStorageBytes: number; 429 468 }> { 469 + return xrpc("_admin.getServerStats", { token }); 430 - return xrpc("com.tranquil.admin.getServerStats", { token }); 431 470 }, 432 471 433 472 async getServerConfig(): Promise<{ ··· 438 477 secondaryColorDark: string | null; 439 478 logoCid: string | null; 440 479 }> { 480 + return xrpc("_server.getConfig"); 441 - return xrpc("com.tranquil.server.getConfig"); 442 481 }, 443 482 444 483 async updateServerConfig( ··· 452 491 logoCid?: string; 453 492 }, 454 493 ): Promise<{ success: boolean }> { 494 + return xrpc("_admin.updateServerConfig", { 455 - return xrpc("com.tranquil.admin.updateServerConfig", { 456 495 method: "POST", 457 496 token, 458 497 body: config, ··· 495 534 currentPassword: string, 496 535 newPassword: string, 497 536 ): Promise<void> { 537 + await xrpc("_account.changePassword", { 498 - await xrpc("com.tranquil.account.changePassword", { 499 538 method: "POST", 500 539 token, 501 540 body: { currentPassword, newPassword }, ··· 503 542 }, 504 543 505 544 async removePassword(token: string): Promise<{ success: boolean }> { 545 + return xrpc("_account.removePassword", { 506 - return xrpc("com.tranquil.account.removePassword", { 507 546 method: "POST", 508 547 token, 509 548 }); 510 549 }, 511 550 512 551 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 + return xrpc("_account.getPasswordStatus", { token }); 513 - return xrpc("com.tranquil.account.getPasswordStatus", { token }); 514 553 }, 515 554 516 555 async getLegacyLoginPreference( 517 556 token: string, 518 557 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 + return xrpc("_account.getLegacyLoginPreference", { token }); 519 - return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 520 559 }, 521 560 522 561 async updateLegacyLoginPreference( 523 562 token: string, 524 563 allowLegacyLogin: boolean, 525 564 ): Promise<{ allowLegacyLogin: boolean }> { 565 + return xrpc("_account.updateLegacyLoginPreference", { 526 - return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 527 566 method: "POST", 528 567 token, 529 568 body: { allowLegacyLogin }, ··· 534 573 token: string, 535 574 preferredLocale: string, 536 575 ): Promise<{ preferredLocale: string }> { 576 + return xrpc("_account.updateLocale", { 537 - return xrpc("com.tranquil.account.updateLocale", { 538 577 method: "POST", 539 578 token, 540 579 body: { preferredLocale }, ··· 551 590 isCurrent: boolean; 552 591 }>; 553 592 }> { 593 + return xrpc("_account.listSessions", { token }); 554 - return xrpc("com.tranquil.account.listSessions", { token }); 555 594 }, 556 595 557 596 async revokeSession(token: string, sessionId: string): Promise<void> { 597 + await xrpc("_account.revokeSession", { 558 - await xrpc("com.tranquil.account.revokeSession", { 559 598 method: "POST", 560 599 token, 561 600 body: { sessionId }, ··· 563 602 }, 564 603 565 604 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 + return xrpc("_account.revokeAllSessions", { 566 - return xrpc("com.tranquil.account.revokeAllSessions", { 567 606 method: "POST", 568 607 token, 569 608 }); ··· 868 907 lastSeenAt: string; 869 908 }>; 870 909 }> { 910 + return xrpc("_account.listTrustedDevices", { token }); 871 - return xrpc("com.tranquil.account.listTrustedDevices", { token }); 872 911 }, 873 912 874 913 async revokeTrustedDevice( 875 914 token: string, 876 915 deviceId: string, 877 916 ): Promise<{ success: boolean }> { 917 + return xrpc("_account.revokeTrustedDevice", { 878 - return xrpc("com.tranquil.account.revokeTrustedDevice", { 879 918 method: "POST", 880 919 token, 881 920 body: { deviceId }, ··· 887 926 deviceId: string, 888 927 friendlyName: string, 889 928 ): Promise<{ success: boolean }> { 929 + return xrpc("_account.updateTrustedDevice", { 890 - return xrpc("com.tranquil.account.updateTrustedDevice", { 891 930 method: "POST", 892 931 token, 893 932 body: { deviceId, friendlyName }, ··· 899 938 lastReauthAt: string | null; 900 939 availableMethods: string[]; 901 940 }> { 941 + return xrpc("_account.getReauthStatus", { token }); 902 - return xrpc("com.tranquil.account.getReauthStatus", { token }); 903 942 }, 904 943 905 944 async reauthPassword( 906 945 token: string, 907 946 password: string, 908 947 ): Promise<{ success: boolean; reauthAt: string }> { 948 + return xrpc("_account.reauthPassword", { 909 - return xrpc("com.tranquil.account.reauthPassword", { 910 949 method: "POST", 911 950 token, 912 951 body: { password }, ··· 917 956 token: string, 918 957 code: string, 919 958 ): Promise<{ success: boolean; reauthAt: string }> { 959 + return xrpc("_account.reauthTotp", { 920 - return xrpc("com.tranquil.account.reauthTotp", { 921 960 method: "POST", 922 961 token, 923 962 body: { code }, ··· 925 964 }, 926 965 927 966 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 + return xrpc("_account.reauthPasskeyStart", { 928 - return xrpc("com.tranquil.account.reauthPasskeyStart", { 929 968 method: "POST", 930 969 token, 931 970 }); ··· 935 974 token: string, 936 975 credential: unknown, 937 976 ): Promise<{ success: boolean; reauthAt: string }> { 977 + return xrpc("_account.reauthPasskeyFinish", { 938 - return xrpc("com.tranquil.account.reauthPasskeyFinish", { 939 978 method: "POST", 940 979 token, 941 980 body: { credential }, ··· 982 1021 setupToken: string; 983 1022 setupExpiresAt: string; 984 1023 }> { 1024 + const url = `${API_BASE}/_account.createPasskeyAccount`; 985 - const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 986 1025 const headers: Record<string, string> = { 987 1026 "Content-Type": "application/json", 988 1027 }; ··· 1009 1048 setupToken: string, 1010 1049 friendlyName?: string, 1011 1050 ): Promise<{ options: unknown }> { 1051 + return xrpc("_account.startPasskeyRegistrationForSetup", { 1012 - return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 1013 1052 method: "POST", 1014 1053 body: { did, setupToken, friendlyName }, 1015 1054 }); ··· 1026 1065 appPassword: string; 1027 1066 appPasswordName: string; 1028 1067 }> { 1068 + return xrpc("_account.completePasskeySetup", { 1029 - return xrpc("com.tranquil.account.completePasskeySetup", { 1030 1069 method: "POST", 1031 1070 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1032 1071 }); 1033 1072 }, 1034 1073 1035 1074 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 + return xrpc("_account.requestPasskeyRecovery", { 1036 - return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1037 1076 method: "POST", 1038 1077 body: { email }, 1039 1078 }); ··· 1044 1083 recoveryToken: string, 1045 1084 newPassword: string, 1046 1085 ): Promise<{ success: boolean }> { 1086 + return xrpc("_account.recoverPasskeyAccount", { 1047 - return xrpc("com.tranquil.account.recoverPasskeyAccount", { 1048 1087 method: "POST", 1049 1088 body: { did, recoveryToken, newPassword }, 1050 1089 }); ··· 1077 1116 purpose: string; 1078 1117 channel: string; 1079 1118 }> { 1119 + return xrpc("_account.verifyToken", { 1080 - return xrpc("com.tranquil.account.verifyToken", { 1081 1120 method: "POST", 1082 1121 body: { token, identifier }, 1083 1122 token: accessToken, ··· 1085 1124 }, 1086 1125 1087 1126 async getDidDocument(token: string): Promise<DidDocument> { 1127 + return xrpc("_account.getDidDocument", { token }); 1088 - return xrpc("com.tranquil.account.getDidDocument", { token }); 1089 1128 }, 1090 1129 1091 1130 async updateDidDocument( ··· 1096 1135 serviceEndpoint?: string; 1097 1136 }, 1098 1137 ): Promise<{ success: boolean }> { 1138 + return xrpc("_account.updateDidDocument", { 1099 - return xrpc("com.tranquil.account.updateDidDocument", { 1100 1139 method: "POST", 1101 1140 token, 1102 1141 body: params, ··· 1106 1145 async deactivateAccount( 1107 1146 token: string, 1108 1147 deleteAfter?: string, 1109 - migratingTo?: string, 1110 1148 ): Promise<void> { 1111 1149 await xrpc("com.atproto.server.deactivateAccount", { 1112 1150 method: "POST", 1113 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}` }, 1114 - body: { deleteAfter, migratingTo }, 1115 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(); 1116 1171 }, 1117 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; 1118 - async getMigrationStatus(token: string): Promise<{ 1119 - migratedToPds?: string; 1120 - migratedAt?: string; 1121 - forwardingEnabled: boolean; 1122 1183 }> { 1184 + return xrpc("_backup.listBackups", { token }); 1123 - return xrpc("com.tranquil.account.getMigrationStatus", { token }); 1124 1185 }, 1125 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", { 1126 - async updateMigrationForwarding( 1127 - token: string, 1128 - forwardingPds?: string, 1129 - ): Promise<{ success: boolean }> { 1130 - return xrpc("com.tranquil.account.updateMigrationForwarding", { 1131 1209 method: "POST", 1132 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 }, 1133 - body: { forwardingPds }, 1134 1219 }); 1135 1220 }, 1136 1221 1222 + async setBackupEnabled( 1223 + token: string, 1224 + enabled: boolean, 1225 + ): Promise<{ enabled: boolean }> { 1226 + return xrpc("_backup.setEnabled", { 1137 - async clearMigrationForwarding(token: string): Promise<{ success: boolean }> { 1138 - return xrpc("com.tranquil.account.clearMigrationForwarding", { 1139 1227 method: "POST", 1140 1228 token, 1229 + body: { enabled }, 1141 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 + } 1142 1250 }, 1143 1251 };
+16 -42
frontend/src/lib/migration/atproto-client.ts
··· 372 372 ); 373 373 } 374 374 375 - async deactivateAccount(migratingTo?: string): Promise<void> { 375 + async deactivateAccount(): Promise<void> { 376 376 apiLog( 377 377 "POST", 378 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 - { 380 - migratingTo, 381 - }, 382 379 ); 383 380 const start = Date.now(); 384 381 try { 385 - const body: { migratingTo?: string } = {}; 386 - if (migratingTo) { 387 - body.migratingTo = migratingTo; 388 - } 389 382 await this.xrpc("com.atproto.server.deactivateAccount", { 390 383 httpMethod: "POST", 391 - body, 392 384 }); 393 385 apiLog( 394 386 "POST", ··· 396 388 { 397 389 durationMs: Date.now() - start, 398 390 success: true, 399 - migratingTo, 400 391 }, 401 392 ); 402 393 } catch (e) { ··· 409 400 error: err.message, 410 401 errorCode: err.error, 411 402 status: err.status, 412 - migratingTo, 413 403 }, 414 404 ); 415 405 throw e; ··· 420 410 return this.xrpc("com.atproto.server.checkAccountStatus"); 421 411 } 422 412 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 413 async resolveHandle(handle: string): Promise<{ did: string }> { 451 414 return this.xrpc("com.atproto.identity.resolveHandle", { 452 415 params: { handle }, ··· 468 431 return session; 469 432 } 470 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 + 471 445 async verifyToken( 472 446 token: string, 473 447 identifier: string, 474 448 ): Promise< 475 449 { success: boolean; did: string; purpose: string; channel: string } 476 450 > { 477 - return this.xrpc("com.tranquil.account.verifyToken", { 451 + return this.xrpc("_account.verifyToken", { 478 452 httpMethod: "POST", 479 453 body: { token, identifier }, 480 454 }); ··· 498 472 } 499 473 500 474 const res = await fetch( 501 - `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 475 + `${this.baseUrl}/xrpc/_account.createPasskeyAccount`, 502 476 { 503 477 method: "POST", 504 478 headers, ··· 530 504 setupToken: string, 531 505 friendlyName?: string, 532 506 ): Promise<StartPasskeyRegistrationResponse> { 533 - return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 507 + return this.xrpc("_account.startPasskeyRegistrationForSetup", { 534 508 httpMethod: "POST", 535 509 body: { did, setupToken, friendlyName }, 536 510 }); ··· 542 516 passkeyCredential: unknown, 543 517 passkeyFriendlyName?: string, 544 518 ): Promise<CompletePasskeySetupResponse> { 545 - return this.xrpc("com.tranquil.account.completePasskeySetup", { 519 + return this.xrpc("_account.completePasskeySetup", { 546 520 httpMethod: "POST", 547 521 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 548 522 });
+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 + }
+18 -289
frontend/src/lib/migration/flow.svelte.ts
··· 2 2 InboundMigrationState, 3 3 InboundStep, 4 4 MigrationProgress, 5 - OutboundMigrationState, 6 - OutboundStep, 7 5 PasskeyAccountSetup, 8 6 ServerDescription, 9 7 StoredMigrationState, ··· 32 30 updateProgress, 33 31 updateStep, 34 32 } from "./storage"; 33 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 35 34 36 35 function migrationLog(stage: string, data?: Record<string, unknown>) { 37 36 const timestamp = new Date().toISOString(); ··· 88 87 let sourceClient: AtprotoClient | null = null; 89 88 let localClient: AtprotoClient | null = null; 90 89 let localServerInfo: ServerDescription | null = null; 91 - let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = null; 90 + let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 91 + null; 92 92 93 93 function setStep(step: InboundStep) { 94 94 state.step = step; 95 95 state.error = null; 96 - saveMigrationState(state); 97 - updateStep(step); 96 + if (step !== "success") { 97 + saveMigrationState(state); 98 + updateStep(step); 99 + } 98 100 } 99 101 100 102 function setError(error: string | null) { ··· 518 520 519 521 cursor = nextCursor; 520 522 } while (cursor); 523 + const result = await migrateBlobsUtil( 524 + localClient, 525 + sourceClient, 526 + state.sourceDid, 527 + setProgress, 528 + ); 529 + 530 + state.progress.blobsFailed = result.failed; 521 531 } 522 532 523 533 async function migratePreferences(): Promise<void> { ··· 607 617 608 618 checkingEmailVerification = true; 609 619 try { 620 + const verified = await localClient.checkEmailVerified(state.targetEmail); 621 + if (!verified) return false; 622 + 610 623 await localClient.loginDeactivated( 611 624 state.targetEmail, 612 625 state.targetPassword, ··· 1007 1020 }; 1008 1021 } 1009 1022 1010 - export function createOutboundMigrationFlow() { 1011 - // @ts-ignore 1012 - let state = $state<OutboundMigrationState>({ 1013 - direction: "outbound", 1014 - step: "welcome", 1015 - localDid: "", 1016 - localHandle: "", 1017 - targetPdsUrl: "", 1018 - targetPdsDid: "", 1019 - targetHandle: "", 1020 - targetEmail: "", 1021 - targetPassword: "", 1022 - inviteCode: "", 1023 - targetAccessToken: null, 1024 - targetRefreshToken: null, 1025 - serviceAuthToken: null, 1026 - plcToken: "", 1027 - progress: createInitialProgress(), 1028 - error: null, 1029 - targetServerInfo: null, 1030 - }); 1031 - 1032 - let localClient: AtprotoClient | null = null; 1033 - let targetClient: AtprotoClient | null = null; 1034 - 1035 - function setStep(step: OutboundStep) { 1036 - state.step = step; 1037 - state.error = null; 1038 - saveMigrationState(state); 1039 - updateStep(step); 1040 - } 1041 - 1042 - function setError(error: string) { 1043 - state.error = error; 1044 - saveMigrationState(state); 1045 - } 1046 - 1047 - function setProgress(updates: Partial<MigrationProgress>) { 1048 - state.progress = { ...state.progress, ...updates }; 1049 - updateProgress(updates); 1050 - } 1051 - 1052 - async function validateTargetPds(url: string): Promise<ServerDescription> { 1053 - const normalizedUrl = url.replace(/\/$/, ""); 1054 - targetClient = new AtprotoClient(normalizedUrl); 1055 - 1056 - try { 1057 - const serverInfo = await targetClient.describeServer(); 1058 - state.targetPdsUrl = normalizedUrl; 1059 - state.targetPdsDid = serverInfo.did; 1060 - state.targetServerInfo = serverInfo; 1061 - return serverInfo; 1062 - } catch (e) { 1063 - throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 1064 - } 1065 - } 1066 - 1067 - function initLocalClient( 1068 - accessToken: string, 1069 - did?: string, 1070 - handle?: string, 1071 - ): void { 1072 - localClient = createLocalClient(); 1073 - localClient.setAccessToken(accessToken); 1074 - if (did) { 1075 - state.localDid = did; 1076 - } 1077 - if (handle) { 1078 - state.localHandle = handle; 1079 - } 1080 - } 1081 - 1082 - async function startMigration(currentDid: string): Promise<void> { 1083 - if (!localClient || !targetClient) { 1084 - throw new Error("Not connected to PDSes"); 1085 - } 1086 - 1087 - setStep("migrating"); 1088 - setProgress({ currentOperation: "Getting service auth token..." }); 1089 - 1090 - try { 1091 - const { token } = await localClient.getServiceAuth( 1092 - state.targetPdsDid, 1093 - "com.atproto.server.createAccount", 1094 - ); 1095 - state.serviceAuthToken = token; 1096 - 1097 - setProgress({ currentOperation: "Creating account on new PDS..." }); 1098 - 1099 - const accountParams = { 1100 - did: currentDid, 1101 - handle: state.targetHandle, 1102 - email: state.targetEmail, 1103 - password: state.targetPassword, 1104 - inviteCode: state.inviteCode || undefined, 1105 - }; 1106 - 1107 - const session = await targetClient.createAccount(accountParams, token); 1108 - state.targetAccessToken = session.accessJwt; 1109 - state.targetRefreshToken = session.refreshJwt; 1110 - targetClient.setAccessToken(session.accessJwt); 1111 - 1112 - setProgress({ currentOperation: "Exporting repository..." }); 1113 - 1114 - const car = await localClient.getRepo(currentDid); 1115 - setProgress({ 1116 - repoExported: true, 1117 - currentOperation: "Importing repository...", 1118 - }); 1119 - 1120 - await targetClient.importRepo(car); 1121 - setProgress({ 1122 - repoImported: true, 1123 - currentOperation: "Counting blobs...", 1124 - }); 1125 - 1126 - const accountStatus = await targetClient.checkAccountStatus(); 1127 - setProgress({ 1128 - blobsTotal: accountStatus.expectedBlobs, 1129 - currentOperation: "Migrating blobs...", 1130 - }); 1131 - 1132 - await migrateBlobs(currentDid); 1133 - 1134 - setProgress({ currentOperation: "Migrating preferences..." }); 1135 - await migratePreferences(); 1136 - 1137 - setProgress({ currentOperation: "Requesting PLC operation token..." }); 1138 - await localClient.requestPlcOperationSignature(); 1139 - 1140 - setStep("plc-token"); 1141 - } catch (e) { 1142 - const err = e as Error & { error?: string; status?: number }; 1143 - const message = err.message || err.error || 1144 - `Unknown error (status ${err.status || "unknown"})`; 1145 - setError(message); 1146 - setStep("error"); 1147 - } 1148 - } 1149 - 1150 - async function migrateBlobs(did: string): Promise<void> { 1151 - if (!localClient || !targetClient) return; 1152 - 1153 - let cursor: string | undefined; 1154 - let migrated = 0; 1155 - 1156 - do { 1157 - const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 1158 - cursor, 1159 - 100, 1160 - ); 1161 - 1162 - for (const blob of blobs) { 1163 - try { 1164 - setProgress({ 1165 - currentOperation: `Migrating blob ${migrated + 1 1166 - }/${state.progress.blobsTotal}...`, 1167 - }); 1168 - 1169 - const blobData = await localClient.getBlob(did, blob.cid); 1170 - await targetClient.uploadBlob(blobData, "application/octet-stream"); 1171 - migrated++; 1172 - setProgress({ blobsMigrated: migrated }); 1173 - } catch { 1174 - state.progress.blobsFailed.push(blob.cid); 1175 - } 1176 - } 1177 - 1178 - cursor = nextCursor; 1179 - } while (cursor); 1180 - } 1181 - 1182 - async function migratePreferences(): Promise<void> { 1183 - if (!localClient || !targetClient) return; 1184 - 1185 - try { 1186 - const prefs = await localClient.getPreferences(); 1187 - await targetClient.putPreferences(prefs); 1188 - setProgress({ prefsMigrated: true }); 1189 - } catch { /* optional, best-effort */ } 1190 - } 1191 - 1192 - async function submitPlcToken(token: string): Promise<void> { 1193 - if (!localClient || !targetClient) { 1194 - throw new Error("Not connected to PDSes"); 1195 - } 1196 - 1197 - state.plcToken = token; 1198 - setStep("finalizing"); 1199 - setProgress({ currentOperation: "Signing PLC operation..." }); 1200 - 1201 - try { 1202 - const credentials = await targetClient.getRecommendedDidCredentials(); 1203 - 1204 - const { operation } = await localClient.signPlcOperation({ 1205 - token, 1206 - ...credentials, 1207 - }); 1208 - 1209 - setProgress({ 1210 - plcSigned: true, 1211 - currentOperation: "Submitting PLC operation...", 1212 - }); 1213 - 1214 - await targetClient.submitPlcOperation(operation); 1215 - 1216 - setProgress({ currentOperation: "Activating account on new PDS..." }); 1217 - await targetClient.activateAccount(); 1218 - setProgress({ activated: true }); 1219 - 1220 - setProgress({ currentOperation: "Deactivating old account..." }); 1221 - try { 1222 - await localClient.deactivateAccount(state.targetPdsUrl); 1223 - setProgress({ deactivated: true }); 1224 - } catch { /* optional, best-effort */ } 1225 - 1226 - setStep("success"); 1227 - clearMigrationState(); 1228 - } catch (e) { 1229 - const err = e as Error & { error?: string; status?: number }; 1230 - const message = err.message || err.error || 1231 - `Unknown error (status ${err.status || "unknown"})`; 1232 - setError(message); 1233 - setStep("plc-token"); 1234 - } 1235 - } 1236 - 1237 - async function resendPlcToken(): Promise<void> { 1238 - if (!localClient) { 1239 - throw new Error("Not connected to local PDS"); 1240 - } 1241 - await localClient.requestPlcOperationSignature(); 1242 - } 1243 - 1244 - function reset(): void { 1245 - state = { 1246 - direction: "outbound", 1247 - step: "welcome", 1248 - localDid: "", 1249 - localHandle: "", 1250 - targetPdsUrl: "", 1251 - targetPdsDid: "", 1252 - targetHandle: "", 1253 - targetEmail: "", 1254 - targetPassword: "", 1255 - inviteCode: "", 1256 - targetAccessToken: null, 1257 - targetRefreshToken: null, 1258 - serviceAuthToken: null, 1259 - plcToken: "", 1260 - progress: createInitialProgress(), 1261 - error: null, 1262 - targetServerInfo: null, 1263 - }; 1264 - localClient = null; 1265 - targetClient = null; 1266 - clearMigrationState(); 1267 - } 1268 - 1269 - return { 1270 - get state() { 1271 - return state; 1272 - }, 1273 - setStep, 1274 - setError, 1275 - validateTargetPds, 1276 - initLocalClient, 1277 - startMigration, 1278 - submitPlcToken, 1279 - resendPlcToken, 1280 - reset, 1281 - 1282 - updateField<K extends keyof OutboundMigrationState>( 1283 - field: K, 1284 - value: OutboundMigrationState[K], 1285 - ) { 1286 - state[field] = value; 1287 - }, 1288 - }; 1289 - } 1290 - 1291 1023 export type InboundMigrationFlow = ReturnType< 1292 1024 typeof createInboundMigrationFlow 1293 - >; 1294 - export type OutboundMigrationFlow = ReturnType< 1295 - typeof createOutboundMigrationFlow 1296 1025 >;
+8 -2
frontend/src/lib/migration/index.ts
··· 1 1 export * from "./types"; 2 2 export * from "./atproto-client"; 3 3 export * from "./storage"; 4 + export * from "./blob-migration"; 4 5 export { 5 6 createInboundMigrationFlow, 6 - createOutboundMigrationFlow, 7 7 type InboundMigrationFlow, 8 - type OutboundMigrationFlow, 9 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
··· 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
··· 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
··· 13 13 | "success" 14 14 | "error"; 15 15 16 - export type AuthMethod = "password" | "passkey"; 17 - 18 - export type OutboundStep = 16 + export type OfflineInboundStep = 19 17 | "welcome" 20 - | "target-pds" 21 - | "new-account" 18 + | "provide-did" 19 + | "upload-car" 20 + | "provide-rotation-key" 21 + | "choose-handle" 22 22 | "review" 23 - | "migrating" 24 - | "plc-token" 23 + | "creating" 24 + | "importing" 25 + | "migrating-blobs" 26 + | "plc-signing" 27 + | "email-verify" 28 + | "passkey-setup" 29 + | "app-password" 25 30 | "finalizing" 26 31 | "success" 27 32 | "error"; 28 33 29 - export type MigrationDirection = "inbound" | "outbound"; 34 + export type AuthMethod = "password" | "passkey"; 30 35 36 + export type MigrationDirection = "inbound"; 37 + 31 38 export interface MigrationProgress { 32 39 repoExported: boolean; 33 40 ··· 68 75 resumeToStep?: InboundStep; 69 76 } 70 77 71 - export interface OutboundMigrationState { 72 - direction: "outbound"; 73 - step: OutboundStep; 74 - localDid: string; 75 - localHandle: string; 76 - targetPdsUrl: string; 77 - targetPdsDid: string; 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; 78 89 targetHandle: string; 79 90 targetEmail: string; 80 91 targetPassword: string; 81 92 inviteCode: string; 82 - targetAccessToken: string | null; 83 - targetRefreshToken: string | null; 84 - serviceAuthToken: string | null; 85 - plcToken: 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; 86 100 progress: MigrationProgress; 87 101 error: string | null; 88 - targetServerInfo: ServerDescription | null; 102 + plcUpdatedTemporarily: boolean; 89 103 } 90 104 91 - export type MigrationState = InboundMigrationState | OutboundMigrationState; 105 + export type MigrationState = InboundMigrationState; 92 106 93 107 export interface StoredMigrationState { 94 108 version: 1;
+152 -98
frontend/src/locales/en.json
··· 17 17 "dashboard": "Dashboard", 18 18 "backToDashboard": "โ† Dashboard", 19 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" 20 - "copyToClipboard": "Copy to Clipboard" 21 67 }, 22 68 "login": { 23 69 "title": "Sign In", ··· 49 95 "codeLabel": "Verification Code", 50 96 "codePlaceholder": "Enter 6-digit code", 51 97 "verifyButton": "Verify Account", 98 + "resent": "Verification code resent!" 52 - "verifying": "Verifying...", 53 - "resendButton": "Resend Code", 54 - "resending": "Resending...", 55 - "resent": "Verification code resent!", 56 - "backToLogin": "Back to Login" 57 99 }, 58 100 "register": { 59 101 "title": "Create Account", ··· 124 166 "inviteCodePlaceholder": "Enter your invite code", 125 167 "inviteCodeRequired": "required", 126 168 "createButton": "Create Account", 127 - "creating": "Creating account...", 128 169 "alreadyHaveAccount": "Already have an account?", 129 170 "signIn": "Sign in", 130 171 "wantPasswordless": "Want passwordless security?", ··· 179 220 "navAdminDesc": "Server stats and admin operations", 180 221 "navDidDocument": "DID Document", 181 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", 182 228 "migrated": "Migrated", 183 229 "migratedTitle": "Account Migrated", 184 230 "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", ··· 208 254 "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.", 209 255 "currentPds": "Current PDS URL", 210 256 "save": "Save Changes", 211 - "saving": "Saving...", 212 257 "success": "DID document updated successfully", 213 258 "saveFailed": "Failed to save DID document", 214 259 "loadFailed": "Failed to load DID document", ··· 246 291 "yourDomain": "Your Domain", 247 292 "yourDomainPlaceholder": "example.com", 248 293 "verifyAndUpdate": "Verify & Update Handle", 249 - "verifying": "Verifying...", 250 294 "newHandle": "New Handle", 251 295 "newHandlePlaceholder": "yourhandle", 252 296 "changeHandleButton": "Change Handle", ··· 262 306 "exportData": "Export Data", 263 307 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 264 308 "downloadRepo": "Download Repository", 309 + "downloadBlobs": "Download Media", 265 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 + }, 266 337 "deleteAccount": "Delete Account", 267 338 "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 268 339 "requestDeletion": "Request Account Deletion", ··· 291 362 "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 292 363 "deletionFailed": "Failed to delete account", 293 364 "repoExported": "Repository exported successfully", 365 + "blobsExported": "Media files exported successfully", 366 + "noBlobsToExport": "No media files to export", 367 + "exportFailed": "Failed to export", 294 - "exportFailed": "Failed to export repository", 295 368 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 296 369 } 297 370 }, ··· 306 379 "noPasswords": "No app passwords yet", 307 380 "revoke": "Revoke", 308 381 "revoking": "Revoking...", 309 - "creating": "Creating...", 310 382 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 311 383 "saveWarningTitle": "Important: Save this app password!", 312 384 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", ··· 354 426 "used": "Used by @{handle}", 355 427 "disabled": "Disabled", 356 428 "usedBy": "Used by", 357 - "creating": "Creating...", 358 429 "disableConfirm": "Disable this invite code? It can no longer be used.", 359 430 "created": "Invite Code Created", 360 431 "copy": "Copy", ··· 482 553 "verifyButton": "Verify", 483 554 "verifyCodePlaceholder": "Enter verification code", 484 555 "submit": "Submit", 485 - "saving": "Saving...", 486 556 "savePreferences": "Save Preferences", 487 557 "preferencesSaved": "Communication preferences saved", 488 558 "verifiedSuccess": "{channel} verified successfully", ··· 521 591 "noCollectionsYet": "No collections yet. Create your first record to get started.", 522 592 "loadMore": "Load More", 523 593 "recordJson": "Record JSON", 524 - "saving": "Saving...", 525 594 "updateRecord": "Update Record", 526 595 "collectionNsid": "Collection (NSID)", 527 596 "recordKeyOptional": "Record Key (optional)", 528 597 "autoGenerated": "Auto-generated if empty (TID)", 529 598 "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 530 - "creating": "Creating...", 531 599 "demoPostText": "Hello from my PDS! This is my first post.", 532 600 "demoDisplayName": "Your Display Name", 533 601 "demoBio": "A short bio about yourself." ··· 551 619 "secondaryLight": "Secondary (Light Mode)", 552 620 "secondaryDark": "Secondary (Dark Mode)", 553 621 "configSaved": "Server configuration saved", 554 - "saving": "Saving...", 555 622 "saveConfig": "Save Configuration", 556 623 "serverStats": "Server Statistics", 557 624 "users": "Users", ··· 639 706 "title": "Two-Factor Authentication", 640 707 "subtitle": "Additional verification is required", 641 708 "usePasskey": "Use Passkey", 709 + "useTotp": "Use Authenticator App" 642 - "useTotp": "Use Authenticator App", 643 - "verifying": "Verifying..." 644 710 }, 645 711 "twoFactorCode": { 646 712 "title": "Two-Factor Authentication", 647 713 "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 648 714 "codeLabel": "Verification Code", 649 715 "codePlaceholder": "Enter 6-digit code", 650 - "verify": "Verify", 651 - "verifying": "Verifying...", 652 716 "errors": { 653 717 "missingRequestUri": "Missing request_uri parameter", 654 718 "verificationFailed": "Verification failed", ··· 660 724 "title": "Enter Authenticator Code", 661 725 "subtitle": "Enter the 6-digit code from your authenticator app", 662 726 "codePlaceholder": "Enter 6-digit code", 663 - "verify": "Verify", 664 - "verifying": "Verifying...", 665 727 "useBackupCode": "Use backup code instead", 666 728 "backupCodePlaceholder": "Enter backup code", 667 729 "trustDevice": "Trust this device for 30 days", ··· 691 753 "codeLabel": "Verification Code", 692 754 "codeHelp": "Copy the entire code from your message, including dashes", 693 755 "verifyButton": "Verify Account", 694 - "verify": "Verify", 695 - "verifying": "Verifying...", 696 756 "pleaseWait": "Please wait...", 697 - "resendCode": "Resend Code", 698 - "resending": "Resending...", 699 - "sending": "Sending...", 700 757 "codeResent": "Verification code resent!", 701 758 "codeResentDetail": "Verification code sent! Check your inbox.", 702 - "backToLogin": "Back to Login", 703 - "backToSettings": "Back to Settings", 704 759 "verifyingAccount": "Verifying account: @{handle}", 705 760 "startOver": "Start over with a different account", 706 761 "noPending": "No pending verification found.", ··· 746 801 "resetButton": "Reset Password", 747 802 "resetting": "Resetting...", 748 803 "success": "Password reset successfully!", 749 - "backToLogin": "Back to Sign In", 750 804 "requestNewCode": "Request New Code", 751 805 "passwordsMismatch": "Passwords do not match", 752 806 "passwordLength": "Password must be at least 8 characters" ··· 790 844 "howItWorks": "How it works", 791 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.", 792 846 "sendRecoveryLink": "Send Recovery Link", 847 + "sending": "Sending..." 793 - "sending": "Sending...", 794 - "backToLogin": "Back to Sign In" 795 848 }, 796 849 "registerPasskey": { 797 850 "title": "Create Passkey Account", ··· 814 867 "inviteCode": "Invite Code", 815 868 "inviteCodePlaceholder": "Enter your invite code", 816 869 "createButton": "Create Account", 817 - "creating": "Creating...", 818 870 "continue": "Continue", 819 871 "back": "Back", 820 872 "alreadyHaveAccount": "Already have an account?", ··· 911 963 "useTotp": "Use Authenticator", 912 964 "passwordPlaceholder": "Enter your password", 913 965 "totpPlaceholder": "Enter 6-digit code", 914 - "verify": "Verify", 915 - "verifying": "Verifying...", 916 966 "authenticating": "Authenticating...", 917 967 "passkeyPrompt": "Click the button below to authenticate with your passkey.", 918 968 "cancel": "Cancel" ··· 947 997 "handle": "Handle", 948 998 "emailOptional": "Email (optional)", 949 999 "yourAccessLevel": "Your Access Level", 950 - "creating": "Creating...", 951 1000 "createAccount": "Create Account", 952 1001 "createDelegatedAccountButton": "+ Create Delegated Account", 953 1002 "accountCreated": "Created delegated account: {handle}", ··· 1059 1108 "navDesc": "Move your account to or from another PDS", 1060 1109 "migrateHere": "Migrate Here", 1061 1110 "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 1111 "bringDid": "Bring your DID and identity", 1066 1112 "transferData": "Transfer all your data", 1067 1113 "keepFollowers": "Keep your followers", 1068 - "exportRepo": "Export your repository", 1069 - "transferToPds": "Transfer to new PDS", 1070 - "updateIdentity": "Update your identity", 1071 1114 "whatIsMigration": "What is account migration?", 1072 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.", 1073 1116 "beforeMigrate": "Before you migrate", ··· 1077 1120 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1078 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.", 1079 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", 1080 - "comingSoon": "Coming soon", 1081 1128 "oauthCompleting": "Completing authentication...", 1082 1129 "oauthFailed": "Authentication Failed", 1083 1130 "tryAgain": "Try Again", ··· 1086 1133 "incomplete": "You have an incomplete migration in progress:", 1087 1134 "direction": "Direction", 1088 1135 "migratingHere": "Migrating here", 1089 - "migratingAway": "Migrating away", 1090 1136 "from": "From", 1091 1137 "to": "To", 1092 1138 "progress": "Progress", ··· 1229 1275 "error": { 1230 1276 "title": "Migration Error", 1231 1277 "desc": "An error occurred during migration.", 1278 + "startOver": "Start Over", 1279 + "unknown": "An unknown error occurred." 1232 - "startOver": "Start Over" 1233 1280 }, 1234 1281 "common": { 1235 1282 "back": "Back", ··· 1247 1294 "warning3": "Your old account will be deactivated after migration" 1248 1295 } 1249 1296 }, 1297 + "offline": { 1250 - "outbound": { 1251 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." 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 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.", 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 1335 "validating": "Validating...", 1336 + "validate": "Validate Key" 1266 - "connected": "Connected to {name}", 1267 - "inviteRequired": "Invite code required", 1268 - "privacyPolicy": "Privacy Policy", 1269 - "termsOfService": "Terms of Service" 1270 1337 }, 1338 + "chooseHandle": { 1339 + "migratingDid": "Restoring DID" 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 1340 }, 1281 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." 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 1348 }, 1291 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" 1292 - "title": "Migrating Your Account", 1293 - "desc": "Please wait while we transfer your data..." 1294 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." 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 1365 }, 1304 1366 "success": { 1367 + "desc": "Your account has been successfully restored to this PDS." 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 1368 } 1315 1369 }, 1316 1370 "progress": {
+154 -100
frontend/src/locales/fi.json
··· 17 17 "dashboard": "Hallintapaneeli", 18 18 "backToDashboard": "โ† Hallintapaneeli", 19 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รค" 20 - "copyToClipboard": "Kopioi" 21 67 }, 22 68 "login": { 23 69 "title": "Kirjaudu sisรครคn", ··· 49 95 "codeLabel": "Vahvistuskoodi", 50 96 "codePlaceholder": "Syรถtรค 6-numeroinen koodi", 51 97 "verifyButton": "Vahvista tili", 98 + "resent": "Vahvistuskoodi lรคhetetty uudelleen!" 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 99 }, 58 100 "register": { 59 101 "title": "Luo tili", ··· 124 166 "inviteCodePlaceholder": "Syรถtรค kutsukoodisi", 125 167 "inviteCodeRequired": "vaaditaan", 126 168 "createButton": "Luo tili", 127 - "creating": "Luodaan tiliรค...", 128 169 "alreadyHaveAccount": "Onko sinulla jo tili?", 129 170 "signIn": "Kirjaudu sisรครคn", 130 171 "wantPasswordless": "Haluatko salasanattoman turvallisuuden?", ··· 179 220 "navAdminDesc": "Palvelintilastot ja yllรคpitotoiminnot", 180 221 "navDidDocument": "DID-dokumentti", 181 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", 182 228 "migrated": "Siirretty", 183 229 "migratedTitle": "Tili siirretty", 184 230 "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isรคnnรถidรครคn edelleen tรครคllรค, ja voit pรคivittรครค sen tulevia siirtoja varten.", ··· 208 254 "serviceEndpointDesc": "PDS, joka tรคllรค hetkellรค isรคnnรถi tilitietojasi. Pรคivitรค tรคmรค siirron yhteydessรค.", 209 255 "currentPds": "Nykyinen PDS-URL", 210 256 "save": "Tallenna muutokset", 211 - "saving": "Tallennetaan...", 212 257 "success": "DID-dokumentti pรคivitetty onnistuneesti", 213 258 "saveFailed": "DID-dokumentin tallennus epรคonnistui", 214 259 "loadFailed": "DID-dokumentin lataus epรคonnistui", ··· 246 291 "yourDomain": "Verkkotunnuksesi", 247 292 "yourDomainPlaceholder": "esimerkki.fi", 248 293 "verifyAndUpdate": "Vahvista ja pรคivitรค kรคyttรคjรคnimi", 249 - "verifying": "Vahvistetaan...", 250 294 "newHandle": "Uusi kรคyttรคjรคnimi", 251 295 "newHandlePlaceholder": "kรคyttรคjรคnimesi", 252 296 "changeHandleButton": "Vaihda kรคyttรคjรคnimi", ··· 262 306 "exportData": "Vie tiedot", 263 307 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tรคmรค sisรคltรครค kaikki julkaisusi, tykkรคyksesi, seuraamisesi ja muut tiedot.", 264 308 "downloadRepo": "Lataa tietovarasto", 309 + "downloadBlobs": "Lataa media", 265 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 + }, 266 337 "deleteAccount": "Poista tili", 267 338 "deleteWarning": "Tรคmรค toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvรคsti.", 268 339 "requestDeletion": "Pyydรค tilin poistoa", ··· 291 362 "deleteConfirmation": "Oletko tรคysin varma, ettรค haluat poistaa tilisi? Tรคtรค ei voi perua.", 292 363 "deletionFailed": "Tilin poisto epรคonnistui", 293 364 "repoExported": "Tietovarasto viety", 365 + "blobsExported": "Mediatiedostot viety", 366 + "noBlobsToExport": "Ei vietรคviรค mediatiedostoja", 367 + "exportFailed": "Vienti epรคonnistui", 294 - "exportFailed": "Tietovaraston vienti epรคonnistui", 295 368 "confirmDelete": "Oletko tรคysin varma, ettรค haluat poistaa tilisi? Tรคtรค ei voi perua." 296 369 } 297 370 }, ··· 306 379 "noPasswords": "Ei vielรค sovellusten salasanoja", 307 380 "revoke": "Peruuta", 308 381 "revoking": "Peruutetaan...", 309 - "creating": "Luodaan...", 310 382 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka kรคyttรคvรคt tรคtรค salasanaa, eivรคt enรครค pรครคse tilillesi.", 311 383 "saveWarningTitle": "Tรคrkeรครค: Tallenna tรคmรค sovelluksen salasana!", 312 384 "saveWarningMessage": "Tรคmรค salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivรคt tue pรครคsyavaimia tai OAuthia. Nรคet sen vain kerran.", ··· 354 426 "used": "Kรคyttรคnyt @{handle}", 355 427 "disabled": "Poistettu kรคytรถstรค", 356 428 "usedBy": "Kรคyttรคnyt", 357 - "creating": "Luodaan...", 358 429 "disableConfirm": "Poista tรคmรค kutsukoodi kรคytรถstรค? Sitรค ei voi enรครค kรคyttรครค.", 359 430 "created": "Kutsukoodi luotu", 360 431 "copy": "Kopioi", ··· 482 553 "verifyButton": "Vahvista", 483 554 "verifyCodePlaceholder": "Syรถtรค vahvistuskoodi", 484 555 "submit": "Lรคhetรค", 485 - "saving": "Tallennetaan...", 486 556 "savePreferences": "Tallenna asetukset", 487 557 "preferencesSaved": "Viestintรคasetukset tallennettu", 488 558 "verifiedSuccess": "{channel} vahvistettu", ··· 521 591 "noCollectionsYet": "Ei vielรค kokoelmia. Luo ensimmรคinen tietueesi aloittaaksesi.", 522 592 "loadMore": "Lataa lisรครค", 523 593 "recordJson": "Tietueen JSON", 524 - "saving": "Tallennetaan...", 525 594 "updateRecord": "Pรคivitรค tietue", 526 595 "collectionNsid": "Kokoelma (NSID)", 527 596 "recordKeyOptional": "Tietueavain (valinnainen)", 528 597 "autoGenerated": "Luodaan automaattisesti jos tyhjรค (TID)", 529 598 "autoGeneratedHint": "Jรคtรค tyhjรคksi luodaksesi TID-pohjaisen avaimen automaattisesti", 530 - "creating": "Luodaan...", 531 599 "demoPostText": "Hei PDS:ltรคni! Tรคmรค on ensimmรคinen julkaisuni.", 532 600 "demoDisplayName": "Nรคyttรถnimesi", 533 601 "demoBio": "Lyhyt kuvaus itsestรคsi." ··· 548 616 "primaryLight": "Ensisijainen (vaalea tila)", 549 617 "primaryDark": "Ensisijainen (tumma tila)", 550 618 "configSaved": "Palvelinasetukset tallennettu", 551 - "saving": "Tallennetaan...", 552 619 "saveConfig": "Tallenna asetukset", 553 620 "serverStats": "Palvelintilastot", 554 621 "users": "Kรคyttรคjรคt", ··· 639 706 "title": "Kaksivaiheinen tunnistautuminen", 640 707 "subtitle": "Lisรคvahvistus vaaditaan", 641 708 "usePasskey": "Kรคytรค pรครคsyavainta", 709 + "useTotp": "Kรคytรค todentajasovellusta" 642 - "useTotp": "Kรคytรค todentajasovellusta", 643 - "verifying": "Vahvistetaan..." 644 710 }, 645 711 "twoFactorCode": { 646 712 "title": "Kaksivaiheinen tunnistautuminen", 647 713 "subtitle": "Vahvistuskoodi on lรคhetetty {channel}. Syรถtรค koodi alla jatkaaksesi.", 648 714 "codeLabel": "Vahvistuskoodi", 649 715 "codePlaceholder": "Syรถtรค 6-numeroinen koodi", 650 - "verify": "Vahvista", 651 - "verifying": "Vahvistetaan...", 652 716 "errors": { 653 717 "missingRequestUri": "Puuttuva request_uri-parametri", 654 718 "verificationFailed": "Vahvistus epรคonnistui", ··· 660 724 "title": "Syรถtรค todentajakoodi", 661 725 "subtitle": "Syรถtรค 6-numeroinen koodi todentajasovelluksestasi", 662 726 "codePlaceholder": "Syรถtรค 6-numeroinen koodi", 663 - "verify": "Vahvista", 664 - "verifying": "Vahvistetaan...", 665 727 "useBackupCode": "Kรคytรค varakoodia sen sijaan", 666 728 "backupCodePlaceholder": "Syรถtรค varakoodi", 667 729 "trustDevice": "Luota tรคhรคn laitteeseen 30 pรคivรครค", ··· 691 753 "codeLabel": "Vahvistuskoodi", 692 754 "codeHelp": "Kopioi koko koodi viestistรคsi, mukaan lukien vรคliviivat", 693 755 "verifyButton": "Vahvista tili", 694 - "verify": "Vahvista", 695 - "verifying": "Vahvistetaan...", 696 756 "pleaseWait": "Odota...", 697 - "sending": "Lรคhetetรครคn...", 698 - "resendCode": "Lรคhetรค koodi uudelleen", 699 - "resending": "Lรคhetetรครคn uudelleen...", 700 757 "codeResent": "Vahvistuskoodi lรคhetetty uudelleen!", 701 758 "codeResentDetail": "Vahvistuskoodi lรคhetetty! Tarkista saapuneet-kansiosi.", 702 759 "verified": "Vahvistettu!", ··· 706 763 "identifierLabel": "Sรคhkรถposti tai tunniste", 707 764 "identifierPlaceholder": "sinรค@esimerkki.fi", 708 765 "identifierHelp": "Sรคhkรถpostiosoite tai tunniste, johon koodi lรคhetettiin", 709 - "backToLogin": "Takaisin kirjautumiseen", 710 766 "verifyingAccount": "Vahvistetaan tiliรค: @{handle}", 711 767 "startOver": "Aloita alusta toisella tilillรค", 712 768 "noPending": "Odottavaa vahvistusta ei lรถytynyt.", 713 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.", 714 770 "createAccount": "Luo tili", 715 771 "signIn": "Kirjaudu sisรครคn", 716 - "backToSettings": "Takaisin asetuksiin", 717 772 "emailUpdateCodeHelp": "Koodi lรคhetettiin nykyiseen sรคhkรถpostiosoitteeseesi", 718 773 "emailUpdateFailed": "Sรคhkรถpostiosoitteen pรคivitys epรคonnistui", 719 774 "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisรครคn pรคivittรครคksesi sรคhkรถpostiosoitteesi.", ··· 746 801 "resetButton": "Palauta salasana", 747 802 "resetting": "Palautetaan...", 748 803 "success": "Salasana palautettu!", 749 - "backToLogin": "Takaisin kirjautumiseen", 750 804 "requestNewCode": "Pyydรค uusi koodi", 751 805 "passwordsMismatch": "Salasanat eivรคt tรคsmรครค", 752 806 "passwordLength": "Salasanan on oltava vรคhintรครคn 8 merkkiรค" ··· 790 844 "howItWorks": "Miten se toimii", 791 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.", 792 846 "sendRecoveryLink": "Lรคhetรค palautuslinkki", 847 + "sending": "Lรคhetetรครคn..." 793 - "sending": "Lรคhetetรครคn...", 794 - "backToLogin": "Takaisin kirjautumiseen" 795 848 }, 796 849 "registerPasskey": { 797 850 "title": "Luo pรครคsyavaintili", ··· 812 865 "externalDid": "Sinun did:web", 813 866 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 814 867 "createButton": "Luo tili", 815 - "creating": "Luodaan...", 816 868 "alreadyHaveAccount": "Onko sinulla jo tili?", 817 869 "signIn": "Kirjaudu sisรครคn", 818 870 "wantPassword": "Haluatko kรคyttรครค salasanaa?", ··· 911 963 "useTotp": "Kรคytรค todentajaa", 912 964 "passwordPlaceholder": "Syรถtรค salasanasi", 913 965 "totpPlaceholder": "Syรถtรค 6-numeroinen koodi", 914 - "verify": "Vahvista", 915 - "verifying": "Vahvistetaan...", 916 966 "authenticating": "Todennetaan...", 917 967 "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pรครคsyavaimellasi.", 918 968 "cancel": "Peruuta" ··· 967 1017 "handle": "Kรคyttรคjรคnimi", 968 1018 "emailOptional": "Sรคhkรถposti (valinnainen)", 969 1019 "yourAccessLevel": "Kรคyttรถoikeustasosi", 970 - "creating": "Luodaan...", 971 1020 "createAccount": "Luo tili", 972 1021 "createDelegatedAccountButton": "+ Luo delegoitu tili", 973 1022 "accountCreated": "Delegoitu tili luotu: {handle}", ··· 1059 1108 "navDesc": "Siirrรค tilisi toiseen tai toisesta PDS:stรค", 1060 1109 "migrateHere": "Siirrรค tรคnne", 1061 1110 "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 1111 "bringDid": "Tuo DID ja identiteettisi", 1066 1112 "transferData": "Siirrรค kaikki tietosi", 1067 1113 "keepFollowers": "Sรคilytรค seuraajasi", 1068 - "exportRepo": "Vie tietovarastosi", 1069 - "transferToPds": "Siirrรค uuteen PDS:รครคn", 1070 - "updateIdentity": "Pรคivitรค identiteettisi", 1071 1114 "whatIsMigration": "Mikรค on tilin siirto?", 1072 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.", 1073 1116 "beforeMigrate": "Ennen siirtoa", ··· 1077 1120 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1078 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รค.", 1079 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", 1080 - "comingSoon": "Tulossa pian", 1081 1128 "oauthCompleting": "Viimeistellรครคn todennusta...", 1082 1129 "oauthFailed": "Todennus epรคonnistui", 1083 1130 "tryAgain": "Yritรค uudelleen", ··· 1086 1133 "incomplete": "Sinulla on keskenerรคinen siirto:", 1087 1134 "direction": "Suunta", 1088 1135 "migratingHere": "Siirretรครคn tรคnne", 1089 - "migratingAway": "Siirretรครคn pois", 1090 1136 "from": "Mistรค", 1091 1137 "to": "Minne", 1092 1138 "progress": "Edistyminen", ··· 1229 1275 "error": { 1230 1276 "title": "Siirtovirhe", 1231 1277 "desc": "Siirron aikana tapahtui virhe.", 1278 + "startOver": "Aloita alusta", 1279 + "unknown": "Tuntematon virhe tapahtui." 1232 - "startOver": "Aloita alusta" 1233 1280 }, 1234 1281 "common": { 1235 1282 "back": "Takaisin", ··· 1247 1294 "warning3": "Vanha tilisi deaktivoidaan siirron jรคlkeen" 1248 1295 } 1249 1296 }, 1297 + "offline": { 1250 - "outbound": { 1251 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รค." 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 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" 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 1337 }, 1338 + "chooseHandle": { 1339 + "migratingDid": "Palautetaan DID" 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 1340 }, 1281 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." 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 1348 }, 1291 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" 1292 - "title": "Siirretรครคn tiliรคsi", 1293 - "desc": "Odota, kun siirrรคmme tietojasi..." 1294 1356 }, 1357 + "success": { 1358 + "desc": "Tilisi on palautettu onnistuneesti tรคhรคn PDS:รครคn." 1295 - "plcToken": { 1296 - "title": "Vahvista henkilรถllisyytesi", 1297 - "desc": "Vahvistuskoodi on lรคhetetty sรคhkรถpostiisi." 1298 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." 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 1368 } 1315 1369 }, 1316 1370 "progress": {
+147 -100
frontend/src/locales/ja.json
··· 17 17 "dashboard": "ใƒ€ใƒƒใ‚ทใƒฅใƒœใƒผใƒ‰", 18 18 "backToDashboard": "โ† ใƒ€ใƒƒใ‚ทใƒฅใƒœใƒผใƒ‰", 19 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ๆ–‡ๅญ—ไปฅไธŠๅฟ…่ฆใงใ™" 20 - "copyToClipboard": "ใ‚ฏใƒชใƒƒใƒ—ใƒœใƒผใƒ‰ใซใ‚ณใƒ”ใƒผ" 21 60 }, 22 61 "login": { 23 62 "title": "ใ‚ตใ‚คใƒณใ‚คใƒณ", ··· 49 88 "codeLabel": "็ขบ่ชใ‚ณใƒผใƒ‰", 50 89 "codePlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 51 90 "verifyButton": "็ขบ่ชใ™ใ‚‹", 91 + "resent": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟกใ—ใพใ—ใŸ๏ผ" 52 - "verifying": "็ขบ่ชไธญ...", 53 - "resendButton": "ใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟก", 54 - "resending": "้€ไฟกไธญ...", 55 - "resent": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟกใ—ใพใ—ใŸ๏ผ", 56 - "backToLogin": "ใƒญใ‚ฐใ‚คใƒณใซๆˆปใ‚‹" 57 92 }, 58 93 "register": { 59 94 "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆไฝœๆˆ", ··· 124 159 "inviteCodePlaceholder": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 125 160 "inviteCodeRequired": "ๅฟ…้ ˆ", 126 161 "createButton": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 127 - "creating": "ไฝœๆˆไธญ...", 128 162 "alreadyHaveAccount": "ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใŠๆŒใกใงใ™ใ‹๏ผŸ", 129 163 "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 130 164 "wantPasswordless": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใƒฌใ‚นใ‚’ใ”ๅธŒๆœ›ใงใ™ใ‹๏ผŸ", ··· 179 213 "navAdminDesc": "ใ‚ตใƒผใƒใƒผ็ตฑ่จˆใจ็ฎก็†ๆ“ไฝœ", 180 214 "navDidDocument": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆ", 181 215 "navDidDocumentDesc": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใจใ‚ญใƒผใ‚’็ฎก็†", 216 + "navDidDocumentDescActive": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆ่จญๅฎšใ‚’็ทจ้›†", 217 + "navBackup": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 218 + "navBackupDesc": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ CAR ใƒ•ใ‚กใ‚คใƒซใจใ—ใฆใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 219 + "downloadingBackup": "ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ไธญ...", 220 + "backupFailed": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใฎใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 182 221 "migrated": "็งป่กŒๆธˆใฟ", 183 222 "migratedTitle": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒๆธˆใฟ", 184 223 "migratedMessage": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏ {pds} ใซ็งป่กŒใ•ใ‚Œใพใ—ใŸใ€‚DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฏๅผ•ใ็ถšใใ“ใ“ใงใƒ›ใ‚นใƒˆใ•ใ‚Œใฆใ„ใพใ™ใ€‚", ··· 208 247 "serviceEndpointDesc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใƒ‡ใƒผใ‚ฟใ‚’็พๅœจใƒ›ใ‚นใƒˆใ—ใฆใ„ใ‚‹PDSใ€‚็งป่กŒๆ™‚ใซๆ›ดๆ–ฐใ—ใฆใใ ใ•ใ„ใ€‚", 209 248 "currentPds": "็พๅœจใฎPDS URL", 210 249 "save": "ๅค‰ๆ›ดใ‚’ไฟๅญ˜", 211 - "saving": "ไฟๅญ˜ไธญ...", 212 250 "success": "DID ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚’ๆ›ดๆ–ฐใ—ใพใ—ใŸ", 213 251 "saveFailed": "DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎไฟๅญ˜ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 214 252 "loadFailed": "DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใฎ่ชญใฟ่พผใฟใซๅคฑๆ•—ใ—ใพใ—ใŸ", ··· 246 284 "yourDomain": "ใƒ‰ใƒกใ‚คใƒณ", 247 285 "yourDomainPlaceholder": "example.com", 248 286 "verifyAndUpdate": "็ขบ่ชใ—ใฆใƒใƒณใƒ‰ใƒซใ‚’ๆ›ดๆ–ฐ", 249 - "verifying": "็ขบ่ชไธญ...", 250 287 "newHandle": "ๆ–ฐใ—ใ„ใƒใƒณใƒ‰ใƒซ", 251 288 "newHandlePlaceholder": "yourhandle", 252 289 "changeHandleButton": "ใƒใƒณใƒ‰ใƒซใ‚’ๅค‰ๆ›ด", ··· 262 299 "exportData": "ใƒ‡ใƒผใ‚ฟใ‚จใ‚ฏใ‚นใƒใƒผใƒˆ", 263 300 "exportDataDescription": "ใƒชใƒใ‚ธใƒˆใƒชๅ…จไฝ“ใ‚’ CAR๏ผˆContent Addressable Archive๏ผ‰ใƒ•ใ‚กใ‚คใƒซใจใ—ใฆใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰ใ—ใพใ™ใ€‚ๆŠ•็จฟใ€ใ„ใ„ใญใ€ใƒ•ใ‚ฉใƒญใƒผใชใฉใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใŒๅซใพใ‚Œใพใ™ใ€‚", 264 301 "downloadRepo": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 302 + "downloadBlobs": "ใƒกใƒ‡ใ‚ฃใ‚ขใ‚’ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", 265 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 + }, 266 330 "deleteAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ‰Š้™ค", 267 331 "deleteWarning": "ใ“ใฎๆ“ไฝœใฏๅ–ใ‚Šๆถˆใ›ใพใ›ใ‚“ใ€‚ใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใŒๅฎŒๅ…จใซๅ‰Š้™คใ•ใ‚Œใพใ™ใ€‚", 268 332 "requestDeletion": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆๅ‰Š้™คใ‚’ใƒชใ‚ฏใ‚จใ‚นใƒˆ", ··· 291 355 "deleteConfirmation": "ๆœฌๅฝ“ใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅ‰Š้™คใ—ใพใ™ใ‹๏ผŸใ“ใฎๆ“ไฝœใฏๅ–ใ‚Šๆถˆใ›ใพใ›ใ‚“ใ€‚", 292 356 "deletionFailed": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎๅ‰Š้™คใซๅคฑๆ•—ใ—ใพใ—ใŸ", 293 357 "repoExported": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ—ใพใ—ใŸ", 358 + "blobsExported": "ใƒกใƒ‡ใ‚ฃใ‚ขใƒ•ใ‚กใ‚คใƒซใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ—ใพใ—ใŸ", 359 + "noBlobsToExport": "ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใ™ใ‚‹ใƒกใƒ‡ใ‚ฃใ‚ขใƒ•ใ‚กใ‚คใƒซใŒใ‚ใ‚Šใพใ›ใ‚“", 360 + "exportFailed": "ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", 294 - "exportFailed": "ใƒชใƒใ‚ธใƒˆใƒชใฎใ‚จใ‚ฏใ‚นใƒใƒผใƒˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", 295 361 "confirmDelete": "ๆœฌๅฝ“ใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅ‰Š้™คใ—ใพใ™ใ‹๏ผŸใ“ใฎๆ“ไฝœใฏๅ–ใ‚Šๆถˆใ›ใพใ›ใ‚“ใ€‚" 296 362 } 297 363 }, ··· 306 372 "noPasswords": "ใ‚ขใƒ—ใƒชใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏใพใ ใ‚ใ‚Šใพใ›ใ‚“", 307 373 "revoke": "ๅ–ใ‚Šๆถˆใ™", 308 374 "revoking": "ๅ–ใ‚Šๆถˆใ—ไธญ...", 309 - "creating": "ไฝœๆˆไธญ...", 310 375 "revokeConfirm": "ใ‚ขใƒ—ใƒชใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ€Œ{name}ใ€ใ‚’ๅ–ใ‚Šๆถˆใ—ใพใ™ใ‹๏ผŸใ“ใฎใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ไฝฟ็”จใ—ใฆใ„ใ‚‹ใ‚ขใƒ—ใƒชใฏใ‚ขใ‚ซใ‚ฆใƒณใƒˆใซใ‚ขใ‚ฏใ‚ปใ‚นใงใใชใใชใ‚Šใพใ™ใ€‚", 311 376 "saveWarningTitle": "้‡่ฆ: ใ“ใฎใ‚ขใƒ—ใƒชใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ไฟๅญ˜ใ—ใฆใใ ใ•ใ„๏ผ", 312 377 "saveWarningMessage": "ใ“ใฎใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏใƒ‘ใ‚นใ‚ญใƒผใ‚„ OAuth ใ‚’ใ‚ตใƒใƒผใƒˆใ—ใฆใ„ใชใ„ใ‚ขใƒ—ใƒชใซใ‚ตใ‚คใƒณใ‚คใƒณใ™ใ‚‹ใŸใ‚ใซๅฟ…่ฆใงใ™ใ€‚ไธ€ๅบฆใ—ใ‹่กจ็คบใ•ใ‚Œใพใ›ใ‚“ใ€‚", ··· 354 419 "used": "@{handle} ใŒไฝฟ็”จๆธˆใฟ", 355 420 "disabled": "็„กๅŠน", 356 421 "usedBy": "ไฝฟ็”จ่€…", 357 - "creating": "ไฝœๆˆไธญ...", 358 422 "disableConfirm": "ใ“ใฎๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’็„กๅŠนใซใ—ใพใ™ใ‹๏ผŸไฝฟ็”จใงใใชใใชใ‚Šใพใ™ใ€‚", 359 423 "created": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰ใ‚’ไฝœๆˆใ—ใพใ—ใŸ", 360 424 "copy": "ใ‚ณใƒ”ใƒผ", ··· 482 546 "verifyButton": "็ขบ่ช", 483 547 "verifyCodePlaceholder": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 484 548 "submit": "้€ไฟก", 485 - "saving": "ไฟๅญ˜ไธญ...", 486 549 "savePreferences": "่จญๅฎšใ‚’ไฟๅญ˜", 487 550 "preferencesSaved": "้€ฃ็ตก่จญๅฎšใ‚’ไฟๅญ˜ใ—ใพใ—ใŸ", 488 551 "verifiedSuccess": "{channel} ใ‚’็ขบ่ชใ—ใพใ—ใŸ", ··· 521 584 "noCollectionsYet": "ใ‚ณใƒฌใ‚ฏใ‚ทใƒงใƒณใŒใพใ ใ‚ใ‚Šใพใ›ใ‚“ใ€‚ๆœ€ๅˆใฎใƒฌใ‚ณใƒผใƒ‰ใ‚’ไฝœๆˆใ—ใฆ้–‹ๅง‹ใ—ใพใ—ใ‚‡ใ†ใ€‚", 522 585 "loadMore": "ใ•ใ‚‰ใซ่ชญใฟ่พผใ‚€", 523 586 "recordJson": "ใƒฌใ‚ณใƒผใƒ‰ JSON", 524 - "saving": "ไฟๅญ˜ไธญ...", 525 587 "updateRecord": "ใƒฌใ‚ณใƒผใƒ‰ใ‚’ๆ›ดๆ–ฐ", 526 588 "collectionNsid": "ใ‚ณใƒฌใ‚ฏใ‚ทใƒงใƒณ (NSID)", 527 589 "recordKeyOptional": "ใƒฌใ‚ณใƒผใƒ‰ใ‚ญใƒผ๏ผˆไปปๆ„๏ผ‰", 528 590 "autoGenerated": "็ฉบ็™ฝใง่‡ชๅ‹•็”Ÿๆˆ (TID)", 529 591 "autoGeneratedHint": "็ฉบ็™ฝใซใ™ใ‚‹ใจ TID ใƒ™ใƒผใ‚นใฎใ‚ญใƒผใŒ่‡ชๅ‹•็”Ÿๆˆใ•ใ‚Œใพใ™", 530 - "creating": "ไฝœๆˆไธญ...", 531 592 "demoPostText": "ใ“ใ‚“ใซใกใฏใ€็งใฎ PDS ใ‹ใ‚‰ใฎๅˆๆŠ•็จฟใงใ™๏ผ", 532 593 "demoDisplayName": "่กจ็คบๅ", 533 594 "demoBio": "่‡ชๅทฑ็ดนไป‹ใ‚’ๆ›ธใ„ใฆใใ ใ•ใ„ใ€‚" ··· 548 609 "primaryLight": "ใƒ—ใƒฉใ‚คใƒžใƒช๏ผˆใƒฉใ‚คใƒˆใƒขใƒผใƒ‰๏ผ‰", 549 610 "primaryDark": "ใƒ—ใƒฉใ‚คใƒžใƒช๏ผˆใƒ€ใƒผใ‚ฏใƒขใƒผใƒ‰๏ผ‰", 550 611 "configSaved": "ใ‚ตใƒผใƒใƒผ่จญๅฎšใ‚’ไฟๅญ˜ใ—ใพใ—ใŸ", 551 - "saving": "ไฟๅญ˜ไธญ...", 552 612 "saveConfig": "่จญๅฎšใ‚’ไฟๅญ˜", 553 613 "serverStats": "ใ‚ตใƒผใƒใƒผ็ตฑ่จˆ", 554 614 "users": "ใƒฆใƒผใ‚ถใƒผ", ··· 639 699 "title": "ไบŒ่ฆ็ด ่ช่จผ", 640 700 "subtitle": "่ฟฝๅŠ ใฎ็ขบ่ชใŒๅฟ…่ฆใงใ™", 641 701 "usePasskey": "ใƒ‘ใ‚นใ‚ญใƒผใ‚’ไฝฟ็”จ", 702 + "useTotp": "่ช่จผใ‚ขใƒ—ใƒชใ‚’ไฝฟ็”จ" 642 - "useTotp": "่ช่จผใ‚ขใƒ—ใƒชใ‚’ไฝฟ็”จ", 643 - "verifying": "็ขบ่ชไธญ..." 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "ไบŒ่ฆ็ด ่ช่จผ", 647 706 "subtitle": "{channel} ใซ็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’้€ไฟกใ—ใพใ—ใŸใ€‚ไปฅไธ‹ใซใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆ็ถš่กŒใ—ใฆใใ ใ•ใ„ใ€‚", 648 707 "codeLabel": "็ขบ่ชใ‚ณใƒผใƒ‰", 649 708 "codePlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 650 - "verify": "็ขบ่ช", 651 - "verifying": "็ขบ่ชไธญ...", 652 709 "errors": { 653 710 "missingRequestUri": "request_uri ใƒ‘ใƒฉใƒกใƒผใ‚ฟใŒใ‚ใ‚Šใพใ›ใ‚“", 654 711 "verificationFailed": "็ขบ่ชใซๅคฑๆ•—ใ—ใพใ—ใŸ", ··· 660 717 "title": "่ช่จผใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 661 718 "subtitle": "่ช่จผใ‚ขใƒ—ใƒชใฎ6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 662 719 "codePlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 663 - "verify": "็ขบ่ช", 664 - "verifying": "็ขบ่ชไธญ...", 665 720 "useBackupCode": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚ณใƒผใƒ‰ใ‚’ไฝฟ็”จ", 666 721 "backupCodePlaceholder": "ใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 667 722 "trustDevice": "ใ“ใฎใƒ‡ใƒใ‚คใ‚นใ‚’30ๆ—ฅ้–“ไฟก้ ผใ™ใ‚‹", ··· 691 746 "codeLabel": "็ขบ่ชใ‚ณใƒผใƒ‰", 692 747 "codeHelp": "ใƒ€ใƒƒใ‚ทใƒฅใ‚’ๅซใ‚€ๅฎŒๅ…จใชใ‚ณใƒผใƒ‰ใ‚’ใƒกใƒƒใ‚ปใƒผใ‚ธใ‹ใ‚‰ใ‚ณใƒ”ใƒผใ—ใฆใใ ใ•ใ„", 693 748 "verifyButton": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็ขบ่ช", 694 - "verify": "็ขบ่ช", 695 - "verifying": "็ขบ่ชไธญ...", 696 749 "pleaseWait": "ใŠๅพ…ใกใใ ใ•ใ„...", 697 - "sending": "้€ไฟกไธญ...", 698 - "resendCode": "ใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟก", 699 - "resending": "้€ไฟกไธญ...", 700 750 "codeResent": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’ๅ†้€ไฟกใ—ใพใ—ใŸ๏ผ", 701 751 "codeResentDetail": "็ขบ่ชใ‚ณใƒผใƒ‰ใ‚’้€ไฟกใ—ใพใ—ใŸ๏ผๅ—ไฟกใƒˆใƒฌใ‚คใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 702 752 "verified": "็ขบ่ชๅฎŒไบ†๏ผ", ··· 706 756 "identifierLabel": "ใƒกใƒผใƒซใพใŸใฏ่ญ˜ๅˆฅๅญ", 707 757 "identifierPlaceholder": "you@example.com", 708 758 "identifierHelp": "ใ‚ณใƒผใƒ‰ใŒ้€ไฟกใ•ใ‚ŒใŸใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใพใŸใฏ่ญ˜ๅˆฅๅญ", 709 - "backToLogin": "ใƒญใ‚ฐใ‚คใƒณใซๆˆปใ‚‹", 710 759 "verifyingAccount": "็ขบ่ชไธญใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆ: @{handle}", 711 760 "startOver": "ๅˆฅใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใงใ‚„ใ‚Š็›ดใ™", 712 761 "noPending": "ไฟ็•™ไธญใฎ็ขบ่ชใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ใ€‚", 713 762 "noPendingInfo": "ๆœ€่ฟ‘ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ—ใฆ็ขบ่ชใŒๅฟ…่ฆใชๅ ดๅˆใฏใ€ๆ–ฐใ—ใ„ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็ขบ่ชใ—ใŸๅ ดๅˆใฏใ€ใ‚ตใ‚คใƒณใ‚คใƒณใงใใพใ™ใ€‚", 714 763 "createAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 715 764 "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 716 - "backToSettings": "่จญๅฎšใซๆˆปใ‚‹", 717 765 "emailUpdateCodeHelp": "ใ‚ณใƒผใƒ‰ใฏ็พๅœจใฎใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใซ้€ไฟกใ•ใ‚Œใพใ—ใŸ", 718 766 "emailUpdateFailed": "ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใฎๆ›ดๆ–ฐใซๅคฑๆ•—ใ—ใพใ—ใŸ", 719 767 "emailUpdateRequiresAuth": "ใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใ‚’ๆ›ดๆ–ฐใ™ใ‚‹ใซใฏใ‚ตใ‚คใƒณใ‚คใƒณใŒๅฟ…่ฆใงใ™ใ€‚", ··· 746 794 "resetButton": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ใƒชใ‚ปใƒƒใƒˆ", 747 795 "resetting": "ใƒชใ‚ปใƒƒใƒˆไธญ...", 748 796 "success": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ใƒชใ‚ปใƒƒใƒˆใ—ใพใ—ใŸ๏ผ", 749 - "backToLogin": "ใ‚ตใ‚คใƒณใ‚คใƒณใซๆˆปใ‚‹", 750 797 "requestNewCode": "ๆ–ฐใ—ใ„ใ‚ณใƒผใƒ‰ใ‚’ใƒชใ‚ฏใ‚จใ‚นใƒˆ", 751 798 "passwordsMismatch": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใŒไธ€่‡ดใ—ใพใ›ใ‚“", 752 799 "passwordLength": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใฏ8ๆ–‡ๅญ—ไปฅไธŠใงใ‚ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™" ··· 790 837 "howItWorks": "ไป•็ต„ใฟ", 791 838 "howItWorksDetail": "็™ป้Œฒใ•ใ‚ŒใŸ้€š็Ÿฅใƒใƒฃใƒณใƒใƒซใซๅฎ‰ๅ…จใชใƒชใƒณใ‚ฏใ‚’้€ไฟกใ—ใพใ™ใ€‚ใƒชใƒณใ‚ฏใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆไธ€ๆ™‚ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’่จญๅฎšใ—ใพใ™ใ€‚ใใฎๅพŒใ‚ตใ‚คใƒณใ‚คใƒณใ—ใฆๆ–ฐใ—ใ„ใƒ‘ใ‚นใ‚ญใƒผใ‚’่ฟฝๅŠ ใงใใพใ™ใ€‚", 792 839 "sendRecoveryLink": "ๅพฉๆ—งใƒชใƒณใ‚ฏใ‚’้€ไฟก", 840 + "sending": "้€ไฟกไธญ..." 793 - "sending": "้€ไฟกไธญ...", 794 - "backToLogin": "ใ‚ตใ‚คใƒณใ‚คใƒณใซๆˆปใ‚‹" 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "ใƒ‘ใ‚นใ‚ญใƒผใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", ··· 812 858 "externalDid": "ใ‚ใชใŸใฎ did:web", 813 859 "externalDidPlaceholder": "did:web:yourdomain.com", 814 860 "createButton": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 815 - "creating": "ไฝœๆˆไธญ...", 816 861 "alreadyHaveAccount": "ใ™ใงใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใŠๆŒใกใงใ™ใ‹๏ผŸ", 817 862 "signIn": "ใ‚ตใ‚คใƒณใ‚คใƒณ", 818 863 "wantPassword": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ไฝฟ็”จใ—ใพใ™ใ‹๏ผŸ", ··· 911 956 "useTotp": "่ช่จผใ‚ขใƒ—ใƒชใ‚’ไฝฟ็”จ", 912 957 "passwordPlaceholder": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 913 958 "totpPlaceholder": "6ๆกใฎใ‚ณใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›", 914 - "verify": "็ขบ่ช", 915 - "verifying": "็ขบ่ชไธญ...", 916 959 "authenticating": "่ช่จผไธญ...", 917 960 "passkeyPrompt": "ไธ‹ใฎใƒœใ‚ฟใƒณใ‚’ใ‚ฏใƒชใƒƒใ‚ฏใ—ใฆใƒ‘ใ‚นใ‚ญใƒผใง่ช่จผใ—ใฆใใ ใ•ใ„ใ€‚", 918 961 "cancel": "ใ‚ญใƒฃใƒณใ‚ปใƒซ" ··· 985 1028 "createAccount": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 986 1029 "createDelegatedAccount": "ๅง”ไปปใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 987 1030 "createDelegatedAccountButton": "+ ๅง”ไปปใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆ", 988 - "creating": "ไฝœๆˆไธญ...", 989 1031 "emailOptional": "ใƒกใƒผใƒซ๏ผˆไปปๆ„๏ผ‰", 990 1032 "failedToAddController": "ใ‚ณใƒณใƒˆใƒญใƒผใƒฉใƒผใฎ่ฟฝๅŠ ใซๅคฑๆ•—ใ—ใพใ—ใŸ", 991 1033 "failedToCreateAccount": "ๅง”ไปปใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎไฝœๆˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", ··· 1059 1101 "navDesc": "ๅˆฅใฎPDSใธใ€ใพใŸใฏๅˆฅใฎPDSใ‹ใ‚‰ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งปๅ‹•", 1060 1102 "migrateHere": "ใ“ใ“ใซ็งป่กŒ", 1061 1103 "migrateHereDesc": "ๆ—ขๅญ˜ใฎAT Protocolใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใ“ใฎPDSใซ็งปๅ‹•ใ—ใพใ™ใ€‚", 1062 - "migrateAway": "ๅˆฅใฎๅ ดๆ‰€ใซ็งป่กŒ", 1063 - "migrateAwayDesc": "ใ“ใฎPDSใ‹ใ‚‰ๅˆฅใฎใ‚ตใƒผใƒใƒผใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งปๅ‹•ใ—ใพใ™ใ€‚", 1064 - "loginRequired": "ใƒญใ‚ฐใ‚คใƒณใŒๅฟ…่ฆใงใ™", 1065 1104 "bringDid": "DIDใจใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ๆŒใก่พผใ‚€", 1066 1105 "transferData": "ใ™ในใฆใฎใƒ‡ใƒผใ‚ฟใ‚’่ปข้€", 1067 1106 "keepFollowers": "ใƒ•ใ‚ฉใƒญใƒฏใƒผใ‚’็ถญๆŒ", 1068 - "exportRepo": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใ‚จใ‚ฏใ‚นใƒใƒผใƒˆ", 1069 - "transferToPds": "ๆ–ฐใ—ใ„PDSใซ่ปข้€", 1070 - "updateIdentity": "ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ๆ›ดๆ–ฐ", 1071 1107 "whatIsMigration": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒใจใฏ๏ผŸ", 1072 1108 "whatIsMigrationDesc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒใซใ‚ˆใ‚Šใ€AT Protocolใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ใƒ‘ใƒผใ‚ฝใƒŠใƒซใƒ‡ใƒผใ‚ฟใ‚ตใƒผใƒใƒผ๏ผˆPDS๏ผ‰้–“ใง็งปๅ‹•ใงใใพใ™ใ€‚DID๏ผˆๅˆ†ๆ•ฃๅž‹่ญ˜ๅˆฅๅญ๏ผ‰ใฏๅค‰ใ‚ใ‚‰ใชใ„ใŸใ‚ใ€ใƒ•ใ‚ฉใƒญใƒฏใƒผใ‚„ใ‚ฝใƒผใ‚ทใƒฃใƒซใ‚ณใƒใ‚ฏใ‚ทใƒงใƒณใฏ็ถญๆŒใ•ใ‚Œใพใ™ใ€‚", 1073 1109 "beforeMigrate": "็งป่กŒๅ‰ใฎ็ขบ่ชไบ‹้ …", ··· 1077 1113 "beforeMigrate4": "ๅคใ„PDSใซใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎ็„กๅŠนๅŒ–ใŒ้€š็Ÿฅใ•ใ‚Œใพใ™", 1078 1114 "importantWarning": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็งป่กŒใฏ้‡่ฆใชๆ“ไฝœใงใ™ใ€‚็งป่กŒๅ…ˆใฎPDSใ‚’ไฟก้ ผใ—ใ€ใƒ‡ใƒผใ‚ฟใŒ็งปๅ‹•ใ•ใ‚Œใ‚‹ใ“ใจใ‚’็†่งฃใ—ใฆใใ ใ•ใ„ใ€‚ๅ•้กŒใŒ็™บ็”Ÿใ—ใŸๅ ดๅˆใ€ๆ‰‹ๅ‹•ใงใฎๅพฉๆ—งใŒๅฟ…่ฆใซใชใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚", 1079 1115 "learnMore": "็งป่กŒใฎใƒชใ‚นใ‚ฏใซใคใ„ใฆ่ฉณใ—ใ", 1116 + "offlineRestore": "ใ‚ชใƒ•ใƒฉใ‚คใƒณๅพฉๅ…ƒ", 1117 + "offlineRestoreDesc": "ๆ—งPDSใŒๅˆฉ็”จใงใใชใ„ๅ ดๅˆใซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‹ใ‚‰ๅพฉๅ…ƒใ—ใพใ™ใ€‚", 1118 + "offlineFeature1": "CARใƒ•ใ‚กใ‚คใƒซใƒใƒƒใ‚ฏใ‚ขใƒƒใƒ—ใ‚’ไฝฟ็”จ", 1119 + "offlineFeature2": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใงๆ‰€ๆœ‰ๆจฉใ‚’่จผๆ˜Ž", 1120 + "offlineFeature3": "ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณใ—ใŸใ‚ตใƒผใƒใƒผใฎๅพฉๆ—ง", 1080 - "comingSoon": "่ฟ‘ๆ—ฅๅ…ฌ้–‹", 1081 1121 "oauthCompleting": "่ช่จผใ‚’ๅฎŒไบ†ใ—ใฆใ„ใพใ™...", 1082 1122 "oauthFailed": "่ช่จผใซๅคฑๆ•—ใ—ใพใ—ใŸ", 1083 1123 "tryAgain": "ๅ†่ฉฆ่กŒ", ··· 1086 1126 "incomplete": "ๆœชๅฎŒไบ†ใฎ็งป่กŒใŒใ‚ใ‚Šใพใ™๏ผš", 1087 1127 "direction": "ๆ–นๅ‘", 1088 1128 "migratingHere": "ใ“ใ“ใซ็งป่กŒไธญ", 1089 - "migratingAway": "ๅˆฅใฎๅ ดๆ‰€ใซ็งป่กŒไธญ", 1090 1129 "from": "็งป่กŒๅ…ƒ", 1091 1130 "to": "็งป่กŒๅ…ˆ", 1092 1131 "progress": "้€ฒ่กŒ็Šถๆณ", ··· 1229 1268 "error": { 1230 1269 "title": "็งป่กŒใ‚จใƒฉใƒผ", 1231 1270 "desc": "็งป่กŒไธญใซใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸใ€‚", 1271 + "startOver": "ๆœ€ๅˆใ‹ใ‚‰ใ‚„ใ‚Š็›ดใ™", 1272 + "unknown": "ไธๆ˜Žใชใ‚จใƒฉใƒผใŒ็™บ็”Ÿใ—ใพใ—ใŸใ€‚" 1232 - "startOver": "ๆœ€ๅˆใ‹ใ‚‰ใ‚„ใ‚Š็›ดใ™" 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "ๆˆปใ‚‹", ··· 1247 1287 "warning3": "็งป่กŒๅพŒใ€ๅคใ„ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏ็„กๅŠนๅŒ–ใ•ใ‚Œใพใ™" 1248 1288 } 1249 1289 }, 1290 + "offline": { 1250 - "outbound": { 1251 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ใƒ•ใ‚กใ‚คใƒซใ‚’ๅ†ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚ใ‚ปใ‚ญใƒฅใƒชใƒ†ใ‚ฃไธŠใฎ็†็”ฑใ‹ใ‚‰ใ€ใƒ•ใ‚กใ‚คใƒซใฎๅ†…ๅฎนใฏใ‚ปใƒƒใ‚ทใƒงใƒณ้–“ใงไฟๅญ˜ใ•ใ‚Œใพใ›ใ‚“ใ€‚" 1252 - "title": "ใ“ใฎPDSใ‹ใ‚‰็งป่กŒ", 1253 - "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅˆฅใฎใƒ‘ใƒผใ‚ฝใƒŠใƒซใƒ‡ใƒผใ‚ฟใ‚ตใƒผใƒใƒผใซ็งปๅ‹•ใ—ใพใ™ใ€‚", 1254 - "warning": "็งป่กŒๅพŒใ€ใ“ใ“ใงใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏ็„กๅŠนๅŒ–ใ•ใ‚Œใพใ™ใ€‚", 1255 - "didWebNotice": "did:web็งป่กŒใฎใŠ็Ÿฅใ‚‰ใ›", 1256 - "didWebNoticeDesc": "ใ‚ใชใŸใฎใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏdid:web่ญ˜ๅˆฅๅญ๏ผˆ{did}๏ผ‰ใ‚’ไฝฟ็”จใ—ใฆใ„ใพใ™ใ€‚็งป่กŒๅพŒใ€ใ“ใฎPDSใฏๆ–ฐใ—ใ„PDSใ‚’ๆŒ‡ใ™DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใ‚’ๅผ•ใ็ถšใๆไพ›ใ—ใพใ™ใ€‚ใ“ใฎใ‚ตใƒผใƒใƒผใŒใ‚ชใƒณใƒฉใ‚คใƒณใงใ‚ใ‚‹้™ใ‚Šใ€ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใฏๆฉŸ่ƒฝใ—็ถšใ‘ใพใ™ใ€‚", 1257 - "understand": "ใƒชใ‚นใ‚ฏใ‚’็†่งฃใ—ใ€็ถš่กŒใ—ใพใ™" 1258 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": "ใ‚ญใƒผใ‚’ๆคœ่จผ" 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 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "DIDใ‚’ๅพฉๅ…ƒไธญ" 1271 - "newAccount": { 1272 - "title": "ๆ–ฐใ—ใ„ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฎ่ฉณ็ดฐ", 1273 - "desc": "ๆ–ฐใ—ใ„PDSใงใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’่จญๅฎšใ—ใพใ™ใ€‚", 1274 - "handle": "ใƒใƒณใƒ‰ใƒซ", 1275 - "availableDomains": "ๅˆฉ็”จๅฏ่ƒฝใชใƒ‰ใƒกใ‚คใƒณ", 1276 - "email": "ใƒกใƒผใƒซ", 1277 - "password": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰", 1278 - "confirmPassword": "ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’็ขบ่ช", 1279 - "inviteCode": "ๆ‹›ๅพ…ใ‚ณใƒผใƒ‰" 1280 1333 }, 1281 1334 "review": { 1335 + "desc": "ใ‚ชใƒ•ใƒฉใ‚คใƒณๅพฉๅ…ƒใฎ่ฉณ็ดฐใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 1336 + "carFile": "CARใƒ•ใ‚กใ‚คใƒซ", 1337 + "rotationKey": "ใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผ", 1338 + "warning": "ๅพฉๅ…ƒใ‚’้–‹ๅง‹ใ™ใ‚‹ใจใ€ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใŒใ“ใฎPDSใ‚’ๆŒ‡ใ™ใ‚ˆใ†ใซๆ›ดๆ–ฐใ•ใ‚Œใพใ™ใ€‚ใ“ใ‚Œใฏ็ฐกๅ˜ใซๅ…ƒใซๆˆปใ™ใ“ใจใŒใงใใพใ›ใ‚“ใ€‚", 1339 + "plcWarningTitle": "ๅผ•ใ่ฟ”ใ›ใชใ„ใƒใ‚คใƒณใƒˆ", 1340 + "plcWarning": "้–‹ๅง‹ใ™ใ‚‹ใจใ€DIDใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆใŒใ“ใฎPDSใ‚’ๆŒ‡ใ™ใ‚ˆใ†ใซๆ›ดๆ–ฐใ•ใ‚Œใพใ™ใ€‚ๅ•้กŒใŒ็™บ็”Ÿใ—ใŸๅ ดๅˆใฏใƒญใƒผใƒ†ใƒผใ‚ทใƒงใƒณใ‚ญใƒผใ‚’ไฝฟ็”จใ—ใฆๅ›žๅพฉใงใใพใ™ใŒใ€ๅฃŠใ‚ŒใŸใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃ็Šถๆ…‹ใ‚’้ฟใ‘ใ‚‹ใŸใ‚ใซ็งป่กŒใ‚’ๅฎŒไบ†ใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚" 1282 - "title": "็งป่กŒใฎ็ขบ่ช", 1283 - "desc": "็งป่กŒใฎ่ฉณ็ดฐใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 1284 - "currentHandle": "็พๅœจใฎใƒใƒณใƒ‰ใƒซ", 1285 - "newHandle": "ๆ–ฐใ—ใ„ใƒใƒณใƒ‰ใƒซ", 1286 - "sourcePds": "ใ“ใฎPDS", 1287 - "targetPds": "็งป่กŒๅ…ˆPDS", 1288 - "confirm": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งป่กŒใ™ใ‚‹ใ“ใจใ‚’็ขบ่ชใ—ใพใ™", 1289 - "startMigration": "็งป่กŒใ‚’้–‹ๅง‹" 1290 1341 }, 1291 1342 "migrating": { 1343 + "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅพฉๅ…ƒไธญ", 1344 + "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ๅพฉๅ…ƒใ—ใฆใ„ใพใ™...", 1345 + "creating": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆไธญ", 1346 + "importing": "ใƒชใƒใ‚ธใƒˆใƒชใ‚’ใ‚คใƒณใƒใƒผใƒˆไธญ", 1347 + "plcSigning": "ใ‚ขใ‚คใƒ‡ใƒณใƒ†ใ‚ฃใƒ†ใ‚ฃใ‚’ๆ›ดๆ–ฐไธญ", 1348 + "activating": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ใ‚ขใ‚ฏใƒ†ใ‚ฃใƒ™ใƒผใƒˆไธญ" 1292 - "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’็งป่กŒไธญ", 1293 - "desc": "ใƒ‡ใƒผใ‚ฟใ‚’่ปข้€ใ—ใฆใ„ใพใ™..." 1294 1349 }, 1350 + "success": { 1351 + "desc": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใฏใ“ใฎPDSใซๆญฃๅธธใซๅพฉๅ…ƒใ•ใ‚Œใพใ—ใŸใ€‚" 1295 - "plcToken": { 1296 - "title": "ๆœฌไบบ็ขบ่ช", 1297 - "desc": "็ขบ่ชใ‚ณใƒผใƒ‰ใŒใƒกใƒผใƒซใซ้€ไฟกใ•ใ‚Œใพใ—ใŸใ€‚" 1298 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ใซๆŽฅ็ถšใ—ใฆใƒกใƒ‡ใ‚ฃใ‚ขใƒ•ใ‚กใ‚คใƒซใ‚’ๅ–ๅพ—ใงใใพใ›ใ‚“ใงใ—ใŸใ€‚ใ‚ทใƒฃใƒƒใƒˆใƒ€ใ‚ฆใƒณใ—ใŸใ‚ตใƒผใƒใƒผใ‹ใ‚‰ใฎ็งป่กŒใงใฏใ‚ˆใใ‚ใ‚‹ใ“ใจใงใ™ใ€‚ๆŠ•็จฟใฏๆฉŸ่ƒฝใ—ใพใ™ใŒใ€ไธ€้ƒจใฎ็”ปๅƒใŒๆฌ ่ฝใ™ใ‚‹ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚" 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 1361 } 1315 1362 }, 1316 1363 "progress": {
+147 -100
frontend/src/locales/ko.json
··· 17 17 "dashboard": "๋Œ€์‹œ๋ณด๋“œ", 18 18 "backToDashboard": "โ† ๋Œ€์‹œ๋ณด๋“œ", 19 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์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค" 20 - "copyToClipboard": "ํด๋ฆฝ๋ณด๋“œ์— ๋ณต์‚ฌ" 21 60 }, 22 61 "login": { 23 62 "title": "๋กœ๊ทธ์ธ", ··· 49 88 "codeLabel": "์ธ์ฆ ์ฝ”๋“œ", 50 89 "codePlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 51 90 "verifyButton": "๊ณ„์ • ์ธ์ฆ", 91 + "resent": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค!" 52 - "verifying": "์ธ์ฆ ์ค‘...", 53 - "resendButton": "์ฝ”๋“œ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ", 54 - "resending": "์ „์†ก ์ค‘...", 55 - "resent": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค!", 56 - "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ" 57 92 }, 58 93 "register": { 59 94 "title": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", ··· 124 159 "inviteCodePlaceholder": "์ดˆ๋Œ€ ์ฝ”๋“œ ์ž…๋ ฅ", 125 160 "inviteCodeRequired": "ํ•„์ˆ˜", 126 161 "createButton": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 127 - "creating": "๊ณ„์ • ์ƒ์„ฑ ์ค‘...", 128 162 "alreadyHaveAccount": "์ด๋ฏธ ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”?", 129 163 "signIn": "๋กœ๊ทธ์ธ", 130 164 "wantPasswordless": "๋น„๋ฐ€๋ฒˆํ˜ธ ์—†๋Š” ๋ณด์•ˆ์„ ์›ํ•˜์‹œ๋‚˜์š”?", ··· 179 213 "navAdminDesc": "์„œ๋ฒ„ ํ†ต๊ณ„ ๋ฐ ๊ด€๋ฆฌ ์ž‘์—…", 180 214 "navDidDocument": "DID ๋ฌธ์„œ", 181 215 "navDidDocumentDesc": "DID ๋ฌธ์„œ ๋ฐ ํ‚ค ๊ด€๋ฆฌ", 216 + "navDidDocumentDescActive": "DID ๋ฌธ์„œ ์„ค์ • ํŽธ์ง‘", 217 + "navBackup": "๋ฐฑ์—… ๋‹ค์šด๋กœ๋“œ", 218 + "navBackupDesc": "์ €์žฅ์†Œ๋ฅผ CAR ํŒŒ์ผ๋กœ ๋‹ค์šด๋กœ๋“œ", 219 + "downloadingBackup": "๋‹ค์šด๋กœ๋“œ ์ค‘...", 220 + "backupFailed": "๋ฐฑ์—… ๋‹ค์šด๋กœ๋“œ ์‹คํŒจ", 182 221 "migrated": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋จ", 183 222 "migratedTitle": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋จ", 184 223 "migratedMessage": "๊ณ„์ •์ด {pds}๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค. DID ๋ฌธ์„œ๋Š” ์—ฌ์ „ํžˆ ์—ฌ๊ธฐ์—์„œ ํ˜ธ์ŠคํŒ…๋ฉ๋‹ˆ๋‹ค.", ··· 208 247 "serviceEndpointDesc": "ํ˜„์žฌ ๊ณ„์ • ๋ฐ์ดํ„ฐ๋ฅผ ํ˜ธ์ŠคํŒ…ํ•˜๋Š” PDS์ž…๋‹ˆ๋‹ค. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•  ๋•Œ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.", 209 248 "currentPds": "ํ˜„์žฌ PDS URL", 210 249 "save": "๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ", 211 - "saving": "์ €์žฅ ์ค‘...", 212 250 "success": "DID ๋ฌธ์„œ๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 213 251 "saveFailed": "DID ๋ฌธ์„œ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 214 252 "loadFailed": "DID ๋ฌธ์„œ ๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", ··· 246 284 "yourDomain": "๋„๋ฉ”์ธ", 247 285 "yourDomainPlaceholder": "example.com", 248 286 "verifyAndUpdate": "ํ™•์ธ ํ›„ ํ•ธ๋“ค ์—…๋ฐ์ดํŠธ", 249 - "verifying": "ํ™•์ธ ์ค‘...", 250 287 "newHandle": "์ƒˆ ํ•ธ๋“ค", 251 288 "newHandlePlaceholder": "yourhandle", 252 289 "changeHandleButton": "ํ•ธ๋“ค ๋ณ€๊ฒฝ", ··· 262 299 "exportData": "๋ฐ์ดํ„ฐ ๋‚ด๋ณด๋‚ด๊ธฐ", 263 300 "exportDataDescription": "์ „์ฒด ์ €์žฅ์†Œ๋ฅผ CAR (Content Addressable Archive) ํŒŒ์ผ๋กœ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ๋ชจ๋“  ๊ฒŒ์‹œ๋ฌผ, ์ข‹์•„์š”, ํŒ”๋กœ์šฐ ๋ฐ ๊ธฐํƒ€ ๋ฐ์ดํ„ฐ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.", 264 301 "downloadRepo": "์ €์žฅ์†Œ ๋‹ค์šด๋กœ๋“œ", 302 + "downloadBlobs": "๋ฏธ๋””์–ด ๋‹ค์šด๋กœ๋“œ", 265 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 + }, 266 330 "deleteAccount": "๊ณ„์ • ์‚ญ์ œ", 267 331 "deleteWarning": "์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋ชจ๋“  ๋ฐ์ดํ„ฐ๊ฐ€ ์˜๊ตฌ์ ์œผ๋กœ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.", 268 332 "requestDeletion": "๊ณ„์ • ์‚ญ์ œ ์š”์ฒญ", ··· 291 355 "deleteConfirmation": "์ •๋ง๋กœ ๊ณ„์ •์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 292 356 "deletionFailed": "๊ณ„์ • ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 293 357 "repoExported": "์ €์žฅ์†Œ๋ฅผ ๋‚ด๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค", 358 + "blobsExported": "๋ฏธ๋””์–ด ํŒŒ์ผ์„ ๋‚ด๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค", 359 + "noBlobsToExport": "๋‚ด๋ณด๋‚ผ ๋ฏธ๋””์–ด ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค", 360 + "exportFailed": "๋‚ด๋ณด๋‚ด๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 294 - "exportFailed": "์ €์žฅ์†Œ ๋‚ด๋ณด๋‚ด๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 295 361 "confirmDelete": "์ •๋ง๋กœ ๊ณ„์ •์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." 296 362 } 297 363 }, ··· 306 372 "noPasswords": "์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค", 307 373 "revoke": "์ทจ์†Œ", 308 374 "revoking": "์ทจ์†Œ ์ค‘...", 309 - "creating": "์ƒ์„ฑ ์ค‘...", 310 375 "revokeConfirm": "์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ \"{name}\"์„(๋ฅผ) ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ์ด ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์•ฑ์€ ๋” ์ด์ƒ ๊ณ„์ •์— ์•ก์„ธ์Šคํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 311 376 "saveWarningTitle": "์ค‘์š”: ์ด ์•ฑ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ €์žฅํ•˜์„ธ์š”!", 312 377 "saveWarningMessage": "์ด ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํŒจ์Šคํ‚ค ๋˜๋Š” OAuth๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ์•ฑ์— ๋กœ๊ทธ์ธํ•˜๋Š” ๋ฐ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ํ•œ ๋ฒˆ๋งŒ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", ··· 354 419 "used": "@{handle}์ด(๊ฐ€) ์‚ฌ์šฉํ•จ", 355 420 "disabled": "๋น„ํ™œ์„ฑํ™”๋จ", 356 421 "usedBy": "์‚ฌ์šฉ์ž", 357 - "creating": "์ƒ์„ฑ ์ค‘...", 358 422 "disableConfirm": "์ด ์ดˆ๋Œ€ ์ฝ”๋“œ๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ๋” ์ด์ƒ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 359 423 "created": "์ดˆ๋Œ€ ์ฝ”๋“œ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 360 424 "copy": "๋ณต์‚ฌ", ··· 482 546 "verifyButton": "์ธ์ฆ", 483 547 "verifyCodePlaceholder": "์ธ์ฆ ์ฝ”๋“œ ์ž…๋ ฅ", 484 548 "submit": "์ œ์ถœ", 485 - "saving": "์ €์žฅ ์ค‘...", 486 549 "savePreferences": "์„ค์ • ์ €์žฅ", 487 550 "preferencesSaved": "ํ†ต์‹  ์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 488 551 "verifiedSuccess": "{channel} ์ธ์ฆ ์™„๋ฃŒ", ··· 521 584 "noCollectionsYet": "์ปฌ๋ ‰์…˜์ด ์•„์ง ์—†์Šต๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋งŒ๋“ค์–ด ์‹œ์ž‘ํ•˜์„ธ์š”.", 522 585 "loadMore": "๋” ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", 523 586 "recordJson": "๋ ˆ์ฝ”๋“œ JSON", 524 - "saving": "์ €์žฅ ์ค‘...", 525 587 "updateRecord": "๋ ˆ์ฝ”๋“œ ์—…๋ฐ์ดํŠธ", 526 588 "collectionNsid": "์ปฌ๋ ‰์…˜ (NSID)", 527 589 "recordKeyOptional": "๋ ˆ์ฝ”๋“œ ํ‚ค (์„ ํƒ์‚ฌํ•ญ)", 528 590 "autoGenerated": "๋น„์›Œ๋‘๋ฉด ์ž๋™ ์ƒ์„ฑ (TID)", 529 591 "autoGeneratedHint": "๋น„์›Œ๋‘๋ฉด TID ๊ธฐ๋ฐ˜ ํ‚ค๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค", 530 - "creating": "์ƒ์„ฑ ์ค‘...", 531 592 "demoPostText": "์•ˆ๋…•ํ•˜์„ธ์š”, ์ œ PDS์—์„œ ๋ณด๋‚ด๋Š” ์ฒซ ๋ฒˆ์งธ ๊ฒŒ์‹œ๋ฌผ์ž…๋‹ˆ๋‹ค!", 532 593 "demoDisplayName": "ํ‘œ์‹œ ์ด๋ฆ„", 533 594 "demoBio": "๊ฐ„๋‹จํ•œ ์ž๊ธฐ์†Œ๊ฐœ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”." ··· 548 609 "primaryLight": "๊ธฐ๋ณธ (๋ผ์ดํŠธ ๋ชจ๋“œ)", 549 610 "primaryDark": "๊ธฐ๋ณธ (๋‹คํฌ ๋ชจ๋“œ)", 550 611 "configSaved": "์„œ๋ฒ„ ์„ค์ •์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 551 - "saving": "์ €์žฅ ์ค‘...", 552 612 "saveConfig": "์„ค์ • ์ €์žฅ", 553 613 "serverStats": "์„œ๋ฒ„ ํ†ต๊ณ„", 554 614 "users": "์‚ฌ์šฉ์ž", ··· 639 699 "title": "2๋‹จ๊ณ„ ์ธ์ฆ", 640 700 "subtitle": "์ถ”๊ฐ€ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค", 641 701 "usePasskey": "ํŒจ์Šคํ‚ค ์‚ฌ์šฉ", 702 + "useTotp": "์ธ์ฆ ์•ฑ ์‚ฌ์šฉ" 642 - "useTotp": "์ธ์ฆ ์•ฑ ์‚ฌ์šฉ", 643 - "verifying": "ํ™•์ธ ์ค‘..." 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "2๋‹จ๊ณ„ ์ธ์ฆ", 647 706 "subtitle": "{channel}(์œผ)๋กœ ์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค. ์•„๋ž˜์— ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์—ฌ ๊ณ„์†ํ•˜์„ธ์š”.", 648 707 "codeLabel": "์ธ์ฆ ์ฝ”๋“œ", 649 708 "codePlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 650 - "verify": "ํ™•์ธ", 651 - "verifying": "ํ™•์ธ ์ค‘...", 652 709 "errors": { 653 710 "missingRequestUri": "request_uri ๋งค๊ฐœ๋ณ€์ˆ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค", 654 711 "verificationFailed": "์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", ··· 660 717 "title": "์ธ์ฆ ์ฝ”๋“œ ์ž…๋ ฅ", 661 718 "subtitle": "์ธ์ฆ ์•ฑ์˜ 6์ž๋ฆฌ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”", 662 719 "codePlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 663 - "verify": "ํ™•์ธ", 664 - "verifying": "ํ™•์ธ ์ค‘...", 665 720 "useBackupCode": "๋ฐฑ์—… ์ฝ”๋“œ ์‚ฌ์šฉ", 666 721 "backupCodePlaceholder": "๋ฐฑ์—… ์ฝ”๋“œ ์ž…๋ ฅ", 667 722 "trustDevice": "์ด ๊ธฐ๊ธฐ๋ฅผ 30์ผ๊ฐ„ ์‹ ๋ขฐ", ··· 691 746 "codeLabel": "์ธ์ฆ ์ฝ”๋“œ", 692 747 "codeHelp": "๋ฉ”์‹œ์ง€์—์„œ ํ•˜์ดํ”ˆ์„ ํฌํ•จํ•œ ์ „์ฒด ์ฝ”๋“œ๋ฅผ ๋ณต์‚ฌํ•˜์„ธ์š”", 693 748 "verifyButton": "๊ณ„์ • ์ธ์ฆ", 694 - "verify": "์ธ์ฆ", 695 - "verifying": "์ธ์ฆ ์ค‘...", 696 749 "pleaseWait": "์ž ์‹œ ๊ธฐ๋‹ค๋ ค ์ฃผ์„ธ์š”...", 697 - "sending": "์ „์†ก ์ค‘...", 698 - "resendCode": "์ฝ”๋“œ ๋‹ค์‹œ ๋ณด๋‚ด๊ธฐ", 699 - "resending": "์ „์†ก ์ค‘...", 700 750 "codeResent": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค!", 701 751 "codeResentDetail": "์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค! ๋ฐ›์€ ํŽธ์ง€ํ•จ์„ ํ™•์ธํ•˜์„ธ์š”.", 702 752 "verified": "์ธ์ฆ ์™„๋ฃŒ!", ··· 706 756 "identifierLabel": "์ด๋ฉ”์ผ ๋˜๋Š” ์‹๋ณ„์ž", 707 757 "identifierPlaceholder": "you@example.com", 708 758 "identifierHelp": "์ฝ”๋“œ๊ฐ€ ์ „์†ก๋œ ์ด๋ฉ”์ผ ์ฃผ์†Œ ๋˜๋Š” ์‹๋ณ„์ž", 709 - "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 710 759 "verifyingAccount": "์ธ์ฆ ์ค‘์ธ ๊ณ„์ •: @{handle}", 711 760 "startOver": "๋‹ค๋ฅธ ๊ณ„์ •์œผ๋กœ ๋‹ค์‹œ ์‹œ์ž‘", 712 761 "noPending": "๋ณด๋ฅ˜ ์ค‘์ธ ์ธ์ฆ์ด ์—†์Šต๋‹ˆ๋‹ค.", 713 762 "noPendingInfo": "์ตœ๊ทผ์— ๊ณ„์ •์„ ๋งŒ๋“ค๊ณ  ์ธ์ฆ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์ƒˆ ๊ณ„์ •์„ ๋งŒ๋“ค์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฏธ ๊ณ„์ •์„ ์ธ์ฆํ•œ ๊ฒฝ์šฐ ๋กœ๊ทธ์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 714 763 "createAccount": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 715 764 "signIn": "๋กœ๊ทธ์ธ", 716 - "backToSettings": "์„ค์ •์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 717 765 "emailUpdateCodeHelp": "์ฝ”๋“œ๊ฐ€ ํ˜„์žฌ ์ด๋ฉ”์ผ ์ฃผ์†Œ๋กœ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค", 718 766 "emailUpdateFailed": "์ด๋ฉ”์ผ ์ฃผ์†Œ ์—…๋ฐ์ดํŠธ ์‹คํŒจ", 719 767 "emailUpdateRequiresAuth": "์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", ··· 746 794 "resetButton": "๋น„๋ฐ€๋ฒˆํ˜ธ ์žฌ์„ค์ •", 747 795 "resetting": "์žฌ์„ค์ • ์ค‘...", 748 796 "success": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์žฌ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", 749 - "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", 750 797 "requestNewCode": "์ƒˆ ์ฝ”๋“œ ์š”์ฒญ", 751 798 "passwordsMismatch": "๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค", 752 799 "passwordLength": "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค" ··· 790 837 "howItWorks": "์ž‘๋™ ๋ฐฉ์‹", 791 838 "howItWorksDetail": "๋“ฑ๋ก๋œ ์•Œ๋ฆผ ์ฑ„๋„๋กœ ๋ณด์•ˆ ๋งํฌ๋ฅผ ๋ณด๋ƒ…๋‹ˆ๋‹ค. ๋งํฌ๋ฅผ ํด๋ฆญํ•˜์—ฌ ์ž„์‹œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ ๋‹ค์Œ ๋กœ๊ทธ์ธํ•˜์—ฌ ์ƒˆ ํŒจ์Šคํ‚ค๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 792 839 "sendRecoveryLink": "๋ณต๊ตฌ ๋งํฌ ๋ณด๋‚ด๊ธฐ", 840 + "sending": "์ „์†ก ์ค‘..." 793 - "sending": "์ „์†ก ์ค‘...", 794 - "backToLogin": "๋กœ๊ทธ์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ" 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "ํŒจ์Šคํ‚ค ๊ณ„์ • ๋งŒ๋“ค๊ธฐ", ··· 812 858 "externalDid": "๊ท€ํ•˜์˜ did:web", 813 859 "externalDidPlaceholder": "did:web:yourdomain.com", 814 860 "createButton": "๊ณ„์ • ๋งŒ๋“ค๊ธฐ", 815 - "creating": "์ƒ์„ฑ ์ค‘...", 816 861 "alreadyHaveAccount": "์ด๋ฏธ ๊ณ„์ •์ด ์žˆ์œผ์‹ ๊ฐ€์š”?", 817 862 "signIn": "๋กœ๊ทธ์ธ", 818 863 "wantPassword": "๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", ··· 911 956 "useTotp": "์ธ์ฆ ์•ฑ ์‚ฌ์šฉ", 912 957 "passwordPlaceholder": "๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ", 913 958 "totpPlaceholder": "6์ž๋ฆฌ ์ฝ”๋“œ ์ž…๋ ฅ", 914 - "verify": "ํ™•์ธ", 915 - "verifying": "ํ™•์ธ ์ค‘...", 916 959 "authenticating": "์ธ์ฆ ์ค‘...", 917 960 "passkeyPrompt": "์•„๋ž˜ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ํŒจ์Šคํ‚ค๋กœ ์ธ์ฆํ•˜์„ธ์š”.", 918 961 "cancel": "์ทจ์†Œ" ··· 985 1028 "createAccount": "๊ณ„์ • ์ƒ์„ฑ", 986 1029 "createDelegatedAccount": "์œ„์ž„ ๊ณ„์ • ์ƒ์„ฑ", 987 1030 "createDelegatedAccountButton": "+ ์œ„์ž„ ๊ณ„์ • ์ƒ์„ฑ", 988 - "creating": "์ƒ์„ฑ ์ค‘...", 989 1031 "emailOptional": "์ด๋ฉ”์ผ (์„ ํƒ์‚ฌํ•ญ)", 990 1032 "failedToAddController": "์ปจํŠธ๋กค๋Ÿฌ ์ถ”๊ฐ€์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", 991 1033 "failedToCreateAccount": "์œ„์ž„ ๊ณ„์ • ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", ··· 1059 1101 "navDesc": "๋‹ค๋ฅธ PDS๋กœ ๋˜๋Š” ๋‹ค๋ฅธ PDS์—์„œ ๊ณ„์ • ์ด๋™", 1060 1102 "migrateHere": "์—ฌ๊ธฐ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜", 1061 1103 "migrateHereDesc": "๊ธฐ์กด AT Protocol ๊ณ„์ •์„ ๋‹ค๋ฅธ ์„œ๋ฒ„์—์„œ ์ด PDS๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.", 1062 - "migrateAway": "๋‹ค๋ฅธ ๊ณณ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜", 1063 - "migrateAwayDesc": "์ด PDS์—์„œ ๋‹ค๋ฅธ ์„œ๋ฒ„๋กœ ๊ณ„์ •์„ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.", 1064 - "loginRequired": "๋กœ๊ทธ์ธ ํ•„์š”", 1065 1104 "bringDid": "DID์™€ ์•„์ด๋ดํ‹ฐํ‹ฐ ๊ฐ€์ ธ์˜ค๊ธฐ", 1066 1105 "transferData": "๋ชจ๋“  ๋ฐ์ดํ„ฐ ์ „์†ก", 1067 1106 "keepFollowers": "ํŒ”๋กœ์›Œ ์œ ์ง€", 1068 - "exportRepo": "์ €์žฅ์†Œ ๋‚ด๋ณด๋‚ด๊ธฐ", 1069 - "transferToPds": "์ƒˆ PDS๋กœ ์ „์†ก", 1070 - "updateIdentity": "์•„์ด๋ดํ‹ฐํ‹ฐ ์—…๋ฐ์ดํŠธ", 1071 1107 "whatIsMigration": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด๋ž€?", 1072 1108 "whatIsMigrationDesc": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ํ†ตํ•ด AT Protocol ์•„์ด๋ดํ‹ฐํ‹ฐ๋ฅผ ๊ฐœ์ธ ๋ฐ์ดํ„ฐ ์„œ๋ฒ„(PDS) ๊ฐ„์— ์ด๋™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. DID(๋ถ„์‚ฐ ์‹๋ณ„์ž)๋Š” ๋™์ผํ•˜๊ฒŒ ์œ ์ง€๋˜๋ฏ€๋กœ ํŒ”๋กœ์›Œ์™€ ์†Œ์…œ ์—ฐ๊ฒฐ์ด ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.", 1073 1109 "beforeMigrate": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ „ ํ™•์ธ์‚ฌํ•ญ", ··· 1077 1113 "beforeMigrate4": "์ด์ „ PDS์— ๊ณ„์ • ๋น„ํ™œ์„ฑํ™”๊ฐ€ ํ†ต๋ณด๋ฉ๋‹ˆ๋‹ค", 1078 1114 "importantWarning": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์€ ์ค‘์š”ํ•œ ์ž‘์—…์ž…๋‹ˆ๋‹ค. ๋Œ€์ƒ PDS๋ฅผ ์‹ ๋ขฐํ•˜๊ณ  ๋ฐ์ดํ„ฐ๊ฐ€ ์ด๋™๋œ๋‹ค๋Š” ๊ฒƒ์„ ์ดํ•ดํ•˜์„ธ์š”. ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์ˆ˜๋™ ๋ณต๊ตฌ๊ฐ€ ํ•„์š”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", 1079 1115 "learnMore": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์œ„ํ—˜์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์•Œ์•„๋ณด๊ธฐ", 1116 + "offlineRestore": "์˜คํ”„๋ผ์ธ ๋ณต์›", 1117 + "offlineRestoreDesc": "์ด์ „ PDS๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์„ ๋•Œ ๋ฐฑ์—…์—์„œ ๋ณต์›ํ•ฉ๋‹ˆ๋‹ค.", 1118 + "offlineFeature1": "CAR ํŒŒ์ผ ๋ฐฑ์—… ์‚ฌ์šฉ", 1119 + "offlineFeature2": "ํšŒ์ „ ํ‚ค๋กœ ์†Œ์œ ๊ถŒ ์ฆ๋ช…", 1120 + "offlineFeature3": "์ข…๋ฃŒ๋œ ์„œ๋ฒ„ ๋ณต๊ตฌ", 1080 - "comingSoon": "๊ณง ์ถœ์‹œ ์˜ˆ์ •", 1081 1121 "oauthCompleting": "์ธ์ฆ ์™„๋ฃŒ ์ค‘...", 1082 1122 "oauthFailed": "์ธ์ฆ ์‹คํŒจ", 1083 1123 "tryAgain": "๋‹ค์‹œ ์‹œ๋„", ··· 1086 1126 "incomplete": "์™„๋ฃŒ๋˜์ง€ ์•Š์€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์ด ์žˆ์Šต๋‹ˆ๋‹ค:", 1087 1127 "direction": "๋ฐฉํ–ฅ", 1088 1128 "migratingHere": "์—ฌ๊ธฐ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1089 - "migratingAway": "๋‹ค๋ฅธ ๊ณณ์œผ๋กœ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1090 1129 "from": "์ถœ๋ฐœ์ง€", 1091 1130 "to": "๋ชฉ์ ์ง€", 1092 1131 "progress": "์ง„ํ–‰ ์ƒํ™ฉ", ··· 1229 1268 "error": { 1230 1269 "title": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์˜ค๋ฅ˜", 1231 1270 "desc": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", 1271 + "startOver": "์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘", 1272 + "unknown": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค." 1232 - "startOver": "์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ ์‹œ์ž‘" 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "๋’ค๋กœ", ··· 1247 1287 "warning3": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ์ด์ „ ๊ณ„์ •์€ ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค" 1248 1288 } 1249 1289 }, 1290 + "offline": { 1250 - "outbound": { 1251 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 ํŒŒ์ผ์„ ๋‹ค์‹œ ์—…๋กœ๋“œํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ณด์•ˆ์ƒ์˜ ์ด์œ ๋กœ ํŒŒ์ผ ๋‚ด์šฉ์€ ์„ธ์…˜ ๊ฐ„์— ์ €์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค." 1252 - "title": "์ด PDS์—์„œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜", 1253 - "desc": "๊ณ„์ •์„ ๋‹ค๋ฅธ ๊ฐœ์ธ ๋ฐ์ดํ„ฐ ์„œ๋ฒ„๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.", 1254 - "warning": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ์ด PDS์—์„œ ๊ณ„์ •์ด ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.", 1255 - "didWebNotice": "did:web ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์•Œ๋ฆผ", 1256 - "didWebNoticeDesc": "๊ท€ํ•˜์˜ ๊ณ„์ •์€ did:web ์‹๋ณ„์ž({did})๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํ›„ ์ด PDS๋Š” ์ƒˆ PDS๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” DID ๋ฌธ์„œ๋ฅผ ๊ณ„์† ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด ์„œ๋ฒ„๊ฐ€ ์˜จ๋ผ์ธ์ธ ํ•œ ์•„์ด๋ดํ‹ฐํ‹ฐ๋Š” ๊ณ„์† ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.", 1257 - "understand": "์œ„ํ—˜์„ ์ดํ•ดํ•˜๊ณ  ๊ณ„์† ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค" 1258 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": "ํ‚ค ๊ฒ€์ฆ" 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 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "DID ๋ณต์› ์ค‘" 1271 - "newAccount": { 1272 - "title": "์ƒˆ ๊ณ„์ • ์„ธ๋ถ€ ์ •๋ณด", 1273 - "desc": "์ƒˆ PDS์—์„œ ๊ณ„์ •์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.", 1274 - "handle": "ํ•ธ๋“ค", 1275 - "availableDomains": "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋„๋ฉ”์ธ", 1276 - "email": "์ด๋ฉ”์ผ", 1277 - "password": "๋น„๋ฐ€๋ฒˆํ˜ธ", 1278 - "confirmPassword": "๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ", 1279 - "inviteCode": "์ดˆ๋Œ€ ์ฝ”๋“œ" 1280 1333 }, 1281 1334 "review": { 1335 + "desc": "์˜คํ”„๋ผ์ธ ๋ณต์› ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ํ™•์ธํ•˜์„ธ์š”.", 1336 + "carFile": "CAR ํŒŒ์ผ", 1337 + "rotationKey": "ํšŒ์ „ ํ‚ค", 1338 + "warning": "๋ณต์›์„ ์‹œ์ž‘ํ•˜๋ฉด ์•„์ด๋ดํ‹ฐํ‹ฐ๊ฐ€ ์ด PDS๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋„๋ก ์—…๋ฐ์ดํŠธ๋ฉ๋‹ˆ๋‹ค. ์ด๊ฒƒ์€ ์‰ฝ๊ฒŒ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", 1339 + "plcWarningTitle": "๋˜๋Œ๋ฆด ์ˆ˜ ์—†๋Š” ์ง€์ ", 1340 + "plcWarning": "์‹œ์ž‘ํ•˜๋ฉด DID ๋ฌธ์„œ๊ฐ€ ์ด PDS๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋„๋ก ์—…๋ฐ์ดํŠธ๋ฉ๋‹ˆ๋‹ค. ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ํšŒ์ „ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ณต๊ตฌํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์†์ƒ๋œ ์•„์ด๋ดํ‹ฐํ‹ฐ ์ƒํƒœ๋ฅผ ํ”ผํ•˜๋ ค๋ฉด ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ์™„๋ฃŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." 1282 - "title": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฒ€ํ† ", 1283 - "desc": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์„ธ๋ถ€ ์ •๋ณด๋ฅผ ๊ฒ€ํ† ํ•˜๊ณ  ํ™•์ธํ•˜์„ธ์š”.", 1284 - "currentHandle": "ํ˜„์žฌ ํ•ธ๋“ค", 1285 - "newHandle": "์ƒˆ ํ•ธ๋“ค", 1286 - "sourcePds": "์ด PDS", 1287 - "targetPds": "๋Œ€์ƒ PDS", 1288 - "confirm": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค", 1289 - "startMigration": "๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹œ์ž‘" 1290 1341 }, 1291 1342 "migrating": { 1343 + "title": "๊ณ„์ • ๋ณต์› ์ค‘", 1344 + "desc": "๊ณ„์ •์„ ๋ณต์›ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค...", 1345 + "creating": "๊ณ„์ • ์ƒ์„ฑ ์ค‘", 1346 + "importing": "์ €์žฅ์†Œ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘", 1347 + "plcSigning": "์•„์ด๋ดํ‹ฐํ‹ฐ ์—…๋ฐ์ดํŠธ ์ค‘", 1348 + "activating": "๊ณ„์ • ํ™œ์„ฑํ™” ์ค‘" 1292 - "title": "๊ณ„์ • ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ค‘", 1293 - "desc": "๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..." 1294 1349 }, 1350 + "success": { 1351 + "desc": "๊ณ„์ •์ด ์ด PDS์— ์„ฑ๊ณต์ ์œผ๋กœ ๋ณต์›๋˜์—ˆ์Šต๋‹ˆ๋‹ค." 1295 - "plcToken": { 1296 - "title": "์‹ ์› ํ™•์ธ", 1297 - "desc": "์ด๋ฉ”์ผ๋กœ ์ธ์ฆ ์ฝ”๋“œ๊ฐ€ ์ „์†ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค." 1298 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์— ์—ฐ๊ฒฐํ•˜์—ฌ ๋ฏธ๋””์–ด ํŒŒ์ผ์„ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ข…๋ฃŒ๋œ ์„œ๋ฒ„์—์„œ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ํ•  ๋•Œ ํ”ํžˆ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ๊ฒŒ์‹œ๋ฌผ์€ ์ž‘๋™ํ•˜์ง€๋งŒ ์ผ๋ถ€ ์ด๋ฏธ์ง€๊ฐ€ ๋ˆ„๋ฝ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." 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 1361 } 1315 1362 }, 1316 1363 "progress": {
+147 -100
frontend/src/locales/sv.json
··· 17 17 "dashboard": "Kontrollpanel", 18 18 "backToDashboard": "โ† Kontrollpanel", 19 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" 20 - "copyToClipboard": "Kopiera" 21 60 }, 22 61 "login": { 23 62 "title": "Logga in", ··· 49 88 "codeLabel": "Verifieringskod", 50 89 "codePlaceholder": "Ange 6-siffrig kod", 51 90 "verifyButton": "Verifiera konto", 91 + "resent": "Verifieringskod skickad igen!" 52 - "verifying": "Verifierar...", 53 - "resendButton": "Skicka kod igen", 54 - "resending": "Skickar igen...", 55 - "resent": "Verifieringskod skickad igen!", 56 - "backToLogin": "Tillbaka till inloggning" 57 92 }, 58 93 "register": { 59 94 "title": "Skapa konto", ··· 124 159 "inviteCodePlaceholder": "Ange din inbjudningskod", 125 160 "inviteCodeRequired": "krรคvs", 126 161 "createButton": "Skapa konto", 127 - "creating": "Skapar konto...", 128 162 "alreadyHaveAccount": "Har du redan ett konto?", 129 163 "signIn": "Logga in", 130 164 "wantPasswordless": "Vill du ha lรถsenordsfri sรคkerhet?", ··· 179 213 "navAdminDesc": "Serverstatistik och administratรถrsoperationer", 180 214 "navDidDocument": "DID-dokument", 181 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", 182 221 "migrated": "Flyttad", 183 222 "migratedTitle": "Konto flyttat", 184 223 "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande hรคr.", ··· 208 247 "serviceEndpointDesc": "PDS som fรถr nรคrvarande lagrar din kontodata. Uppdatera detta vid migrering.", 209 248 "currentPds": "Nuvarande PDS-URL", 210 249 "save": "Spara รคndringar", 211 - "saving": "Sparar...", 212 250 "success": "DID-dokumentet har uppdaterats", 213 251 "saveFailed": "Kunde inte spara DID-dokument", 214 252 "loadFailed": "Kunde inte ladda DID-dokument", ··· 246 284 "yourDomain": "Din domรคn", 247 285 "yourDomainPlaceholder": "exempel.se", 248 286 "verifyAndUpdate": "Verifiera och uppdatera anvรคndarnamn", 249 - "verifying": "Verifierar...", 250 287 "newHandle": "Nytt anvรคndarnamn", 251 288 "newHandlePlaceholder": "dittanvรคndarnamn", 252 289 "changeHandleButton": "ร„ndra anvรคndarnamn", ··· 262 299 "exportData": "Exportera data", 263 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.", 264 301 "downloadRepo": "Ladda ner arkiv", 302 + "downloadBlobs": "Ladda ner media", 265 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 + }, 266 330 "deleteAccount": "Radera konto", 267 331 "deleteWarning": "Denna รฅtgรคrd รคr oรฅterkallelig. All din data kommer att raderas permanent.", 268 332 "requestDeletion": "Begรคr kontoradering", ··· 291 355 "deleteConfirmation": "ร„r du helt sรคker pรฅ att du vill radera ditt konto? Detta kan inte รฅngras.", 292 356 "deletionFailed": "Kunde inte radera kontot", 293 357 "repoExported": "Arkiv exporterat", 358 + "blobsExported": "Mediafiler exporterade", 359 + "noBlobsToExport": "Inga mediafiler att exportera", 360 + "exportFailed": "Export misslyckades", 294 - "exportFailed": "Kunde inte exportera arkiv", 295 361 "confirmDelete": "ร„r du helt sรคker pรฅ att du vill radera ditt konto? Detta kan inte รฅngras." 296 362 } 297 363 }, ··· 306 372 "noPasswords": "Inga applรถsenord รคnnu", 307 373 "revoke": "ร…terkalla", 308 374 "revoking": "ร…terkallar...", 309 - "creating": "Skapar...", 310 375 "revokeConfirm": "ร…terkalla applรถsenord \"{name}\"? Appar som anvรคnder detta lรถsenord kommer inte lรคngre att kunna komma รฅt ditt konto.", 311 376 "saveWarningTitle": "Viktigt: Spara detta applรถsenord!", 312 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.", ··· 354 419 "used": "Anvรคnd av @{handle}", 355 420 "disabled": "Inaktiverad", 356 421 "usedBy": "Anvรคnd av", 357 - "creating": "Skapar...", 358 422 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte lรคngre anvรคndas.", 359 423 "created": "Inbjudningskod skapad", 360 424 "copy": "Kopiera", ··· 482 546 "verifyButton": "Verifiera", 483 547 "verifyCodePlaceholder": "Ange verifieringskod", 484 548 "submit": "Skicka", 485 - "saving": "Sparar...", 486 549 "savePreferences": "Spara instรคllningar", 487 550 "preferencesSaved": "Kommunikationsinstรคllningar sparade", 488 551 "verifiedSuccess": "{channel} verifierad", ··· 521 584 "noCollectionsYet": "Inga samlingar รคnnu. Skapa din fรถrsta post fรถr att komma igรฅng.", 522 585 "loadMore": "Ladda fler", 523 586 "recordJson": "Post-JSON", 524 - "saving": "Sparar...", 525 587 "updateRecord": "Uppdatera post", 526 588 "collectionNsid": "Samling (NSID)", 527 589 "recordKeyOptional": "Postnyckel (valfri)", 528 590 "autoGenerated": "Genereras automatiskt om tom (TID)", 529 591 "autoGeneratedHint": "Lรคmna tom fรถr att automatiskt generera en TID-baserad nyckel", 530 - "creating": "Skapar...", 531 592 "demoPostText": "Hej frรฅn min PDS! Detta รคr mitt fรถrsta inlรคgg.", 532 593 "demoDisplayName": "Ditt visningsnamn", 533 594 "demoBio": "En kort presentation om dig sjรคlv." ··· 548 609 "primaryLight": "Primรคr (ljust lรคge)", 549 610 "primaryDark": "Primรคr (mรถrkt lรคge)", 550 611 "configSaved": "Serverkonfiguration sparad", 551 - "saving": "Sparar...", 552 612 "saveConfig": "Spara konfiguration", 553 613 "serverStats": "Serverstatistik", 554 614 "users": "Anvรคndare", ··· 639 699 "title": "Tvรฅfaktorsautentisering", 640 700 "subtitle": "Ytterligare verifiering krรคvs", 641 701 "usePasskey": "Anvรคnd nyckel", 702 + "useTotp": "Anvรคnd autentiseringsapp" 642 - "useTotp": "Anvรคnd autentiseringsapp", 643 - "verifying": "Verifierar..." 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "Tvรฅfaktorsautentisering", 647 706 "subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan fรถr att fortsรคtta.", 648 707 "codeLabel": "Verifieringskod", 649 708 "codePlaceholder": "Ange 6-siffrig kod", 650 - "verify": "Verifiera", 651 - "verifying": "Verifierar...", 652 709 "errors": { 653 710 "missingRequestUri": "Saknar request_uri-parameter", 654 711 "verificationFailed": "Verifiering misslyckades", ··· 660 717 "title": "Ange autentiseringskod", 661 718 "subtitle": "Ange den 6-siffriga koden frรฅn din autentiseringsapp", 662 719 "codePlaceholder": "Ange 6-siffrig kod", 663 - "verify": "Verifiera", 664 - "verifying": "Verifierar...", 665 720 "useBackupCode": "Anvรคnd reservkod istรคllet", 666 721 "backupCodePlaceholder": "Ange reservkod", 667 722 "trustDevice": "Lita pรฅ denna enhet i 30 dagar", ··· 691 746 "codeLabel": "Verifieringskod", 692 747 "codeHelp": "Kopiera hela koden frรฅn ditt meddelande, inklusive bindestreck", 693 748 "verifyButton": "Verifiera konto", 694 - "verify": "Verifiera", 695 - "verifying": "Verifierar...", 696 749 "pleaseWait": "Vรคnta...", 697 - "sending": "Skickar...", 698 - "resendCode": "Skicka kod igen", 699 - "resending": "Skickar igen...", 700 750 "codeResent": "Verifieringskod skickad igen!", 701 751 "codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.", 702 752 "verified": "Verifierad!", ··· 706 756 "identifierLabel": "E-post eller identifierare", 707 757 "identifierPlaceholder": "du@exempel.se", 708 758 "identifierHelp": "E-postadressen eller identifieraren koden skickades till", 709 - "backToLogin": "Tillbaka till inloggning", 710 759 "verifyingAccount": "Verifierar konto: @{handle}", 711 760 "startOver": "Bรถrja om med ett annat konto", 712 761 "noPending": "Ingen vรคntande verifiering hittades.", 713 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.", 714 763 "createAccount": "Skapa konto", 715 764 "signIn": "Logga in", 716 - "backToSettings": "Tillbaka till instรคllningar", 717 765 "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 718 766 "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 719 767 "emailUpdateRequiresAuth": "Du mรฅste vara inloggad fรถr att uppdatera din e-postadress.", ··· 746 794 "resetButton": "ร…terstรคll lรถsenord", 747 795 "resetting": "ร…terstรคller...", 748 796 "success": "Lรถsenord รฅterstรคllt!", 749 - "backToLogin": "Tillbaka till inloggning", 750 797 "requestNewCode": "Begรคr ny kod", 751 798 "passwordsMismatch": "Lรถsenorden matchar inte", 752 799 "passwordLength": "Lรถsenordet mรฅste vara minst 8 tecken" ··· 790 837 "howItWorks": "Sรฅ fungerar det", 791 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.", 792 839 "sendRecoveryLink": "Skicka รฅterstรคllningslรคnk", 840 + "sending": "Skickar..." 793 - "sending": "Skickar...", 794 - "backToLogin": "Tillbaka till inloggning" 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "Skapa nyckelkonto", ··· 812 858 "externalDid": "Din did:web", 813 859 "externalDidPlaceholder": "did:web:dindomรคn.se", 814 860 "createButton": "Skapa konto", 815 - "creating": "Skapar...", 816 861 "alreadyHaveAccount": "Har du redan ett konto?", 817 862 "signIn": "Logga in", 818 863 "wantPassword": "Vill du anvรคnda ett lรถsenord?", ··· 911 956 "useTotp": "Anvรคnd autentiserare", 912 957 "passwordPlaceholder": "Ange ditt lรถsenord", 913 958 "totpPlaceholder": "Ange 6-siffrig kod", 914 - "verify": "Verifiera", 915 - "verifying": "Verifierar...", 916 959 "authenticating": "Autentiserar...", 917 960 "passkeyPrompt": "Klicka pรฅ knappen nedan fรถr att autentisera med din passkey.", 918 961 "cancel": "Avbryt" ··· 985 1028 "createAccount": "Skapa konto", 986 1029 "createDelegatedAccount": "Skapa delegerat konto", 987 1030 "createDelegatedAccountButton": "+ Skapa delegerat konto", 988 - "creating": "Skapar...", 989 1031 "emailOptional": "E-post (valfritt)", 990 1032 "failedToAddController": "Kunde inte lรคgga till kontrollant", 991 1033 "failedToCreateAccount": "Kunde inte skapa delegerat konto", ··· 1059 1101 "navDesc": "Flytta ditt konto till eller frรฅn en annan PDS", 1060 1102 "migrateHere": "Flytta hit", 1061 1103 "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 1104 "bringDid": "Ta med din DID och identitet", 1066 1105 "transferData": "ร–verfรถr all din data", 1067 1106 "keepFollowers": "Behรฅll dina fรถljare", 1068 - "exportRepo": "Exportera ditt arkiv", 1069 - "transferToPds": "ร–verfรถr till ny PDS", 1070 - "updateIdentity": "Uppdatera din identitet", 1071 1107 "whatIsMigration": "Vad รคr kontoflyttning?", 1072 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.", 1073 1109 "beforeMigrate": "Innan du flyttar", ··· 1077 1113 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1078 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.", 1079 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", 1080 - "comingSoon": "Kommer snart", 1081 1121 "oauthCompleting": "Slutfรถr autentisering...", 1082 1122 "oauthFailed": "Autentisering misslyckades", 1083 1123 "tryAgain": "Fรถrsรถk igen", ··· 1086 1126 "incomplete": "Du har en ofullstรคndig flytt pรฅgรฅende:", 1087 1127 "direction": "Riktning", 1088 1128 "migratingHere": "Flyttar hit", 1089 - "migratingAway": "Flyttar bort", 1090 1129 "from": "Frรฅn", 1091 1130 "to": "Till", 1092 1131 "progress": "Framsteg", ··· 1229 1268 "error": { 1230 1269 "title": "Flyttfel", 1231 1270 "desc": "Ett fel uppstod under flytten.", 1271 + "startOver": "Bรถrja om", 1272 + "unknown": "Ett okรคnt fel uppstod." 1232 - "startOver": "Bรถrja om" 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "Tillbaka", ··· 1247 1287 "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1248 1288 } 1249 1289 }, 1290 + "offline": { 1250 - "outbound": { 1251 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." 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 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" 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 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "ร…terstรคller DID" 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 1333 }, 1281 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." 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 1341 }, 1291 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" 1292 - "title": "Flyttar ditt konto", 1293 - "desc": "Vรคnta medan vi รถverfรถr din data..." 1294 1349 }, 1350 + "success": { 1351 + "desc": "Ditt konto har framgรฅngsrikt รฅterstรคllts till denna PDS." 1295 - "plcToken": { 1296 - "title": "Verifiera din identitet", 1297 - "desc": "En verifieringskod har skickats till din e-post." 1298 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." 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 1361 } 1315 1362 }, 1316 1363 "progress": {
+147 -100
frontend/src/locales/zh.json
··· 17 17 "dashboard": "ๆŽงๅˆถๅฐ", 18 18 "backToDashboard": "โ† ่ฟ”ๅ›žๆŽงๅˆถๅฐ", 19 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ไธชๅญ—็ฌฆ" 20 - "copyToClipboard": "ๅคๅˆถ" 21 60 }, 22 61 "login": { 23 62 "title": "็™ปๅฝ•", ··· 49 88 "codeLabel": "้ชŒ่ฏ็ ", 50 89 "codePlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 51 90 "verifyButton": "้ชŒ่ฏ่ดฆๆˆท", 91 + "resent": "้ชŒ่ฏ็ ๅทฒ้‡ๆ–ฐๅ‘้€๏ผ" 52 - "verifying": "้ชŒ่ฏไธญ...", 53 - "resendButton": "้‡ๆ–ฐๅ‘้€้ชŒ่ฏ็ ", 54 - "resending": "ๅ‘้€ไธญ...", 55 - "resent": "้ชŒ่ฏ็ ๅทฒ้‡ๆ–ฐๅ‘้€๏ผ", 56 - "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•" 57 92 }, 58 93 "register": { 59 94 "title": "ๅˆ›ๅปบ่ดฆๆˆท", ··· 124 159 "inviteCodePlaceholder": "่พ“ๅ…ฅๆ‚จ็š„้‚€่ฏท็ ", 125 160 "inviteCodeRequired": "ๅฟ…ๅกซ", 126 161 "createButton": "ๅˆ›ๅปบ่ดฆๆˆท", 127 - "creating": "ๆญฃๅœจๅˆ›ๅปบ...", 128 162 "alreadyHaveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ", 129 163 "signIn": "็ซ‹ๅณ็™ปๅฝ•", 130 164 "wantPasswordless": "ๆƒณ่ฆๆ— ๅฏ†็ ็™ปๅฝ•๏ผŸ", ··· 179 213 "navAdminDesc": "ๆœๅŠกๅ™จ็ปŸ่ฎกๅ’Œ็ฎก็†ๆ“ไฝœ", 180 214 "navDidDocument": "DID ๆ–‡ๆกฃ", 181 215 "navDidDocumentDesc": "็ฎก็†ๆ‚จ็š„ DID ๆ–‡ๆกฃๅ’Œๅฏ†้’ฅ", 216 + "navDidDocumentDescActive": "็ผ–่พ‘ๆ‚จ็š„ DID ๆ–‡ๆกฃ่ฎพ็ฝฎ", 217 + "navBackup": "ไธ‹่ฝฝๅค‡ไปฝ", 218 + "navBackupDesc": "ๅฐ†ๆ‚จ็š„ๅญ˜ๅ‚จๅบ“ไธ‹่ฝฝไธบ CAR ๆ–‡ไปถ", 219 + "downloadingBackup": "ไธ‹่ฝฝไธญ...", 220 + "backupFailed": "ไธ‹่ฝฝๅค‡ไปฝๅคฑ่ดฅ", 182 221 "migrated": "ๅทฒ่ฟ็งป", 183 222 "migratedTitle": "่ดฆๆˆทๅทฒ่ฟ็งป", 184 223 "migratedMessage": "ๆ‚จ็š„่ดฆๆˆทๅทฒ่ฟ็งปๅˆฐ {pds}ใ€‚ๆ‚จ็š„ DID ๆ–‡ๆกฃไปๅœจๆญคๅค„ๆ‰˜็ฎกใ€‚", ··· 208 247 "serviceEndpointDesc": "ๅฝ“ๅ‰ๆ‰˜็ฎกๆ‚จ่ดฆๆˆทๆ•ฐๆฎ็š„ PDSใ€‚่ฟ็งปๆ—ถ่ฏทๆ›ดๆ–ฐๆญค้กนใ€‚", 209 248 "currentPds": "ๅฝ“ๅ‰ PDS URL", 210 249 "save": "ไฟๅญ˜ๆ›ดๆ”น", 211 - "saving": "ไฟๅญ˜ไธญ...", 212 250 "success": "DID ๆ–‡ๆกฃๅทฒๆ›ดๆ–ฐ", 213 251 "saveFailed": "ไฟๅญ˜ DID ๆ–‡ๆกฃๅคฑ่ดฅ", 214 252 "loadFailed": "ๅŠ ่ฝฝ DID ๆ–‡ๆกฃๅคฑ่ดฅ", ··· 246 284 "yourDomain": "ๆ‚จ็š„ๅŸŸๅ", 247 285 "yourDomainPlaceholder": "example.com", 248 286 "verifyAndUpdate": "้ชŒ่ฏๅนถๆ›ดๆ–ฐ็”จๆˆทๅ", 249 - "verifying": "้ชŒ่ฏไธญ...", 250 287 "newHandle": "ๆ–ฐ็”จๆˆทๅ", 251 288 "newHandlePlaceholder": "yourhandle", 252 289 "changeHandleButton": "ๆ›ดๆ”น็”จๆˆทๅ", ··· 262 299 "exportData": "ๅฏผๅ‡บๆ•ฐๆฎ", 263 300 "exportDataDescription": "ๅฐ†ๆ‚จ็š„ๆ‰€ๆœ‰ๆ•ฐๆฎไธ‹่ฝฝไธบ CAR ๆ–‡ไปถใ€‚ๅŒ…ๆ‹ฌๆ‚จ็š„ๆ‰€ๆœ‰ๅธ–ๅญใ€็‚น่ตžใ€ๅ…ณๆณจ็ญ‰ๆ•ฐๆฎใ€‚", 264 301 "downloadRepo": "ไธ‹่ฝฝๆ•ฐๆฎ", 302 + "downloadBlobs": "ไธ‹่ฝฝๅช’ไฝ“ๆ–‡ไปถ", 265 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 + }, 266 330 "deleteAccount": "ๅˆ ้™ค่ดฆๆˆท", 267 331 "deleteWarning": "ๆญคๆ“ไฝœไธๅฏ้€†ใ€‚ๆ‚จ็š„ๆ‰€ๆœ‰ๆ•ฐๆฎๅฐ†่ขซๆฐธไน…ๅˆ ้™คใ€‚", 268 332 "requestDeletion": "่ฏทๆฑ‚ๅˆ ้™ค่ดฆๆˆท", ··· 291 355 "deleteConfirmation": "ๆ‚จ็กฎๅฎš่ฆๅˆ ้™ค่ดฆๆˆทๅ—๏ผŸๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚", 292 356 "deletionFailed": "่ดฆๆˆทๅˆ ้™คๅคฑ่ดฅ", 293 357 "repoExported": "ๆ•ฐๆฎๅฏผๅ‡บๆˆๅŠŸ", 358 + "blobsExported": "ๅช’ไฝ“ๆ–‡ไปถๅฏผๅ‡บๆˆๅŠŸ", 359 + "noBlobsToExport": "ๆฒกๆœ‰ๅฏๅฏผๅ‡บ็š„ๅช’ไฝ“ๆ–‡ไปถ", 360 + "exportFailed": "ๅฏผๅ‡บๅคฑ่ดฅ", 294 - "exportFailed": "ๆ•ฐๆฎๅฏผๅ‡บๅคฑ่ดฅ", 295 361 "confirmDelete": "ๆ‚จ็กฎๅฎš่ฆๅˆ ้™ค่ดฆๆˆทๅ—๏ผŸๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚" 296 362 } 297 363 }, ··· 306 372 "noPasswords": "ๆš‚ๆ— ๅบ”็”จไธ“็”จๅฏ†็ ", 307 373 "revoke": "ๆ’ค้”€", 308 374 "revoking": "ๆ’ค้”€ไธญ...", 309 - "creating": "ๅˆ›ๅปบไธญ...", 310 375 "revokeConfirm": "ๆ’ค้”€ใ€Œ{name}ใ€็š„ๅฏ†็ ๏ผŸไฝฟ็”จๆญคๅฏ†็ ็š„ๅบ”็”จๅฐ†ๆ— ๆณ•ๅ†่ฎฟ้—ฎๆ‚จ็š„่ดฆๆˆทใ€‚", 311 376 "saveWarningTitle": "้‡่ฆ๏ผš่ฏทไฟๅญ˜ๆญคๅบ”็”จไธ“็”จๅฏ†็ ๏ผ", 312 377 "saveWarningMessage": "ๆญคๅฏ†็ ็”จไบŽ็™ปๅฝ•ไธๆ”ฏๆŒ้€š่กŒๅฏ†้’ฅๆˆ– OAuth ็š„ๅบ”็”จใ€‚ๆ‚จๅช่ƒฝ็œ‹ๅˆฐไธ€ๆฌกใ€‚", ··· 354 419 "used": "ๅทฒ่ขซ @{handle} ไฝฟ็”จ", 355 420 "disabled": "ๅทฒ็ฆ็”จ", 356 421 "usedBy": "ไฝฟ็”จ่€…", 357 - "creating": "ๅˆ›ๅปบไธญ...", 358 422 "disableConfirm": "็ฆ็”จๆญค้‚€่ฏท็ ๏ผŸๅฎƒๅฐ†ๆ— ๆณ•ๅ†่ขซไฝฟ็”จใ€‚", 359 423 "created": "้‚€่ฏท็ ๅทฒๅˆ›ๅปบ", 360 424 "copy": "ๅคๅˆถ", ··· 482 546 "verifyButton": "้ชŒ่ฏ", 483 547 "verifyCodePlaceholder": "่พ“ๅ…ฅ้ชŒ่ฏ็ ", 484 548 "submit": "ๆไบค", 485 - "saving": "ไฟๅญ˜ไธญ...", 486 549 "savePreferences": "ไฟๅญ˜ๅๅฅฝ่ฎพ็ฝฎ", 487 550 "preferencesSaved": "้€š่ฎฏๅๅฅฝๅทฒไฟๅญ˜", 488 551 "verifiedSuccess": "{channel} ้ชŒ่ฏๆˆๅŠŸ", ··· 521 584 "noCollectionsYet": "ๆš‚ๆ— ้›†ๅˆใ€‚ๅˆ›ๅปบๆ‚จ็š„็ฌฌไธ€ๆก่ฎฐๅฝ•ๅผ€ๅง‹ไฝฟ็”จใ€‚", 522 585 "loadMore": "ๅŠ ่ฝฝๆ›ดๅคš", 523 586 "recordJson": "่ฎฐๅฝ• JSON", 524 - "saving": "ไฟๅญ˜ไธญ...", 525 587 "updateRecord": "ๆ›ดๆ–ฐ่ฎฐๅฝ•", 526 588 "collectionNsid": "้›†ๅˆ (NSID)", 527 589 "recordKeyOptional": "่ฎฐๅฝ•้”ฎ๏ผˆๅฏ้€‰๏ผ‰", 528 590 "autoGenerated": "็•™็ฉบ่‡ชๅŠจ็”Ÿๆˆ (TID)", 529 591 "autoGeneratedHint": "็•™็ฉบๅฐ†่‡ชๅŠจ็”ŸๆˆๅŸบไบŽ TID ็š„้”ฎ", 530 - "creating": "ๅˆ›ๅปบไธญ...", 531 592 "demoPostText": "ไฝ ๅฅฝ๏ผŒ่ฟ™ๆ˜ฏๆˆ‘็š„็ฌฌไธ€ๆกๅธ–ๅญ๏ผๆฅ่‡ชๆˆ‘็š„ PDSใ€‚", 532 593 "demoDisplayName": "ไฝ ็š„ๆ˜พ็คบๅ็งฐ", 533 594 "demoBio": "ๅ†™ไธ€ๆฎต็ฎ€็Ÿญ็š„่‡ชๆˆ‘ไป‹็ปใ€‚" ··· 551 612 "secondaryLight": "ๅ‰ฏ่‰ฒ๏ผˆๆต…่‰ฒๆจกๅผ๏ผ‰", 552 613 "secondaryDark": "ๅ‰ฏ่‰ฒ๏ผˆๆทฑ่‰ฒๆจกๅผ๏ผ‰", 553 614 "configSaved": "ๆœๅŠกๅ™จ้…็ฝฎๅทฒไฟๅญ˜", 554 - "saving": "ไฟๅญ˜ไธญ...", 555 615 "saveConfig": "ไฟๅญ˜้…็ฝฎ", 556 616 "serverStats": "ๆœๅŠกๅ™จ็ปŸ่ฎก", 557 617 "users": "็”จๆˆท", ··· 639 699 "title": "ๅŒ้‡่บซไปฝ้ชŒ่ฏ", 640 700 "subtitle": "้œ€่ฆ้ขๅค–้ชŒ่ฏ", 641 701 "usePasskey": "ไฝฟ็”จ้€š่กŒๅฏ†้’ฅ", 702 + "useTotp": "ไฝฟ็”จ่บซไปฝ้ชŒ่ฏๅ™จ" 642 - "useTotp": "ไฝฟ็”จ่บซไปฝ้ชŒ่ฏๅ™จ", 643 - "verifying": "้ชŒ่ฏไธญ..." 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "ๅŒ้‡่บซไปฝ้ชŒ่ฏ", 647 706 "subtitle": "้ชŒ่ฏ็ ๅทฒๅ‘้€ๅˆฐๆ‚จ็š„ {channel}ใ€‚่ฏทๅœจไธ‹ๆ–น่พ“ๅ…ฅ้ชŒ่ฏ็ ็ปง็ปญใ€‚", 648 707 "codeLabel": "้ชŒ่ฏ็ ", 649 708 "codePlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 650 - "verify": "้ชŒ่ฏ", 651 - "verifying": "้ชŒ่ฏไธญ...", 652 709 "errors": { 653 710 "missingRequestUri": "็ผบๅฐ‘ request_uri ๅ‚ๆ•ฐ", 654 711 "verificationFailed": "้ชŒ่ฏๅคฑ่ดฅ", ··· 660 717 "title": "่พ“ๅ…ฅ้ชŒ่ฏ็ ", 661 718 "subtitle": "่ฏท่พ“ๅ…ฅ่บซไปฝ้ชŒ่ฏๅ™จๅบ”็”จไธญ็š„6ไฝ้ชŒ่ฏ็ ", 662 719 "codePlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 663 - "verify": "้ชŒ่ฏ", 664 - "verifying": "้ชŒ่ฏไธญ...", 665 720 "useBackupCode": "ไฝฟ็”จๅค‡็”จ้ชŒ่ฏ็ ", 666 721 "backupCodePlaceholder": "่พ“ๅ…ฅๅค‡็”จ้ชŒ่ฏ็ ", 667 722 "trustDevice": "ไฟกไปปๆญค่ฎพๅค‡30ๅคฉ", ··· 691 746 "codeLabel": "้ชŒ่ฏ็ ", 692 747 "codeHelp": "ๅคๅˆถๆถˆๆฏไธญ็š„ๅฎŒๆ•ด้ชŒ่ฏ็ ๏ผŒๅŒ…ๆ‹ฌๆจช็บฟ", 693 748 "verifyButton": "้ชŒ่ฏ่ดฆๆˆท", 694 - "verify": "้ชŒ่ฏ", 695 - "verifying": "้ชŒ่ฏไธญ...", 696 749 "pleaseWait": "่ฏท็จๅ€™...", 697 - "resendCode": "้‡ๆ–ฐๅ‘้€้ชŒ่ฏ็ ", 698 - "resending": "ๅ‘้€ไธญ...", 699 - "sending": "ๅ‘้€ไธญ...", 700 750 "codeResent": "้ชŒ่ฏ็ ๅทฒ้‡ๆ–ฐๅ‘้€๏ผ", 701 751 "codeResentDetail": "้ชŒ่ฏ็ ๅทฒๅ‘้€๏ผ่ฏทๆŸฅๆ”ถใ€‚", 702 - "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•", 703 752 "verifyingAccount": "ๆญฃๅœจ้ชŒ่ฏ่ดฆๆˆท๏ผš@{handle}", 704 753 "startOver": "ไฝฟ็”จๅ…ถไป–่ดฆๆˆท้‡ๆ–ฐๅผ€ๅง‹", 705 754 "noPending": "ๆœชๆ‰พๅˆฐๅพ…้ชŒ่ฏ็š„่ดฆๆˆท", ··· 713 762 "identifierLabel": "้‚ฎ็ฎฑๆˆ–ๆ ‡่ฏ†็ฌฆ", 714 763 "identifierPlaceholder": "you@example.com", 715 764 "identifierHelp": "ๆŽฅๆ”ถ้ชŒ่ฏ็ ็š„้‚ฎ็ฎฑๅœฐๅ€ๆˆ–ๆ ‡่ฏ†็ฌฆ", 716 - "backToSettings": "่ฟ”ๅ›ž่ฎพ็ฝฎ", 717 765 "emailUpdateCodeHelp": "้ชŒ่ฏ็ ๅทฒๅ‘้€ๅˆฐๆ‚จๅฝ“ๅ‰็š„้‚ฎ็ฎฑๅœฐๅ€", 718 766 "emailUpdateFailed": "ๆ›ดๆ–ฐ้‚ฎ็ฎฑๅœฐๅ€ๅคฑ่ดฅ", 719 767 "emailUpdateRequiresAuth": "ๆ‚จ้œ€่ฆ็™ปๅฝ•ๆ‰่ƒฝๆ›ดๆ–ฐ้‚ฎ็ฎฑๅœฐๅ€ใ€‚", ··· 746 794 "resetButton": "้‡็ฝฎๅฏ†็ ", 747 795 "resetting": "้‡็ฝฎไธญ...", 748 796 "success": "ๅฏ†็ ้‡็ฝฎๆˆๅŠŸ๏ผ", 749 - "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•", 750 797 "requestNewCode": "้‡ๆ–ฐ่Žทๅ–้ชŒ่ฏ็ ", 751 798 "passwordsMismatch": "ไธคๆฌก่พ“ๅ…ฅ็š„ๅฏ†็ ไธไธ€่‡ด", 752 799 "passwordLength": "ๅฏ†็ ่‡ณๅฐ‘้œ€่ฆ8ไฝๅญ—็ฌฆ" ··· 790 837 "howItWorks": "ๅฆ‚ไฝ•ๆขๅค", 791 838 "howItWorksDetail": "ๆˆ‘ไปฌๅฐ†ๅ‘ๆ‚จๆณจๅ†Œ็š„้€š็Ÿฅๆธ ้“ๅ‘้€ๅฎ‰ๅ…จ้“พๆŽฅใ€‚็‚นๅ‡ป้“พๆŽฅ่ฎพ็ฝฎไธดๆ—ถๅฏ†็ ๏ผŒ็„ถๅŽๆ‚จๅฐฑๅฏไปฅ็™ปๅฝ•ๅนถๆทปๅŠ ๆ–ฐ็š„้€š่กŒๅฏ†้’ฅใ€‚", 792 839 "sendRecoveryLink": "ๅ‘้€ๆขๅค้“พๆŽฅ", 840 + "sending": "ๅ‘้€ไธญ..." 793 - "sending": "ๅ‘้€ไธญ...", 794 - "backToLogin": "่ฟ”ๅ›ž็™ปๅฝ•" 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "ๅˆ›ๅปบ้€š่กŒๅฏ†้’ฅ่ดฆๆˆท", ··· 814 860 "inviteCode": "้‚€่ฏท็ ", 815 861 "inviteCodePlaceholder": "่พ“ๅ…ฅๆ‚จ็š„้‚€่ฏท็ ", 816 862 "createButton": "ๅˆ›ๅปบ่ดฆๆˆท", 817 - "creating": "ๅˆ›ๅปบไธญ...", 818 863 "continue": "็ปง็ปญ", 819 864 "back": "่ฟ”ๅ›ž", 820 865 "alreadyHaveAccount": "ๅทฒๆœ‰่ดฆๆˆท๏ผŸ", ··· 911 956 "useTotp": "ไฝฟ็”จ่บซไปฝ้ชŒ่ฏๅ™จ", 912 957 "passwordPlaceholder": "่พ“ๅ…ฅๆ‚จ็š„ๅฏ†็ ", 913 958 "totpPlaceholder": "่พ“ๅ…ฅ6ไฝ้ชŒ่ฏ็ ", 914 - "verify": "้ชŒ่ฏ", 915 - "verifying": "้ชŒ่ฏไธญ...", 916 959 "authenticating": "ๆญฃๅœจ้ชŒ่ฏ...", 917 960 "passkeyPrompt": "็‚นๅ‡ปไธ‹ๆ–นๆŒ‰้’ฎไฝฟ็”จ้€š่กŒๅฏ†้’ฅ่ฟ›่กŒ้ชŒ่ฏใ€‚", 918 961 "cancel": "ๅ–ๆถˆ" ··· 986 1029 "createAccount": "ๅˆ›ๅปบ่ดฆๆˆท", 987 1030 "createDelegatedAccount": "ๅˆ›ๅปบๅง”ๆ‰˜่ดฆๆˆท", 988 1031 "createDelegatedAccountButton": "+ ๅˆ›ๅปบๅง”ๆ‰˜่ดฆๆˆท", 989 - "creating": "ๅˆ›ๅปบไธญ...", 990 1032 "emailOptional": "้‚ฎ็ฎฑ๏ผˆๅฏ้€‰๏ผ‰", 991 1033 "failedToAddController": "ๆทปๅŠ ๆŽงๅˆถ่€…ๅคฑ่ดฅ", 992 1034 "failedToCreateAccount": "ๅˆ›ๅปบๅง”ๆ‰˜่ดฆๆˆทๅคฑ่ดฅ", ··· 1059 1101 "navDesc": "ๅฐ†ๆ‚จ็š„่ดฆๆˆท็งป่‡ณๅ…ถไป–PDSๆˆ–ไปŽๅ…ถไป–PDS็งปๅ…ฅ", 1060 1102 "migrateHere": "่ฟ็งปๅˆฐๆญคๅค„", 1061 1103 "migrateHereDesc": "ๅฐ†ๆ‚จ็Žฐๆœ‰็š„AT Protocol่ดฆๆˆทไปŽๅ…ถไป–ๆœๅŠกๅ™จ็งป่‡ณๆญคPDSใ€‚", 1062 - "migrateAway": "่ฟ็งป็ฆปๅผ€", 1063 - "migrateAwayDesc": "ๅฐ†ๆ‚จ็š„่ดฆๆˆทไปŽๆญคPDS็งป่‡ณๅ…ถไป–ๆœๅŠกๅ™จใ€‚", 1064 - "loginRequired": "้œ€่ฆ็™ปๅฝ•", 1065 1104 "bringDid": "ๆบๅธฆๆ‚จ็š„DIDๅ’Œ่บซไปฝ", 1066 1105 "transferData": "่ฝฌ็งปๆ‰€ๆœ‰ๆ•ฐๆฎ", 1067 1106 "keepFollowers": "ไฟ็•™ๆ‚จ็š„ๅ…ณๆณจ่€…", 1068 - "exportRepo": "ๅฏผๅ‡บๆ‚จ็š„ๅญ˜ๅ‚จๅบ“", 1069 - "transferToPds": "่ฝฌ็งปๅˆฐๆ–ฐPDS", 1070 - "updateIdentity": "ๆ›ดๆ–ฐๆ‚จ็š„่บซไปฝ", 1071 1107 "whatIsMigration": "ไป€ไนˆๆ˜ฏ่ดฆๆˆท่ฟ็งป๏ผŸ", 1072 1108 "whatIsMigrationDesc": "่ดฆๆˆท่ฟ็งปๅ…่ฎธๆ‚จๅœจไธชไบบๆ•ฐๆฎๆœๅŠกๅ™จ๏ผˆPDS๏ผ‰ไน‹้—ด็งปๅŠจAT Protocol่บซไปฝใ€‚ๆ‚จ็š„DID๏ผˆๅŽปไธญๅฟƒๅŒ–ๆ ‡่ฏ†็ฌฆ๏ผ‰ไฟๆŒไธๅ˜๏ผŒๅ› ๆญคๆ‚จ็š„ๅ…ณๆณจ่€…ๅ’Œ็คพไบค่ฟžๆŽฅๅพ—ไปฅไฟ็•™ใ€‚", 1073 1109 "beforeMigrate": "่ฟ็งปๅ‰้กป็Ÿฅ", ··· 1077 1113 "beforeMigrate4": "ๆ‚จ็š„ๆ—งPDSๅฐ†ๆ”ถๅˆฐ่ดฆๆˆทๅœ็”จ้€š็Ÿฅ", 1078 1114 "importantWarning": "่ดฆๆˆท่ฟ็งปๆ˜ฏไธ€้กน้‡่ฆๆ“ไฝœใ€‚่ฏท็กฎไฟๆ‚จไฟกไปป็›ฎๆ ‡PDS๏ผŒๅนถไบ†่งฃๆ‚จ็š„ๆ•ฐๆฎๅฐ†่ขซ็งปๅŠจใ€‚ๅฆ‚ๆžœๅ‡บ็Žฐ้—ฎ้ข˜๏ผŒๅฏ่ƒฝ้œ€่ฆๆ‰‹ๅŠจๆขๅคใ€‚", 1079 1115 "learnMore": "ไบ†่งฃๆ›ดๅคš่ฟ็งป้ฃŽ้™ฉ", 1116 + "offlineRestore": "็ฆป็บฟๆขๅค", 1117 + "offlineRestoreDesc": "ๅฝ“ๆ—ง PDS ไธๅฏ็”จๆ—ถไปŽๅค‡ไปฝๆขๅคใ€‚", 1118 + "offlineFeature1": "ไฝฟ็”จ CAR ๆ–‡ไปถๅค‡ไปฝ", 1119 + "offlineFeature2": "ไฝฟ็”จ่ฝฎๆขๅฏ†้’ฅ่ฏๆ˜Žๆ‰€ๆœ‰ๆƒ", 1120 + "offlineFeature3": "็”จไบŽๅทฒๅ…ณ้—ญๆœๅŠกๅ™จ็š„ๆขๅค", 1080 - "comingSoon": "ๅณๅฐ†ๆŽจๅ‡บ", 1081 1121 "oauthCompleting": "ๆญฃๅœจๅฎŒๆˆ่บซไปฝ้ชŒ่ฏ...", 1082 1122 "oauthFailed": "่บซไปฝ้ชŒ่ฏๅคฑ่ดฅ", 1083 1123 "tryAgain": "้‡่ฏ•", ··· 1086 1126 "incomplete": "ๆ‚จๆœ‰ไธ€ไธชๆœชๅฎŒๆˆ็š„่ฟ็งป๏ผš", 1087 1127 "direction": "ๆ–นๅ‘", 1088 1128 "migratingHere": "ๆญฃๅœจ่ฟ็งปๅˆฐๆญคๅค„", 1089 - "migratingAway": "ๆญฃๅœจ่ฟ็งป็ฆปๅผ€", 1090 1129 "from": "ไปŽ", 1091 1130 "to": "ๅˆฐ", 1092 1131 "progress": "่ฟ›ๅบฆ", ··· 1229 1268 "error": { 1230 1269 "title": "่ฟ็งป้”™่ฏฏ", 1231 1270 "desc": "่ฟ็งป่ฟ‡็จ‹ไธญๅ‘็”Ÿ้”™่ฏฏใ€‚", 1271 + "startOver": "้‡ๆ–ฐๅผ€ๅง‹", 1272 + "unknown": "ๅ‘็”Ÿๆœช็Ÿฅ้”™่ฏฏใ€‚" 1232 - "startOver": "้‡ๆ–ฐๅผ€ๅง‹" 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "่ฟ”ๅ›ž", ··· 1247 1287 "warning3": "่ฟ็งปๅŽๆ‚จ็š„ๆ—ง่ดฆๆˆทๅฐ†่ขซๅœ็”จ" 1248 1288 } 1249 1289 }, 1290 + "offline": { 1250 - "outbound": { 1251 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 ๆ–‡ไปถใ€‚ๅ‡บไบŽๅฎ‰ๅ…จๅŽŸๅ› ๏ผŒๆ–‡ไปถๅ†…ๅฎนไธไผšๅœจไผš่ฏไน‹้—ดไฟๅญ˜ใ€‚" 1252 - "title": "ไปŽๆญคPDS่ฟ็งป็ฆปๅผ€", 1253 - "desc": "ๅฐ†ๆ‚จ็š„่ดฆๆˆท็งป่‡ณๅฆไธ€ไธชไธชไบบๆ•ฐๆฎๆœๅŠกๅ™จใ€‚", 1254 - "warning": "่ฟ็งปๅŽ๏ผŒๆ‚จๅœจๆญคๅค„็š„่ดฆๆˆทๅฐ†่ขซๅœ็”จใ€‚", 1255 - "didWebNotice": "did:web่ฟ็งป้€š็Ÿฅ", 1256 - "didWebNoticeDesc": "ๆ‚จ็š„่ดฆๆˆทไฝฟ็”จdid:webๆ ‡่ฏ†็ฌฆ๏ผˆ{did}๏ผ‰ใ€‚่ฟ็งปๅŽ๏ผŒๆญคPDSๅฐ†็ปง็ปญๆไพ›ๆŒ‡ๅ‘ๆ–ฐPDS็š„DIDๆ–‡ๆกฃใ€‚ๅช่ฆๆญคๆœๅŠกๅ™จๅœจ็บฟ๏ผŒๆ‚จ็š„่บซไปฝๅฐ†็ปง็ปญๆœ‰ๆ•ˆใ€‚", 1257 - "understand": "ๆˆ‘ไบ†่งฃ้ฃŽ้™ฉๅนถๅธŒๆœ›็ปง็ปญ" 1258 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": "้ชŒ่ฏๅฏ†้’ฅ" 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 1330 }, 1331 + "chooseHandle": { 1332 + "migratingDid": "ๆขๅค DID" 1271 - "newAccount": { 1272 - "title": "ๆ–ฐ่ดฆๆˆท่ฏฆๆƒ…", 1273 - "desc": "ๅœจๆ–ฐPDSไธŠ่ฎพ็ฝฎๆ‚จ็š„่ดฆๆˆทใ€‚", 1274 - "handle": "็”จๆˆทๅ", 1275 - "availableDomains": "ๅฏ็”จๅŸŸๅ", 1276 - "email": "้‚ฎ็ฎฑ", 1277 - "password": "ๅฏ†็ ", 1278 - "confirmPassword": "็กฎ่ฎคๅฏ†็ ", 1279 - "inviteCode": "้‚€่ฏท็ " 1280 1333 }, 1281 1334 "review": { 1335 + "desc": "ๆฃ€ๆŸฅๆ‚จ็š„็ฆป็บฟๆขๅค่ฏฆๆƒ…ใ€‚", 1336 + "carFile": "CAR ๆ–‡ไปถ", 1337 + "rotationKey": "่ฝฎๆขๅฏ†้’ฅ", 1338 + "warning": "ๅผ€ๅง‹ๆขๅคๅŽ๏ผŒๆ‚จ็š„่บซไปฝๅฐ†ๆ›ดๆ–ฐไธบๆŒ‡ๅ‘ๆญค PDSใ€‚ๆญคๆ“ไฝœๆ— ๆณ•่ฝปๆ˜“ๆ’ค้”€ใ€‚", 1339 + "plcWarningTitle": "ไธๅฏ้€†่ฝฌ็‚น", 1340 + "plcWarning": "ไธ€ๆ—ฆๅผ€ๅง‹๏ผŒๆ‚จ็š„ DID ๆ–‡ๆกฃๅฐ†ๆ›ดๆ–ฐไธบๆŒ‡ๅ‘ๆญค PDSใ€‚ๅฆ‚ๆžœๅ‡บ็Žฐ้—ฎ้ข˜๏ผŒๆ‚จๅฏไปฅไฝฟ็”จ่ฝฎๆขๅฏ†้’ฅๆขๅค๏ผŒไฝ†ๆ‚จๅบ”่ฏฅๅฎŒๆˆ่ฟ็งปไปฅ้ฟๅ…่บซไปฝ็Šถๆ€ๆŸๅใ€‚" 1282 - "title": "ๆฃ€ๆŸฅ่ฟ็งป", 1283 - "desc": "่ฏทๆฃ€ๆŸฅๅนถ็กฎ่ฎคๆ‚จ็š„่ฟ็งป่ฏฆๆƒ…ใ€‚", 1284 - "currentHandle": "ๅฝ“ๅ‰็”จๆˆทๅ", 1285 - "newHandle": "ๆ–ฐ็”จๆˆทๅ", 1286 - "sourcePds": "ๆญคPDS", 1287 - "targetPds": "็›ฎๆ ‡PDS", 1288 - "confirm": "ๆˆ‘็กฎ่ฎค่ฆ่ฟ็งปๆˆ‘็š„่ดฆๆˆท", 1289 - "startMigration": "ๅผ€ๅง‹่ฟ็งป" 1290 1341 }, 1291 1342 "migrating": { 1343 + "title": "ๆขๅค่ดฆๆˆท", 1344 + "desc": "่ฏท็จๅ€™๏ผŒๆญฃๅœจๆขๅคๆ‚จ็š„่ดฆๆˆท...", 1345 + "creating": "ๅˆ›ๅปบ่ดฆๆˆท", 1346 + "importing": "ๅฏผๅ…ฅๅญ˜ๅ‚จๅบ“", 1347 + "plcSigning": "ๆ›ดๆ–ฐ่บซไปฝ", 1348 + "activating": "ๆฟ€ๆดป่ดฆๆˆท" 1292 - "title": "ๆญฃๅœจ่ฟ็งปๆ‚จ็š„่ดฆๆˆท", 1293 - "desc": "่ฏท็จๅ€™๏ผŒๆญฃๅœจ่ฝฌ็งปๆ‚จ็š„ๆ•ฐๆฎ..." 1294 1349 }, 1350 + "success": { 1351 + "desc": "ๆ‚จ็š„่ดฆๆˆทๅทฒๆˆๅŠŸๆขๅคๅˆฐๆญค PDSใ€‚" 1295 - "plcToken": { 1296 - "title": "้ชŒ่ฏๆ‚จ็š„่บซไปฝ", 1297 - "desc": "้ชŒ่ฏ็ ๅทฒๅ‘้€ๅˆฐๆ‚จ็š„้‚ฎ็ฎฑใ€‚" 1298 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 ๆฅ่Žทๅ–ๅช’ไฝ“ๆ–‡ไปถใ€‚ไปŽๅทฒๅ…ณ้—ญ็š„ๆœๅŠกๅ™จ่ฟ็งปๆ—ถ่ฟ™ๅพˆๅธธ่งใ€‚ๆ‚จ็š„ๅธ–ๅญๅฐ†ๆญฃๅธธๅทฅไฝœ๏ผŒไฝ†้ƒจๅˆ†ๅ›พ็‰‡ๅฏ่ƒฝไผšไธขๅคฑใ€‚" 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 1361 } 1315 1362 }, 1316 1363 "progress": {
+1 -1
frontend/src/routes/ActAs.svelte
··· 37 37 38 38 try { 39 39 const response = await fetch( 40 + `/xrpc/_delegation.listControlledAccounts`, 40 - `/xrpc/com.tranquil.delegation.listControlledAccounts`, 41 41 { 42 42 headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` } 43 43 }
+1 -1
frontend/src/routes/Admin.svelte
··· 435 435 <div class="message success">{$_('admin.configSaved')}</div> 436 436 {/if} 437 437 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 438 + {serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')} 438 - {serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')} 439 439 </button> 440 440 </form> 441 441 </section>
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 155 155 </div> 156 156 </div> 157 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 158 + {creating ? $_('common.creating') : $_('common.create')} 158 - {creating ? $_('appPasswords.creating') : $_('common.create')} 159 159 </button> 160 160 </form> 161 161 </section>
+1 -1
frontend/src/routes/Comms.svelte
··· 341 341 342 342 <div class="actions"> 343 343 <button type="submit" disabled={saving}> 344 + {saving ? $_('common.saving') : $_('comms.savePreferences')} 344 - {saving ? $_('comms.saving') : $_('comms.savePreferences')} 345 345 </button> 346 346 </div> 347 347 </form>
+7 -7
frontend/src/routes/Controllers.svelte
··· 75 75 async function loadControllers() { 76 76 if (!auth.session) return 77 77 try { 78 + const response = await fetch('/xrpc/_delegation.listControllers', { 78 - const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', { 79 79 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 80 80 }) 81 81 if (response.ok) { ··· 90 90 async function loadControlledAccounts() { 91 91 if (!auth.session) return 92 92 try { 93 + const response = await fetch('/xrpc/_delegation.listControlledAccounts', { 93 - const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', { 94 94 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 95 95 }) 96 96 if (response.ok) { ··· 104 104 105 105 async function loadScopePresets() { 106 106 try { 107 + const response = await fetch('/xrpc/_delegation.getScopePresets') 107 - const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets') 108 108 if (response.ok) { 109 109 const data = await response.json() 110 110 scopePresets = data.presets || [] ··· 121 121 success = null 122 122 123 123 try { 124 + const response = await fetch('/xrpc/_delegation.addController', { 124 - const response = await fetch('/xrpc/com.tranquil.delegation.addController', { 125 125 method: 'POST', 126 126 headers: { 127 127 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 159 159 success = null 160 160 161 161 try { 162 + const response = await fetch('/xrpc/_delegation.removeController', { 162 - const response = await fetch('/xrpc/com.tranquil.delegation.removeController', { 163 163 method: 'POST', 164 164 headers: { 165 165 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 188 188 success = null 189 189 190 190 try { 191 + const response = await fetch('/xrpc/_delegation.createDelegatedAccount', { 191 - const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', { 192 192 method: 'POST', 193 193 headers: { 194 194 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 407 407 {$_('common.cancel')} 408 408 </button> 409 409 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 410 + {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 410 - {creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')} 411 411 </button> 412 412 </div> 413 413 </div>
+21
frontend/src/routes/Dashboard.svelte
··· 10 10 let switching = $state(false) 11 11 let inviteCodesEnabled = $state(false) 12 12 13 + const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false) 14 + 13 15 onMount(async () => { 14 16 try { 15 17 const serverInfo = await api.describeServer() ··· 176 178 <h3>{$_('dashboard.navSecurity')}</h3> 177 179 <p>{$_('dashboard.navSecurityDesc')}</p> 178 180 </a> 181 + <a href="#/settings" class="nav-card"> 182 + <h3>{$_('dashboard.navSettings')}</h3> 183 + <p>{$_('dashboard.navSettingsDesc')}</p> 184 + </a> 179 185 <a href="#/migrate" class="nav-card"> 180 186 <h3>{$_('dashboard.navMigrateAgain')}</h3> 181 187 <p>{$_('dashboard.navMigrateAgainDesc')}</p> ··· 215 221 <h3>{$_('dashboard.navDelegation')}</h3> 216 222 <p>{$_('dashboard.navDelegationDesc')}</p> 217 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} 218 230 <a href="#/migrate" class="nav-card"> 219 231 <h3>{$_('migration.navTitle')}</h3> 220 232 <p>{$_('migration.navDesc')}</p> ··· 503 515 504 516 .nav-card.migrated-card h3 { 505 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); 506 527 } 507 528 </style>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 41 41 42 42 try { 43 43 const response = await fetch( 44 + `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 44 - `/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`, 45 45 { 46 46 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 47 47 }
+1 -1
frontend/src/routes/DidDocumentEditor.svelte
··· 230 230 231 231 <div class="actions"> 232 232 <button onclick={handleSave} disabled={saving}> 233 + {saving ? $_('common.saving') : $_('common.save')} 233 - {saving ? $_('didEditor.saving') : $_('didEditor.save')} 234 234 </button> 235 235 </div> 236 236 {/if}
+5
frontend/src/routes/Home.svelte
··· 183 183 <h3>Delegate without sharing passwords</h3> 184 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 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> 186 191 </div> 187 192 188 193 <h2>Everything in one place</h2>
+1 -1
frontend/src/routes/InviteCodes.svelte
··· 111 111 {#if auth.session?.isAdmin} 112 112 <section class="create-section"> 113 113 <button onclick={handleCreate} disabled={creating}> 114 + {creating ? $_('common.creating') : $_('inviteCodes.createNew')} 114 - {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 115 115 </button> 116 116 </section> 117 117 {/if}
+3 -3
frontend/src/routes/Login.svelte
··· 107 107 </div> 108 108 <div class="actions"> 109 109 <button type="submit" disabled={submitting || !verificationCode.trim()}> 110 + {submitting ? $_('common.verifying') : $_('common.verify')} 110 - {submitting ? $_('verification.verifying') : $_('verification.verifyButton')} 111 111 </button> 112 112 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 113 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 113 - {resendingCode ? $_('verification.resending') : $_('verification.resendButton')} 114 114 </button> 115 115 <button type="button" class="tertiary" onclick={backToLogin}> 116 + {$_('common.backToLogin')} 116 - {$_('verification.backToLogin')} 117 117 </button> 118 118 </div> 119 119 </form>
+63 -69
frontend/src/routes/Migration.svelte
··· 1 1 <script lang="ts"> 2 + import { setSession } from '../lib/auth.svelte' 2 - import { getAuthState, logout, setSession } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { _ } from '../lib/i18n' 5 5 import { 6 6 createInboundMigrationFlow, 7 + createOfflineInboundMigrationFlow, 7 - createOutboundMigrationFlow, 8 8 hasPendingMigration, 9 + hasPendingOfflineMigration, 9 10 getResumeInfo, 11 + getOfflineResumeInfo, 10 12 clearMigrationState, 13 + clearOfflineState, 11 14 loadMigrationState, 12 15 } from '../lib/migration' 13 16 import InboundWizard from '../components/migration/InboundWizard.svelte' 17 + import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte' 14 - import OutboundWizard from '../components/migration/OutboundWizard.svelte' 15 - 16 - const auth = getAuthState() 17 18 19 + type Direction = 'select' | 'inbound' | 'offline-inbound' 18 - type Direction = 'select' | 'inbound' | 'outbound' 19 20 let direction = $state<Direction>('select') 20 21 let showResumeModal = $state(false) 21 22 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) ··· 23 24 let oauthLoading = $state(false) 24 25 25 26 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 27 + let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null) 26 - let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 27 28 let oauthCallbackProcessed = $state(false) 28 29 29 30 $effect(() => { ··· 66 67 const urlParams = new URLSearchParams(window.location.search) 67 68 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 68 69 70 + if (!hasOAuthCallback) { 71 + if (hasPendingMigration()) { 72 + resumeInfo = getResumeInfo() 73 + if (resumeInfo) { 74 + if (resumeInfo.step === 'success') { 75 + clearMigrationState() 76 + resumeInfo = null 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 77 } else { 78 + const stored = loadMigrationState() 79 + if (stored && stored.direction === 'inbound') { 80 + direction = 'inbound' 81 + inboundFlow = createInboundMigrationFlow() 82 + inboundFlow.resumeFromState(stored) 83 + } 79 - direction = 'outbound' 80 - outboundFlow = createOutboundMigrationFlow() 81 84 } 82 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 + } 83 95 } 84 96 } 85 97 ··· 88 100 inboundFlow = createInboundMigrationFlow() 89 101 } 90 102 103 + function selectOfflineInbound() { 104 + direction = 'offline-inbound' 105 + offlineFlow = createOfflineInboundMigrationFlow() 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 106 } 100 107 101 108 function handleResume() { ··· 108 115 direction = 'inbound' 109 116 inboundFlow = createInboundMigrationFlow() 110 117 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 118 } 120 119 } 121 120 ··· 130 129 inboundFlow.reset() 131 130 inboundFlow = null 132 131 } 132 + if (offlineFlow) { 133 + offlineFlow.reset() 134 + offlineFlow = null 133 - if (outboundFlow) { 134 - outboundFlow.reset() 135 - outboundFlow = null 136 135 } 137 136 direction = 'select' 138 137 } ··· 150 149 navigate('/dashboard') 151 150 } 152 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') 153 - async function handleOutboundComplete() { 154 - await logout() 155 - navigate('/login') 156 163 } 157 164 </script> 158 165 ··· 165 172 <div class="resume-details"> 166 173 <div class="detail-row"> 167 174 <span class="label">{$_('migration.resume.direction')}:</span> 175 + <span class="value">{$_('migration.resume.migratingHere')}</span> 168 - <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 169 176 </div> 170 177 {#if resumeInfo.sourceHandle} 171 178 <div class="detail-row"> ··· 212 219 213 220 <div class="direction-cards"> 214 221 <button class="direction-card ghost" onclick={selectInbound}> 215 - <div class="card-icon">โ†“</div> 216 222 <h2>{$_('migration.migrateHere')}</h2> 217 223 <p>{$_('migration.migrateHereDesc')}</p> 218 224 <ul class="features"> ··· 222 228 </ul> 223 229 </button> 224 230 231 + <button class="direction-card ghost offline-card" onclick={selectOfflineInbound}> 232 + <h2>{$_('migration.offlineRestore')}</h2> 233 + <p>{$_('migration.offlineRestoreDesc')}</p> 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 234 <ul class="features"> 235 + <li>{$_('migration.offlineFeature1')}</li> 236 + <li>{$_('migration.offlineFeature2')}</li> 237 + <li>{$_('migration.offlineFeature3')}</li> 230 - <li>{$_('migration.exportRepo')}</li> 231 - <li>{$_('migration.transferToPds')}</li> 232 - <li>{$_('migration.updateIdentity')}</li> 233 238 </ul> 234 - <p class="login-required">{$_('migration.comingSoon')}</p> 235 239 </button> 236 240 </div> 237 241 ··· 263 267 onComplete={handleInboundComplete} 264 268 /> 265 269 270 + {:else if direction === 'offline-inbound' && offlineFlow} 271 + <OfflineInboundWizard 272 + flow={offlineFlow} 266 - {:else if direction === 'outbound' && outboundFlow} 267 - <OutboundWizard 268 - flow={outboundFlow} 269 273 onBack={handleBack} 274 + onComplete={handleOfflineComplete} 270 - onComplete={handleOutboundComplete} 271 275 /> 272 276 {/if} 273 277 </div> ··· 302 306 } 303 307 304 308 .direction-card { 309 + display: flex; 310 + flex-direction: column; 311 + align-items: stretch; 305 312 background: var(--bg-secondary); 306 313 border: 1px solid var(--border); 307 314 border-radius: var(--radius-xl); ··· 322 329 cursor: not-allowed; 323 330 } 324 331 325 - .card-icon { 326 - font-size: var(--text-3xl); 327 - margin-bottom: var(--space-4); 328 - color: var(--accent); 329 - } 330 - 331 332 .direction-card h2 { 332 333 margin: 0 0 var(--space-3) 0; 333 334 font-size: var(--text-xl); ··· 349 350 350 351 .features li { 351 352 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 353 } 359 354 360 355 .info-section { ··· 402 397 } 403 398 404 399 .warning-box a { 400 + display: inline; 401 + margin-top: var(--space-2); 405 - display: block; 406 - margin-top: var(--space-3); 407 - color: var(--accent); 408 402 } 409 403 410 404 .modal-overlay {
+1 -1
frontend/src/routes/OAuth2FA.svelte
··· 105 105 {$_('common.cancel')} 106 106 </button> 107 107 <button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}> 108 + {submitting ? $_('common.verifying') : $_('common.verify')} 108 - {submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')} 109 109 </button> 110 110 </div> 111 111 </form>
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 171 171 <h1>{$_('oauth.error.title')}</h1> 172 172 <div class="error">{error}</div> 173 173 <button type="button" onclick={() => navigate('/login')}> 174 + {$_('common.backToLogin')} 174 - {$_('verify.backToLogin')} 175 175 </button> 176 176 </div> 177 177 {:else if consentData}
+1 -1
frontend/src/routes/OAuthTotp.svelte
··· 121 121 {$_('common.cancel')} 122 122 </button> 123 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 124 + {submitting ? $_('common.verifying') : $_('common.verify')} 124 - {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')} 125 125 </button> 126 126 </div> 127 127 </form>
+3 -3
frontend/src/routes/Register.svelte
··· 145 145 case 'info': return $_('register.subtitle') 146 146 case 'key-choice': return $_('register.subtitleKeyChoice') 147 147 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 148 + case 'creating': return $_('common.creating') 148 - case 'creating': return $_('register.creating') 149 149 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 150 150 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 151 151 case 'activating': return $_('register.subtitleActivating') ··· 375 375 {/if} 376 376 377 377 <button type="submit" disabled={flow.state.submitting}> 378 + {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 378 - {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 379 379 </button> 380 380 </form> 381 381 ··· 413 413 /> 414 414 415 415 {:else if flow.state.step === 'creating'} 416 + <p class="loading">{$_('common.creating')}</p> 416 - <p class="loading">{$_('register.creating')}</p> 417 417 418 418 {:else if flow.state.step === 'verify'} 419 419 <VerificationStep {flow} />
+1 -1
frontend/src/routes/RegisterPasskey.svelte
··· 408 408 </div> 409 409 410 410 <button type="submit" disabled={flow.state.submitting}> 411 + {flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')} 411 - {flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')} 412 412 </button> 413 413 </form> 414 414
+2 -2
frontend/src/routes/RepoExplorer.svelte
··· 417 417 </div> 418 418 <div class="actions"> 419 419 <button type="submit" class="primary" disabled={saving || !!jsonError}> 420 + {saving ? $_('common.saving') : $_('repoExplorer.updateRecord')} 420 - {saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')} 421 421 </button> 422 422 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 423 423 {$_('common.delete')} ··· 464 464 </div> 465 465 <div class="actions"> 466 466 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 467 + {saving ? $_('common.creating') : $_('repoExplorer.createRecord')} 467 - {saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')} 468 468 </button> 469 469 <button type="button" class="secondary" onclick={goBack}> 470 470 {$_('common.cancel')}
+2 -2
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 36 36 <h1>{$_('requestPasskeyRecovery.successTitle')}</h1> 37 37 <p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p> 38 38 <p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p> 39 + <button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button> 39 - <button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button> 40 40 </div> 41 41 {:else} 42 42 <h1>{$_('requestPasskeyRecovery.title')}</h1> ··· 71 71 {/if} 72 72 73 73 <p class="link-text"> 74 + <a href="#/login">{$_('common.backToLogin')}</a> 74 - <a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a> 75 75 </p> 76 76 </div> 77 77
+1 -1
frontend/src/routes/ResetPassword.svelte
··· 141 141 {/if} 142 142 143 143 <p class="link-text"> 144 + <a href="#/login">{$_('common.backToLogin')}</a> 144 - <a href="#/login">{$_('resetPassword.backToLogin')}</a> 145 145 </p> 146 146 </div> 147 147
+341 -3
frontend/src/routes/Settings.svelte
··· 40 40 let deleteToken = $state('') 41 41 let deleteTokenSent = $state(false) 42 42 let exportLoading = $state(false) 43 + let exportBlobsLoading = $state(false) 43 44 let passwordLoading = $state(false) 44 45 let currentPassword = $state('') 45 46 let newPassword = $state('') ··· 173 174 exportLoading = false 174 175 } 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 + 176 343 async function handleChangePassword(e: Event) { 177 344 e.preventDefault() 178 345 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return ··· 323 490 /> 324 491 </div> 325 492 <button type="submit" disabled={handleLoading || !newHandle}> 493 + {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')} 326 - {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 327 494 </button> 328 495 </form> 329 496 </div> ··· 394 561 <section> 395 562 <h2>{$_('settings.exportData')}</h2> 396 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')} 397 - <button onclick={handleExportRepo} disabled={exportLoading}> 398 - {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 399 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} 400 635 </section> 401 636 </div> 402 637 <section class="danger-zone"> ··· 658 893 white-space: nowrap; 659 894 border-left: 1px solid var(--border-color); 660 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 + } 661 999 } 662 1000 </style>
+8 -8
frontend/src/routes/Verify.svelte
··· 225 225 <div class="verify-page"> 226 226 {#if autoSubmitting} 227 227 <div class="loading-container"> 228 + <h1>{$_('common.verifying')}</h1> 228 - <h1>{$_('verify.verifying')}</h1> 229 229 <p class="subtitle">{$_('verify.pleaseWait')}</p> 230 230 </div> 231 231 {:else if success} ··· 235 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 237 <div class="actions"> 238 + <a href="#/settings" class="btn">{$_('common.backToSettings')}</a> 238 - <a href="#/settings" class="btn">{$_('verify.backToSettings')}</a> 239 239 </div> 240 240 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> ··· 301 301 </form> 302 302 303 303 <p class="link-text"> 304 + <a href="#/settings">{$_('common.backToSettings')}</a> 304 - <a href="#/settings">{$_('verify.backToSettings')}</a> 305 305 </p> 306 306 {/if} 307 307 {:else if mode === 'token'} ··· 347 347 </div> 348 348 349 349 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 350 + {submitting ? $_('common.verifying') : $_('common.verify')} 350 - {submitting ? $_('verify.verifying') : $_('verify.verify')} 351 351 </button> 352 352 353 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 354 - {resendingCode ? $_('verify.sending') : $_('verify.resendCode')} 355 355 </button> 356 356 </form> 357 357 358 358 <p class="link-text"> 359 + <a href="#/login">{$_('common.backToLogin')}</a> 359 - <a href="#/login">{$_('verify.backToLogin')}</a> 360 360 </p> 361 361 {:else if pendingVerification} 362 362 <h1>{$_('verify.title')}</h1> ··· 390 390 </div> 391 391 392 392 <button type="submit" disabled={submitting || !verificationCode.trim()}> 393 + {submitting ? $_('common.verifying') : $_('common.verify')} 393 - {submitting ? $_('verify.verifying') : $_('verify.verifyButton')} 394 394 </button> 395 395 396 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 397 - {resendingCode ? $_('verify.resending') : $_('verify.resendCode')} 398 398 </button> 399 399 </form> 400 400
+5 -5
frontend/src/styles/base.css
··· 54 54 } 55 55 56 56 a { 57 + color: var(--accent); 58 + text-decoration: underline; 59 + text-underline-offset: 2px; 57 - color: var(--secondary); 58 - text-decoration: none; 59 - transition: color 0.3s ease; 60 60 } 61 61 62 62 a:hover { 63 + color: var(--accent-hover); 63 - color: var(--secondary-hover); 64 - text-decoration: none; 65 64 } 66 65 67 66 ::selection { ··· 372 371 color: var(--text-secondary); 373 372 font-size: var(--text-sm); 374 373 margin-bottom: var(--space-3); 374 + text-decoration: none; 375 375 } 376 376 377 377 .back-link:hover {
+90
frontend/src/styles/migration.css
··· 190 190 191 191 .current-info .value { 192 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); 193 199 } 194 200 195 201 .review-card { ··· 268 274 text-align: center; 269 275 color: var(--text-secondary); 270 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; 271 302 } 272 303 273 304 .success-content { ··· 567 598 font-size: var(--text-sm); 568 599 font-style: italic; 569 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
··· 29 29 beforeEach(() => { 30 30 setupAuthenticatedUser(); 31 31 mockEndpoint( 32 + "_account.getNotificationPrefs", 32 - "com.tranquil.account.getNotificationPrefs", 33 33 () => jsonResponse(mockData.notificationPrefs()), 34 34 ); 35 35 mockEndpoint( ··· 37 37 () => jsonResponse(mockData.describeServer()), 38 38 ); 39 39 mockEndpoint( 40 + "_account.getNotificationHistory", 40 - "com.tranquil.account.getNotificationHistory", 41 41 () => jsonResponse({ notifications: [] }), 42 42 ); 43 43 }); ··· 67 67 () => jsonResponse(mockData.describeServer()), 68 68 ); 69 69 mockEndpoint( 70 + "_account.getNotificationHistory", 70 - "com.tranquil.account.getNotificationHistory", 71 71 () => jsonResponse({ notifications: [] }), 72 72 ); 73 73 }); 74 74 it("shows loading text while fetching preferences", async () => { 75 + mockEndpoint("_account.getNotificationPrefs", async () => { 75 - mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { 76 76 await new Promise((resolve) => setTimeout(resolve, 100)); 77 77 return jsonResponse(mockData.notificationPrefs()); 78 78 }); ··· 88 88 () => jsonResponse(mockData.describeServer()), 89 89 ); 90 90 mockEndpoint( 91 + "_account.getNotificationHistory", 91 - "com.tranquil.account.getNotificationHistory", 92 92 () => jsonResponse({ notifications: [] }), 93 93 ); 94 94 }); 95 95 it("displays all four channel options", async () => { 96 96 mockEndpoint( 97 + "_account.getNotificationPrefs", 97 - "com.tranquil.account.getNotificationPrefs", 98 98 () => jsonResponse(mockData.notificationPrefs()), 99 99 ); 100 100 render(Comms); ··· 111 111 }); 112 112 it("email channel is always selectable", async () => { 113 113 mockEndpoint( 114 + "_account.getNotificationPrefs", 114 - "com.tranquil.account.getNotificationPrefs", 115 115 () => jsonResponse(mockData.notificationPrefs()), 116 116 ); 117 117 render(Comms); ··· 122 122 }); 123 123 it("discord channel is disabled when not configured", async () => { 124 124 mockEndpoint( 125 + "_account.getNotificationPrefs", 125 - "com.tranquil.account.getNotificationPrefs", 126 126 () => jsonResponse(mockData.notificationPrefs({ discordId: null })), 127 127 ); 128 128 render(Comms); ··· 133 133 }); 134 134 it("discord channel is enabled when configured", async () => { 135 135 mockEndpoint( 136 + "_account.getNotificationPrefs", 136 - "com.tranquil.account.getNotificationPrefs", 137 137 () => 138 138 jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })), 139 139 ); ··· 145 145 }); 146 146 it("shows hint for disabled channels", async () => { 147 147 mockEndpoint( 148 + "_account.getNotificationPrefs", 148 - "com.tranquil.account.getNotificationPrefs", 149 149 () => jsonResponse(mockData.notificationPrefs()), 150 150 ); 151 151 render(Comms); ··· 156 156 }); 157 157 it("selects current preferred channel", async () => { 158 158 mockEndpoint( 159 + "_account.getNotificationPrefs", 159 - "com.tranquil.account.getNotificationPrefs", 160 160 () => 161 161 jsonResponse( 162 162 mockData.notificationPrefs({ preferredChannel: "email" }), ··· 179 179 () => jsonResponse(mockData.describeServer()), 180 180 ); 181 181 mockEndpoint( 182 + "_account.getNotificationHistory", 182 - "com.tranquil.account.getNotificationHistory", 183 183 () => jsonResponse({ notifications: [] }), 184 184 ); 185 185 }); 186 186 it("displays email as readonly with current value", async () => { 187 187 mockEndpoint( 188 + "_account.getNotificationPrefs", 188 - "com.tranquil.account.getNotificationPrefs", 189 189 () => jsonResponse(mockData.notificationPrefs()), 190 190 ); 191 191 render(Comms); ··· 199 199 }); 200 200 it("displays all channel inputs with current values", async () => { 201 201 mockEndpoint( 202 + "_account.getNotificationPrefs", 202 - "com.tranquil.account.getNotificationPrefs", 203 203 () => 204 204 jsonResponse(mockData.notificationPrefs({ 205 205 discordId: "123456789", ··· 231 231 () => jsonResponse(mockData.describeServer()), 232 232 ); 233 233 mockEndpoint( 234 + "_account.getNotificationHistory", 234 - "com.tranquil.account.getNotificationHistory", 235 235 () => jsonResponse({ notifications: [] }), 236 236 ); 237 237 }); 238 238 it("shows Primary badge for email", async () => { 239 239 mockEndpoint( 240 + "_account.getNotificationPrefs", 240 - "com.tranquil.account.getNotificationPrefs", 241 241 () => jsonResponse(mockData.notificationPrefs()), 242 242 ); 243 243 render(Comms); ··· 247 247 }); 248 248 it("shows Verified badge for verified discord", async () => { 249 249 mockEndpoint( 250 + "_account.getNotificationPrefs", 250 - "com.tranquil.account.getNotificationPrefs", 251 251 () => 252 252 jsonResponse(mockData.notificationPrefs({ 253 253 discordId: "123456789", ··· 262 262 }); 263 263 it("shows Not verified badge for unverified discord", async () => { 264 264 mockEndpoint( 265 + "_account.getNotificationPrefs", 265 - "com.tranquil.account.getNotificationPrefs", 266 266 () => 267 267 jsonResponse(mockData.notificationPrefs({ 268 268 discordId: "123456789", ··· 276 276 }); 277 277 it("does not show badge when channel not configured", async () => { 278 278 mockEndpoint( 279 + "_account.getNotificationPrefs", 279 - "com.tranquil.account.getNotificationPrefs", 280 280 () => jsonResponse(mockData.notificationPrefs()), 281 281 ); 282 282 render(Comms); ··· 294 294 () => jsonResponse(mockData.describeServer()), 295 295 ); 296 296 mockEndpoint( 297 + "_account.getNotificationHistory", 297 - "com.tranquil.account.getNotificationHistory", 298 298 () => jsonResponse({ notifications: [] }), 299 299 ); 300 300 }); 301 301 it("calls updateNotificationPrefs with correct data", async () => { 302 302 let capturedBody: Record<string, unknown> | null = null; 303 303 mockEndpoint( 304 + "_account.getNotificationPrefs", 304 - "com.tranquil.account.getNotificationPrefs", 305 305 () => jsonResponse(mockData.notificationPrefs()), 306 306 ); 307 307 mockEndpoint( 308 + "_account.updateNotificationPrefs", 308 - "com.tranquil.account.updateNotificationPrefs", 309 309 (_url, options) => { 310 310 capturedBody = JSON.parse((options?.body as string) || "{}"); 311 311 return jsonResponse({ success: true }); ··· 329 329 }); 330 330 it("shows loading state while saving", async () => { 331 331 mockEndpoint( 332 + "_account.getNotificationPrefs", 332 - "com.tranquil.account.getNotificationPrefs", 333 333 () => jsonResponse(mockData.notificationPrefs()), 334 334 ); 335 + mockEndpoint("_account.updateNotificationPrefs", async () => { 335 - mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => { 336 336 await new Promise((resolve) => setTimeout(resolve, 100)); 337 337 return jsonResponse({ success: true }); 338 338 }); ··· 350 350 }); 351 351 it("shows success message after saving", async () => { 352 352 mockEndpoint( 353 + "_account.getNotificationPrefs", 353 - "com.tranquil.account.getNotificationPrefs", 354 354 () => jsonResponse(mockData.notificationPrefs()), 355 355 ); 356 356 mockEndpoint( 357 + "_account.updateNotificationPrefs", 357 - "com.tranquil.account.updateNotificationPrefs", 358 358 () => jsonResponse({ success: true }), 359 359 ); 360 360 render(Comms); ··· 372 372 }); 373 373 it("shows error when save fails", async () => { 374 374 mockEndpoint( 375 + "_account.getNotificationPrefs", 375 - "com.tranquil.account.getNotificationPrefs", 376 376 () => jsonResponse(mockData.notificationPrefs()), 377 377 ); 378 378 mockEndpoint( 379 + "_account.updateNotificationPrefs", 379 - "com.tranquil.account.updateNotificationPrefs", 380 380 () => 381 381 errorResponse("InvalidRequest", "Invalid channel configuration", 400), 382 382 ); ··· 400 400 }); 401 401 it("reloads preferences after successful save", async () => { 402 402 let loadCount = 0; 403 + mockEndpoint("_account.getNotificationPrefs", () => { 403 - mockEndpoint("com.tranquil.account.getNotificationPrefs", () => { 404 404 loadCount++; 405 405 return jsonResponse(mockData.notificationPrefs()); 406 406 }); 407 407 mockEndpoint( 408 + "_account.updateNotificationPrefs", 408 - "com.tranquil.account.updateNotificationPrefs", 409 409 () => jsonResponse({ success: true }), 410 410 ); 411 411 render(Comms); ··· 430 430 () => jsonResponse(mockData.describeServer()), 431 431 ); 432 432 mockEndpoint( 433 + "_account.getNotificationHistory", 433 - "com.tranquil.account.getNotificationHistory", 434 434 () => jsonResponse({ notifications: [] }), 435 435 ); 436 436 }); 437 437 it("enables discord channel after entering discord ID", async () => { 438 438 mockEndpoint( 439 + "_account.getNotificationPrefs", 439 - "com.tranquil.account.getNotificationPrefs", 440 440 () => jsonResponse(mockData.notificationPrefs()), 441 441 ); 442 442 render(Comms); ··· 453 453 }); 454 454 it("allows selecting a configured channel", async () => { 455 455 mockEndpoint( 456 + "_account.getNotificationPrefs", 456 - "com.tranquil.account.getNotificationPrefs", 457 457 () => 458 458 jsonResponse(mockData.notificationPrefs({ 459 459 discordId: "123456789", ··· 480 480 () => jsonResponse(mockData.describeServer()), 481 481 ); 482 482 mockEndpoint( 483 + "_account.getNotificationHistory", 483 - "com.tranquil.account.getNotificationHistory", 484 484 () => jsonResponse({ notifications: [] }), 485 485 ); 486 486 }); 487 487 it("shows error when loading preferences fails", async () => { 488 488 mockEndpoint( 489 + "_account.getNotificationPrefs", 489 - "com.tranquil.account.getNotificationPrefs", 490 490 () => errorResponse("InternalError", "Database connection failed", 500), 491 491 ); 492 492 render(Comms);
+2 -2
frontend/src/tests/Settings.test.ts
··· 8 8 mockData, 9 9 mockEndpoint, 10 10 setupAuthenticatedUser, 11 + setupDefaultMocks, 11 - setupFetchMock, 12 12 setupUnauthenticatedUser, 13 13 } from "./mocks"; 14 14 describe("Settings", () => { 15 15 beforeEach(() => { 16 16 clearMocks(); 17 + setupDefaultMocks(); 17 - setupFetchMock(); 18 18 globalThis.confirm = vi.fn(() => true); 19 19 }); 20 20 describe("authentication guard", () => {
+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
··· 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
··· 206 206 () => jsonResponse({ code: "new-invite-" + Date.now() }), 207 207 ); 208 208 mockEndpoint( 209 + "_account.getNotificationPrefs", 209 - "com.tranquil.account.getNotificationPrefs", 210 210 () => jsonResponse(mockData.notificationPrefs()), 211 211 ); 212 212 mockEndpoint( 213 + "_account.updateNotificationPrefs", 213 - "com.tranquil.account.updateNotificationPrefs", 214 214 () => jsonResponse({ success: true }), 215 215 ); 216 216 mockEndpoint( 217 + "_account.getNotificationHistory", 217 - "com.tranquil.account.getNotificationHistory", 218 218 () => jsonResponse({ notifications: [] }), 219 219 ); 220 220 mockEndpoint( ··· 240 240 mockEndpoint( 241 241 "com.atproto.repo.listRecords", 242 242 () => jsonResponse({ records: [] }), 243 + ); 244 + mockEndpoint( 245 + "_backup.listBackups", 246 + () => jsonResponse({ backups: [] }), 243 247 ); 244 248 } 245 249 export function setupAuthenticatedUser(
+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
··· 44 44 sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true 45 45 sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true 46 46 47 + log_info "Removing minio buckets..." 47 - log_info "Removing minio bucket..." 48 48 if command -v mc &>/dev/null; then 49 49 mc rb local/pds-blobs --force 2>/dev/null || true 50 + mc rb local/pds-backups --force 2>/dev/null || true 50 51 mc alias remove local 2>/dev/null || true 51 52 fi 52 53 systemctl stop minio 2>/dev/null || true ··· 78 79 echo " - PostgreSQL database 'pds' and all data" 79 80 echo " - All Tranquil PDS configuration and credentials" 80 81 echo " - All source code in /opt/tranquil-pds" 82 + echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data" 81 - echo " - MinIO bucket 'pds-blobs' and all blobs" 82 83 echo "" 83 84 read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE 84 85 if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then ··· 274 275 mc alias remove local 2>/dev/null || true 275 276 mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 276 277 mc mb local/pds-blobs --ignore-existing 278 + mc mb local/pds-backups --ignore-existing 279 + log_success "minio buckets created" 277 - log_success "minio bucket created" 278 280 279 281 log_info "Installing rust..." 280 282 if [[ -f "$HOME/.cargo/env" ]]; then ··· 382 384 S3_ENDPOINT=http://localhost:9000 383 385 AWS_REGION=us-east-1 384 386 S3_BUCKET=pds-blobs 387 + BACKUP_S3_BUCKET=pds-backups 385 388 AWS_ACCESS_KEY_ID=minioadmin 386 389 AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 387 390 VALKEY_URL=redis://localhost:6379
+5 -1
scripts/test-infra.sh
··· 83 83 echo "Waiting for Valkey... ($i/30)" 84 84 sleep 1 85 85 done 86 + echo "Creating MinIO buckets..." 86 - echo "Creating MinIO bucket..." 87 87 $CONTAINER_CMD run --rm --network host \ 88 88 -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 89 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 90 93 cat > "$INFRA_FILE" << EOF 91 94 export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 92 95 export TEST_DB_PORT="${PG_PORT}" 93 96 export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 94 97 export S3_BUCKET="test-bucket" 98 + export BACKUP_S3_BUCKET="test-backups" 95 99 export AWS_ACCESS_KEY_ID="minioadmin" 96 100 export AWS_SECRET_ACCESS_KEY="minioadmin" 97 101 export AWS_REGION="us-east-1"
+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 1 pub mod actor; 2 2 pub mod admin; 3 3 pub mod age_assurance; 4 + pub mod backup; 4 5 pub mod delegation; 5 6 pub mod error; 6 7 pub mod identity;
+26 -7
src/api/notification_prefs.rs
··· 182 182 .into_response(), 183 183 }; 184 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 + 185 197 let notifications = rows 186 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 + } 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 213 }) 195 214 .collect(); 196 215
+1 -1
src/api/repo/blob.rs
··· 312 312 r#" 313 313 SELECT rb.blob_cid, rb.record_uri 314 314 FROM record_blobs rb 315 + LEFT JOIN blobs b ON rb.blob_cid = b.cid 315 - LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id 316 316 WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2 317 317 ORDER BY rb.blob_cid 318 318 LIMIT $3
+4 -2
src/api/repo/record/batch.rs
··· 345 345 let rkey = rkey 346 346 .clone() 347 347 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 348 + let record_ipld = crate::util::json_to_ipld(value); 348 349 let mut record_bytes = Vec::new(); 350 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 349 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 350 351 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 351 352 } 352 353 let record_cid = match tracking_store.put(&record_bytes).await { ··· 409 410 } 410 411 }; 411 412 all_blob_cids.extend(extract_blob_cids(value)); 413 + let record_ipld = crate::util::json_to_ipld(value); 412 414 let mut record_bytes = Vec::new(); 415 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 413 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 414 416 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 415 417 } 416 418 let record_cid = match tracking_store.put(&record_bytes).await {
src/api/repo/record/read.rs

This file has not been changed.

+2 -1
src/api/repo/record/utils.rs
··· 382 382 let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) 383 383 .map_err(|e| format!("Failed to parse commit: {:?}", e))?; 384 384 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 385 + let record_ipld = crate::util::json_to_ipld(record); 385 386 let mut record_bytes = Vec::new(); 387 + serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) 386 - serde_ipld_dagcbor::to_writer(&mut record_bytes, record) 387 388 .map_err(|e| format!("Failed to serialize record: {:?}", e))?; 388 389 let record_cid = tracking_store 389 390 .put(&record_bytes)
+4 -2
src/api/repo/record/write.rs
··· 297 297 let rkey = input 298 298 .rkey 299 299 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 300 + let record_ipld = crate::util::json_to_ipld(&input.record); 300 301 let mut record_bytes = Vec::new(); 302 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 301 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 302 303 return ( 303 304 StatusCode::BAD_REQUEST, 304 305 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), ··· 550 551 } 551 552 } 552 553 let existing_cid = mst.get(&key).await.ok().flatten(); 554 + let record_ipld = crate::util::json_to_ipld(&input.record); 553 555 let mut record_bytes = Vec::new(); 556 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 554 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 555 557 return ( 556 558 StatusCode::BAD_REQUEST, 557 559 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+15 -44
src/api/server/account_status.rs
··· 567 567 #[serde(rename_all = "camelCase")] 568 568 pub struct DeactivateAccountInput { 569 569 pub delete_after: Option<String>, 570 - pub migrating_to: Option<String>, 571 570 } 572 571 573 572 pub async fn deactivate_account( ··· 618 617 619 618 let did = auth_user.did; 620 619 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 620 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 637 621 .fetch_optional(&state.db) 638 622 .await 639 623 .ok() 640 624 .flatten(); 641 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; 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 633 667 634 match result { 668 635 Ok(_) => { 669 636 if let Some(ref h) = handle { 670 637 let _ = state.cache.delete(&format!("handle:{}", h)).await; 671 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 672 - if let Err(e) = 673 - crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status)) 674 - .await 675 646 { 647 + warn!("Failed to sequence account deactivated event: {}", e); 676 - warn!("Failed to sequence account {} event: {}", status, e); 677 648 } 678 649 (StatusCode::OK, Json(json!({}))).into_response() 679 650 }
+54
src/api/server/email.rs
··· 476 476 info!("Email updated for user {}", user_id); 477 477 (StatusCode::OK, Json(json!({}))).into_response() 478 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 6 http::StatusCode, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 + use chrono::Utc; 9 - use chrono::{DateTime, Utc}; 10 10 use serde::{Deserialize, Serialize}; 11 11 use serde_json::json; 12 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 13 #[derive(Debug, Clone, Serialize, Deserialize)] 242 14 #[serde(rename_all = "camelCase")] 243 15 pub struct VerificationMethod { ··· 275 47 }; 276 48 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 277 49 let http_uri = format!( 50 + "https://{}/xrpc/_account.updateDidDocument", 278 - "https://{}/xrpc/com.tranquil.account.updateDidDocument", 279 51 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 280 52 ); 281 53 let auth_user = match crate::auth::validate_token_with_dpop( ··· 305 77 } 306 78 307 79 let user = match sqlx::query!( 80 + "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 308 - "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 309 81 auth_user.did 310 82 ) 311 83 .fetch_optional(&state.db) ··· 319 91 } 320 92 }; 321 93 94 + if user.deactivated_at.is_some() { 95 + return ApiError::AccountDeactivated.into_response(); 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 96 } 332 97 333 98 if let Some(ref methods) = input.verification_methods { ··· 452 217 }; 453 218 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 454 219 let http_uri = format!( 220 + "https://{}/xrpc/_account.getDidDocument", 455 - "https://{}/xrpc/com.tranquil.account.getDidDocument", 456 221 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 457 222 ); 458 223 let auth_user = match crate::auth::validate_token_with_dpop(
+2 -5
src/api/server/mod.rs
··· 22 22 request_account_delete, 23 23 }; 24 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}; 25 - pub use email::{confirm_email, request_email_update, update_email}; 26 26 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 27 27 pub use logo::get_logo; 28 28 pub use meta::{describe_server, health, robots_txt}; 29 + pub use migration::{get_did_document, update_did_document}; 29 - pub use migration::{ 30 - clear_migration_forwarding, get_did_document, get_migration_status, update_did_document, 31 - update_migration_forwarding, 32 - }; 33 30 pub use passkey_account::{ 34 31 complete_passkey_setup, create_passkey_account, recover_passkey_account, 35 32 request_passkey_recovery, start_passkey_registration_for_setup,
+59 -55
src/lib.rs
··· 57 57 get(api::server::get_session), 58 58 ) 59 59 .route( 60 + "/xrpc/_account.listSessions", 60 - "/xrpc/com.tranquil.account.listSessions", 61 61 get(api::server::list_sessions), 62 62 ) 63 63 .route( 64 + "/xrpc/_account.revokeSession", 64 - "/xrpc/com.tranquil.account.revokeSession", 65 65 post(api::server::revoke_session), 66 66 ) 67 67 .route( 68 + "/xrpc/_account.revokeAllSessions", 68 - "/xrpc/com.tranquil.account.revokeAllSessions", 69 69 post(api::server::revoke_all_sessions), 70 70 ) 71 71 .route( ··· 208 208 post(api::server::reset_password), 209 209 ) 210 210 .route( 211 + "/xrpc/_account.changePassword", 211 - "/xrpc/com.tranquil.account.changePassword", 212 212 post(api::server::change_password), 213 213 ) 214 214 .route( 215 + "/xrpc/_account.removePassword", 215 - "/xrpc/com.tranquil.account.removePassword", 216 216 post(api::server::remove_password), 217 217 ) 218 218 .route( 219 + "/xrpc/_account.getPasswordStatus", 219 - "/xrpc/com.tranquil.account.getPasswordStatus", 220 220 get(api::server::get_password_status), 221 221 ) 222 222 .route( 223 + "/xrpc/_account.getReauthStatus", 223 - "/xrpc/com.tranquil.account.getReauthStatus", 224 224 get(api::server::get_reauth_status), 225 225 ) 226 226 .route( 227 + "/xrpc/_account.reauthPassword", 227 - "/xrpc/com.tranquil.account.reauthPassword", 228 228 post(api::server::reauth_password), 229 229 ) 230 + .route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp)) 230 231 .route( 232 + "/xrpc/_account.reauthPasskeyStart", 231 - "/xrpc/com.tranquil.account.reauthTotp", 232 - post(api::server::reauth_totp), 233 - ) 234 - .route( 235 - "/xrpc/com.tranquil.account.reauthPasskeyStart", 236 233 post(api::server::reauth_passkey_start), 237 234 ) 238 235 .route( 236 + "/xrpc/_account.reauthPasskeyFinish", 239 - "/xrpc/com.tranquil.account.reauthPasskeyFinish", 240 237 post(api::server::reauth_passkey_finish), 241 238 ) 242 239 .route( 240 + "/xrpc/_account.getLegacyLoginPreference", 243 - "/xrpc/com.tranquil.account.getLegacyLoginPreference", 244 241 get(api::server::get_legacy_login_preference), 245 242 ) 246 243 .route( 244 + "/xrpc/_account.updateLegacyLoginPreference", 247 - "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 248 245 post(api::server::update_legacy_login_preference), 249 246 ) 250 247 .route( 248 + "/xrpc/_account.updateLocale", 251 - "/xrpc/com.tranquil.account.updateLocale", 252 249 post(api::server::update_locale), 253 250 ) 254 251 .route( 252 + "/xrpc/_account.listTrustedDevices", 255 - "/xrpc/com.tranquil.account.listTrustedDevices", 256 253 get(api::server::list_trusted_devices), 257 254 ) 258 255 .route( 256 + "/xrpc/_account.revokeTrustedDevice", 259 - "/xrpc/com.tranquil.account.revokeTrustedDevice", 260 257 post(api::server::revoke_trusted_device), 261 258 ) 262 259 .route( 260 + "/xrpc/_account.updateTrustedDevice", 263 - "/xrpc/com.tranquil.account.updateTrustedDevice", 264 261 post(api::server::update_trusted_device), 265 262 ) 266 263 .route( 264 + "/xrpc/_account.createPasskeyAccount", 267 - "/xrpc/com.tranquil.account.createPasskeyAccount", 268 265 post(api::server::create_passkey_account), 269 266 ) 270 267 .route( 268 + "/xrpc/_account.startPasskeyRegistrationForSetup", 271 - "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", 272 269 post(api::server::start_passkey_registration_for_setup), 273 270 ) 274 271 .route( 272 + "/xrpc/_account.completePasskeySetup", 275 - "/xrpc/com.tranquil.account.completePasskeySetup", 276 273 post(api::server::complete_passkey_setup), 277 274 ) 278 275 .route( 276 + "/xrpc/_account.requestPasskeyRecovery", 279 - "/xrpc/com.tranquil.account.requestPasskeyRecovery", 280 277 post(api::server::request_passkey_recovery), 281 278 ) 282 279 .route( 280 + "/xrpc/_account.recoverPasskeyAccount", 283 - "/xrpc/com.tranquil.account.recoverPasskeyAccount", 284 281 post(api::server::recover_passkey_account), 285 282 ) 286 283 .route( 284 + "/xrpc/_account.updateDidDocument", 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 285 post(api::server::update_did_document), 301 286 ) 302 287 .route( 288 + "/xrpc/_account.getDidDocument", 303 - "/xrpc/com.tranquil.account.getDidDocument", 304 289 get(api::server::get_did_document), 305 290 ) 306 291 .route( 307 292 "/xrpc/com.atproto.server.requestEmailUpdate", 308 293 post(api::server::request_email_update), 294 + ) 295 + .route( 296 + "/xrpc/_checkEmailVerified", 297 + post(api::server::check_email_verified), 309 298 ) 310 299 .route( 311 300 "/xrpc/com.atproto.server.confirmEmail", ··· 432 421 get(api::admin::get_invite_codes), 433 422 ) 434 423 .route( 424 + "/xrpc/_admin.getServerStats", 435 - "/xrpc/com.tranquil.admin.getServerStats", 436 425 get(api::admin::get_server_stats), 437 426 ) 438 427 .route( 428 + "/xrpc/_server.getConfig", 439 - "/xrpc/com.tranquil.server.getConfig", 440 429 get(api::admin::get_server_config), 441 430 ) 442 431 .route( 432 + "/xrpc/_admin.updateServerConfig", 443 - "/xrpc/com.tranquil.admin.updateServerConfig", 444 433 post(api::admin::update_server_config), 445 434 ) 446 435 .route( ··· 575 564 post(api::temp::dereference_scope), 576 565 ) 577 566 .route( 567 + "/xrpc/_account.getNotificationPrefs", 578 - "/xrpc/com.tranquil.account.getNotificationPrefs", 579 568 get(api::notification_prefs::get_notification_prefs), 580 569 ) 581 570 .route( 571 + "/xrpc/_account.updateNotificationPrefs", 582 - "/xrpc/com.tranquil.account.updateNotificationPrefs", 583 572 post(api::notification_prefs::update_notification_prefs), 584 573 ) 585 574 .route( 575 + "/xrpc/_account.getNotificationHistory", 586 - "/xrpc/com.tranquil.account.getNotificationHistory", 587 576 get(api::notification_prefs::get_notification_history), 588 577 ) 589 578 .route( 579 + "/xrpc/_account.confirmChannelVerification", 590 - "/xrpc/com.tranquil.account.confirmChannelVerification", 591 580 post(api::verification::confirm_channel_verification), 592 581 ) 593 582 .route( 583 + "/xrpc/_account.verifyToken", 594 - "/xrpc/com.tranquil.account.verifyToken", 595 584 post(api::server::verify_token), 596 585 ) 597 586 .route( 587 + "/xrpc/_delegation.listControllers", 598 - "/xrpc/com.tranquil.delegation.listControllers", 599 588 get(api::delegation::list_controllers), 600 589 ) 601 590 .route( 591 + "/xrpc/_delegation.addController", 602 - "/xrpc/com.tranquil.delegation.addController", 603 592 post(api::delegation::add_controller), 604 593 ) 605 594 .route( 595 + "/xrpc/_delegation.removeController", 606 - "/xrpc/com.tranquil.delegation.removeController", 607 596 post(api::delegation::remove_controller), 608 597 ) 609 598 .route( 599 + "/xrpc/_delegation.updateControllerScopes", 610 - "/xrpc/com.tranquil.delegation.updateControllerScopes", 611 600 post(api::delegation::update_controller_scopes), 612 601 ) 613 602 .route( 603 + "/xrpc/_delegation.listControlledAccounts", 614 - "/xrpc/com.tranquil.delegation.listControlledAccounts", 615 604 get(api::delegation::list_controlled_accounts), 616 605 ) 617 606 .route( 607 + "/xrpc/_delegation.getAuditLog", 618 - "/xrpc/com.tranquil.delegation.getAuditLog", 619 608 get(api::delegation::get_audit_log), 620 609 ) 621 610 .route( 611 + "/xrpc/_delegation.getScopePresets", 622 - "/xrpc/com.tranquil.delegation.getScopePresets", 623 612 get(api::delegation::get_scope_presets), 624 613 ) 625 614 .route( 615 + "/xrpc/_delegation.createDelegatedAccount", 626 - "/xrpc/com.tranquil.delegation.createDelegatedAccount", 627 616 post(api::delegation::create_delegated_account), 628 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)) 629 633 .route( 630 634 "/xrpc/app.bsky.ageassurance.getState", 631 635 get(api::age_assurance::get_state),
+18 -1
src/main.rs
··· 7 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 8 use tranquil_pds::scheduled::{ 9 9 backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 + start_backup_tasks, start_scheduled_tasks, 10 - start_scheduled_tasks, 11 11 }; 12 12 use tranquil_pds::state::AppState; 13 13 ··· 83 83 None 84 84 }; 85 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 + 86 99 let scheduled_handle = tokio::spawn(start_scheduled_tasks( 87 100 state.db.clone(), 88 101 state.blob_store.clone(), ··· 114 127 comms_handle.await.ok(); 115 128 116 129 if let Some(handle) = crawlers_handle { 130 + handle.await.ok(); 131 + } 132 + 133 + if let Some(handle) = backup_handle { 117 134 handle.await.ok(); 118 135 } 119 136
+4
src/rate_limit.rs
··· 32 32 pub totp_verify: Arc<KeyedRateLimiter>, 33 33 pub handle_update: Arc<KeyedRateLimiter>, 34 34 pub handle_update_daily: Arc<KeyedRateLimiter>, 35 + pub verification_check: Arc<KeyedRateLimiter>, 35 36 } 36 37 37 38 impl Default for RateLimiters { ··· 91 92 .unwrap() 92 93 .allow_burst(NonZeroU32::new(50).unwrap()), 93 94 )), 95 + verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( 96 + NonZeroU32::new(60).unwrap(), 97 + ))), 94 98 } 95 99 } 96 100
+311 -1
src/scheduled.rs
··· 11 11 use tracing::{debug, error, info, warn}; 12 12 13 13 use crate::repo::PostgresBlockStore; 14 + use crate::storage::{BackupStorage, BlobStorage}; 15 + use crate::sync::car::encode_car_header; 14 - use crate::storage::BlobStorage; 15 16 16 17 pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) { 17 18 let broken_genesis_commits = match sqlx::query!( ··· 563 564 564 565 Ok(()) 565 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
··· 4 4 use crate::config::AuthConfig; 5 5 use crate::rate_limit::RateLimiters; 6 6 use crate::repo::PostgresBlockStore; 7 + use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage}; 7 - use crate::storage::{BlobStorage, S3BlobStorage}; 8 8 use crate::sync::firehose::SequencedEvent; 9 9 use sqlx::PgPool; 10 10 use std::error::Error; ··· 16 16 pub db: PgPool, 17 17 pub block_store: PostgresBlockStore, 18 18 pub blob_store: Arc<dyn BlobStorage>, 19 + pub backup_storage: Option<Arc<BackupStorage>>, 19 20 pub firehose_tx: broadcast::Sender<SequencedEvent>, 20 21 pub rate_limiters: Arc<RateLimiters>, 21 22 pub circuit_breakers: Arc<CircuitBreakers>, ··· 39 40 TotpVerify, 40 41 HandleUpdate, 41 42 HandleUpdateDaily, 43 + VerificationCheck, 42 44 } 43 45 44 46 impl RateLimitKind { ··· 58 60 Self::TotpVerify => "totp_verify", 59 61 Self::HandleUpdate => "handle_update", 60 62 Self::HandleUpdateDaily => "handle_update_daily", 63 + Self::VerificationCheck => "verification_check", 61 64 } 62 65 } 63 66 ··· 77 80 Self::TotpVerify => (5, 300_000), 78 81 Self::HandleUpdate => (10, 300_000), 79 82 Self::HandleUpdateDaily => (50, 86_400_000), 83 + Self::VerificationCheck => (60, 60_000), 80 84 } 81 85 } 82 86 } ··· 131 135 132 136 let block_store = PostgresBlockStore::new(db.clone()); 133 137 let blob_store = S3BlobStorage::new().await; 138 + let backup_storage = BackupStorage::new().await.map(Arc::new); 134 139 135 140 let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 136 141 .ok() ··· 147 152 db, 148 153 block_store, 149 154 blob_store: Arc::new(blob_store), 155 + backup_storage, 150 156 firehose_tx, 151 157 rate_limiters, 152 158 circuit_breakers, ··· 199 205 RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 200 206 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 201 207 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 208 + RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, 202 209 }; 203 210 204 211 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+119 -16
src/storage/mod.rs
··· 32 32 33 33 impl S3BlobStorage { 34 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); 35 - let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 36 70 71 + if !backup_enabled { 72 + return None; 73 + } 37 - let config = aws_config::defaults(BehaviorVersion::latest()) 38 - .region(region_provider) 39 - .load() 40 - .await; 41 74 75 + let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; 76 + let client = create_s3_client().await; 77 + Some(Self { client, bucket }) 78 + } 42 - let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 43 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 + } 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 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(()) 54 - Self { client, bucket } 55 158 } 56 159 } 57 160
+23 -12
src/sync/import.rs
··· 77 77 Ipld::Map(obj) => { 78 78 if let Some(Ipld::String(type_str)) = obj.get("$type") 79 79 && type_str == "blob" 80 - && let Some(Ipld::Link(link_cid)) = obj.get("ref") 81 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 + } 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 104 } 94 105 obj.values() 95 106 .flat_map(|v| find_blob_refs_ipld(v, depth + 1))
+129
src/util.rs
··· 1 1 use axum::http::HeaderMap; 2 + use cid::Cid; 3 + use ipld_core::ipld::Ipld; 2 4 use rand::Rng; 5 + use serde_json::Value as JsonValue; 3 6 use sqlx::PgPool; 7 + use std::collections::BTreeMap; 8 + use std::str::FromStr; 4 9 use std::sync::OnceLock; 5 10 use uuid::Uuid; 6 11 ··· 150 155 format!("{}{}", pds_public_url(), path) 151 156 } 152 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 + 153 189 #[cfg(test)] 154 190 mod tests { 155 191 use super::*; ··· 223 259 for part in parts { 224 260 assert_eq!(part.len(), 4); 225 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"); 226 355 } 227 356 }
+10 -40
tests/account_notifications.rs
··· 27 27 } 28 28 29 29 let resp = client 30 + .get(format!("{}/xrpc/_account.getNotificationHistory", base)) 30 - .get(format!( 31 - "{}/xrpc/com.tranquil.account.getNotificationHistory", 32 - base 33 - )) 34 31 .header("Authorization", format!("Bearer {}", token)) 35 32 .send() 36 33 .await ··· 56 53 "discordId": "123456789" 57 54 }); 58 55 let resp = client 56 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 59 - .post(format!( 60 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 61 - base 62 - )) 63 57 .header("Authorization", format!("Bearer {}", token)) 64 58 .json(&prefs) 65 59 .send() ··· 101 95 "code": code 102 96 }); 103 97 let resp = client 98 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 104 - .post(format!( 105 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 106 - base 107 - )) 108 99 .header("Authorization", format!("Bearer {}", token)) 109 100 .json(&input) 110 101 .send() ··· 113 104 assert_eq!(resp.status(), 200); 114 105 115 106 let resp = client 107 + .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 116 - .get(format!( 117 - "{}/xrpc/com.tranquil.account.getNotificationPrefs", 118 - base 119 - )) 120 108 .header("Authorization", format!("Bearer {}", token)) 121 109 .send() 122 110 .await ··· 136 124 "telegramUsername": "testuser" 137 125 }); 138 126 let resp = client 127 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 139 - .post(format!( 140 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 141 - base 142 - )) 143 128 .header("Authorization", format!("Bearer {}", token)) 144 129 .json(&prefs) 145 130 .send() ··· 153 138 "code": "XXXX-XXXX-XXXX-XXXX" 154 139 }); 155 140 let resp = client 141 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 156 - .post(format!( 157 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 158 - base 159 - )) 160 142 .header("Authorization", format!("Bearer {}", token)) 161 143 .json(&input) 162 144 .send() ··· 181 163 "code": "XXXX-XXXX-XXXX-XXXX" 182 164 }); 183 165 let resp = client 166 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 184 - .post(format!( 185 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 186 - base 187 - )) 188 167 .header("Authorization", format!("Bearer {}", token)) 189 168 .json(&input) 190 169 .send() ··· 209 188 "email": unique_email 210 189 }); 211 190 let resp = client 191 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 212 - .post(format!( 213 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 214 - base 215 - )) 216 192 .header("Authorization", format!("Bearer {}", token)) 217 193 .json(&prefs) 218 194 .send() ··· 263 239 "code": code 264 240 }); 265 241 let resp = client 242 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 266 - .post(format!( 267 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 268 - base 269 - )) 270 243 .header("Authorization", format!("Bearer {}", token)) 271 244 .json(&input) 272 245 .send() ··· 275 248 assert_eq!(resp.status(), 200); 276 249 277 250 let resp = client 251 + .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 278 - .get(format!( 279 - "{}/xrpc/com.tranquil.account.getNotificationPrefs", 280 - base 281 - )) 282 252 .header("Authorization", format!("Bearer {}", token)) 283 253 .send() 284 254 .await
+2 -2
tests/admin_stats.rs
··· 11 11 let (_, _) = create_admin_account_and_login(&client).await; 12 12 13 13 let resp = client 14 + .get(format!("{}/xrpc/_admin.getServerStats", base)) 14 - .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 15 15 .header("Authorization", format!("Bearer {}", token1)) 16 16 .send() 17 17 .await ··· 33 33 let client = client(); 34 34 let base = base_url().await; 35 35 let resp = client 36 + .get(format!("{}/xrpc/_admin.getServerStats", base)) 36 - .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 37 37 .send() 38 38 .await 39 39 .unwrap();
+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
··· 32 32 let did = create_body["did"].as_str().unwrap(); 33 33 let jwt = verify_new_account(&client, did).await; 34 34 let change_res = client 35 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 35 - .post(format!( 36 - "{}/xrpc/com.tranquil.account.changePassword", 37 - base_url().await 38 - )) 39 36 .bearer_auth(&jwt) 40 37 .json(&json!({ 41 38 "currentPassword": old_password, ··· 86 83 let client = client(); 87 84 let (_, jwt) = setup_new_user("change-pw-wrong").await; 88 85 let res = client 86 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 89 - .post(format!( 90 - "{}/xrpc/com.tranquil.account.changePassword", 91 - base_url().await 92 - )) 93 87 .bearer_auth(&jwt) 94 88 .json(&json!({ 95 89 "currentPassword": "Wrongpass999!", ··· 129 123 let did = create_body["did"].as_str().unwrap(); 130 124 let jwt = verify_new_account(&client, did).await; 131 125 let res = client 126 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 132 - .post(format!( 133 - "{}/xrpc/com.tranquil.account.changePassword", 134 - base_url().await 135 - )) 136 127 .bearer_auth(&jwt) 137 128 .json(&json!({ 138 129 "currentPassword": password, ··· 151 142 let client = client(); 152 143 let (_, jwt) = setup_new_user("change-pw-empty").await; 153 144 let res = client 145 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 154 - .post(format!( 155 - "{}/xrpc/com.tranquil.account.changePassword", 156 - base_url().await 157 - )) 158 146 .bearer_auth(&jwt) 159 147 .json(&json!({ 160 148 "currentPassword": "", ··· 171 159 let client = client(); 172 160 let (_, jwt) = setup_new_user("change-pw-emptynew").await; 173 161 let res = client 162 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 174 - .post(format!( 175 - "{}/xrpc/com.tranquil.account.changePassword", 176 - base_url().await 177 - )) 178 163 .bearer_auth(&jwt) 179 164 .json(&json!({ 180 165 "currentPassword": "E2epass123!", ··· 190 175 async fn test_change_password_requires_auth() { 191 176 let client = client(); 192 177 let res = client 178 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 193 - .post(format!( 194 - "{}/xrpc/com.tranquil.account.changePassword", 195 - base_url().await 196 - )) 197 179 .json(&json!({ 198 180 "currentPassword": "Oldpass123!", 199 181 "newPassword": "Newpass123!"
+28 -247
tests/did_web.rs
··· 547 547 } 548 548 549 549 #[tokio::test] 550 + async fn test_did_web_can_edit_did_document() { 550 - async fn test_deactivate_with_migrating_to() { 551 551 let client = client(); 552 552 let base = base_url().await; 553 + let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 553 - let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 554 554 let payload = json!({ 555 555 "handle": handle, 556 556 "email": format!("{}@example.com", handle), ··· 567 567 let body: Value = res.json().await.expect("Response was not JSON"); 568 568 let did = body["did"].as_str().expect("No DID").to_string(); 569 569 let jwt = verify_new_account(&client, &did).await; 570 - let target_pds = "https://pds2.example.com"; 571 570 let res = client 571 + .get(format!("{}/xrpc/_account.getDidDocument", base)) 572 - .post(format!( 573 - "{}/xrpc/com.atproto.server.deactivateAccount", 574 - base 575 - )) 576 572 .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 573 .send() 616 574 .await 617 575 .expect("Failed to send request"); 618 576 assert_eq!(res.status(), StatusCode::OK); 619 577 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 578 assert!( 579 + body["didDocument"].is_object(), 580 + "Should return DID document" 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 581 ); 582 + assert_eq!( 583 + body["didDocument"]["id"], did, 584 + "DID document should have correct id" 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 585 ); 705 586 let res = client 587 + .post(format!("{}/xrpc/_account.updateDidDocument", base)) 706 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 707 588 .bearer_auth(&jwt) 708 589 .json(&json!({ 590 + "alsoKnownAs": ["at://custom.handle.test"] 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 591 })) 720 592 .send() 721 593 .await 722 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" 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 599 ); 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), 751 - "password": "Testpass123!", 752 - "didType": "web" 753 - }); 754 - let res = client 755 - .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 756 - .json(&payload) 757 - .send() 758 - .await 759 - .expect("Failed to send request"); 760 - assert_eq!(res.status(), StatusCode::OK); 761 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"); 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 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" 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"); 788 - assert_eq!(res.status(), StatusCode::OK); 789 - let res = client 790 - .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 791 - .bearer_auth(&jwt) 792 - .send() 793 - .await 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 610 ); 809 611 } 810 612 811 613 #[tokio::test] 614 + async fn test_deactivate_account_basic() { 812 - async fn test_migrating_to_ignored_for_did_plc() { 813 615 let client = client(); 814 616 let base = base_url().await; 617 + let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 815 - let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 816 618 let payload = json!({ 817 619 "handle": handle, 818 620 "email": format!("{}@example.com", handle), 819 621 "password": "Testpass123!", 622 + "didType": "web" 820 - "didType": "plc" 821 623 }); 822 624 let res = client 823 625 .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) ··· 828 630 assert_eq!(res.status(), StatusCode::OK); 829 631 let body: Value = res.json().await.expect("Response was not JSON"); 830 632 let did = body["did"].as_str().expect("No DID").to_string(); 831 - assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 832 633 let jwt = verify_new_account(&client, &did).await; 833 634 let res = client 834 635 .post(format!( ··· 836 637 base 837 638 )) 838 639 .bearer_auth(&jwt) 640 + .json(&json!({})) 839 - .json(&json!({ "migratingTo": "https://pds2.example.com" })) 840 641 .send() 841 642 .await 842 643 .expect("Failed to send request"); 843 644 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 645 let res = client 861 646 .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 862 647 .bearer_auth(&jwt) ··· 865 650 .expect("Failed to send request"); 866 651 assert_eq!(res.status(), StatusCode::OK); 867 652 let body: Value = res.json().await.expect("Response was not JSON"); 653 + assert_eq!(body["active"], false, "Account should be deactivated"); 868 - assert_eq!(body["active"], false); 869 654 assert_eq!( 870 655 body["status"], "deactivated", 656 + "Status should be '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 657 ); 877 658 }
-1
tests/oauth.rs
··· 1 1 mod common; 2 2 mod helpers; 3 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 - use chrono::Utc; 5 4 use common::{base_url, client, get_test_db_pool}; 6 5 use helpers::verify_new_account; 7 6 use reqwest::{StatusCode, redirect};
+1 -4
tests/oauth_security.rs
··· 1116 1116 1117 1117 let delegated_handle = format!("dg{}", suffix); 1118 1118 let delegated_res = http_client 1119 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1119 - .post(format!( 1120 - "{}/xrpc/com.tranquil.delegation.createDelegatedAccount", 1121 - url 1122 - )) 1123 1120 .bearer_auth(controller_jwt) 1124 1121 .json(&json!({ 1125 1122 "handle": delegated_handle,
+9 -36
tests/session_management.rs
··· 10 10 let client = client(); 11 11 let (did, jwt) = setup_new_user("list-sessions").await; 12 12 let res = client 13 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 13 - .get(format!( 14 - "{}/xrpc/com.tranquil.account.listSessions", 15 - base_url().await 16 - )) 17 14 .bearer_auth(&jwt) 18 15 .send() 19 16 .await ··· 83 80 let login_body: Value = login_res.json().await.unwrap(); 84 81 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 85 82 let list_res = client 83 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 86 - .get(format!( 87 - "{}/xrpc/com.tranquil.account.listSessions", 88 - base_url().await 89 - )) 90 84 .bearer_auth(jwt2) 91 85 .send() 92 86 .await ··· 106 100 async fn test_list_sessions_requires_auth() { 107 101 let client = client(); 108 102 let res = client 103 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 109 - .get(format!( 110 - "{}/xrpc/com.tranquil.account.listSessions", 111 - base_url().await 112 - )) 113 104 .send() 114 105 .await 115 106 .expect("Failed to send request"); ··· 158 149 let login_body: Value = login_res.json().await.unwrap(); 159 150 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 160 151 let list_res = client 152 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 161 - .get(format!( 162 - "{}/xrpc/com.tranquil.account.listSessions", 163 - base_url().await 164 - )) 165 153 .bearer_auth(jwt2) 166 154 .send() 167 155 .await ··· 177 165 ); 178 166 let session_id = other_session.unwrap()["id"].as_str().unwrap(); 179 167 let revoke_res = client 168 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 180 - .post(format!( 181 - "{}/xrpc/com.tranquil.account.revokeSession", 182 - base_url().await 183 - )) 184 169 .bearer_auth(jwt2) 185 170 .json(&json!({"sessionId": session_id})) 186 171 .send() ··· 188 173 .expect("Failed to revoke session"); 189 174 assert_eq!(revoke_res.status(), StatusCode::OK); 190 175 let list_after_res = client 176 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 191 - .get(format!( 192 - "{}/xrpc/com.tranquil.account.listSessions", 193 - base_url().await 194 - )) 195 177 .bearer_auth(jwt2) 196 178 .send() 197 179 .await ··· 213 195 let client = client(); 214 196 let (_, jwt) = setup_new_user("revoke-invalid").await; 215 197 let res = client 198 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 216 - .post(format!( 217 - "{}/xrpc/com.tranquil.account.revokeSession", 218 - base_url().await 219 - )) 220 199 .bearer_auth(&jwt) 221 200 .json(&json!({"sessionId": "not-a-number"})) 222 201 .send() ··· 230 209 let client = client(); 231 210 let (_, jwt) = setup_new_user("revoke-notfound").await; 232 211 let res = client 212 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 233 - .post(format!( 234 - "{}/xrpc/com.tranquil.account.revokeSession", 235 - base_url().await 236 - )) 237 213 .bearer_auth(&jwt) 238 214 .json(&json!({"sessionId": "jwt:999999999"})) 239 215 .send() ··· 246 222 async fn test_revoke_session_requires_auth() { 247 223 let client = client(); 248 224 let res = client 225 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 249 - .post(format!( 250 - "{}/xrpc/com.tranquil.account.revokeSession", 251 - base_url().await 252 - )) 253 226 .json(&json!({"sessionId": "1"})) 254 227 .send() 255 228 .await

History

4 rounds 12 comments
sign up or login to add to the discussion
5 commits
expand
40568e20
try to fix
149ed8e3
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
ced673cf
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
19e88430
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
1694091e
Backups, adversarial migrations
expand 0 comments
closed without merging
6 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
085621dc
Backups, adversarial migrations
expand 1 comment
5 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
expand 5 comments

there, i fixed it

Would you mind rebasing or something? :p i can't see nothin in there (I know it's my fault for pushing massive changes while your PR is open but whatchagonnadoabouttit

i cant resubmit

it wont let me..

yeah it sucks

4 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
expand 6 comments

tangled is so funny why did it include the commit already merged here

i dont know how to fix that merge conflict tangled is the best git platform oat

what is this mystical BSKY_APPVIEW_ENDPOINT about :p what if im running tranquil and I wanna support every bsky-esque appview equally?? zeppelin may return in spirit

it's so i can add a backdoor to the ccp

i would reset to the commit before the port/host hardoded thing fix i did and then merge this one as it includes it

like lewis mentioned i dont quite get why the dedicated proxying to a configured bsky appview is needed here? it makes tranquil less generic, less spec compliant and i dont see what functionality it offers over the spec compliant proxying thats already there? (and that modern social-app has luckily been updated to use as it should)