mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-28 21:32:38 +00:00
Backups, adversarial migrations
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, backup_enabled FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "backup_enabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "backup_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "deactivated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "repo_root_cid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "repo_rev",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "deactivated_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d"
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "migrated_to_pds",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "migrated_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "repo_rev",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "repo_rev",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "repo_root_cid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "block_count",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "size_bytes",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4"
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "repo_root_cid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "repo_rev",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab"
|
||||
}
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
|
||||
"query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -10,13 +10,13 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "migrated_to_pds",
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
"name": "deactivated_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -26,9 +26,9 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073"
|
||||
"hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -26,5 +26,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115"
|
||||
"hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "mime_type",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET backup_enabled = $1 WHERE did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM account_backups WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532"
|
||||
}
|
||||
Generated
+64
@@ -110,6 +110,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.7.1"
|
||||
@@ -1620,6 +1629,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "1.0.0"
|
||||
@@ -1973,6 +1993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
@@ -3459,6 +3480,15 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
@@ -6286,6 +6316,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"futures",
|
||||
"governor",
|
||||
"hex",
|
||||
"hickory-resolver",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
@@ -6329,6 +6360,7 @@ dependencies = [
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"wiremock",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7291,6 +7323,38 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap 2.12.1",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.0"
|
||||
|
||||
@@ -19,6 +19,7 @@ cid = "0.11.1"
|
||||
dotenvy = "0.15.7"
|
||||
futures = "0.3.30"
|
||||
governor = "0.10"
|
||||
hex = "0.4"
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
aes-gcm = "0.10"
|
||||
@@ -62,6 +63,7 @@ bs58 = "0.5.1"
|
||||
totp-rs = { version = "5", features = ["qr"] }
|
||||
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
|
||||
webauthn-rs-proto = "0.5.4"
|
||||
zip = { version = "7.0.0", default-features = false, features = ["deflate"] }
|
||||
[features]
|
||||
external-infra = []
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -14,7 +14,7 @@ Another excellent PDS is [Cocoon](https://tangled.org/hailey.at/cocoon), written
|
||||
|
||||
This software isn't an afterthought by a company with limited resources.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
|
||||
## Active development
|
||||
|
||||
### Migration tool
|
||||
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
|
||||
|
||||
- [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
|
||||
- [x] Support `createAccount` with existing DID + service auth token
|
||||
- [x] Progress tracking with resume capability
|
||||
- [ ] Scheduled automatic backups (CAR export)
|
||||
- [ ] One-click restore from backup
|
||||
|
||||
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.
|
||||
|
||||
- [ ] Remove `migratingTo` field and related state machine
|
||||
- [ ] Let did:web users edit their DID doc fields (serviceEndpoint, keys) whenever
|
||||
- [ ] Repo export as standalone feature, not tied to migration wizard
|
||||
|
||||
### Plugin system
|
||||
Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language.
|
||||
|
||||
@@ -69,3 +54,5 @@ Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP w
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Generated
+94
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"npm:@atcute/cbor@^2.2.8": "2.2.8",
|
||||
"npm:@atcute/crypto@^2.3.0": "2.3.0",
|
||||
"npm:@atcute/did-plc@~0.3.1": "0.3.1",
|
||||
"npm:@atcute/multibase@^1.1.6": "1.1.6",
|
||||
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
|
||||
"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",
|
||||
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
|
||||
@@ -30,6 +34,80 @@
|
||||
"lru-cache"
|
||||
]
|
||||
},
|
||||
"@atcute/cbor@2.2.8": {
|
||||
"integrity": "sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==",
|
||||
"dependencies": [
|
||||
"@atcute/cid",
|
||||
"@atcute/multibase",
|
||||
"@atcute/uint8array"
|
||||
]
|
||||
},
|
||||
"@atcute/cid@2.3.0": {
|
||||
"integrity": "sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==",
|
||||
"dependencies": [
|
||||
"@atcute/multibase",
|
||||
"@atcute/uint8array"
|
||||
]
|
||||
},
|
||||
"@atcute/crypto@2.3.0": {
|
||||
"integrity": "sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==",
|
||||
"dependencies": [
|
||||
"@atcute/multibase",
|
||||
"@atcute/uint8array",
|
||||
"@noble/secp256k1@3.0.0"
|
||||
]
|
||||
},
|
||||
"@atcute/did-plc@0.3.1": {
|
||||
"integrity": "sha512-KsuVdRtaaIPMmlcCDcxZzLg6OWm7rajczquhIHfA3s57+c34PFQbdY4Lsc2BvDwZ0fUjmbwzvQI3Zio2VcZa7w==",
|
||||
"dependencies": [
|
||||
"@atcute/cbor",
|
||||
"@atcute/cid",
|
||||
"@atcute/crypto",
|
||||
"@atcute/identity",
|
||||
"@atcute/lexicons",
|
||||
"@atcute/multibase",
|
||||
"@atcute/uint8array",
|
||||
"@atcute/util-fetch",
|
||||
"@badrap/valita"
|
||||
]
|
||||
},
|
||||
"@atcute/identity@1.1.3": {
|
||||
"integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==",
|
||||
"dependencies": [
|
||||
"@atcute/lexicons",
|
||||
"@badrap/valita"
|
||||
]
|
||||
},
|
||||
"@atcute/lexicons@1.2.6": {
|
||||
"integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==",
|
||||
"dependencies": [
|
||||
"@atcute/uint8array",
|
||||
"@atcute/util-text",
|
||||
"@standard-schema/spec",
|
||||
"esm-env"
|
||||
]
|
||||
},
|
||||
"@atcute/multibase@1.1.6": {
|
||||
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
|
||||
"dependencies": [
|
||||
"@atcute/uint8array"
|
||||
]
|
||||
},
|
||||
"@atcute/uint8array@1.0.6": {
|
||||
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
|
||||
},
|
||||
"@atcute/util-fetch@1.0.4": {
|
||||
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
|
||||
"dependencies": [
|
||||
"@badrap/valita"
|
||||
]
|
||||
},
|
||||
"@atcute/util-text@0.0.1": {
|
||||
"integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==",
|
||||
"dependencies": [
|
||||
"unicode-segmenter"
|
||||
]
|
||||
},
|
||||
"@babel/code-frame@7.27.1": {
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dependencies": [
|
||||
@@ -44,6 +122,9 @@
|
||||
"@babel/runtime@7.28.4": {
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
|
||||
},
|
||||
"@badrap/valita@0.4.6": {
|
||||
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
|
||||
},
|
||||
"@csstools/color-helpers@5.1.0": {
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
|
||||
},
|
||||
@@ -498,6 +579,9 @@
|
||||
"@noble/secp256k1@2.3.0": {
|
||||
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
|
||||
},
|
||||
"@noble/secp256k1@3.0.0": {
|
||||
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
|
||||
},
|
||||
"@rollup/rollup-android-arm-eabi@4.53.3": {
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"os": ["android"],
|
||||
@@ -608,6 +692,9 @@
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@standard-schema/spec@1.1.0": {
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
|
||||
},
|
||||
"@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
|
||||
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
||||
"dependencies": [
|
||||
@@ -1545,6 +1632,9 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"bin": true
|
||||
},
|
||||
"unicode-segmenter@0.14.4": {
|
||||
"integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg=="
|
||||
},
|
||||
"vite-node@2.1.9": {
|
||||
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
||||
"dependencies": [
|
||||
@@ -1671,6 +1761,10 @@
|
||||
"workspace": {
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@atcute/cbor@^2.2.8",
|
||||
"npm:@atcute/crypto@^2.3.0",
|
||||
"npm:@atcute/did-plc@~0.3.1",
|
||||
"npm:@atcute/multibase@^1.1.6",
|
||||
"npm:@noble/secp256k1@^2.1.0",
|
||||
"npm:@sveltejs/vite-plugin-svelte@5",
|
||||
"npm:@testing-library/jest-dom@^6.6.3",
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atcute/cbor": "^2.2.8",
|
||||
"@atcute/crypto": "^2.3.0",
|
||||
"@atcute/did-plc": "^0.3.1",
|
||||
"@atcute/multibase": "^1.1.6",
|
||||
"@noble/secp256k1": "^2.1.0",
|
||||
"multiformats": "^13.3.1",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !password}>
|
||||
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'totp'}
|
||||
@@ -247,7 +247,7 @@
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
|
||||
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</form>
|
||||
{:else if activeMethod === 'passkey'}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
appPassword: string
|
||||
appPasswordName: string
|
||||
loading: boolean
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
appPassword,
|
||||
appPasswordName,
|
||||
loading,
|
||||
onContinue,
|
||||
}: Props = $props()
|
||||
|
||||
let copied = $state(false)
|
||||
let acknowledged = $state(false)
|
||||
|
||||
function copyPassword() {
|
||||
navigator.clipboard.writeText(appPassword)
|
||||
copied = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.appPassword.title')}</h2>
|
||||
<p>{$_('migration.inbound.appPassword.desc')}</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
|
||||
</div>
|
||||
|
||||
<div class="app-password-display">
|
||||
<div class="app-password-label">
|
||||
{$_('migration.inbound.appPassword.label')}: <strong>{appPasswordName}</strong>
|
||||
</div>
|
||||
<code class="app-password-code">{appPassword}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyPassword}>
|
||||
{copied ? $_('common.copied') : $_('common.copyToClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={acknowledged} />
|
||||
<span>{$_('migration.inbound.appPassword.saved')}</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button onclick={onContinue} disabled={!acknowledged || loading}>
|
||||
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-password-display {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
.app-password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.app-password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
}
|
||||
.copy-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
handleInput: string
|
||||
selectedDomain: string
|
||||
handleAvailable: boolean | null
|
||||
checkingHandle: boolean
|
||||
email: string
|
||||
password: string
|
||||
authMethod: AuthMethod
|
||||
inviteCode: string
|
||||
serverInfo: ServerDescription | null
|
||||
migratingFromLabel: string
|
||||
migratingFromValue: string
|
||||
loading?: boolean
|
||||
onHandleChange: (handle: string) => void
|
||||
onDomainChange: (domain: string) => void
|
||||
onCheckHandle: () => void
|
||||
onEmailChange: (email: string) => void
|
||||
onPasswordChange: (password: string) => void
|
||||
onAuthMethodChange: (method: AuthMethod) => void
|
||||
onInviteCodeChange: (code: string) => void
|
||||
onBack: () => void
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
handleInput,
|
||||
selectedDomain,
|
||||
handleAvailable,
|
||||
checkingHandle,
|
||||
email,
|
||||
password,
|
||||
authMethod,
|
||||
inviteCode,
|
||||
serverInfo,
|
||||
migratingFromLabel,
|
||||
migratingFromValue,
|
||||
loading = false,
|
||||
onHandleChange,
|
||||
onDomainChange,
|
||||
onCheckHandle,
|
||||
onEmailChange,
|
||||
onPasswordChange,
|
||||
onAuthMethodChange,
|
||||
onInviteCodeChange,
|
||||
onBack,
|
||||
onContinue,
|
||||
}: Props = $props()
|
||||
|
||||
const canContinue = $derived(
|
||||
handleInput.trim() &&
|
||||
email &&
|
||||
(authMethod === 'passkey' || password) &&
|
||||
handleAvailable !== false
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
|
||||
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
|
||||
|
||||
<div class="current-info">
|
||||
<span class="label">{migratingFromLabel}:</span>
|
||||
<span class="value">{migratingFromValue}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
|
||||
<div class="handle-input-group">
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
value={handleInput}
|
||||
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
|
||||
onblur={onCheckHandle}
|
||||
/>
|
||||
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
|
||||
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
|
||||
{#each serverInfo.availableUserDomains as domain}
|
||||
<option value={domain}>.{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if checkingHandle}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
|
||||
{:else if handleAvailable === true}
|
||||
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
|
||||
{:else if handleAvailable === false}
|
||||
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
|
||||
{:else}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
|
||||
<div class="auth-method-options">
|
||||
<label class="auth-option" class:selected={authMethod === 'password'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-method"
|
||||
value="password"
|
||||
checked={authMethod === 'password'}
|
||||
onchange={() => onAuthMethodChange('password')}
|
||||
/>
|
||||
<div class="auth-option-content">
|
||||
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
|
||||
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="auth-option" class:selected={authMethod === 'passkey'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-method"
|
||||
value="passkey"
|
||||
checked={authMethod === 'passkey'}
|
||||
onchange={() => onAuthMethodChange('passkey')}
|
||||
/>
|
||||
<div class="auth-option-content">
|
||||
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
|
||||
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if authMethod === 'password'}
|
||||
<div class="field">
|
||||
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
placeholder="Password for your new account"
|
||||
value={password}
|
||||
oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)}
|
||||
required
|
||||
minlength={8}
|
||||
/>
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="info-box">
|
||||
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
|
||||
<input
|
||||
id="invite"
|
||||
type="text"
|
||||
placeholder="Enter invite code"
|
||||
value={inviteCode}
|
||||
oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button>
|
||||
<button disabled={!canContinue || loading} onclick={onContinue}>
|
||||
{$_('migration.inbound.common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
email: string
|
||||
token: string
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onTokenChange: (token: string) => void
|
||||
onSubmit: (e: Event) => void
|
||||
onResend: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
email,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
onTokenChange,
|
||||
onSubmit,
|
||||
onResend,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
|
||||
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
{$_('migration.inbound.emailVerify.hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={onSubmit}>
|
||||
<div class="field">
|
||||
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
|
||||
<input
|
||||
id="email-verify-token"
|
||||
type="text"
|
||||
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
|
||||
value={token}
|
||||
oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost" onclick={onResend} disabled={loading}>
|
||||
{$_('migration.inbound.emailVerify.resend')}
|
||||
</button>
|
||||
<button type="submit" disabled={loading || !token}>
|
||||
{loading ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
error: string | null
|
||||
onStartOver: () => void
|
||||
}
|
||||
|
||||
let { error, onStartOver }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.error.title')}</h2>
|
||||
<p>{$_('migration.inbound.error.desc')}</p>
|
||||
|
||||
<div class="message error">
|
||||
{error || $_('migration.inbound.error.unknown')}
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onStartOver}>{$_('migration.inbound.error.startOver')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,9 +5,15 @@
|
||||
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
|
||||
import { _ } from '../../lib/i18n'
|
||||
import '../../styles/migration.css'
|
||||
import ErrorStep from './ErrorStep.svelte'
|
||||
import SuccessStep from './SuccessStep.svelte'
|
||||
import ChooseHandleStep from './ChooseHandleStep.svelte'
|
||||
import EmailVerifyStep from './EmailVerifyStep.svelte'
|
||||
import PasskeySetupStep from './PasskeySetupStep.svelte'
|
||||
import AppPasswordStep from './AppPasswordStep.svelte'
|
||||
|
||||
interface ResumeInfo {
|
||||
direction: 'inbound' | 'outbound'
|
||||
direction: 'inbound'
|
||||
sourceHandle: string
|
||||
targetHandle: string
|
||||
sourcePdsUrl: string
|
||||
@@ -37,8 +43,6 @@
|
||||
let checkingHandle = $state(false)
|
||||
let selectedAuthMethod = $state<AuthMethod>('password')
|
||||
let passkeyName = $state('')
|
||||
let appPasswordCopied = $state(false)
|
||||
let appPasswordAcknowledged = $state(false)
|
||||
|
||||
const isResuming = $derived(flow.state.needsReauth === true)
|
||||
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
|
||||
@@ -234,13 +238,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function copyAppPassword() {
|
||||
if (flow.state.generatedAppPassword) {
|
||||
navigator.clipboard.writeText(flow.state.generatedAppPassword)
|
||||
appPasswordCopied = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProceedFromAppPassword() {
|
||||
loading = true
|
||||
try {
|
||||
@@ -352,8 +349,8 @@
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
|
||||
<button disabled={!understood} onclick={() => flow.setStep('source-handle')}>
|
||||
<button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
|
||||
<button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}>
|
||||
{$_('migration.inbound.common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -409,131 +406,29 @@
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'choose-handle'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
|
||||
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
|
||||
|
||||
<div class="current-info">
|
||||
<span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span>
|
||||
<span class="value">{flow.state.sourceHandle}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
|
||||
<div class="handle-input-group">
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
bind:value={handleInput}
|
||||
onblur={checkHandle}
|
||||
/>
|
||||
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
|
||||
<select bind:value={selectedDomain}>
|
||||
{#each serverInfo.availableUserDomains as domain}
|
||||
<option value={domain}>.{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if checkingHandle}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
|
||||
{:else if handleAvailable === true}
|
||||
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
|
||||
{:else if handleAvailable === false}
|
||||
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
|
||||
{:else}
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={flow.state.targetEmail}
|
||||
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
|
||||
<div class="auth-method-options">
|
||||
<label class="auth-option" class:selected={selectedAuthMethod === 'password'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-method"
|
||||
value="password"
|
||||
bind:group={selectedAuthMethod}
|
||||
/>
|
||||
<div class="auth-option-content">
|
||||
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
|
||||
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}>
|
||||
<input
|
||||
type="radio"
|
||||
name="auth-method"
|
||||
value="passkey"
|
||||
bind:group={selectedAuthMethod}
|
||||
/>
|
||||
<div class="auth-option-content">
|
||||
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
|
||||
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedAuthMethod === 'password'}
|
||||
<div class="field">
|
||||
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
placeholder="Password for your new account"
|
||||
bind:value={flow.state.targetPassword}
|
||||
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="info-box">
|
||||
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
|
||||
<input
|
||||
id="invite"
|
||||
type="text"
|
||||
placeholder="Enter invite code"
|
||||
bind:value={flow.state.inviteCode}
|
||||
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button>
|
||||
<button
|
||||
disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false}
|
||||
onclick={proceedToReviewWithAuth}
|
||||
>
|
||||
{$_('migration.inbound.common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChooseHandleStep
|
||||
{handleInput}
|
||||
{selectedDomain}
|
||||
{handleAvailable}
|
||||
{checkingHandle}
|
||||
email={flow.state.targetEmail}
|
||||
password={flow.state.targetPassword}
|
||||
authMethod={selectedAuthMethod}
|
||||
inviteCode={flow.state.inviteCode}
|
||||
{serverInfo}
|
||||
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
|
||||
migratingFromValue={flow.state.sourceHandle}
|
||||
{loading}
|
||||
onHandleChange={(h) => handleInput = h}
|
||||
onDomainChange={(d) => selectedDomain = d}
|
||||
onCheckHandle={checkHandle}
|
||||
onEmailChange={(e) => flow.updateField('targetEmail', e)}
|
||||
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
|
||||
onAuthMethodChange={(m) => selectedAuthMethod = m}
|
||||
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
|
||||
onBack={() => flow.setStep('source-handle')}
|
||||
onContinue={proceedToReviewWithAuth}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'review'}
|
||||
<div class="step-content">
|
||||
@@ -620,108 +515,32 @@
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'passkey-setup'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
|
||||
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
|
||||
|
||||
{#if flow.state.error}
|
||||
<div class="message error">
|
||||
{flow.state.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
|
||||
<input
|
||||
id="passkey-name"
|
||||
type="text"
|
||||
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
|
||||
bind:value={passkeyName}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="passkey-section">
|
||||
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
|
||||
<button class="primary" onclick={registerPasskey} disabled={loading}>
|
||||
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PasskeySetupStep
|
||||
{passkeyName}
|
||||
{loading}
|
||||
error={flow.state.error}
|
||||
onPasskeyNameChange={(n) => passkeyName = n}
|
||||
onRegister={registerPasskey}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'app-password'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.appPassword.title')}</h2>
|
||||
<p>{$_('migration.inbound.appPassword.desc')}</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
|
||||
</div>
|
||||
|
||||
<div class="app-password-display">
|
||||
<div class="app-password-label">
|
||||
{$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong>
|
||||
</div>
|
||||
<code class="app-password-code">{flow.state.generatedAppPassword}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyAppPassword}>
|
||||
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
|
||||
<span>{$_('migration.inbound.appPassword.saved')}</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}>
|
||||
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AppPasswordStep
|
||||
appPassword={flow.state.generatedAppPassword || ''}
|
||||
appPasswordName={flow.state.generatedAppPasswordName || ''}
|
||||
{loading}
|
||||
onContinue={handleProceedFromAppPassword}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'email-verify'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
|
||||
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
{$_('migration.inbound.emailVerify.hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if flow.state.error}
|
||||
<div class="message error">
|
||||
{flow.state.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={submitEmailVerify}>
|
||||
<div class="field">
|
||||
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
|
||||
<input
|
||||
id="email-verify-token"
|
||||
type="text"
|
||||
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
|
||||
bind:value={flow.state.emailVerifyToken}
|
||||
oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}>
|
||||
{$_('migration.inbound.emailVerify.resend')}
|
||||
</button>
|
||||
<button type="submit" disabled={loading || !flow.state.emailVerifyToken}>
|
||||
{loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<EmailVerifyStep
|
||||
email={flow.state.targetEmail}
|
||||
token={flow.state.emailVerifyToken}
|
||||
{loading}
|
||||
error={flow.state.error}
|
||||
onTokenChange={(t) => flow.updateField('emailVerifyToken', t)}
|
||||
onSubmit={submitEmailVerify}
|
||||
onResend={resendEmailVerify}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'plc-token'}
|
||||
<div class="step-content">
|
||||
@@ -837,83 +656,22 @@
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'success'}
|
||||
<div class="step-content success-content">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>{$_('migration.inbound.success.title')}</h2>
|
||||
<p>{$_('migration.inbound.success.desc')}</p>
|
||||
|
||||
<div class="success-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
|
||||
<span class="value">{flow.state.targetHandle}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">{$_('migration.inbound.success.did')}:</span>
|
||||
<span class="value mono">{flow.state.sourceDid}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flow.state.progress.blobsFailed.length > 0}
|
||||
<div class="message warning">
|
||||
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
|
||||
</div>
|
||||
<SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}>
|
||||
{#snippet extraContent()}
|
||||
{#if flow.state.progress.blobsFailed.length > 0}
|
||||
<div class="message warning">
|
||||
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SuccessStep>
|
||||
|
||||
{:else if flow.state.step === 'error'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.error.title')}</h2>
|
||||
<p>{$_('migration.inbound.error.desc')}</p>
|
||||
|
||||
<div class="message error">
|
||||
{flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorStep error={flow.state.error} onStartOver={onBack} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.passkey-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.passkey-section button {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.app-password-display {
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
.app-password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.app-password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
}
|
||||
.copy-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.resume-info {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
<script lang="ts">
|
||||
import type { OfflineInboundMigrationFlow } from '../../lib/migration'
|
||||
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
|
||||
import { getErrorMessage } from '../../lib/migration/types'
|
||||
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
|
||||
import { _ } from '../../lib/i18n'
|
||||
import '../../styles/migration.css'
|
||||
import ErrorStep from './ErrorStep.svelte'
|
||||
import SuccessStep from './SuccessStep.svelte'
|
||||
import ChooseHandleStep from './ChooseHandleStep.svelte'
|
||||
import EmailVerifyStep from './EmailVerifyStep.svelte'
|
||||
import PasskeySetupStep from './PasskeySetupStep.svelte'
|
||||
import AppPasswordStep from './AppPasswordStep.svelte'
|
||||
|
||||
interface Props {
|
||||
flow: OfflineInboundMigrationFlow
|
||||
onBack: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
let { flow, onBack, onComplete }: Props = $props()
|
||||
|
||||
let serverInfo = $state<ServerDescription | null>(null)
|
||||
let loading = $state(false)
|
||||
let understood = $state(false)
|
||||
let handleInput = $state('')
|
||||
let selectedDomain = $state('')
|
||||
let handleAvailable = $state<boolean | null>(null)
|
||||
let checkingHandle = $state(false)
|
||||
let validatingKey = $state(false)
|
||||
let keyValid = $state<boolean | null>(null)
|
||||
let fileInputRef = $state<HTMLInputElement | null>(null)
|
||||
let selectedAuthMethod = $state<AuthMethod>('password')
|
||||
let passkeyName = $state('')
|
||||
|
||||
let redirectTriggered = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') {
|
||||
loadServerInfo()
|
||||
}
|
||||
if (flow.state.step === 'choose-handle') {
|
||||
handleInput = ''
|
||||
handleAvailable = null
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'success' && !redirectTriggered) {
|
||||
redirectTriggered = true
|
||||
setTimeout(() => {
|
||||
onComplete()
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'email-verify') {
|
||||
const interval = setInterval(async () => {
|
||||
if (flow.state.emailVerifyToken.trim()) return
|
||||
await flow.checkEmailVerifiedAndProceed()
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
if (!serverInfo) {
|
||||
serverInfo = await flow.loadLocalServerInfo()
|
||||
if (serverInfo.availableUserDomains.length > 0) {
|
||||
selectedDomain = serverInfo.availableUserDomains[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const arrayBuffer = reader.result as ArrayBuffer
|
||||
flow.setCarFile(new Uint8Array(arrayBuffer), file.name)
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
async function validateRotationKey() {
|
||||
if (!flow.state.rotationKey || !flow.state.userDid) return
|
||||
|
||||
validatingKey = true
|
||||
keyValid = null
|
||||
|
||||
try {
|
||||
const isValid = await flow.validateRotationKey()
|
||||
keyValid = isValid
|
||||
if (isValid) {
|
||||
flow.setStep('choose-handle')
|
||||
}
|
||||
} catch (err) {
|
||||
flow.setError(getErrorMessage(err))
|
||||
keyValid = false
|
||||
} finally {
|
||||
validatingKey = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
loading = true
|
||||
try {
|
||||
await flow.runMigration()
|
||||
} catch (err) {
|
||||
flow.setError(getErrorMessage(err))
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const steps = $derived(
|
||||
flow.state.authMethod === 'passkey'
|
||||
? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete']
|
||||
: ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete']
|
||||
)
|
||||
|
||||
function getCurrentStepIndex(): number {
|
||||
const isPasskey = flow.state.authMethod === 'passkey'
|
||||
switch (flow.state.step) {
|
||||
case 'welcome': return 0
|
||||
case 'provide-did': return 0
|
||||
case 'upload-car': return 1
|
||||
case 'provide-rotation-key': return 2
|
||||
case 'choose-handle': return 3
|
||||
case 'review': return 4
|
||||
case 'creating':
|
||||
case 'importing': return 5
|
||||
case 'migrating-blobs': return 6
|
||||
case 'email-verify': return 7
|
||||
case 'passkey-setup': return isPasskey ? 8 : 7
|
||||
case 'app-password': return 9
|
||||
case 'plc-signing':
|
||||
case 'finalizing': return isPasskey ? 10 : 8
|
||||
case 'success': return isPasskey ? 10 : 8
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHandle() {
|
||||
if (!handleInput.trim()) return
|
||||
|
||||
const fullHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
: `${handleInput}.${selectedDomain}`
|
||||
|
||||
checkingHandle = true
|
||||
handleAvailable = null
|
||||
|
||||
try {
|
||||
handleAvailable = await flow.checkHandleAvailability(fullHandle)
|
||||
} catch {
|
||||
handleAvailable = true
|
||||
} finally {
|
||||
checkingHandle = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceedToReview() {
|
||||
const fullHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
: `${handleInput}.${selectedDomain}`
|
||||
|
||||
flow.setTargetHandle(fullHandle)
|
||||
flow.setAuthMethod(selectedAuthMethod)
|
||||
flow.setStep('review')
|
||||
}
|
||||
|
||||
async function submitEmailVerify(e: Event) {
|
||||
e.preventDefault()
|
||||
loading = true
|
||||
try {
|
||||
await flow.submitEmailVerifyToken(flow.state.emailVerifyToken)
|
||||
} catch (err) {
|
||||
flow.setError(getErrorMessage(err))
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resendEmailVerify() {
|
||||
loading = true
|
||||
try {
|
||||
await flow.resendEmailVerification()
|
||||
flow.setError(null)
|
||||
} catch (err) {
|
||||
flow.setError(getErrorMessage(err))
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPasskey() {
|
||||
loading = true
|
||||
flow.setError(null)
|
||||
|
||||
try {
|
||||
if (!window.PublicKeyCredential) {
|
||||
throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.')
|
||||
}
|
||||
|
||||
await flow.registerPasskey(passkeyName || undefined)
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err)
|
||||
if (message.includes('cancelled') || message.includes('AbortError')) {
|
||||
flow.setError('Passkey registration was cancelled. Please try again.')
|
||||
} else {
|
||||
flow.setError(message)
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProceedFromAppPassword() {
|
||||
loading = true
|
||||
try {
|
||||
await flow.proceedFromAppPassword()
|
||||
} catch (err) {
|
||||
flow.setError(getErrorMessage(err))
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="migration-wizard">
|
||||
<div class="step-indicator">
|
||||
{#each steps as _, i}
|
||||
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
|
||||
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
|
||||
</div>
|
||||
{#if i < steps.length - 1}
|
||||
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="current-step-label">
|
||||
<strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length}
|
||||
</div>
|
||||
|
||||
{#if flow.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.state.step === 'welcome'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.offline.welcome.title')}</h2>
|
||||
<p>{$_('migration.offline.welcome.desc')}</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.offline.welcome.warningTitle')}</strong>
|
||||
<p>{$_('migration.offline.welcome.warningDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>{$_('migration.offline.welcome.requirementsTitle')}</h3>
|
||||
<ul>
|
||||
<li>{$_('migration.offline.welcome.requirement1')}</li>
|
||||
<li>{$_('migration.offline.welcome.requirement2')}</li>
|
||||
<li>{$_('migration.offline.welcome.requirement3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={understood} />
|
||||
<span>{$_('migration.offline.welcome.understand')}</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
|
||||
<button disabled={!understood} onclick={() => flow.setStep('provide-did')}>
|
||||
{$_('migration.inbound.common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'provide-did'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.offline.provideDid.title')}</h2>
|
||||
<p>{$_('migration.offline.provideDid.desc')}</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="user-did">{$_('migration.offline.provideDid.label')}</label>
|
||||
<input
|
||||
id="user-did"
|
||||
type="text"
|
||||
placeholder="did:plc:abc123..."
|
||||
value={flow.state.userDid}
|
||||
oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<p class="hint">{$_('migration.offline.provideDid.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button>
|
||||
<button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}>
|
||||
{$_('migration.inbound.common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'upload-car'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.offline.uploadCar.title')}</h2>
|
||||
<p>{$_('migration.offline.uploadCar.desc')}</p>
|
||||
|
||||
{#if flow.state.carNeedsReupload}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong>
|
||||
<p>{$_('migration.offline.uploadCar.reuploadWarning')}</p>
|
||||
{#if flow.state.carFileName}
|
||||
<p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="car-file">{$_('migration.offline.uploadCar.label')}</label>
|
||||
<div class="file-input-container">
|
||||
<input
|
||||
id="car-file"
|
||||
type="file"
|
||||
accept=".car"
|
||||
onchange={handleFileSelect}
|
||||
bind:this={fileInputRef}
|
||||
/>
|
||||
{#if flow.state.carFile && flow.state.carFileName}
|
||||
<div class="file-info">
|
||||
<span class="file-name">{flow.state.carFileName}</span>
|
||||
<span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="hint">{$_('migration.offline.uploadCar.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button>
|
||||
<button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}>
|
||||
{$_('migration.inbound.common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'provide-rotation-key'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.offline.rotationKey.title')}</h2>
|
||||
<p>{$_('migration.offline.rotationKey.desc')}</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li>{$_('migration.offline.rotationKey.securityWarning1')}</li>
|
||||
<li>{$_('migration.offline.rotationKey.securityWarning2')}</li>
|
||||
<li>{$_('migration.offline.rotationKey.securityWarning3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label>
|
||||
<textarea
|
||||
id="rotation-key"
|
||||
rows={4}
|
||||
placeholder={$_('migration.offline.rotationKey.placeholder')}
|
||||
value={flow.state.rotationKey}
|
||||
oninput={(e) => {
|
||||
flow.setRotationKey((e.target as HTMLTextAreaElement).value)
|
||||
keyValid = null
|
||||
}}
|
||||
></textarea>
|
||||
<p class="hint">{$_('migration.offline.rotationKey.hint')}</p>
|
||||
</div>
|
||||
|
||||
{#if keyValid === true}
|
||||
<div class="message success">{$_('migration.offline.rotationKey.valid')}</div>
|
||||
{:else if keyValid === false}
|
||||
<div class="message error">{$_('migration.offline.rotationKey.invalid')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button>
|
||||
<button
|
||||
disabled={!flow.state.rotationKey || validatingKey}
|
||||
onclick={validateRotationKey}
|
||||
>
|
||||
{validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'choose-handle'}
|
||||
<ChooseHandleStep
|
||||
{handleInput}
|
||||
{selectedDomain}
|
||||
{handleAvailable}
|
||||
{checkingHandle}
|
||||
email={flow.state.targetEmail}
|
||||
password={flow.state.targetPassword}
|
||||
authMethod={selectedAuthMethod}
|
||||
inviteCode={flow.state.inviteCode}
|
||||
{serverInfo}
|
||||
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
|
||||
migratingFromValue={flow.state.userDid}
|
||||
{loading}
|
||||
onHandleChange={(h) => handleInput = h}
|
||||
onDomainChange={(d) => selectedDomain = d}
|
||||
onCheckHandle={checkHandle}
|
||||
onEmailChange={(e) => flow.setTargetEmail(e)}
|
||||
onPasswordChange={(p) => flow.setTargetPassword(p)}
|
||||
onAuthMethodChange={(m) => selectedAuthMethod = m}
|
||||
onInviteCodeChange={(c) => flow.setInviteCode(c)}
|
||||
onBack={() => flow.setStep('provide-rotation-key')}
|
||||
onContinue={proceedToReview}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'review'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.review.title')}</h2>
|
||||
<p>{$_('migration.offline.review.desc')}</p>
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.inbound.review.did')}:</span>
|
||||
<span class="value mono">{flow.state.userDid}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.inbound.review.newHandle')}:</span>
|
||||
<span class="value">{flow.state.targetHandle}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.offline.review.carFile')}:</span>
|
||||
<span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.offline.review.rotationKey')}:</span>
|
||||
<span class="value mono">{flow.state.rotationKeyDidKey}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.inbound.review.targetPds')}:</span>
|
||||
<span class="value">{window.location.origin}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.inbound.review.email')}:</span>
|
||||
<span class="value">{flow.state.targetEmail}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">{$_('migration.inbound.review.authentication')}:</span>
|
||||
<span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.offline.review.plcWarningTitle')}</strong>
|
||||
<p>{$_('migration.offline.review.plcWarning')}</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button>
|
||||
<button onclick={startMigration} disabled={loading}>
|
||||
{loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'creating' || flow.state.step === 'importing'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.offline.migrating.title')}</h2>
|
||||
<p>{$_('migration.offline.migrating.desc')}</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}>
|
||||
<span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span>
|
||||
<span>{$_('migration.offline.migrating.creating')}</span>
|
||||
</div>
|
||||
<div class="progress-item" class:active={flow.state.step === 'importing'}>
|
||||
<span class="icon">○</span>
|
||||
<span>{$_('migration.offline.migrating.importing')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'migrating-blobs'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.offline.blobs.title')}</h2>
|
||||
<p>{$_('migration.offline.blobs.desc')}</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item completed">
|
||||
<span class="icon">✓</span>
|
||||
<span>{$_('migration.offline.migrating.importing')}</span>
|
||||
</div>
|
||||
<div class="progress-item active">
|
||||
<span class="icon">○</span>
|
||||
<span>{$_('migration.offline.blobs.migrating')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flow.state.progress.blobsTotal > 0}
|
||||
<div class="blob-progress">
|
||||
<div class="blob-progress-bar">
|
||||
<div
|
||||
class="blob-progress-fill"
|
||||
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="blob-progress-text">
|
||||
{flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
|
||||
{#if flow.state.progress.blobsFailed.length > 0}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('migration.offline.blobs.failedTitle')}</strong>
|
||||
<p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'email-verify'}
|
||||
<EmailVerifyStep
|
||||
email={flow.state.targetEmail}
|
||||
token={flow.state.emailVerifyToken}
|
||||
{loading}
|
||||
error={flow.state.error}
|
||||
onTokenChange={(t) => flow.updateField('emailVerifyToken', t)}
|
||||
onSubmit={submitEmailVerify}
|
||||
onResend={resendEmailVerify}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'passkey-setup'}
|
||||
<PasskeySetupStep
|
||||
{passkeyName}
|
||||
{loading}
|
||||
error={flow.state.error}
|
||||
onPasskeyNameChange={(n) => passkeyName = n}
|
||||
onRegister={registerPasskey}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'app-password'}
|
||||
<AppPasswordStep
|
||||
appPassword={flow.state.generatedAppPassword || ''}
|
||||
appPasswordName={flow.state.generatedAppPasswordName || ''}
|
||||
{loading}
|
||||
onContinue={handleProceedFromAppPassword}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'}
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.finalizing.title')}</h2>
|
||||
<p>{$_('migration.inbound.finalizing.desc')}</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
|
||||
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
|
||||
<span>{$_('migration.inbound.finalizing.signingPlc')}</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.activated}>
|
||||
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
|
||||
<span>{$_('migration.inbound.finalizing.activating')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'success'}
|
||||
<SuccessStep
|
||||
handle={flow.state.targetHandle}
|
||||
did={flow.state.userDid}
|
||||
description={$_('migration.offline.success.desc')}
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'error'}
|
||||
<ErrorStep error={flow.state.error} onStartOver={onBack} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { OutboundMigrationFlow } from '../../lib/migration'
|
||||
import type { ServerDescription } from '../../lib/migration/types'
|
||||
import { getAuthState, logout } from '../../lib/auth.svelte'
|
||||
import '../../styles/migration.css'
|
||||
|
||||
interface Props {
|
||||
flow: OutboundMigrationFlow
|
||||
onBack: () => void
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
let { flow, onBack, onComplete }: Props = $props()
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let loading = $state(false)
|
||||
let understood = $state(false)
|
||||
let pdsUrlInput = $state('')
|
||||
let handleInput = $state('')
|
||||
let selectedDomain = $state('')
|
||||
let confirmFinal = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.step === 'success') {
|
||||
setTimeout(async () => {
|
||||
await logout()
|
||||
onComplete()
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (flow.state.targetServerInfo?.availableUserDomains?.length) {
|
||||
selectedDomain = flow.state.targetServerInfo.availableUserDomains[0]
|
||||
}
|
||||
})
|
||||
|
||||
async function validatePds(e: Event) {
|
||||
e.preventDefault()
|
||||
loading = true
|
||||
flow.updateField('error', null)
|
||||
|
||||
try {
|
||||
let url = pdsUrlInput.trim()
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
await flow.validateTargetPds(url)
|
||||
flow.setStep('new-account')
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function proceedToReview() {
|
||||
const fullHandle = handleInput.includes('.')
|
||||
? handleInput
|
||||
: `${handleInput}.${selectedDomain}`
|
||||
|
||||
flow.updateField('targetHandle', fullHandle)
|
||||
flow.setStep('review')
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
try {
|
||||
await flow.startMigration(auth.session.did)
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPlcToken(e: Event) {
|
||||
e.preventDefault()
|
||||
loading = true
|
||||
try {
|
||||
await flow.submitPlcToken(flow.state.plcToken)
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resendToken() {
|
||||
loading = true
|
||||
try {
|
||||
await flow.resendPlcToken()
|
||||
flow.setError(null)
|
||||
} catch (err) {
|
||||
flow.setError((err as Error).message)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function isDidWeb(): boolean {
|
||||
return auth.session?.did?.startsWith('did:web:') ?? false
|
||||
}
|
||||
|
||||
const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete']
|
||||
function getCurrentStepIndex(): number {
|
||||
switch (flow.state.step) {
|
||||
case 'welcome': return -1
|
||||
case 'target-pds': return 0
|
||||
case 'new-account': return 1
|
||||
case 'review': return 2
|
||||
case 'migrating': return 3
|
||||
case 'plc-token':
|
||||
case 'finalizing': return 4
|
||||
case 'success': return 5
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="migration-wizard">
|
||||
{#if flow.state.step !== 'welcome'}
|
||||
<div class="step-indicator">
|
||||
{#each steps as stepName, i}
|
||||
<div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}>
|
||||
<div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div>
|
||||
<span class="step-label">{stepName}</span>
|
||||
</div>
|
||||
{#if i < steps.length - 1}
|
||||
<div class="step-line" class:completed={i < getCurrentStepIndex()}></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.state.error}
|
||||
<div class="migration-message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.state.step === 'welcome'}
|
||||
<div class="step-content">
|
||||
<h2>Migrate Your Account Away</h2>
|
||||
<p>This wizard will help you move your AT Protocol account from this PDS to another one.</p>
|
||||
|
||||
<div class="current-account">
|
||||
<span class="label">Current account:</span>
|
||||
<span class="value">@{auth.session?.handle}</span>
|
||||
</div>
|
||||
|
||||
{#if isDidWeb()}
|
||||
<div class="migration-warning-box">
|
||||
<strong>did:web Migration Notice</strong>
|
||||
<p>
|
||||
Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
|
||||
continue serving your DID document with an updated service endpoint pointing to your new PDS.
|
||||
</p>
|
||||
<p>
|
||||
You can return here anytime to update the forwarding if you migrate again in the future.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="migration-info-box">
|
||||
<h3>What will happen:</h3>
|
||||
<ol>
|
||||
<li>Choose your new PDS</li>
|
||||
<li>Set up your account on the new server</li>
|
||||
<li>Your repository and blobs will be transferred</li>
|
||||
<li>Verify the migration via email</li>
|
||||
<li>Your identity will be updated to point to the new PDS</li>
|
||||
<li>Your account here will be deactivated</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="migration-warning-box">
|
||||
<strong>Before you proceed:</strong>
|
||||
<ul>
|
||||
<li>You need access to the email registered with this account</li>
|
||||
<li>You will lose access to this account on this PDS</li>
|
||||
<li>Make sure you trust the destination PDS</li>
|
||||
<li>Large accounts may take several minutes to transfer</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={understood} />
|
||||
<span>I understand that my account will be moved and deactivated here</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>Cancel</button>
|
||||
<button disabled={!understood} onclick={() => flow.setStep('target-pds')}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'target-pds'}
|
||||
<div class="step-content">
|
||||
<h2>Choose Your New PDS</h2>
|
||||
<p>Enter the URL of the PDS you want to migrate to.</p>
|
||||
|
||||
<form onsubmit={validatePds}>
|
||||
<div class="migration-field">
|
||||
<label for="pds-url">PDS URL</label>
|
||||
<input
|
||||
id="pds-url"
|
||||
type="text"
|
||||
placeholder="pds.example.com"
|
||||
bind:value={pdsUrlInput}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button>
|
||||
<button type="submit" disabled={loading || !pdsUrlInput.trim()}>
|
||||
{loading ? 'Checking...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if flow.state.targetServerInfo}
|
||||
<div class="server-info">
|
||||
<h3>Connected to PDS</h3>
|
||||
<div class="info-row">
|
||||
<span class="label">Server:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
{#if flow.state.targetServerInfo.availableUserDomains.length > 0}
|
||||
<div class="info-row">
|
||||
<span class="label">Available domains:</span>
|
||||
<span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-row">
|
||||
<span class="label">Invite required:</span>
|
||||
<span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
{#if flow.state.targetServerInfo.links?.termsOfService}
|
||||
<a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener">
|
||||
Terms of Service
|
||||
</a>
|
||||
{/if}
|
||||
{#if flow.state.targetServerInfo.links?.privacyPolicy}
|
||||
<a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener">
|
||||
Privacy Policy
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'new-account'}
|
||||
<div class="step-content">
|
||||
<h2>Set Up Your New Account</h2>
|
||||
<p>Configure your account details on the new PDS.</p>
|
||||
|
||||
<div class="current-info">
|
||||
<span class="label">Migrating to:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
|
||||
<div class="migration-field">
|
||||
<label for="new-handle">New Handle</label>
|
||||
<div class="handle-input-group">
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
bind:value={handleInput}
|
||||
/>
|
||||
{#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
|
||||
<select bind:value={selectedDomain}>
|
||||
{#each flow.state.targetServerInfo.availableUserDomains as domain}
|
||||
<option value={domain}>.{domain}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p>
|
||||
</div>
|
||||
|
||||
<div class="migration-field">
|
||||
<label for="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={flow.state.targetEmail}
|
||||
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="migration-field">
|
||||
<label for="new-password">Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
placeholder="Password for your new account"
|
||||
bind:value={flow.state.targetPassword}
|
||||
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p>
|
||||
</div>
|
||||
|
||||
{#if flow.state.targetServerInfo?.inviteCodeRequired}
|
||||
<div class="migration-field">
|
||||
<label for="invite">Invite Code</label>
|
||||
<input
|
||||
id="invite"
|
||||
type="text"
|
||||
placeholder="Enter invite code"
|
||||
bind:value={flow.state.inviteCode}
|
||||
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
|
||||
required
|
||||
/>
|
||||
<p class="migration-hint">Required by this PDS to create an account</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button>
|
||||
<button
|
||||
disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword}
|
||||
onclick={proceedToReview}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'review'}
|
||||
<div class="step-content">
|
||||
<h2>Review Migration</h2>
|
||||
<p>Please confirm the details of your migration.</p>
|
||||
|
||||
<div class="review-card">
|
||||
<div class="review-row">
|
||||
<span class="label">Current Handle:</span>
|
||||
<span class="value">@{auth.session?.handle}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">New Handle:</span>
|
||||
<span class="value">@{flow.state.targetHandle}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">DID:</span>
|
||||
<span class="value mono">{auth.session?.did}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">From PDS:</span>
|
||||
<span class="value">{window.location.origin}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">To PDS:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="label">New Email:</span>
|
||||
<span class="value">{flow.state.targetEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="migration-warning-box final-warning">
|
||||
<strong>This action cannot be easily undone!</strong>
|
||||
<p>
|
||||
After migration completes, your account on this PDS will be deactivated.
|
||||
To return, you would need to migrate back from the new PDS.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={confirmFinal} />
|
||||
<span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span>
|
||||
</label>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button>
|
||||
<button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}>
|
||||
{loading ? 'Starting...' : 'Start Migration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'migrating'}
|
||||
<div class="step-content">
|
||||
<h2>Migration in Progress</h2>
|
||||
<p>Please wait while your account is being transferred...</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item" class:completed={flow.state.progress.repoExported}>
|
||||
<span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span>
|
||||
<span>Export repository</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.repoImported}>
|
||||
<span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span>
|
||||
<span>Import repository to new PDS</span>
|
||||
</div>
|
||||
<div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}>
|
||||
<span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span>
|
||||
<span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.prefsMigrated}>
|
||||
<span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span>
|
||||
<span>Migrate preferences</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flow.state.progress.blobsTotal > 0}
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'plc-token'}
|
||||
<div class="step-content">
|
||||
<h2>Verify Migration</h2>
|
||||
<p>A verification code has been sent to your email ({auth.session?.email}).</p>
|
||||
|
||||
<div class="migration-info-box">
|
||||
<p>
|
||||
This code confirms you have access to the account and authorizes updating your identity
|
||||
to point to the new PDS.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={submitPlcToken}>
|
||||
<div class="migration-field">
|
||||
<label for="plc-token">Verification Code</label>
|
||||
<input
|
||||
id="plc-token"
|
||||
type="text"
|
||||
placeholder="Enter code from email"
|
||||
bind:value={flow.state.plcToken}
|
||||
oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button type="button" class="ghost" onclick={resendToken} disabled={loading}>
|
||||
Resend Code
|
||||
</button>
|
||||
<button type="submit" disabled={loading || !flow.state.plcToken}>
|
||||
{loading ? 'Verifying...' : 'Complete Migration'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'finalizing'}
|
||||
<div class="step-content">
|
||||
<h2>Finalizing Migration</h2>
|
||||
<p>Please wait while we complete the migration...</p>
|
||||
|
||||
<div class="progress-section">
|
||||
<div class="progress-item" class:completed={flow.state.progress.plcSigned}>
|
||||
<span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span>
|
||||
<span>Sign identity update</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.activated}>
|
||||
<span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span>
|
||||
<span>Activate account on new PDS</span>
|
||||
</div>
|
||||
<div class="progress-item" class:completed={flow.state.progress.deactivated}>
|
||||
<span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span>
|
||||
<span>Deactivate account here</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-text">{flow.state.progress.currentOperation}</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'success'}
|
||||
<div class="step-content success-content">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>Migration Complete!</h2>
|
||||
<p>Your account has been successfully migrated to your new PDS.</p>
|
||||
|
||||
<div class="success-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">Your new handle:</span>
|
||||
<span class="value">@{flow.state.targetHandle}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">New PDS:</span>
|
||||
<span class="value">{flow.state.targetPdsUrl}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">DID:</span>
|
||||
<span class="value mono">{auth.session?.did}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if flow.state.progress.blobsFailed.length > 0}
|
||||
<div class="migration-warning-box">
|
||||
<strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated.
|
||||
These may be images or other media that are no longer available.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="next-steps">
|
||||
<h3>Next Steps</h3>
|
||||
<ol>
|
||||
<li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li>
|
||||
<li>Log in with your new credentials</li>
|
||||
<li>Your followers and following will continue to work</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p class="redirect-text">Logging out in a moment...</p>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'error'}
|
||||
<div class="step-content">
|
||||
<h2>Migration Error</h2>
|
||||
<p>An error occurred during migration.</p>
|
||||
|
||||
<div class="migration-error-box">
|
||||
{flow.state.error}
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="ghost" onclick={onBack}>Start Over</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
passkeyName: string
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onPasskeyNameChange: (name: string) => void
|
||||
onRegister: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
passkeyName,
|
||||
loading,
|
||||
error,
|
||||
onPasskeyNameChange,
|
||||
onRegister,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="step-content">
|
||||
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
|
||||
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
|
||||
<input
|
||||
id="passkey-name"
|
||||
type="text"
|
||||
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
|
||||
value={passkeyName}
|
||||
oninput={(e) => onPasskeyNameChange((e.target as HTMLInputElement).value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="passkey-section">
|
||||
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
|
||||
<button class="primary" onclick={onRegister} disabled={loading}>
|
||||
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.passkey-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.passkey-section button {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { _ } from '../../lib/i18n'
|
||||
|
||||
interface Props {
|
||||
handle: string
|
||||
did: string
|
||||
description?: string
|
||||
extraContent?: Snippet
|
||||
}
|
||||
|
||||
let { handle, did, description, extraContent }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="step-content success-content">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>{$_('migration.inbound.success.title')}</h2>
|
||||
<p>{description || $_('migration.inbound.success.desc')}</p>
|
||||
|
||||
<div class="success-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
|
||||
<span class="value">{handle}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="label">{$_('migration.inbound.success.did')}:</span>
|
||||
<span class="value mono">{did}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if extraContent}
|
||||
{@render extraContent()}
|
||||
{/if}
|
||||
|
||||
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
|
||||
</div>
|
||||
+155
-47
@@ -205,6 +205,38 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async createAccountWithServiceAuth(
|
||||
serviceAuthToken: string,
|
||||
params: {
|
||||
did: string;
|
||||
handle: string;
|
||||
email: string;
|
||||
password: string;
|
||||
inviteCode?: string;
|
||||
},
|
||||
): Promise<Session> {
|
||||
const url = `${API_BASE}/com.atproto.server.createAccount`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${serviceAuthToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
did: params.did,
|
||||
handle: params.handle,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
inviteCode: params.inviteCode,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, data.error, data.message);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
async confirmSignup(
|
||||
did: string,
|
||||
verificationCode: string,
|
||||
@@ -229,6 +261,13 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
|
||||
return xrpc("_checkEmailVerified", {
|
||||
method: "POST",
|
||||
body: { identifier },
|
||||
});
|
||||
},
|
||||
|
||||
async getSession(token: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.getSession", { token });
|
||||
},
|
||||
@@ -379,7 +418,7 @@ export const api = {
|
||||
signalNumber: string | null;
|
||||
signalVerified: boolean;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.getNotificationPrefs", { token });
|
||||
return xrpc("_account.getNotificationPrefs", { token });
|
||||
},
|
||||
|
||||
async updateNotificationPrefs(token: string, prefs: {
|
||||
@@ -388,7 +427,7 @@ export const api = {
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.updateNotificationPrefs", {
|
||||
return xrpc("_account.updateNotificationPrefs", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: prefs,
|
||||
@@ -401,7 +440,7 @@ export const api = {
|
||||
identifier: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.confirmChannelVerification", {
|
||||
return xrpc("_account.confirmChannelVerification", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { channel, identifier, code },
|
||||
@@ -418,7 +457,7 @@ export const api = {
|
||||
body: string;
|
||||
}>;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.getNotificationHistory", { token });
|
||||
return xrpc("_account.getNotificationHistory", { token });
|
||||
},
|
||||
|
||||
async getServerStats(token: string): Promise<{
|
||||
@@ -427,7 +466,7 @@ export const api = {
|
||||
recordCount: number;
|
||||
blobStorageBytes: number;
|
||||
}> {
|
||||
return xrpc("com.tranquil.admin.getServerStats", { token });
|
||||
return xrpc("_admin.getServerStats", { token });
|
||||
},
|
||||
|
||||
async getServerConfig(): Promise<{
|
||||
@@ -438,7 +477,7 @@ export const api = {
|
||||
secondaryColorDark: string | null;
|
||||
logoCid: string | null;
|
||||
}> {
|
||||
return xrpc("com.tranquil.server.getConfig");
|
||||
return xrpc("_server.getConfig");
|
||||
},
|
||||
|
||||
async updateServerConfig(
|
||||
@@ -452,7 +491,7 @@ export const api = {
|
||||
logoCid?: string;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.admin.updateServerConfig", {
|
||||
return xrpc("_admin.updateServerConfig", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: config,
|
||||
@@ -495,7 +534,7 @@ export const api = {
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
await xrpc("com.tranquil.account.changePassword", {
|
||||
await xrpc("_account.changePassword", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { currentPassword, newPassword },
|
||||
@@ -503,27 +542,27 @@ export const api = {
|
||||
},
|
||||
|
||||
async removePassword(token: string): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.removePassword", {
|
||||
return xrpc("_account.removePassword", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
|
||||
return xrpc("com.tranquil.account.getPasswordStatus", { token });
|
||||
return xrpc("_account.getPasswordStatus", { token });
|
||||
},
|
||||
|
||||
async getLegacyLoginPreference(
|
||||
token: string,
|
||||
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
|
||||
return xrpc("com.tranquil.account.getLegacyLoginPreference", { token });
|
||||
return xrpc("_account.getLegacyLoginPreference", { token });
|
||||
},
|
||||
|
||||
async updateLegacyLoginPreference(
|
||||
token: string,
|
||||
allowLegacyLogin: boolean,
|
||||
): Promise<{ allowLegacyLogin: boolean }> {
|
||||
return xrpc("com.tranquil.account.updateLegacyLoginPreference", {
|
||||
return xrpc("_account.updateLegacyLoginPreference", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { allowLegacyLogin },
|
||||
@@ -534,7 +573,7 @@ export const api = {
|
||||
token: string,
|
||||
preferredLocale: string,
|
||||
): Promise<{ preferredLocale: string }> {
|
||||
return xrpc("com.tranquil.account.updateLocale", {
|
||||
return xrpc("_account.updateLocale", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { preferredLocale },
|
||||
@@ -551,11 +590,11 @@ export const api = {
|
||||
isCurrent: boolean;
|
||||
}>;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.listSessions", { token });
|
||||
return xrpc("_account.listSessions", { token });
|
||||
},
|
||||
|
||||
async revokeSession(token: string, sessionId: string): Promise<void> {
|
||||
await xrpc("com.tranquil.account.revokeSession", {
|
||||
await xrpc("_account.revokeSession", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { sessionId },
|
||||
@@ -563,7 +602,7 @@ export const api = {
|
||||
},
|
||||
|
||||
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
|
||||
return xrpc("com.tranquil.account.revokeAllSessions", {
|
||||
return xrpc("_account.revokeAllSessions", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
@@ -868,14 +907,14 @@ export const api = {
|
||||
lastSeenAt: string;
|
||||
}>;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.listTrustedDevices", { token });
|
||||
return xrpc("_account.listTrustedDevices", { token });
|
||||
},
|
||||
|
||||
async revokeTrustedDevice(
|
||||
token: string,
|
||||
deviceId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.revokeTrustedDevice", {
|
||||
return xrpc("_account.revokeTrustedDevice", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { deviceId },
|
||||
@@ -887,7 +926,7 @@ export const api = {
|
||||
deviceId: string,
|
||||
friendlyName: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.updateTrustedDevice", {
|
||||
return xrpc("_account.updateTrustedDevice", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { deviceId, friendlyName },
|
||||
@@ -899,14 +938,14 @@ export const api = {
|
||||
lastReauthAt: string | null;
|
||||
availableMethods: string[];
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.getReauthStatus", { token });
|
||||
return xrpc("_account.getReauthStatus", { token });
|
||||
},
|
||||
|
||||
async reauthPassword(
|
||||
token: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
return xrpc("com.tranquil.account.reauthPassword", {
|
||||
return xrpc("_account.reauthPassword", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { password },
|
||||
@@ -917,7 +956,7 @@ export const api = {
|
||||
token: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
return xrpc("com.tranquil.account.reauthTotp", {
|
||||
return xrpc("_account.reauthTotp", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { code },
|
||||
@@ -925,7 +964,7 @@ export const api = {
|
||||
},
|
||||
|
||||
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
|
||||
return xrpc("com.tranquil.account.reauthPasskeyStart", {
|
||||
return xrpc("_account.reauthPasskeyStart", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
@@ -935,7 +974,7 @@ export const api = {
|
||||
token: string,
|
||||
credential: unknown,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
return xrpc("com.tranquil.account.reauthPasskeyFinish", {
|
||||
return xrpc("_account.reauthPasskeyFinish", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { credential },
|
||||
@@ -982,7 +1021,7 @@ export const api = {
|
||||
setupToken: string;
|
||||
setupExpiresAt: string;
|
||||
}> {
|
||||
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`;
|
||||
const url = `${API_BASE}/_account.createPasskeyAccount`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
@@ -1009,7 +1048,7 @@ export const api = {
|
||||
setupToken: string,
|
||||
friendlyName?: string,
|
||||
): Promise<{ options: unknown }> {
|
||||
return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
|
||||
return xrpc("_account.startPasskeyRegistrationForSetup", {
|
||||
method: "POST",
|
||||
body: { did, setupToken, friendlyName },
|
||||
});
|
||||
@@ -1026,14 +1065,14 @@ export const api = {
|
||||
appPassword: string;
|
||||
appPasswordName: string;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.completePasskeySetup", {
|
||||
return xrpc("_account.completePasskeySetup", {
|
||||
method: "POST",
|
||||
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
|
||||
});
|
||||
},
|
||||
|
||||
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.requestPasskeyRecovery", {
|
||||
return xrpc("_account.requestPasskeyRecovery", {
|
||||
method: "POST",
|
||||
body: { email },
|
||||
});
|
||||
@@ -1044,7 +1083,7 @@ export const api = {
|
||||
recoveryToken: string,
|
||||
newPassword: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.recoverPasskeyAccount", {
|
||||
return xrpc("_account.recoverPasskeyAccount", {
|
||||
method: "POST",
|
||||
body: { did, recoveryToken, newPassword },
|
||||
});
|
||||
@@ -1077,7 +1116,7 @@ export const api = {
|
||||
purpose: string;
|
||||
channel: string;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.verifyToken", {
|
||||
return xrpc("_account.verifyToken", {
|
||||
method: "POST",
|
||||
body: { token, identifier },
|
||||
token: accessToken,
|
||||
@@ -1085,7 +1124,7 @@ export const api = {
|
||||
},
|
||||
|
||||
async getDidDocument(token: string): Promise<DidDocument> {
|
||||
return xrpc("com.tranquil.account.getDidDocument", { token });
|
||||
return xrpc("_account.getDidDocument", { token });
|
||||
},
|
||||
|
||||
async updateDidDocument(
|
||||
@@ -1096,7 +1135,7 @@ export const api = {
|
||||
serviceEndpoint?: string;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.updateDidDocument", {
|
||||
return xrpc("_account.updateDidDocument", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: params,
|
||||
@@ -1106,38 +1145,107 @@ export const api = {
|
||||
async deactivateAccount(
|
||||
token: string,
|
||||
deleteAfter?: string,
|
||||
migratingTo?: string,
|
||||
): Promise<void> {
|
||||
await xrpc("com.atproto.server.deactivateAccount", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { deleteAfter, migratingTo },
|
||||
body: { deleteAfter },
|
||||
});
|
||||
},
|
||||
|
||||
async getMigrationStatus(token: string): Promise<{
|
||||
migratedToPds?: string;
|
||||
migratedAt?: string;
|
||||
forwardingEnabled: boolean;
|
||||
async getRepo(token: string, did: string): Promise<ArrayBuffer> {
|
||||
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
|
||||
encodeURIComponent(did)
|
||||
}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({
|
||||
error: "Unknown",
|
||||
message: res.statusText,
|
||||
}));
|
||||
throw new ApiError(res.status, err.error, err.message);
|
||||
}
|
||||
return res.arrayBuffer();
|
||||
},
|
||||
|
||||
async listBackups(token: string): Promise<{
|
||||
backups: Array<{
|
||||
id: string;
|
||||
repoRev: string;
|
||||
repoRootCid: string;
|
||||
blockCount: number;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
backupEnabled: boolean;
|
||||
}> {
|
||||
return xrpc("com.tranquil.account.getMigrationStatus", { token });
|
||||
return xrpc("_backup.listBackups", { token });
|
||||
},
|
||||
|
||||
async updateMigrationForwarding(
|
||||
async getBackup(token: string, id: string): Promise<Blob> {
|
||||
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({
|
||||
error: "Unknown",
|
||||
message: res.statusText,
|
||||
}));
|
||||
throw new ApiError(res.status, err.error, err.message);
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
async createBackup(token: string): Promise<{
|
||||
id: string;
|
||||
repoRev: string;
|
||||
sizeBytes: number;
|
||||
blockCount: number;
|
||||
}> {
|
||||
return xrpc("_backup.createBackup", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async deleteBackup(token: string, id: string): Promise<void> {
|
||||
await xrpc("_backup.deleteBackup", {
|
||||
method: "POST",
|
||||
token,
|
||||
params: { id },
|
||||
});
|
||||
},
|
||||
|
||||
async setBackupEnabled(
|
||||
token: string,
|
||||
forwardingPds?: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.updateMigrationForwarding", {
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
return xrpc("_backup.setEnabled", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { forwardingPds },
|
||||
body: { enabled },
|
||||
});
|
||||
},
|
||||
|
||||
async clearMigrationForwarding(token: string): Promise<{ success: boolean }> {
|
||||
return xrpc("com.tranquil.account.clearMigrationForwarding", {
|
||||
async importRepo(token: string, car: Uint8Array): Promise<void> {
|
||||
const url = `${API_BASE}/com.atproto.repo.importRepo`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
token,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/vnd.ipld.car",
|
||||
},
|
||||
body: car,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({
|
||||
error: "Unknown",
|
||||
message: res.statusText,
|
||||
}));
|
||||
throw new ApiError(res.status, err.error, err.message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -372,23 +372,15 @@ export class AtprotoClient {
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateAccount(migratingTo?: string): Promise<void> {
|
||||
async deactivateAccount(): Promise<void> {
|
||||
apiLog(
|
||||
"POST",
|
||||
`${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`,
|
||||
{
|
||||
migratingTo,
|
||||
},
|
||||
);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const body: { migratingTo?: string } = {};
|
||||
if (migratingTo) {
|
||||
body.migratingTo = migratingTo;
|
||||
}
|
||||
await this.xrpc("com.atproto.server.deactivateAccount", {
|
||||
httpMethod: "POST",
|
||||
body,
|
||||
});
|
||||
apiLog(
|
||||
"POST",
|
||||
@@ -396,7 +388,6 @@ export class AtprotoClient {
|
||||
{
|
||||
durationMs: Date.now() - start,
|
||||
success: true,
|
||||
migratingTo,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -409,7 +400,6 @@ export class AtprotoClient {
|
||||
error: err.message,
|
||||
errorCode: err.error,
|
||||
status: err.status,
|
||||
migratingTo,
|
||||
},
|
||||
);
|
||||
throw e;
|
||||
@@ -420,33 +410,6 @@ export class AtprotoClient {
|
||||
return this.xrpc("com.atproto.server.checkAccountStatus");
|
||||
}
|
||||
|
||||
async getMigrationStatus(): Promise<{
|
||||
did: string;
|
||||
didType: string;
|
||||
migrated: boolean;
|
||||
migratedToPds?: string;
|
||||
migratedAt?: string;
|
||||
}> {
|
||||
return this.xrpc("com.tranquil.account.getMigrationStatus");
|
||||
}
|
||||
|
||||
async updateMigrationForwarding(pdsUrl: string): Promise<{
|
||||
success: boolean;
|
||||
migratedToPds: string;
|
||||
migratedAt: string;
|
||||
}> {
|
||||
return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
|
||||
httpMethod: "POST",
|
||||
body: { pdsUrl },
|
||||
});
|
||||
}
|
||||
|
||||
async clearMigrationForwarding(): Promise<{ success: boolean }> {
|
||||
return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
|
||||
httpMethod: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async resolveHandle(handle: string): Promise<{ did: string }> {
|
||||
return this.xrpc("com.atproto.identity.resolveHandle", {
|
||||
params: { handle },
|
||||
@@ -468,13 +431,24 @@ export class AtprotoClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async checkEmailVerified(identifier: string): Promise<boolean> {
|
||||
const result = await this.xrpc<{ verified: boolean }>(
|
||||
"_checkEmailVerified",
|
||||
{
|
||||
httpMethod: "POST",
|
||||
body: { identifier },
|
||||
},
|
||||
);
|
||||
return result.verified;
|
||||
}
|
||||
|
||||
async verifyToken(
|
||||
token: string,
|
||||
identifier: string,
|
||||
): Promise<
|
||||
{ success: boolean; did: string; purpose: string; channel: string }
|
||||
> {
|
||||
return this.xrpc("com.tranquil.account.verifyToken", {
|
||||
return this.xrpc("_account.verifyToken", {
|
||||
httpMethod: "POST",
|
||||
body: { token, identifier },
|
||||
});
|
||||
@@ -498,7 +472,7 @@ export class AtprotoClient {
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`,
|
||||
`${this.baseUrl}/xrpc/_account.createPasskeyAccount`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
@@ -530,7 +504,7 @@ export class AtprotoClient {
|
||||
setupToken: string,
|
||||
friendlyName?: string,
|
||||
): Promise<StartPasskeyRegistrationResponse> {
|
||||
return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
|
||||
return this.xrpc("_account.startPasskeyRegistrationForSetup", {
|
||||
httpMethod: "POST",
|
||||
body: { did, setupToken, friendlyName },
|
||||
});
|
||||
@@ -542,7 +516,7 @@ export class AtprotoClient {
|
||||
passkeyCredential: unknown,
|
||||
passkeyFriendlyName?: string,
|
||||
): Promise<CompletePasskeySetupResponse> {
|
||||
return this.xrpc("com.tranquil.account.completePasskeySetup", {
|
||||
return this.xrpc("_account.completePasskeySetup", {
|
||||
httpMethod: "POST",
|
||||
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AtprotoClient } from "./atproto-client";
|
||||
import type { MigrationProgress } from "./types";
|
||||
|
||||
export interface BlobMigrationResult {
|
||||
migrated: number;
|
||||
failed: string[];
|
||||
total: number;
|
||||
sourceUnreachable: boolean;
|
||||
}
|
||||
|
||||
export async function migrateBlobs(
|
||||
localClient: AtprotoClient,
|
||||
sourceClient: AtprotoClient | null,
|
||||
userDid: string,
|
||||
onProgress: (update: Partial<MigrationProgress>) => void,
|
||||
): Promise<BlobMigrationResult> {
|
||||
const missingBlobs: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
console.log("[blob-migration] Starting blob migration for", userDid);
|
||||
console.log(
|
||||
"[blob-migration] Source client:",
|
||||
sourceClient ? "available" : "NOT AVAILABLE",
|
||||
);
|
||||
|
||||
onProgress({ currentOperation: "Checking for missing blobs..." });
|
||||
|
||||
do {
|
||||
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
|
||||
cursor,
|
||||
100,
|
||||
);
|
||||
console.log(
|
||||
"[blob-migration] listMissingBlobs returned",
|
||||
blobs.length,
|
||||
"blobs, cursor:",
|
||||
nextCursor,
|
||||
);
|
||||
for (const blob of blobs) {
|
||||
missingBlobs.push(blob.cid);
|
||||
}
|
||||
cursor = nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
console.log("[blob-migration] Total missing blobs:", missingBlobs.length);
|
||||
onProgress({ blobsTotal: missingBlobs.length });
|
||||
|
||||
if (missingBlobs.length === 0) {
|
||||
console.log("[blob-migration] No blobs to migrate");
|
||||
onProgress({ currentOperation: "No blobs to migrate" });
|
||||
return { migrated: 0, failed: [], total: 0, sourceUnreachable: false };
|
||||
}
|
||||
|
||||
if (!sourceClient) {
|
||||
console.warn(
|
||||
"[blob-migration] No source client available, cannot fetch blobs",
|
||||
);
|
||||
onProgress({
|
||||
currentOperation:
|
||||
`${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.`,
|
||||
});
|
||||
return {
|
||||
migrated: 0,
|
||||
failed: missingBlobs,
|
||||
total: missingBlobs.length,
|
||||
sourceUnreachable: true,
|
||||
};
|
||||
}
|
||||
|
||||
onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` });
|
||||
|
||||
let migrated = 0;
|
||||
const failed: string[] = [];
|
||||
let sourceUnreachable = false;
|
||||
|
||||
for (const cid of missingBlobs) {
|
||||
if (sourceUnreachable) {
|
||||
failed.push(cid);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
onProgress({
|
||||
currentOperation: `Migrating blob ${
|
||||
migrated + 1
|
||||
}/${missingBlobs.length}...`,
|
||||
});
|
||||
|
||||
console.log("[blob-migration] Fetching blob", cid, "from source");
|
||||
const blobData = await sourceClient.getBlob(userDid, cid);
|
||||
console.log(
|
||||
"[blob-migration] Got blob",
|
||||
cid,
|
||||
"size:",
|
||||
blobData.byteLength,
|
||||
);
|
||||
await localClient.uploadBlob(blobData, "application/octet-stream");
|
||||
console.log("[blob-migration] Uploaded blob", cid);
|
||||
migrated++;
|
||||
onProgress({ blobsMigrated: migrated });
|
||||
} catch (e) {
|
||||
const errorMessage = (e as Error).message || String(e);
|
||||
console.error(
|
||||
"[blob-migration] Failed to migrate blob",
|
||||
cid,
|
||||
":",
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
const isNetworkError =
|
||||
errorMessage.includes("fetch") ||
|
||||
errorMessage.includes("network") ||
|
||||
errorMessage.includes("CORS") ||
|
||||
errorMessage.includes("Failed to fetch") ||
|
||||
errorMessage.includes("NetworkError") ||
|
||||
errorMessage.includes("blocked by CORS");
|
||||
|
||||
if (isNetworkError) {
|
||||
sourceUnreachable = true;
|
||||
console.warn(
|
||||
"[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs",
|
||||
);
|
||||
const remaining = missingBlobs.length - migrated - 1;
|
||||
if (migrated > 0) {
|
||||
onProgress({
|
||||
currentOperation:
|
||||
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`,
|
||||
});
|
||||
} else {
|
||||
onProgress({
|
||||
currentOperation:
|
||||
`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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
failed.push(cid);
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated === missingBlobs.length) {
|
||||
onProgress({
|
||||
currentOperation: `All ${migrated} blobs migrated successfully`,
|
||||
});
|
||||
} else if (migrated > 0) {
|
||||
onProgress({
|
||||
currentOperation:
|
||||
`${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`,
|
||||
});
|
||||
} else {
|
||||
onProgress({
|
||||
currentOperation: `Could not migrate blobs (${failed.length} missing)`,
|
||||
});
|
||||
}
|
||||
|
||||
return { migrated, failed, total: missingBlobs.length, sourceUnreachable };
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import type {
|
||||
InboundMigrationState,
|
||||
InboundStep,
|
||||
MigrationProgress,
|
||||
OutboundMigrationState,
|
||||
OutboundStep,
|
||||
PasskeyAccountSetup,
|
||||
ServerDescription,
|
||||
StoredMigrationState,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
updateProgress,
|
||||
updateStep,
|
||||
} from "./storage";
|
||||
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
|
||||
|
||||
function migrationLog(stage: string, data?: Record<string, unknown>) {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -85,12 +84,16 @@ export function createInboundMigrationFlow() {
|
||||
let sourceClient: AtprotoClient | null = null;
|
||||
let localClient: AtprotoClient | null = null;
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> =
|
||||
null;
|
||||
|
||||
function setStep(step: InboundStep) {
|
||||
state.step = step;
|
||||
state.error = null;
|
||||
saveMigrationState(state);
|
||||
updateStep(step);
|
||||
if (step !== "success") {
|
||||
saveMigrationState(state);
|
||||
updateStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
function setError(error: string) {
|
||||
@@ -458,37 +461,14 @@ export function createInboundMigrationFlow() {
|
||||
async function migrateBlobs(): Promise<void> {
|
||||
if (!sourceClient || !localClient) return;
|
||||
|
||||
let cursor: string | undefined;
|
||||
let migrated = 0;
|
||||
const result = await migrateBlobsUtil(
|
||||
localClient,
|
||||
sourceClient,
|
||||
state.sourceDid,
|
||||
setProgress,
|
||||
);
|
||||
|
||||
do {
|
||||
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
|
||||
cursor,
|
||||
100,
|
||||
);
|
||||
|
||||
for (const blob of blobs) {
|
||||
try {
|
||||
setProgress({
|
||||
currentOperation: `Migrating blob ${
|
||||
migrated + 1
|
||||
}/${state.progress.blobsTotal}...`,
|
||||
});
|
||||
|
||||
const blobData = await sourceClient.getBlob(
|
||||
state.sourceDid,
|
||||
blob.cid,
|
||||
);
|
||||
await localClient.uploadBlob(blobData, "application/octet-stream");
|
||||
migrated++;
|
||||
setProgress({ blobsMigrated: migrated });
|
||||
} catch {
|
||||
state.progress.blobsFailed.push(blob.cid);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
} while (cursor);
|
||||
state.progress.blobsFailed = result.failed;
|
||||
}
|
||||
|
||||
async function migratePreferences(): Promise<void> {
|
||||
@@ -578,6 +558,9 @@ export function createInboundMigrationFlow() {
|
||||
|
||||
checkingEmailVerification = true;
|
||||
try {
|
||||
const verified = await localClient.checkEmailVerified(state.targetEmail);
|
||||
if (!verified) return false;
|
||||
|
||||
await localClient.loginDeactivated(
|
||||
state.targetEmail,
|
||||
state.targetPassword,
|
||||
@@ -978,290 +961,6 @@ export function createInboundMigrationFlow() {
|
||||
};
|
||||
}
|
||||
|
||||
export function createOutboundMigrationFlow() {
|
||||
let state = $state<OutboundMigrationState>({
|
||||
direction: "outbound",
|
||||
step: "welcome",
|
||||
localDid: "",
|
||||
localHandle: "",
|
||||
targetPdsUrl: "",
|
||||
targetPdsDid: "",
|
||||
targetHandle: "",
|
||||
targetEmail: "",
|
||||
targetPassword: "",
|
||||
inviteCode: "",
|
||||
targetAccessToken: null,
|
||||
targetRefreshToken: null,
|
||||
serviceAuthToken: null,
|
||||
plcToken: "",
|
||||
progress: createInitialProgress(),
|
||||
error: null,
|
||||
targetServerInfo: null,
|
||||
});
|
||||
|
||||
let localClient: AtprotoClient | null = null;
|
||||
let targetClient: AtprotoClient | null = null;
|
||||
|
||||
function setStep(step: OutboundStep) {
|
||||
state.step = step;
|
||||
state.error = null;
|
||||
saveMigrationState(state);
|
||||
updateStep(step);
|
||||
}
|
||||
|
||||
function setError(error: string) {
|
||||
state.error = error;
|
||||
saveMigrationState(state);
|
||||
}
|
||||
|
||||
function setProgress(updates: Partial<MigrationProgress>) {
|
||||
state.progress = { ...state.progress, ...updates };
|
||||
updateProgress(updates);
|
||||
}
|
||||
|
||||
async function validateTargetPds(url: string): Promise<ServerDescription> {
|
||||
const normalizedUrl = url.replace(/\/$/, "");
|
||||
targetClient = new AtprotoClient(normalizedUrl);
|
||||
|
||||
try {
|
||||
const serverInfo = await targetClient.describeServer();
|
||||
state.targetPdsUrl = normalizedUrl;
|
||||
state.targetPdsDid = serverInfo.did;
|
||||
state.targetServerInfo = serverInfo;
|
||||
return serverInfo;
|
||||
} catch (e) {
|
||||
throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function initLocalClient(
|
||||
accessToken: string,
|
||||
did?: string,
|
||||
handle?: string,
|
||||
): void {
|
||||
localClient = createLocalClient();
|
||||
localClient.setAccessToken(accessToken);
|
||||
if (did) {
|
||||
state.localDid = did;
|
||||
}
|
||||
if (handle) {
|
||||
state.localHandle = handle;
|
||||
}
|
||||
}
|
||||
|
||||
async function startMigration(currentDid: string): Promise<void> {
|
||||
if (!localClient || !targetClient) {
|
||||
throw new Error("Not connected to PDSes");
|
||||
}
|
||||
|
||||
setStep("migrating");
|
||||
setProgress({ currentOperation: "Getting service auth token..." });
|
||||
|
||||
try {
|
||||
const { token } = await localClient.getServiceAuth(
|
||||
state.targetPdsDid,
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
state.serviceAuthToken = token;
|
||||
|
||||
setProgress({ currentOperation: "Creating account on new PDS..." });
|
||||
|
||||
const accountParams = {
|
||||
did: currentDid,
|
||||
handle: state.targetHandle,
|
||||
email: state.targetEmail,
|
||||
password: state.targetPassword,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
};
|
||||
|
||||
const session = await targetClient.createAccount(accountParams, token);
|
||||
state.targetAccessToken = session.accessJwt;
|
||||
state.targetRefreshToken = session.refreshJwt;
|
||||
targetClient.setAccessToken(session.accessJwt);
|
||||
|
||||
setProgress({ currentOperation: "Exporting repository..." });
|
||||
|
||||
const car = await localClient.getRepo(currentDid);
|
||||
setProgress({
|
||||
repoExported: true,
|
||||
currentOperation: "Importing repository...",
|
||||
});
|
||||
|
||||
await targetClient.importRepo(car);
|
||||
setProgress({
|
||||
repoImported: true,
|
||||
currentOperation: "Counting blobs...",
|
||||
});
|
||||
|
||||
const accountStatus = await targetClient.checkAccountStatus();
|
||||
setProgress({
|
||||
blobsTotal: accountStatus.expectedBlobs,
|
||||
currentOperation: "Migrating blobs...",
|
||||
});
|
||||
|
||||
await migrateBlobs(currentDid);
|
||||
|
||||
setProgress({ currentOperation: "Migrating preferences..." });
|
||||
await migratePreferences();
|
||||
|
||||
setProgress({ currentOperation: "Requesting PLC operation token..." });
|
||||
await localClient.requestPlcOperationSignature();
|
||||
|
||||
setStep("plc-token");
|
||||
} catch (e) {
|
||||
const err = e as Error & { error?: string; status?: number };
|
||||
const message = err.message || err.error ||
|
||||
`Unknown error (status ${err.status || "unknown"})`;
|
||||
setError(message);
|
||||
setStep("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateBlobs(did: string): Promise<void> {
|
||||
if (!localClient || !targetClient) return;
|
||||
|
||||
let cursor: string | undefined;
|
||||
let migrated = 0;
|
||||
|
||||
do {
|
||||
const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
|
||||
cursor,
|
||||
100,
|
||||
);
|
||||
|
||||
for (const blob of blobs) {
|
||||
try {
|
||||
setProgress({
|
||||
currentOperation: `Migrating blob ${
|
||||
migrated + 1
|
||||
}/${state.progress.blobsTotal}...`,
|
||||
});
|
||||
|
||||
const blobData = await localClient.getBlob(did, blob.cid);
|
||||
await targetClient.uploadBlob(blobData, "application/octet-stream");
|
||||
migrated++;
|
||||
setProgress({ blobsMigrated: migrated });
|
||||
} catch {
|
||||
state.progress.blobsFailed.push(blob.cid);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
} while (cursor);
|
||||
}
|
||||
|
||||
async function migratePreferences(): Promise<void> {
|
||||
if (!localClient || !targetClient) return;
|
||||
|
||||
try {
|
||||
const prefs = await localClient.getPreferences();
|
||||
await targetClient.putPreferences(prefs);
|
||||
setProgress({ prefsMigrated: true });
|
||||
} catch { /* optional, best-effort */ }
|
||||
}
|
||||
|
||||
async function submitPlcToken(token: string): Promise<void> {
|
||||
if (!localClient || !targetClient) {
|
||||
throw new Error("Not connected to PDSes");
|
||||
}
|
||||
|
||||
state.plcToken = token;
|
||||
setStep("finalizing");
|
||||
setProgress({ currentOperation: "Signing PLC operation..." });
|
||||
|
||||
try {
|
||||
const credentials = await targetClient.getRecommendedDidCredentials();
|
||||
|
||||
const { operation } = await localClient.signPlcOperation({
|
||||
token,
|
||||
...credentials,
|
||||
});
|
||||
|
||||
setProgress({
|
||||
plcSigned: true,
|
||||
currentOperation: "Submitting PLC operation...",
|
||||
});
|
||||
|
||||
await targetClient.submitPlcOperation(operation);
|
||||
|
||||
setProgress({ currentOperation: "Activating account on new PDS..." });
|
||||
await targetClient.activateAccount();
|
||||
setProgress({ activated: true });
|
||||
|
||||
setProgress({ currentOperation: "Deactivating old account..." });
|
||||
try {
|
||||
await localClient.deactivateAccount(state.targetPdsUrl);
|
||||
setProgress({ deactivated: true });
|
||||
} catch { /* optional, best-effort */ }
|
||||
|
||||
setStep("success");
|
||||
clearMigrationState();
|
||||
} catch (e) {
|
||||
const err = e as Error & { error?: string; status?: number };
|
||||
const message = err.message || err.error ||
|
||||
`Unknown error (status ${err.status || "unknown"})`;
|
||||
setError(message);
|
||||
setStep("plc-token");
|
||||
}
|
||||
}
|
||||
|
||||
async function resendPlcToken(): Promise<void> {
|
||||
if (!localClient) {
|
||||
throw new Error("Not connected to local PDS");
|
||||
}
|
||||
await localClient.requestPlcOperationSignature();
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
state = {
|
||||
direction: "outbound",
|
||||
step: "welcome",
|
||||
localDid: "",
|
||||
localHandle: "",
|
||||
targetPdsUrl: "",
|
||||
targetPdsDid: "",
|
||||
targetHandle: "",
|
||||
targetEmail: "",
|
||||
targetPassword: "",
|
||||
inviteCode: "",
|
||||
targetAccessToken: null,
|
||||
targetRefreshToken: null,
|
||||
serviceAuthToken: null,
|
||||
plcToken: "",
|
||||
progress: createInitialProgress(),
|
||||
error: null,
|
||||
targetServerInfo: null,
|
||||
};
|
||||
localClient = null;
|
||||
targetClient = null;
|
||||
clearMigrationState();
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
setStep,
|
||||
setError,
|
||||
validateTargetPds,
|
||||
initLocalClient,
|
||||
startMigration,
|
||||
submitPlcToken,
|
||||
resendPlcToken,
|
||||
reset,
|
||||
|
||||
updateField<K extends keyof OutboundMigrationState>(
|
||||
field: K,
|
||||
value: OutboundMigrationState[K],
|
||||
) {
|
||||
state[field] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type InboundMigrationFlow = ReturnType<
|
||||
typeof createInboundMigrationFlow
|
||||
>;
|
||||
export type OutboundMigrationFlow = ReturnType<
|
||||
typeof createOutboundMigrationFlow
|
||||
>;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export * from "./types";
|
||||
export * from "./atproto-client";
|
||||
export * from "./storage";
|
||||
export * from "./blob-migration";
|
||||
export {
|
||||
createInboundMigrationFlow,
|
||||
createOutboundMigrationFlow,
|
||||
type InboundMigrationFlow,
|
||||
type OutboundMigrationFlow,
|
||||
} from "./flow.svelte";
|
||||
export {
|
||||
clearOfflineState,
|
||||
createOfflineInboundMigrationFlow,
|
||||
getOfflineResumeInfo,
|
||||
hasPendingOfflineMigration,
|
||||
} from "./offline-flow.svelte";
|
||||
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
import type {
|
||||
AuthMethod,
|
||||
MigrationProgress,
|
||||
OfflineInboundMigrationState,
|
||||
OfflineInboundStep,
|
||||
ServerDescription,
|
||||
} from "./types";
|
||||
import {
|
||||
AtprotoClient,
|
||||
base64UrlEncode,
|
||||
createLocalClient,
|
||||
prepareWebAuthnCreationOptions,
|
||||
} from "./atproto-client";
|
||||
import { api } from "../api";
|
||||
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops";
|
||||
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
|
||||
import { Secp256k1PrivateKeyExportable } from "@atcute/crypto";
|
||||
|
||||
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface StoredOfflineMigrationState {
|
||||
version: number;
|
||||
step: OfflineInboundStep;
|
||||
startedAt: string;
|
||||
userDid: string;
|
||||
carFileName: string;
|
||||
carSizeBytes: number;
|
||||
rotationKeyDidKey: string;
|
||||
targetHandle: string;
|
||||
targetEmail: string;
|
||||
authMethod: AuthMethod;
|
||||
passkeySetupToken?: string;
|
||||
oldPdsUrl?: string;
|
||||
plcUpdatedTemporarily?: boolean;
|
||||
progress: {
|
||||
accountCreated: boolean;
|
||||
repoImported: boolean;
|
||||
plcSigned: boolean;
|
||||
activated: boolean;
|
||||
};
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
function saveOfflineState(state: OfflineInboundMigrationState): void {
|
||||
const stored: StoredOfflineMigrationState = {
|
||||
version: 1,
|
||||
step: state.step,
|
||||
startedAt: new Date().toISOString(),
|
||||
userDid: state.userDid,
|
||||
carFileName: state.carFileName,
|
||||
carSizeBytes: state.carSizeBytes,
|
||||
rotationKeyDidKey: state.rotationKeyDidKey,
|
||||
targetHandle: state.targetHandle,
|
||||
targetEmail: state.targetEmail,
|
||||
authMethod: state.authMethod,
|
||||
passkeySetupToken: state.passkeySetupToken ?? undefined,
|
||||
oldPdsUrl: state.oldPdsUrl ?? undefined,
|
||||
plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined,
|
||||
progress: {
|
||||
accountCreated: state.progress.repoExported,
|
||||
repoImported: state.progress.repoImported,
|
||||
plcSigned: state.progress.plcSigned,
|
||||
activated: state.progress.activated,
|
||||
},
|
||||
lastError: state.error ?? undefined,
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored));
|
||||
} catch { /* ignore localStorage errors */ }
|
||||
}
|
||||
|
||||
function loadOfflineState(): StoredOfflineMigrationState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
const state = JSON.parse(stored) as StoredOfflineMigrationState;
|
||||
if (state.version !== 1) {
|
||||
clearOfflineState();
|
||||
return null;
|
||||
}
|
||||
const startedAt = new Date(state.startedAt).getTime();
|
||||
if (Date.now() - startedAt > MAX_AGE_MS) {
|
||||
clearOfflineState();
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
clearOfflineState();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearOfflineState(): void {
|
||||
try {
|
||||
localStorage.removeItem(OFFLINE_STORAGE_KEY);
|
||||
} catch { /* ignore localStorage errors */ }
|
||||
}
|
||||
|
||||
export function hasPendingOfflineMigration(): boolean {
|
||||
return loadOfflineState() !== null;
|
||||
}
|
||||
|
||||
export function getOfflineResumeInfo(): {
|
||||
step: OfflineInboundStep;
|
||||
userDid: string;
|
||||
targetHandle: string;
|
||||
} | null {
|
||||
const state = loadOfflineState();
|
||||
if (!state) return null;
|
||||
return {
|
||||
step: state.step,
|
||||
userDid: state.userDid,
|
||||
targetHandle: state.targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
export { clearOfflineState };
|
||||
|
||||
function createInitialProgress(): MigrationProgress {
|
||||
return {
|
||||
repoExported: false,
|
||||
repoImported: false,
|
||||
blobsTotal: 0,
|
||||
blobsMigrated: 0,
|
||||
blobsFailed: [],
|
||||
prefsMigrated: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
deactivated: false,
|
||||
currentOperation: "",
|
||||
};
|
||||
}
|
||||
|
||||
export type OfflineInboundMigrationFlow = ReturnType<
|
||||
typeof createOfflineInboundMigrationFlow
|
||||
>;
|
||||
|
||||
export function createOfflineInboundMigrationFlow() {
|
||||
let state = $state<OfflineInboundMigrationState>({
|
||||
direction: "offline-inbound",
|
||||
step: "welcome",
|
||||
userDid: "",
|
||||
carFile: null,
|
||||
carFileName: "",
|
||||
carSizeBytes: 0,
|
||||
carNeedsReupload: false,
|
||||
rotationKey: "",
|
||||
rotationKeyDidKey: "",
|
||||
oldPdsUrl: null,
|
||||
targetHandle: "",
|
||||
targetEmail: "",
|
||||
targetPassword: "",
|
||||
inviteCode: "",
|
||||
authMethod: "password",
|
||||
localAccessToken: null,
|
||||
localRefreshToken: null,
|
||||
passkeySetupToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
emailVerifyToken: "",
|
||||
progress: createInitialProgress(),
|
||||
error: null,
|
||||
plcUpdatedTemporarily: false,
|
||||
});
|
||||
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
let userRotationKeypair: KeypairInfo | null = null;
|
||||
let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null;
|
||||
|
||||
function setStep(step: OfflineInboundStep) {
|
||||
state.step = step;
|
||||
state.error = null;
|
||||
if (step !== "success") {
|
||||
saveOfflineState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function setError(error: string | null) {
|
||||
state.error = error;
|
||||
saveOfflineState(state);
|
||||
}
|
||||
|
||||
function setProgress(updates: Partial<MigrationProgress>) {
|
||||
state.progress = { ...state.progress, ...updates };
|
||||
saveOfflineState(state);
|
||||
}
|
||||
|
||||
async function loadLocalServerInfo(): Promise<ServerDescription> {
|
||||
if (!localServerInfo) {
|
||||
const client = createLocalClient();
|
||||
localServerInfo = await client.describeServer();
|
||||
}
|
||||
return localServerInfo;
|
||||
}
|
||||
|
||||
async function checkHandleAvailability(handle: string): Promise<boolean> {
|
||||
const client = createLocalClient();
|
||||
try {
|
||||
await client.resolveHandle(handle);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateRotationKey(): Promise<boolean> {
|
||||
if (!state.userDid || !state.rotationKey) {
|
||||
throw new Error("DID and rotation key are required");
|
||||
}
|
||||
|
||||
try {
|
||||
userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim());
|
||||
const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid);
|
||||
const currentRotationKeys = lastOperation.rotationKeys || [];
|
||||
|
||||
if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) {
|
||||
state.rotationKeyDidKey = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
state.rotationKeyDidKey = userRotationKeypair.didPublicKey;
|
||||
|
||||
const pdsService = lastOperation.services?.atproto_pds;
|
||||
if (pdsService?.endpoint) {
|
||||
state.oldPdsUrl = pdsService.endpoint;
|
||||
console.log(
|
||||
"[offline-migration] Captured old PDS URL:",
|
||||
state.oldPdsUrl,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[offline-migration] No PDS service endpoint found in PLC document",
|
||||
);
|
||||
console.log(
|
||||
"[offline-migration] PLC services:",
|
||||
JSON.stringify(lastOperation.services),
|
||||
);
|
||||
}
|
||||
|
||||
saveOfflineState(state);
|
||||
return true;
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse rotation key: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareTempCredentials(): Promise<string> {
|
||||
if (!userRotationKeypair) {
|
||||
throw new Error("Rotation key not validated");
|
||||
}
|
||||
|
||||
setProgress({ currentOperation: "Preparing temporary credentials..." });
|
||||
|
||||
tempVerificationKeypair = await Secp256k1PrivateKeyExportable
|
||||
.createKeypair();
|
||||
const tempVerificationPublicKey = await tempVerificationKeypair
|
||||
.exportPublicKey("did");
|
||||
|
||||
const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc(
|
||||
state.userDid,
|
||||
);
|
||||
const prevCid = base.cid;
|
||||
|
||||
setProgress({ currentOperation: "Updating DID document temporarily..." });
|
||||
|
||||
const localPdsUrl = globalThis.location.origin;
|
||||
await plcOps.signAndPublishNewOp(
|
||||
state.userDid,
|
||||
userRotationKeypair.keypair,
|
||||
lastOperation.alsoKnownAs || [],
|
||||
[userRotationKeypair.didPublicKey],
|
||||
localPdsUrl,
|
||||
tempVerificationPublicKey,
|
||||
prevCid,
|
||||
);
|
||||
|
||||
state.plcUpdatedTemporarily = true;
|
||||
saveOfflineState(state);
|
||||
|
||||
const serverInfo = await loadLocalServerInfo();
|
||||
const serviceAuthToken = await plcOps.createServiceAuthToken(
|
||||
state.userDid,
|
||||
serverInfo.did,
|
||||
tempVerificationKeypair as unknown as PrivateKey,
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
|
||||
return serviceAuthToken;
|
||||
}
|
||||
|
||||
async function createPasswordAccount(
|
||||
serviceAuthToken: string,
|
||||
): Promise<void> {
|
||||
setProgress({ currentOperation: "Creating account on new PDS..." });
|
||||
|
||||
const serverInfo = await loadLocalServerInfo();
|
||||
const fullHandle = state.targetHandle.includes(".")
|
||||
? state.targetHandle
|
||||
: `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
|
||||
|
||||
const createResult = await api.createAccountWithServiceAuth(
|
||||
serviceAuthToken,
|
||||
{
|
||||
did: state.userDid,
|
||||
handle: fullHandle,
|
||||
email: state.targetEmail,
|
||||
password: state.targetPassword,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
},
|
||||
);
|
||||
|
||||
state.targetHandle = fullHandle;
|
||||
state.localAccessToken = createResult.accessJwt;
|
||||
state.localRefreshToken = createResult.refreshJwt;
|
||||
setProgress({ repoExported: true });
|
||||
}
|
||||
|
||||
async function createPasskeyAccount(serviceAuthToken: string): Promise<void> {
|
||||
setProgress({ currentOperation: "Creating passkey account on new PDS..." });
|
||||
|
||||
const serverInfo = await loadLocalServerInfo();
|
||||
const fullHandle = state.targetHandle.includes(".")
|
||||
? state.targetHandle
|
||||
: `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`;
|
||||
|
||||
const createResult = await api.createPasskeyAccount({
|
||||
did: state.userDid,
|
||||
handle: fullHandle,
|
||||
email: state.targetEmail,
|
||||
inviteCode: state.inviteCode || undefined,
|
||||
}, serviceAuthToken);
|
||||
|
||||
state.targetHandle = fullHandle;
|
||||
state.passkeySetupToken = createResult.setupToken;
|
||||
setProgress({ repoExported: true });
|
||||
saveOfflineState(state);
|
||||
}
|
||||
|
||||
async function signFinalPlcOperation(): Promise<void> {
|
||||
if (!userRotationKeypair || !state.localAccessToken) {
|
||||
throw new Error("Prerequisites not met for PLC signing");
|
||||
}
|
||||
|
||||
setProgress({ currentOperation: "Finalizing DID document..." });
|
||||
|
||||
const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid);
|
||||
const prevCid = base.cid;
|
||||
|
||||
const credentials = await api.getRecommendedDidCredentials(
|
||||
state.localAccessToken,
|
||||
);
|
||||
|
||||
await plcOps.signPlcOperationWithCredentials(
|
||||
state.userDid,
|
||||
userRotationKeypair.keypair,
|
||||
{
|
||||
rotationKeys: credentials.rotationKeys,
|
||||
alsoKnownAs: credentials.alsoKnownAs,
|
||||
verificationMethods: credentials.verificationMethods,
|
||||
services: credentials.services,
|
||||
},
|
||||
[userRotationKeypair.didPublicKey],
|
||||
prevCid,
|
||||
);
|
||||
|
||||
setProgress({ plcSigned: true });
|
||||
}
|
||||
|
||||
async function importRepository(): Promise<void> {
|
||||
if (!state.carFile || !state.localAccessToken) {
|
||||
throw new Error("CAR file and access token are required");
|
||||
}
|
||||
|
||||
setProgress({ currentOperation: "Importing repository..." });
|
||||
await api.importRepo(state.localAccessToken, state.carFile);
|
||||
setProgress({ repoImported: true });
|
||||
}
|
||||
|
||||
async function migrateBlobs(): Promise<void> {
|
||||
if (!state.localAccessToken) {
|
||||
throw new Error("Access token required");
|
||||
}
|
||||
|
||||
const localClient = createLocalClient();
|
||||
localClient.setAccessToken(state.localAccessToken);
|
||||
|
||||
if (state.oldPdsUrl) {
|
||||
setProgress({
|
||||
currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`,
|
||||
});
|
||||
} else {
|
||||
setProgress({
|
||||
currentOperation: "No source PDS URL available for blob migration",
|
||||
});
|
||||
}
|
||||
|
||||
const sourceClient = state.oldPdsUrl
|
||||
? new AtprotoClient(state.oldPdsUrl)
|
||||
: null;
|
||||
|
||||
const result = await migrateBlobsUtil(
|
||||
localClient,
|
||||
sourceClient,
|
||||
state.userDid,
|
||||
setProgress,
|
||||
);
|
||||
|
||||
state.progress.blobsFailed = result.failed;
|
||||
state.progress.blobsTotal = result.total;
|
||||
state.progress.blobsMigrated = result.migrated;
|
||||
|
||||
if (result.total === 0) {
|
||||
setProgress({ currentOperation: "No blobs to migrate" });
|
||||
} else if (result.sourceUnreachable) {
|
||||
setProgress({
|
||||
currentOperation:
|
||||
`Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`,
|
||||
});
|
||||
} else if (result.failed.length > 0) {
|
||||
setProgress({
|
||||
currentOperation:
|
||||
`${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`,
|
||||
});
|
||||
} else {
|
||||
setProgress({
|
||||
currentOperation: `All ${result.migrated} blobs migrated successfully`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function activateAccount(): Promise<void> {
|
||||
if (!state.localAccessToken) {
|
||||
throw new Error("Access token required");
|
||||
}
|
||||
|
||||
setProgress({ currentOperation: "Activating account..." });
|
||||
await api.activateAccount(state.localAccessToken);
|
||||
setProgress({ activated: true });
|
||||
}
|
||||
|
||||
async function submitEmailVerifyToken(token: string): Promise<void> {
|
||||
state.emailVerifyToken = token;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.verifyMigrationEmail(token, state.targetEmail);
|
||||
|
||||
if (state.authMethod === "passkey") {
|
||||
setStep("passkey-setup");
|
||||
} else {
|
||||
const session = await api.createSession(
|
||||
state.targetEmail,
|
||||
state.targetPassword,
|
||||
);
|
||||
state.localAccessToken = session.accessJwt;
|
||||
state.localRefreshToken = session.refreshJwt;
|
||||
saveOfflineState(state);
|
||||
|
||||
setStep("plc-signing");
|
||||
await signFinalPlcOperation();
|
||||
|
||||
setStep("finalizing");
|
||||
await activateAccount();
|
||||
|
||||
cleanup();
|
||||
setStep("success");
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as Error & { error?: string };
|
||||
setError(err.message || err.error || "Email verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function resendEmailVerification(): Promise<void> {
|
||||
await api.resendMigrationVerification(state.targetEmail);
|
||||
}
|
||||
|
||||
let checkingEmailVerification = false;
|
||||
|
||||
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
if (checkingEmailVerification) return false;
|
||||
if (state.authMethod === "passkey") return false;
|
||||
|
||||
checkingEmailVerification = true;
|
||||
try {
|
||||
const { verified } = await api.checkEmailVerified(state.targetEmail);
|
||||
if (!verified) return false;
|
||||
|
||||
const session = await api.createSession(
|
||||
state.targetEmail,
|
||||
state.targetPassword,
|
||||
);
|
||||
state.localAccessToken = session.accessJwt;
|
||||
state.localRefreshToken = session.refreshJwt;
|
||||
saveOfflineState(state);
|
||||
|
||||
setStep("plc-signing");
|
||||
await signFinalPlcOperation();
|
||||
|
||||
setStep("finalizing");
|
||||
await activateAccount();
|
||||
|
||||
cleanup();
|
||||
setStep("success");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
checkingEmailVerification = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
|
||||
if (!state.passkeySetupToken) {
|
||||
throw new Error("No passkey setup token");
|
||||
}
|
||||
|
||||
return api.startPasskeyRegistrationForSetup(
|
||||
state.userDid,
|
||||
state.passkeySetupToken,
|
||||
);
|
||||
}
|
||||
|
||||
async function registerPasskey(passkeyName?: string): Promise<void> {
|
||||
if (!state.passkeySetupToken) {
|
||||
throw new Error("No passkey setup token");
|
||||
}
|
||||
|
||||
if (!globalThis.PublicKeyCredential) {
|
||||
throw new Error("Passkeys are not supported in this browser");
|
||||
}
|
||||
|
||||
const { options } = await startPasskeyRegistration();
|
||||
|
||||
const publicKeyOptions = prepareWebAuthnCreationOptions(
|
||||
options as { publicKey: Record<string, unknown> },
|
||||
);
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Passkey creation was cancelled");
|
||||
}
|
||||
|
||||
const publicKeyCredential = credential as PublicKeyCredential;
|
||||
const response = publicKeyCredential
|
||||
.response as AuthenticatorAttestationResponse;
|
||||
|
||||
const credentialData = {
|
||||
id: publicKeyCredential.id,
|
||||
rawId: base64UrlEncode(publicKeyCredential.rawId),
|
||||
type: publicKeyCredential.type,
|
||||
response: {
|
||||
clientDataJSON: base64UrlEncode(response.clientDataJSON),
|
||||
attestationObject: base64UrlEncode(response.attestationObject),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await api.completePasskeySetup(
|
||||
state.userDid,
|
||||
state.passkeySetupToken,
|
||||
credentialData,
|
||||
passkeyName,
|
||||
);
|
||||
|
||||
state.generatedAppPassword = result.appPassword;
|
||||
state.generatedAppPasswordName = result.appPasswordName;
|
||||
|
||||
const session = await api.createSession(
|
||||
state.targetEmail,
|
||||
result.appPassword,
|
||||
);
|
||||
state.localAccessToken = session.accessJwt;
|
||||
state.localRefreshToken = session.refreshJwt;
|
||||
saveOfflineState(state);
|
||||
|
||||
setStep("app-password");
|
||||
}
|
||||
|
||||
async function proceedFromAppPassword(): Promise<void> {
|
||||
setStep("plc-signing");
|
||||
await signFinalPlcOperation();
|
||||
|
||||
setStep("finalizing");
|
||||
await activateAccount();
|
||||
|
||||
cleanup();
|
||||
setStep("success");
|
||||
}
|
||||
|
||||
function cleanup(): void {
|
||||
clearOfflineState();
|
||||
userRotationKeypair = null;
|
||||
tempVerificationKeypair = null;
|
||||
state.rotationKey = "";
|
||||
}
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
try {
|
||||
setStep("creating");
|
||||
|
||||
const serviceAuthToken = await prepareTempCredentials();
|
||||
|
||||
if (state.authMethod === "passkey") {
|
||||
await createPasskeyAccount(serviceAuthToken);
|
||||
} else {
|
||||
await createPasswordAccount(serviceAuthToken);
|
||||
}
|
||||
|
||||
setStep("importing");
|
||||
await importRepository();
|
||||
|
||||
setStep("migrating-blobs");
|
||||
await migrateBlobs();
|
||||
|
||||
if (
|
||||
state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
setStep("email-verify");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setStep("error");
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearOfflineState();
|
||||
userRotationKeypair = null;
|
||||
tempVerificationKeypair = null;
|
||||
state = {
|
||||
direction: "offline-inbound",
|
||||
step: "welcome",
|
||||
userDid: "",
|
||||
carFile: null,
|
||||
carFileName: "",
|
||||
carSizeBytes: 0,
|
||||
carNeedsReupload: false,
|
||||
rotationKey: "",
|
||||
rotationKeyDidKey: "",
|
||||
oldPdsUrl: null,
|
||||
targetHandle: "",
|
||||
targetEmail: "",
|
||||
targetPassword: "",
|
||||
inviteCode: "",
|
||||
authMethod: "password",
|
||||
localAccessToken: null,
|
||||
localRefreshToken: null,
|
||||
passkeySetupToken: null,
|
||||
generatedAppPassword: null,
|
||||
generatedAppPasswordName: null,
|
||||
emailVerifyToken: "",
|
||||
progress: createInitialProgress(),
|
||||
error: null,
|
||||
plcUpdatedTemporarily: false,
|
||||
};
|
||||
localServerInfo = null;
|
||||
}
|
||||
|
||||
function tryResume(): boolean {
|
||||
const stored = loadOfflineState();
|
||||
if (!stored) return false;
|
||||
|
||||
state.userDid = stored.userDid;
|
||||
state.carFileName = stored.carFileName;
|
||||
state.carSizeBytes = stored.carSizeBytes;
|
||||
state.rotationKeyDidKey = stored.rotationKeyDidKey;
|
||||
state.targetHandle = stored.targetHandle;
|
||||
state.targetEmail = stored.targetEmail;
|
||||
state.authMethod = stored.authMethod ?? "password";
|
||||
state.passkeySetupToken = stored.passkeySetupToken ?? null;
|
||||
state.oldPdsUrl = stored.oldPdsUrl ?? null;
|
||||
state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false;
|
||||
state.step = stored.step;
|
||||
state.progress.repoExported = stored.progress.accountCreated;
|
||||
state.progress.repoImported = stored.progress.repoImported;
|
||||
state.progress.plcSigned = stored.progress.plcSigned;
|
||||
state.progress.activated = stored.progress.activated;
|
||||
state.error = stored.lastError ?? null;
|
||||
|
||||
if (stored.carFileName && stored.carSizeBytes > 0) {
|
||||
state.carNeedsReupload = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getLocalSession():
|
||||
| { accessJwt: string; did: string; handle: string }
|
||||
| null {
|
||||
if (!state.localAccessToken) return null;
|
||||
return {
|
||||
accessJwt: state.localAccessToken,
|
||||
did: state.userDid,
|
||||
handle: state.targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
getLocalSession,
|
||||
setStep,
|
||||
setError,
|
||||
setProgress,
|
||||
loadLocalServerInfo,
|
||||
checkHandleAvailability,
|
||||
validateRotationKey,
|
||||
runMigration,
|
||||
submitEmailVerifyToken,
|
||||
resendEmailVerification,
|
||||
checkEmailVerifiedAndProceed,
|
||||
startPasskeyRegistration,
|
||||
registerPasskey,
|
||||
proceedFromAppPassword,
|
||||
reset,
|
||||
tryResume,
|
||||
clearOfflineState,
|
||||
setUserDid(did: string) {
|
||||
state.userDid = did;
|
||||
saveOfflineState(state);
|
||||
},
|
||||
setCarFile(file: Uint8Array, fileName: string) {
|
||||
state.carFile = file;
|
||||
state.carFileName = fileName;
|
||||
state.carSizeBytes = file.length;
|
||||
state.carNeedsReupload = false;
|
||||
saveOfflineState(state);
|
||||
},
|
||||
setRotationKey(key: string) {
|
||||
state.rotationKey = key;
|
||||
},
|
||||
setTargetHandle(handle: string) {
|
||||
state.targetHandle = handle;
|
||||
saveOfflineState(state);
|
||||
},
|
||||
setTargetEmail(email: string) {
|
||||
state.targetEmail = email;
|
||||
saveOfflineState(state);
|
||||
},
|
||||
setTargetPassword(password: string) {
|
||||
state.targetPassword = password;
|
||||
},
|
||||
setInviteCode(code: string) {
|
||||
state.inviteCode = code;
|
||||
},
|
||||
setAuthMethod(method: AuthMethod) {
|
||||
state.authMethod = method;
|
||||
saveOfflineState(state);
|
||||
},
|
||||
updateField<K extends keyof OfflineInboundMigrationState>(
|
||||
field: K,
|
||||
value: OfflineInboundMigrationState[K],
|
||||
) {
|
||||
state[field] = value;
|
||||
saveOfflineState(state);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
defs,
|
||||
type IndexedEntry,
|
||||
normalizeOp,
|
||||
type Operation,
|
||||
} from "@atcute/did-plc";
|
||||
import {
|
||||
P256PrivateKey,
|
||||
parsePrivateMultikey,
|
||||
Secp256k1PrivateKey,
|
||||
Secp256k1PrivateKeyExportable,
|
||||
} from "@atcute/crypto";
|
||||
import * as CBOR from "@atcute/cbor";
|
||||
import { fromBase16, toBase64Url } from "@atcute/multibase";
|
||||
|
||||
export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey;
|
||||
|
||||
export interface KeypairInfo {
|
||||
type: "private_key";
|
||||
didPublicKey: `did:key:${string}`;
|
||||
keypair: PrivateKey;
|
||||
}
|
||||
|
||||
export interface PlcService {
|
||||
type: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export interface PlcOperationData {
|
||||
type: "plc_operation";
|
||||
prev: string;
|
||||
alsoKnownAs: string[];
|
||||
rotationKeys: string[];
|
||||
services: Record<string, PlcService>;
|
||||
verificationMethods: Record<string, string>;
|
||||
sig?: string;
|
||||
}
|
||||
|
||||
const jsonToB64Url = (obj: unknown): string => {
|
||||
const enc = new TextEncoder();
|
||||
const json = JSON.stringify(obj);
|
||||
return toBase64Url(enc.encode(json));
|
||||
};
|
||||
|
||||
export class PlcOps {
|
||||
private plcDirectoryUrl: string;
|
||||
|
||||
constructor(plcDirectoryUrl = "https://plc.directory") {
|
||||
this.plcDirectoryUrl = plcDirectoryUrl;
|
||||
}
|
||||
|
||||
async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> {
|
||||
const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch PLC audit logs: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
return defs.indexedEntryLog.parse(json);
|
||||
}
|
||||
|
||||
async getLastPlcOpFromPlc(
|
||||
did: string,
|
||||
): Promise<{ lastOperation: Operation; base: IndexedEntry }> {
|
||||
const logs = await this.getPlcAuditLogs(did);
|
||||
const lastOp = logs.at(-1);
|
||||
if (!lastOp) {
|
||||
throw new Error("No PLC operations found for this DID");
|
||||
}
|
||||
return { lastOperation: normalizeOp(lastOp.operation), base: lastOp };
|
||||
}
|
||||
|
||||
async getCurrentRotationKeysForUser(did: string): Promise<string[]> {
|
||||
const { lastOperation } = await this.getLastPlcOpFromPlc(did);
|
||||
return lastOperation.rotationKeys || [];
|
||||
}
|
||||
|
||||
async createNewSecp256k1Keypair(): Promise<
|
||||
{ privateKey: string; publicKey: `did:key:${string}` }
|
||||
> {
|
||||
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
|
||||
const publicKey = await keypair.exportPublicKey("did");
|
||||
const privateKey = await keypair.exportPrivateKey("multikey");
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
async getKeyPair(
|
||||
privateKeyString: string,
|
||||
type: "secp256k1" | "p256" = "secp256k1",
|
||||
): Promise<KeypairInfo> {
|
||||
const HEX_REGEX = /^[0-9a-f]+$/i;
|
||||
const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
|
||||
let keypair: PrivateKey | undefined;
|
||||
|
||||
const trimmed = privateKeyString.trim();
|
||||
|
||||
if (HEX_REGEX.test(trimmed) && trimmed.length === 64) {
|
||||
const privateKeyBytes = fromBase16(trimmed);
|
||||
if (type === "p256") {
|
||||
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
|
||||
} else {
|
||||
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
|
||||
}
|
||||
} else if (MULTIKEY_REGEX.test(trimmed)) {
|
||||
const match = parsePrivateMultikey(trimmed);
|
||||
const privateKeyBytes = match.privateKeyBytes;
|
||||
if (match.type === "p256") {
|
||||
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
|
||||
} else if (match.type === "secp256k1") {
|
||||
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
|
||||
} else {
|
||||
throw new Error(`Unsupported key type: ${match.type}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid key format. Expected 64-char hex or multikey format.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!keypair) {
|
||||
throw new Error("Failed to parse private key");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "private_key",
|
||||
didPublicKey: await keypair.exportPublicKey("did"),
|
||||
keypair,
|
||||
};
|
||||
}
|
||||
|
||||
async signAndPublishNewOp(
|
||||
did: string,
|
||||
signingRotationKey: PrivateKey,
|
||||
alsoKnownAs: string[],
|
||||
rotationKeys: string[],
|
||||
pds: string,
|
||||
verificationKey: string,
|
||||
prev: string,
|
||||
): Promise<void> {
|
||||
const rotationKeysToUse = [...new Set(rotationKeys)];
|
||||
if (rotationKeysToUse.length === 0) {
|
||||
throw new Error("No rotation keys provided");
|
||||
}
|
||||
if (rotationKeysToUse.length > 5) {
|
||||
throw new Error("Maximum 5 rotation keys allowed");
|
||||
}
|
||||
|
||||
const operation: PlcOperationData = {
|
||||
type: "plc_operation",
|
||||
prev,
|
||||
alsoKnownAs,
|
||||
rotationKeys: rotationKeysToUse,
|
||||
services: {
|
||||
atproto_pds: {
|
||||
type: "AtprotoPersonalDataServer",
|
||||
endpoint: pds,
|
||||
},
|
||||
},
|
||||
verificationMethods: {
|
||||
atproto: verificationKey,
|
||||
},
|
||||
};
|
||||
|
||||
const opBytes = CBOR.encode(operation);
|
||||
const sigBytes = await signingRotationKey.sign(opBytes);
|
||||
const signature = toBase64Url(sigBytes);
|
||||
|
||||
const signedOperation = {
|
||||
...operation,
|
||||
sig: signature,
|
||||
};
|
||||
|
||||
await this.pushPlcOperation(did, signedOperation);
|
||||
}
|
||||
|
||||
async pushPlcOperation(
|
||||
did: string,
|
||||
operation: PlcOperationData,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${this.plcDirectoryUrl}/${did}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(operation),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
const json = await response.json();
|
||||
if (
|
||||
typeof json === "object" && json !== null &&
|
||||
typeof json.message === "string"
|
||||
) {
|
||||
throw new Error(json.message);
|
||||
}
|
||||
}
|
||||
throw new Error(`PLC directory returned HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createServiceAuthToken(
|
||||
iss: string,
|
||||
aud: string,
|
||||
keypair: PrivateKey,
|
||||
lxm: string,
|
||||
): Promise<string> {
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60;
|
||||
|
||||
const jti = (() => {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
})();
|
||||
|
||||
const header = { typ: "JWT", alg: "ES256K" };
|
||||
const payload = { iat, iss, aud, exp, lxm, jti };
|
||||
|
||||
const headerB64 = jsonToB64Url(header);
|
||||
const payloadB64 = jsonToB64Url(payload);
|
||||
const toSignStr = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const toSignBytes = new TextEncoder().encode(toSignStr);
|
||||
const sigBytes = await keypair.sign(toSignBytes);
|
||||
const sigB64 = toBase64Url(sigBytes);
|
||||
|
||||
return `${toSignStr}.${sigB64}`;
|
||||
}
|
||||
|
||||
async signPlcOperationWithCredentials(
|
||||
did: string,
|
||||
signingKey: PrivateKey,
|
||||
credentials: {
|
||||
rotationKeys?: string[];
|
||||
alsoKnownAs?: string[];
|
||||
verificationMethods?: Record<string, string>;
|
||||
services?: Record<string, PlcService>;
|
||||
},
|
||||
additionalRotationKeys: string[],
|
||||
prevCid: string,
|
||||
): Promise<void> {
|
||||
const rotationKeys = [
|
||||
...new Set([
|
||||
...(additionalRotationKeys || []),
|
||||
...(credentials.rotationKeys || []),
|
||||
]),
|
||||
];
|
||||
|
||||
if (rotationKeys.length === 0) {
|
||||
throw new Error("No rotation keys provided");
|
||||
}
|
||||
if (rotationKeys.length > 5) {
|
||||
throw new Error("Maximum 5 rotation keys allowed");
|
||||
}
|
||||
|
||||
const operation: PlcOperationData = {
|
||||
type: "plc_operation",
|
||||
prev: prevCid,
|
||||
alsoKnownAs: credentials.alsoKnownAs || [],
|
||||
rotationKeys,
|
||||
services: credentials.services || {},
|
||||
verificationMethods: credentials.verificationMethods || {},
|
||||
};
|
||||
|
||||
const opBytes = CBOR.encode(operation);
|
||||
const sigBytes = await signingKey.sign(opBytes);
|
||||
const signature = toBase64Url(sigBytes);
|
||||
|
||||
const signedOperation = {
|
||||
...operation,
|
||||
sig: signature,
|
||||
};
|
||||
|
||||
await this.pushPlcOperation(did, signedOperation);
|
||||
}
|
||||
}
|
||||
|
||||
export const plcOps = new PlcOps();
|
||||
@@ -13,20 +13,27 @@ export type InboundStep =
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export type AuthMethod = "password" | "passkey";
|
||||
|
||||
export type OutboundStep =
|
||||
export type OfflineInboundStep =
|
||||
| "welcome"
|
||||
| "target-pds"
|
||||
| "new-account"
|
||||
| "provide-did"
|
||||
| "upload-car"
|
||||
| "provide-rotation-key"
|
||||
| "choose-handle"
|
||||
| "review"
|
||||
| "migrating"
|
||||
| "plc-token"
|
||||
| "creating"
|
||||
| "importing"
|
||||
| "migrating-blobs"
|
||||
| "plc-signing"
|
||||
| "email-verify"
|
||||
| "passkey-setup"
|
||||
| "app-password"
|
||||
| "finalizing"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export type MigrationDirection = "inbound" | "outbound";
|
||||
export type AuthMethod = "password" | "passkey";
|
||||
|
||||
export type MigrationDirection = "inbound";
|
||||
|
||||
export interface MigrationProgress {
|
||||
repoExported: boolean;
|
||||
@@ -68,27 +75,34 @@ export interface InboundMigrationState {
|
||||
resumeToStep?: InboundStep;
|
||||
}
|
||||
|
||||
export interface OutboundMigrationState {
|
||||
direction: "outbound";
|
||||
step: OutboundStep;
|
||||
localDid: string;
|
||||
localHandle: string;
|
||||
targetPdsUrl: string;
|
||||
targetPdsDid: string;
|
||||
export interface OfflineInboundMigrationState {
|
||||
direction: "offline-inbound";
|
||||
step: OfflineInboundStep;
|
||||
userDid: string;
|
||||
carFile: Uint8Array | null;
|
||||
carFileName: string;
|
||||
carSizeBytes: number;
|
||||
carNeedsReupload: boolean;
|
||||
rotationKey: string;
|
||||
rotationKeyDidKey: string;
|
||||
oldPdsUrl: string | null;
|
||||
targetHandle: string;
|
||||
targetEmail: string;
|
||||
targetPassword: string;
|
||||
inviteCode: string;
|
||||
targetAccessToken: string | null;
|
||||
targetRefreshToken: string | null;
|
||||
serviceAuthToken: string | null;
|
||||
plcToken: string;
|
||||
authMethod: AuthMethod;
|
||||
localAccessToken: string | null;
|
||||
localRefreshToken: string | null;
|
||||
passkeySetupToken: string | null;
|
||||
generatedAppPassword: string | null;
|
||||
generatedAppPasswordName: string | null;
|
||||
emailVerifyToken: string;
|
||||
progress: MigrationProgress;
|
||||
error: string | null;
|
||||
targetServerInfo: ServerDescription | null;
|
||||
plcUpdatedTemporarily: boolean;
|
||||
}
|
||||
|
||||
export type MigrationState = InboundMigrationState | OutboundMigrationState;
|
||||
export type MigrationState = InboundMigrationState;
|
||||
|
||||
export interface StoredMigrationState {
|
||||
version: 1;
|
||||
|
||||
+152
-98
@@ -17,7 +17,53 @@
|
||||
"dashboard": "Dashboard",
|
||||
"backToDashboard": "← Dashboard",
|
||||
"copied": "Copied!",
|
||||
"copyToClipboard": "Copy to Clipboard"
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
|
||||
"verifying": "Verifying...",
|
||||
"saving": "Saving...",
|
||||
"creating": "Creating...",
|
||||
"updating": "Updating...",
|
||||
"sending": "Sending...",
|
||||
"authenticating": "Authenticating...",
|
||||
"checking": "Checking...",
|
||||
"redirecting": "Redirecting...",
|
||||
|
||||
"signIn": "Sign In",
|
||||
"verify": "Verify",
|
||||
"remove": "Remove",
|
||||
"revoke": "Revoke",
|
||||
"resendCode": "Resend Code",
|
||||
"startOver": "Start Over",
|
||||
"tryAgain": "Try Again",
|
||||
|
||||
"password": "Password",
|
||||
"email": "Email",
|
||||
"emailAddress": "Email Address",
|
||||
"handle": "Handle",
|
||||
"did": "DID",
|
||||
"verificationCode": "Verification Code",
|
||||
"inviteCode": "Invite Code",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
|
||||
"enterSixDigitCode": "Enter 6-digit code",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"enterPassword": "Enter your password",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
|
||||
"verified": "Verified",
|
||||
"disabled": "Disabled",
|
||||
"available": "Available",
|
||||
"deactivated": "Deactivated",
|
||||
"unverified": "Unverified",
|
||||
|
||||
"backToLogin": "Back to Login",
|
||||
"backToSettings": "Back to Settings",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"createAccount": "Create account",
|
||||
|
||||
"passwordsMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 8 characters"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign In",
|
||||
@@ -49,11 +95,7 @@
|
||||
"codeLabel": "Verification Code",
|
||||
"codePlaceholder": "Enter 6-digit code",
|
||||
"verifyButton": "Verify Account",
|
||||
"verifying": "Verifying...",
|
||||
"resendButton": "Resend Code",
|
||||
"resending": "Resending...",
|
||||
"resent": "Verification code resent!",
|
||||
"backToLogin": "Back to Login"
|
||||
"resent": "Verification code resent!"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
@@ -124,7 +166,6 @@
|
||||
"inviteCodePlaceholder": "Enter your invite code",
|
||||
"inviteCodeRequired": "required",
|
||||
"createButton": "Create Account",
|
||||
"creating": "Creating account...",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"signIn": "Sign in",
|
||||
"wantPasswordless": "Want passwordless security?",
|
||||
@@ -179,6 +220,11 @@
|
||||
"navAdminDesc": "Server stats and admin operations",
|
||||
"navDidDocument": "DID Document",
|
||||
"navDidDocumentDesc": "Manage your DID document for external migrations",
|
||||
"navDidDocumentDescActive": "Edit your DID document settings",
|
||||
"navBackup": "Download Backup",
|
||||
"navBackupDesc": "Download your repository as a CAR file",
|
||||
"downloadingBackup": "Downloading...",
|
||||
"backupFailed": "Failed to download backup",
|
||||
"migrated": "Migrated",
|
||||
"migratedTitle": "Account Migrated",
|
||||
"migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
|
||||
@@ -208,7 +254,6 @@
|
||||
"serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
|
||||
"currentPds": "Current PDS URL",
|
||||
"save": "Save Changes",
|
||||
"saving": "Saving...",
|
||||
"success": "DID document updated successfully",
|
||||
"saveFailed": "Failed to save DID document",
|
||||
"loadFailed": "Failed to load DID document",
|
||||
@@ -246,7 +291,6 @@
|
||||
"yourDomain": "Your Domain",
|
||||
"yourDomainPlaceholder": "example.com",
|
||||
"verifyAndUpdate": "Verify & Update Handle",
|
||||
"verifying": "Verifying...",
|
||||
"newHandle": "New Handle",
|
||||
"newHandlePlaceholder": "yourhandle",
|
||||
"changeHandleButton": "Change Handle",
|
||||
@@ -262,7 +306,34 @@
|
||||
"exportData": "Export Data",
|
||||
"exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.",
|
||||
"downloadRepo": "Download Repository",
|
||||
"downloadBlobs": "Download Media",
|
||||
"exporting": "Exporting...",
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"description": "Your repository is automatically backed up daily. You can also create manual backups or restore from a previous backup.",
|
||||
"enableAutomatic": "Enable automatic backups",
|
||||
"enabled": "Automatic backups enabled",
|
||||
"disabled": "Automatic backups disabled",
|
||||
"toggleFailed": "Failed to update backup setting",
|
||||
"noBackups": "No backups available yet.",
|
||||
"blocks": "blocks",
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"createNow": "Create Backup Now",
|
||||
"created": "Backup created successfully",
|
||||
"createFailed": "Failed to create backup",
|
||||
"downloadFailed": "Failed to download backup",
|
||||
"deleted": "Backup deleted",
|
||||
"deleteFailed": "Failed to delete backup",
|
||||
"restoreTitle": "Restore from Backup",
|
||||
"restoreDescription": "Upload a CAR file to restore your repository. This will overwrite your current data.",
|
||||
"selectFile": "Select CAR file",
|
||||
"selectedFile": "Selected file",
|
||||
"restore": "Restore",
|
||||
"restoring": "Restoring...",
|
||||
"restored": "Repository restored successfully",
|
||||
"restoreFailed": "Failed to restore repository"
|
||||
},
|
||||
"deleteAccount": "Delete Account",
|
||||
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
|
||||
"requestDeletion": "Request Account Deletion",
|
||||
@@ -291,7 +362,9 @@
|
||||
"deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.",
|
||||
"deletionFailed": "Failed to delete account",
|
||||
"repoExported": "Repository exported successfully",
|
||||
"exportFailed": "Failed to export repository",
|
||||
"blobsExported": "Media files exported successfully",
|
||||
"noBlobsToExport": "No media files to export",
|
||||
"exportFailed": "Failed to export",
|
||||
"confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone."
|
||||
}
|
||||
},
|
||||
@@ -306,7 +379,6 @@
|
||||
"noPasswords": "No app passwords yet",
|
||||
"revoke": "Revoke",
|
||||
"revoking": "Revoking...",
|
||||
"creating": "Creating...",
|
||||
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
|
||||
"saveWarningTitle": "Important: Save this app password!",
|
||||
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
|
||||
@@ -354,7 +426,6 @@
|
||||
"used": "Used by @{handle}",
|
||||
"disabled": "Disabled",
|
||||
"usedBy": "Used by",
|
||||
"creating": "Creating...",
|
||||
"disableConfirm": "Disable this invite code? It can no longer be used.",
|
||||
"created": "Invite Code Created",
|
||||
"copy": "Copy",
|
||||
@@ -482,7 +553,6 @@
|
||||
"verifyButton": "Verify",
|
||||
"verifyCodePlaceholder": "Enter verification code",
|
||||
"submit": "Submit",
|
||||
"saving": "Saving...",
|
||||
"savePreferences": "Save Preferences",
|
||||
"preferencesSaved": "Communication preferences saved",
|
||||
"verifiedSuccess": "{channel} verified successfully",
|
||||
@@ -521,13 +591,11 @@
|
||||
"noCollectionsYet": "No collections yet. Create your first record to get started.",
|
||||
"loadMore": "Load More",
|
||||
"recordJson": "Record JSON",
|
||||
"saving": "Saving...",
|
||||
"updateRecord": "Update Record",
|
||||
"collectionNsid": "Collection (NSID)",
|
||||
"recordKeyOptional": "Record Key (optional)",
|
||||
"autoGenerated": "Auto-generated if empty (TID)",
|
||||
"autoGeneratedHint": "Leave empty to auto-generate a TID-based key",
|
||||
"creating": "Creating...",
|
||||
"demoPostText": "Hello from my PDS! This is my first post.",
|
||||
"demoDisplayName": "Your Display Name",
|
||||
"demoBio": "A short bio about yourself."
|
||||
@@ -551,7 +619,6 @@
|
||||
"secondaryLight": "Secondary (Light Mode)",
|
||||
"secondaryDark": "Secondary (Dark Mode)",
|
||||
"configSaved": "Server configuration saved",
|
||||
"saving": "Saving...",
|
||||
"saveConfig": "Save Configuration",
|
||||
"serverStats": "Server Statistics",
|
||||
"users": "Users",
|
||||
@@ -639,16 +706,13 @@
|
||||
"title": "Two-Factor Authentication",
|
||||
"subtitle": "Additional verification is required",
|
||||
"usePasskey": "Use Passkey",
|
||||
"useTotp": "Use Authenticator App",
|
||||
"verifying": "Verifying..."
|
||||
"useTotp": "Use Authenticator App"
|
||||
},
|
||||
"twoFactorCode": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.",
|
||||
"codeLabel": "Verification Code",
|
||||
"codePlaceholder": "Enter 6-digit code",
|
||||
"verify": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"errors": {
|
||||
"missingRequestUri": "Missing request_uri parameter",
|
||||
"verificationFailed": "Verification failed",
|
||||
@@ -660,8 +724,6 @@
|
||||
"title": "Enter Authenticator Code",
|
||||
"subtitle": "Enter the 6-digit code from your authenticator app",
|
||||
"codePlaceholder": "Enter 6-digit code",
|
||||
"verify": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"useBackupCode": "Use backup code instead",
|
||||
"backupCodePlaceholder": "Enter backup code",
|
||||
"trustDevice": "Trust this device for 30 days",
|
||||
@@ -691,16 +753,9 @@
|
||||
"codeLabel": "Verification Code",
|
||||
"codeHelp": "Copy the entire code from your message, including dashes",
|
||||
"verifyButton": "Verify Account",
|
||||
"verify": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"pleaseWait": "Please wait...",
|
||||
"resendCode": "Resend Code",
|
||||
"resending": "Resending...",
|
||||
"sending": "Sending...",
|
||||
"codeResent": "Verification code resent!",
|
||||
"codeResentDetail": "Verification code sent! Check your inbox.",
|
||||
"backToLogin": "Back to Login",
|
||||
"backToSettings": "Back to Settings",
|
||||
"verifyingAccount": "Verifying account: @{handle}",
|
||||
"startOver": "Start over with a different account",
|
||||
"noPending": "No pending verification found.",
|
||||
@@ -746,7 +801,6 @@
|
||||
"resetButton": "Reset Password",
|
||||
"resetting": "Resetting...",
|
||||
"success": "Password reset successfully!",
|
||||
"backToLogin": "Back to Sign In",
|
||||
"requestNewCode": "Request New Code",
|
||||
"passwordsMismatch": "Passwords do not match",
|
||||
"passwordLength": "Password must be at least 8 characters"
|
||||
@@ -790,8 +844,7 @@
|
||||
"howItWorks": "How it works",
|
||||
"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.",
|
||||
"sendRecoveryLink": "Send Recovery Link",
|
||||
"sending": "Sending...",
|
||||
"backToLogin": "Back to Sign In"
|
||||
"sending": "Sending..."
|
||||
},
|
||||
"registerPasskey": {
|
||||
"title": "Create Passkey Account",
|
||||
@@ -814,7 +867,6 @@
|
||||
"inviteCode": "Invite Code",
|
||||
"inviteCodePlaceholder": "Enter your invite code",
|
||||
"createButton": "Create Account",
|
||||
"creating": "Creating...",
|
||||
"continue": "Continue",
|
||||
"back": "Back",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
@@ -911,8 +963,6 @@
|
||||
"useTotp": "Use Authenticator",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"totpPlaceholder": "Enter 6-digit code",
|
||||
"verify": "Verify",
|
||||
"verifying": "Verifying...",
|
||||
"authenticating": "Authenticating...",
|
||||
"passkeyPrompt": "Click the button below to authenticate with your passkey.",
|
||||
"cancel": "Cancel"
|
||||
@@ -947,7 +997,6 @@
|
||||
"handle": "Handle",
|
||||
"emailOptional": "Email (optional)",
|
||||
"yourAccessLevel": "Your Access Level",
|
||||
"creating": "Creating...",
|
||||
"createAccount": "Create Account",
|
||||
"createDelegatedAccountButton": "+ Create Delegated Account",
|
||||
"accountCreated": "Created delegated account: {handle}",
|
||||
@@ -1059,15 +1108,9 @@
|
||||
"navDesc": "Move your account to or from another PDS",
|
||||
"migrateHere": "Migrate Here",
|
||||
"migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.",
|
||||
"migrateAway": "Migrate Away",
|
||||
"migrateAwayDesc": "Move your account from this PDS to another server.",
|
||||
"loginRequired": "Login required",
|
||||
"bringDid": "Bring your DID and identity",
|
||||
"transferData": "Transfer all your data",
|
||||
"keepFollowers": "Keep your followers",
|
||||
"exportRepo": "Export your repository",
|
||||
"transferToPds": "Transfer to new PDS",
|
||||
"updateIdentity": "Update your identity",
|
||||
"whatIsMigration": "What is account migration?",
|
||||
"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.",
|
||||
"beforeMigrate": "Before you migrate",
|
||||
@@ -1077,7 +1120,11 @@
|
||||
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
|
||||
"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.",
|
||||
"learnMore": "Learn more about migration risks",
|
||||
"comingSoon": "Coming soon",
|
||||
"offlineRestore": "Offline Restore",
|
||||
"offlineRestoreDesc": "Restore from backup when your old PDS is unavailable.",
|
||||
"offlineFeature1": "Use a CAR file backup",
|
||||
"offlineFeature2": "Prove ownership with rotation key",
|
||||
"offlineFeature3": "Recovery for shutdown servers",
|
||||
"oauthCompleting": "Completing authentication...",
|
||||
"oauthFailed": "Authentication Failed",
|
||||
"tryAgain": "Try Again",
|
||||
@@ -1086,7 +1133,6 @@
|
||||
"incomplete": "You have an incomplete migration in progress:",
|
||||
"direction": "Direction",
|
||||
"migratingHere": "Migrating here",
|
||||
"migratingAway": "Migrating away",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"progress": "Progress",
|
||||
@@ -1229,7 +1275,8 @@
|
||||
"error": {
|
||||
"title": "Migration Error",
|
||||
"desc": "An error occurred during migration.",
|
||||
"startOver": "Start Over"
|
||||
"startOver": "Start Over",
|
||||
"unknown": "An unknown error occurred."
|
||||
},
|
||||
"common": {
|
||||
"back": "Back",
|
||||
@@ -1247,70 +1294,77 @@
|
||||
"warning3": "Your old account will be deactivated after migration"
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"offline": {
|
||||
"welcome": {
|
||||
"title": "Migrate Away from This PDS",
|
||||
"desc": "Move your account to another Personal Data Server.",
|
||||
"warning": "After migration, your account here will be deactivated.",
|
||||
"didWebNotice": "did:web Migration Notice",
|
||||
"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.",
|
||||
"understand": "I understand the risks and want to proceed"
|
||||
"title": "Offline Restore",
|
||||
"desc": "Restore your account when your old PDS is unavailable. This is for disaster recovery when you cannot contact your previous server.",
|
||||
"warningTitle": "Advanced Recovery Method",
|
||||
"warningDesc": "This method requires your rotation key private key. Only use this if your previous PDS has shut down or you cannot access it.",
|
||||
"requirementsTitle": "You will need:",
|
||||
"requirement1": "Your DID (did:plc:...)",
|
||||
"requirement2": "A CAR file backup of your repository",
|
||||
"requirement3": "Your rotation key (private key in hex, base58, or JWK format)",
|
||||
"understand": "I understand this is for offline recovery only"
|
||||
},
|
||||
"targetPds": {
|
||||
"title": "Choose Target PDS",
|
||||
"desc": "Enter the URL of the PDS you want to migrate to.",
|
||||
"url": "PDS URL",
|
||||
"urlPlaceholder": "https://pds.example.com",
|
||||
"validate": "Validate & Continue",
|
||||
"provideDid": {
|
||||
"title": "Enter Your DID",
|
||||
"desc": "Enter the DID of the account you want to restore.",
|
||||
"label": "Your DID",
|
||||
"hint": "Your decentralized identifier (e.g., did:plc:abc123...)"
|
||||
},
|
||||
"uploadCar": {
|
||||
"title": "Upload Repository Backup",
|
||||
"desc": "Upload the CAR file containing your repository data.",
|
||||
"label": "CAR File",
|
||||
"hint": "This should be a .car file from a previous backup of your repository",
|
||||
"reuploadWarningTitle": "CAR File Required",
|
||||
"reuploadWarning": "Your session was restored, but you need to re-upload your CAR file. For security reasons, file contents are not stored between sessions."
|
||||
},
|
||||
"rotationKey": {
|
||||
"title": "Provide Rotation Key",
|
||||
"desc": "Enter your rotation key to prove ownership of this DID.",
|
||||
"securityWarningTitle": "Security Warning",
|
||||
"securityWarning1": "Your rotation key is extremely sensitive - anyone with it can take over your identity",
|
||||
"securityWarning2": "Only enter it on trusted devices and connections",
|
||||
"securityWarning3": "The key will not be stored after migration",
|
||||
"label": "Rotation Key",
|
||||
"placeholder": "Paste your rotation key (hex, base58, or JWK)...",
|
||||
"hint": "Supports 64-character hex, base58, or JWK format",
|
||||
"valid": "Rotation key verified! You have control of this DID.",
|
||||
"invalid": "This key is not a valid rotation key for this DID.",
|
||||
"validating": "Validating...",
|
||||
"connected": "Connected to {name}",
|
||||
"inviteRequired": "Invite code required",
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"termsOfService": "Terms of Service"
|
||||
"validate": "Validate Key"
|
||||
},
|
||||
"newAccount": {
|
||||
"title": "New Account Details",
|
||||
"desc": "Set up your account on the new PDS.",
|
||||
"handle": "Handle",
|
||||
"availableDomains": "Available domains",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"inviteCode": "Invite Code"
|
||||
"chooseHandle": {
|
||||
"migratingDid": "Restoring DID"
|
||||
},
|
||||
"review": {
|
||||
"title": "Review Migration",
|
||||
"desc": "Please review and confirm your migration details.",
|
||||
"currentHandle": "Current Handle",
|
||||
"newHandle": "New Handle",
|
||||
"sourcePds": "This PDS",
|
||||
"targetPds": "Target PDS",
|
||||
"confirm": "I confirm I want to migrate my account",
|
||||
"startMigration": "Start Migration"
|
||||
"desc": "Please confirm the details of your offline restoration.",
|
||||
"carFile": "CAR File",
|
||||
"rotationKey": "Rotation Key",
|
||||
"warning": "After you click \"Start Migration\", your repository will be imported and your DID will be updated to point to this PDS.",
|
||||
"plcWarningTitle": "Point of No Return",
|
||||
"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."
|
||||
},
|
||||
"migrating": {
|
||||
"title": "Migrating Your Account",
|
||||
"desc": "Please wait while we transfer your data..."
|
||||
"title": "Restoring Account",
|
||||
"desc": "Please wait while your account is being restored...",
|
||||
"creating": "Creating account",
|
||||
"importing": "Importing repository",
|
||||
"plcSigning": "Signing identity update",
|
||||
"activating": "Activating account"
|
||||
},
|
||||
"plcToken": {
|
||||
"title": "Verify Your Identity",
|
||||
"desc": "A verification code has been sent to your email."
|
||||
},
|
||||
"finalizing": {
|
||||
"title": "Finalizing Migration",
|
||||
"desc": "Please wait while we complete the migration...",
|
||||
"updatingForwarding": "Updating DID document forwarding..."
|
||||
"blobs": {
|
||||
"title": "Migrating Blobs",
|
||||
"desc": "Attempting to recover images and media from your old PDS...",
|
||||
"migrating": "Migrating blobs",
|
||||
"failedTitle": "Some blobs could not be migrated",
|
||||
"failedDesc": "{count} blobs could not be fetched from your old PDS. This may happen if the server is unreachable or the files were deleted.",
|
||||
"sourceUnreachableTitle": "Source PDS Unreachable",
|
||||
"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."
|
||||
},
|
||||
"success": {
|
||||
"title": "Migration Complete!",
|
||||
"desc": "Your account has been successfully migrated to your new PDS.",
|
||||
"newHandle": "New Handle",
|
||||
"newPds": "New PDS",
|
||||
"nextSteps": "Next Steps",
|
||||
"nextSteps1": "Sign in to your new PDS",
|
||||
"nextSteps2": "Update any apps with your new credentials",
|
||||
"nextSteps3": "Your followers will automatically see your new location",
|
||||
"loggingOut": "Logging you out in {seconds} seconds..."
|
||||
"desc": "Your account has been successfully restored to this PDS."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
|
||||
+154
-100
@@ -17,7 +17,53 @@
|
||||
"dashboard": "Hallintapaneeli",
|
||||
"backToDashboard": "← Hallintapaneeli",
|
||||
"copied": "Kopioitu!",
|
||||
"copyToClipboard": "Kopioi"
|
||||
"copyToClipboard": "Kopioi",
|
||||
|
||||
"verifying": "Vahvistetaan...",
|
||||
"saving": "Tallennetaan...",
|
||||
"creating": "Luodaan...",
|
||||
"updating": "Päivitetään...",
|
||||
"sending": "Lähetetään...",
|
||||
"authenticating": "Todennetaan...",
|
||||
"checking": "Tarkistetaan...",
|
||||
"redirecting": "Ohjataan...",
|
||||
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"verify": "Vahvista",
|
||||
"remove": "Poista",
|
||||
"revoke": "Peruuta",
|
||||
"resendCode": "Lähetä koodi uudelleen",
|
||||
"startOver": "Aloita alusta",
|
||||
"tryAgain": "Yritä uudelleen",
|
||||
|
||||
"password": "Salasana",
|
||||
"email": "Sähköposti",
|
||||
"emailAddress": "Sähköpostiosoite",
|
||||
"handle": "Käsittely",
|
||||
"did": "DID",
|
||||
"verificationCode": "Vahvistuskoodi",
|
||||
"inviteCode": "Kutsukoodi",
|
||||
"newPassword": "Uusi salasana",
|
||||
"confirmPassword": "Vahvista salasana",
|
||||
|
||||
"enterSixDigitCode": "Syötä 6-numeroinen koodi",
|
||||
"passwordHint": "Vähintään 8 merkkiä",
|
||||
"enterPassword": "Syötä salasanasi",
|
||||
"emailPlaceholder": "sinä@esimerkki.com",
|
||||
|
||||
"verified": "Vahvistettu",
|
||||
"disabled": "Poistettu käytöstä",
|
||||
"available": "Saatavilla",
|
||||
"deactivated": "Deaktivoitu",
|
||||
"unverified": "Vahvistamaton",
|
||||
|
||||
"backToLogin": "Takaisin kirjautumiseen",
|
||||
"backToSettings": "Takaisin asetuksiin",
|
||||
"alreadyHaveAccount": "Onko sinulla jo tili?",
|
||||
"createAccount": "Luo tili",
|
||||
|
||||
"passwordsMismatch": "Salasanat eivät täsmää",
|
||||
"passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä"
|
||||
},
|
||||
"login": {
|
||||
"title": "Kirjaudu sisään",
|
||||
@@ -49,11 +95,7 @@
|
||||
"codeLabel": "Vahvistuskoodi",
|
||||
"codePlaceholder": "Syötä 6-numeroinen koodi",
|
||||
"verifyButton": "Vahvista tili",
|
||||
"verifying": "Vahvistetaan...",
|
||||
"resendButton": "Lähetä koodi uudelleen",
|
||||
"resending": "Lähetetään uudelleen...",
|
||||
"resent": "Vahvistuskoodi lähetetty uudelleen!",
|
||||
"backToLogin": "Takaisin kirjautumiseen"
|
||||
"resent": "Vahvistuskoodi lähetetty uudelleen!"
|
||||
},
|
||||
"register": {
|
||||
"title": "Luo tili",
|
||||
@@ -124,7 +166,6 @@
|
||||
"inviteCodePlaceholder": "Syötä kutsukoodisi",
|
||||
"inviteCodeRequired": "vaaditaan",
|
||||
"createButton": "Luo tili",
|
||||
"creating": "Luodaan tiliä...",
|
||||
"alreadyHaveAccount": "Onko sinulla jo tili?",
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"wantPasswordless": "Haluatko salasanattoman turvallisuuden?",
|
||||
@@ -179,6 +220,11 @@
|
||||
"navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
|
||||
"navDidDocument": "DID-dokumentti",
|
||||
"navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
|
||||
"navDidDocumentDescActive": "Muokkaa DID-dokumentin asetuksia",
|
||||
"navBackup": "Lataa varmuuskopio",
|
||||
"navBackupDesc": "Lataa tietovarastosi CAR-tiedostona",
|
||||
"downloadingBackup": "Ladataan...",
|
||||
"backupFailed": "Varmuuskopion lataus epäonnistui",
|
||||
"migrated": "Siirretty",
|
||||
"migratedTitle": "Tili siirretty",
|
||||
"migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
|
||||
@@ -208,7 +254,6 @@
|
||||
"serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
|
||||
"currentPds": "Nykyinen PDS-URL",
|
||||
"save": "Tallenna muutokset",
|
||||
"saving": "Tallennetaan...",
|
||||
"success": "DID-dokumentti päivitetty onnistuneesti",
|
||||
"saveFailed": "DID-dokumentin tallennus epäonnistui",
|
||||
"loadFailed": "DID-dokumentin lataus epäonnistui",
|
||||
@@ -246,7 +291,6 @@
|
||||
"yourDomain": "Verkkotunnuksesi",
|
||||
"yourDomainPlaceholder": "esimerkki.fi",
|
||||
"verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi",
|
||||
"verifying": "Vahvistetaan...",
|
||||
"newHandle": "Uusi käyttäjänimi",
|
||||
"newHandlePlaceholder": "käyttäjänimesi",
|
||||
"changeHandleButton": "Vaihda käyttäjänimi",
|
||||
@@ -262,7 +306,34 @@
|
||||
"exportData": "Vie tiedot",
|
||||
"exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.",
|
||||
"downloadRepo": "Lataa tietovarasto",
|
||||
"downloadBlobs": "Lataa media",
|
||||
"exporting": "Viedään...",
|
||||
"backups": {
|
||||
"title": "Varmuuskopiot",
|
||||
"description": "Tietovarastosi varmuuskopioidaan automaattisesti päivittäin. Voit myös luoda manuaalisia varmuuskopioita tai palauttaa aiemmasta varmuuskopiosta.",
|
||||
"enableAutomatic": "Ota automaattiset varmuuskopiot käyttöön",
|
||||
"enabled": "Automaattiset varmuuskopiot käytössä",
|
||||
"disabled": "Automaattiset varmuuskopiot pois käytöstä",
|
||||
"toggleFailed": "Varmuuskopioasetuksen päivitys epäonnistui",
|
||||
"noBackups": "Varmuuskopioita ei ole vielä saatavilla.",
|
||||
"blocks": "lohkoa",
|
||||
"download": "Lataa",
|
||||
"delete": "Poista",
|
||||
"createNow": "Luo varmuuskopio nyt",
|
||||
"created": "Varmuuskopio luotu onnistuneesti",
|
||||
"createFailed": "Varmuuskopion luonti epäonnistui",
|
||||
"downloadFailed": "Varmuuskopion lataus epäonnistui",
|
||||
"deleted": "Varmuuskopio poistettu",
|
||||
"deleteFailed": "Varmuuskopion poisto epäonnistui",
|
||||
"restoreTitle": "Palauta varmuuskopiosta",
|
||||
"restoreDescription": "Lataa CAR-tiedosto palauttaaksesi tietovarastosi. Tämä korvaa nykyiset tietosi.",
|
||||
"selectFile": "Valitse CAR-tiedosto",
|
||||
"selectedFile": "Valittu tiedosto",
|
||||
"restore": "Palauta",
|
||||
"restoring": "Palautetaan...",
|
||||
"restored": "Tietovarasto palautettu onnistuneesti",
|
||||
"restoreFailed": "Tietovaraston palautus epäonnistui"
|
||||
},
|
||||
"deleteAccount": "Poista tili",
|
||||
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
|
||||
"requestDeletion": "Pyydä tilin poistoa",
|
||||
@@ -291,7 +362,9 @@
|
||||
"deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.",
|
||||
"deletionFailed": "Tilin poisto epäonnistui",
|
||||
"repoExported": "Tietovarasto viety",
|
||||
"exportFailed": "Tietovaraston vienti epäonnistui",
|
||||
"blobsExported": "Mediatiedostot viety",
|
||||
"noBlobsToExport": "Ei vietäviä mediatiedostoja",
|
||||
"exportFailed": "Vienti epäonnistui",
|
||||
"confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua."
|
||||
}
|
||||
},
|
||||
@@ -306,7 +379,6 @@
|
||||
"noPasswords": "Ei vielä sovellusten salasanoja",
|
||||
"revoke": "Peruuta",
|
||||
"revoking": "Peruutetaan...",
|
||||
"creating": "Luodaan...",
|
||||
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
|
||||
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
|
||||
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
|
||||
@@ -354,7 +426,6 @@
|
||||
"used": "Käyttänyt @{handle}",
|
||||
"disabled": "Poistettu käytöstä",
|
||||
"usedBy": "Käyttänyt",
|
||||
"creating": "Luodaan...",
|
||||
"disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.",
|
||||
"created": "Kutsukoodi luotu",
|
||||
"copy": "Kopioi",
|
||||
@@ -482,7 +553,6 @@
|
||||
"verifyButton": "Vahvista",
|
||||
"verifyCodePlaceholder": "Syötä vahvistuskoodi",
|
||||
"submit": "Lähetä",
|
||||
"saving": "Tallennetaan...",
|
||||
"savePreferences": "Tallenna asetukset",
|
||||
"preferencesSaved": "Viestintäasetukset tallennettu",
|
||||
"verifiedSuccess": "{channel} vahvistettu",
|
||||
@@ -521,13 +591,11 @@
|
||||
"noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.",
|
||||
"loadMore": "Lataa lisää",
|
||||
"recordJson": "Tietueen JSON",
|
||||
"saving": "Tallennetaan...",
|
||||
"updateRecord": "Päivitä tietue",
|
||||
"collectionNsid": "Kokoelma (NSID)",
|
||||
"recordKeyOptional": "Tietueavain (valinnainen)",
|
||||
"autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)",
|
||||
"autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti",
|
||||
"creating": "Luodaan...",
|
||||
"demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.",
|
||||
"demoDisplayName": "Näyttönimesi",
|
||||
"demoBio": "Lyhyt kuvaus itsestäsi."
|
||||
@@ -548,7 +616,6 @@
|
||||
"primaryLight": "Ensisijainen (vaalea tila)",
|
||||
"primaryDark": "Ensisijainen (tumma tila)",
|
||||
"configSaved": "Palvelinasetukset tallennettu",
|
||||
"saving": "Tallennetaan...",
|
||||
"saveConfig": "Tallenna asetukset",
|
||||
"serverStats": "Palvelintilastot",
|
||||
"users": "Käyttäjät",
|
||||
@@ -639,16 +706,13 @@
|
||||
"title": "Kaksivaiheinen tunnistautuminen",
|
||||
"subtitle": "Lisävahvistus vaaditaan",
|
||||
"usePasskey": "Käytä pääsyavainta",
|
||||
"useTotp": "Käytä todentajasovellusta",
|
||||
"verifying": "Vahvistetaan..."
|
||||
"useTotp": "Käytä todentajasovellusta"
|
||||
},
|
||||
"twoFactorCode": {
|
||||
"title": "Kaksivaiheinen tunnistautuminen",
|
||||
"subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.",
|
||||
"codeLabel": "Vahvistuskoodi",
|
||||
"codePlaceholder": "Syötä 6-numeroinen koodi",
|
||||
"verify": "Vahvista",
|
||||
"verifying": "Vahvistetaan...",
|
||||
"errors": {
|
||||
"missingRequestUri": "Puuttuva request_uri-parametri",
|
||||
"verificationFailed": "Vahvistus epäonnistui",
|
||||
@@ -660,8 +724,6 @@
|
||||
"title": "Syötä todentajakoodi",
|
||||
"subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi",
|
||||
"codePlaceholder": "Syötä 6-numeroinen koodi",
|
||||
"verify": "Vahvista",
|
||||
"verifying": "Vahvistetaan...",
|
||||
"useBackupCode": "Käytä varakoodia sen sijaan",
|
||||
"backupCodePlaceholder": "Syötä varakoodi",
|
||||
"trustDevice": "Luota tähän laitteeseen 30 päivää",
|
||||
@@ -691,12 +753,7 @@
|
||||
"codeLabel": "Vahvistuskoodi",
|
||||
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
|
||||
"verifyButton": "Vahvista tili",
|
||||
"verify": "Vahvista",
|
||||
"verifying": "Vahvistetaan...",
|
||||
"pleaseWait": "Odota...",
|
||||
"sending": "Lähetetään...",
|
||||
"resendCode": "Lähetä koodi uudelleen",
|
||||
"resending": "Lähetetään uudelleen...",
|
||||
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
|
||||
"codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.",
|
||||
"verified": "Vahvistettu!",
|
||||
@@ -706,14 +763,12 @@
|
||||
"identifierLabel": "Sähköposti tai tunniste",
|
||||
"identifierPlaceholder": "sinä@esimerkki.fi",
|
||||
"identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin",
|
||||
"backToLogin": "Takaisin kirjautumiseen",
|
||||
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
|
||||
"startOver": "Aloita alusta toisella tilillä",
|
||||
"noPending": "Odottavaa vahvistusta ei löytynyt.",
|
||||
"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.",
|
||||
"createAccount": "Luo tili",
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"backToSettings": "Takaisin asetuksiin",
|
||||
"emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi",
|
||||
"emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui",
|
||||
"emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.",
|
||||
@@ -746,7 +801,6 @@
|
||||
"resetButton": "Palauta salasana",
|
||||
"resetting": "Palautetaan...",
|
||||
"success": "Salasana palautettu!",
|
||||
"backToLogin": "Takaisin kirjautumiseen",
|
||||
"requestNewCode": "Pyydä uusi koodi",
|
||||
"passwordsMismatch": "Salasanat eivät täsmää",
|
||||
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä"
|
||||
@@ -790,8 +844,7 @@
|
||||
"howItWorks": "Miten se toimii",
|
||||
"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.",
|
||||
"sendRecoveryLink": "Lähetä palautuslinkki",
|
||||
"sending": "Lähetetään...",
|
||||
"backToLogin": "Takaisin kirjautumiseen"
|
||||
"sending": "Lähetetään..."
|
||||
},
|
||||
"registerPasskey": {
|
||||
"title": "Luo pääsyavaintili",
|
||||
@@ -812,7 +865,6 @@
|
||||
"externalDid": "Sinun did:web",
|
||||
"externalDidPlaceholder": "did:web:verkkotunnuksesi.fi",
|
||||
"createButton": "Luo tili",
|
||||
"creating": "Luodaan...",
|
||||
"alreadyHaveAccount": "Onko sinulla jo tili?",
|
||||
"signIn": "Kirjaudu sisään",
|
||||
"wantPassword": "Haluatko käyttää salasanaa?",
|
||||
@@ -911,8 +963,6 @@
|
||||
"useTotp": "Käytä todentajaa",
|
||||
"passwordPlaceholder": "Syötä salasanasi",
|
||||
"totpPlaceholder": "Syötä 6-numeroinen koodi",
|
||||
"verify": "Vahvista",
|
||||
"verifying": "Vahvistetaan...",
|
||||
"authenticating": "Todennetaan...",
|
||||
"passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.",
|
||||
"cancel": "Peruuta"
|
||||
@@ -967,7 +1017,6 @@
|
||||
"handle": "Käyttäjänimi",
|
||||
"emailOptional": "Sähköposti (valinnainen)",
|
||||
"yourAccessLevel": "Käyttöoikeustasosi",
|
||||
"creating": "Luodaan...",
|
||||
"createAccount": "Luo tili",
|
||||
"createDelegatedAccountButton": "+ Luo delegoitu tili",
|
||||
"accountCreated": "Delegoitu tili luotu: {handle}",
|
||||
@@ -1059,15 +1108,9 @@
|
||||
"navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä",
|
||||
"migrateHere": "Siirrä tänne",
|
||||
"migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.",
|
||||
"migrateAway": "Siirrä pois",
|
||||
"migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.",
|
||||
"loginRequired": "Kirjautuminen vaaditaan",
|
||||
"bringDid": "Tuo DID ja identiteettisi",
|
||||
"transferData": "Siirrä kaikki tietosi",
|
||||
"keepFollowers": "Säilytä seuraajasi",
|
||||
"exportRepo": "Vie tietovarastosi",
|
||||
"transferToPds": "Siirrä uuteen PDS:ään",
|
||||
"updateIdentity": "Päivitä identiteettisi",
|
||||
"whatIsMigration": "Mikä on tilin siirto?",
|
||||
"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.",
|
||||
"beforeMigrate": "Ennen siirtoa",
|
||||
@@ -1077,7 +1120,11 @@
|
||||
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
|
||||
"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ä.",
|
||||
"learnMore": "Lue lisää siirron riskeistä",
|
||||
"comingSoon": "Tulossa pian",
|
||||
"offlineRestore": "Offline-palautus",
|
||||
"offlineRestoreDesc": "Palauta varmuuskopiosta, kun vanha PDS ei ole käytettävissä.",
|
||||
"offlineFeature1": "Käytä CAR-tiedoston varmuuskopiota",
|
||||
"offlineFeature2": "Todista omistajuus rotaatioavaimella",
|
||||
"offlineFeature3": "Palautus suljetuille palvelimille",
|
||||
"oauthCompleting": "Viimeistellään todennusta...",
|
||||
"oauthFailed": "Todennus epäonnistui",
|
||||
"tryAgain": "Yritä uudelleen",
|
||||
@@ -1086,7 +1133,6 @@
|
||||
"incomplete": "Sinulla on keskeneräinen siirto:",
|
||||
"direction": "Suunta",
|
||||
"migratingHere": "Siirretään tänne",
|
||||
"migratingAway": "Siirretään pois",
|
||||
"from": "Mistä",
|
||||
"to": "Minne",
|
||||
"progress": "Edistyminen",
|
||||
@@ -1229,7 +1275,8 @@
|
||||
"error": {
|
||||
"title": "Siirtovirhe",
|
||||
"desc": "Siirron aikana tapahtui virhe.",
|
||||
"startOver": "Aloita alusta"
|
||||
"startOver": "Aloita alusta",
|
||||
"unknown": "Tuntematon virhe tapahtui."
|
||||
},
|
||||
"common": {
|
||||
"back": "Takaisin",
|
||||
@@ -1247,70 +1294,77 @@
|
||||
"warning3": "Vanha tilisi deaktivoidaan siirron jälkeen"
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"offline": {
|
||||
"welcome": {
|
||||
"title": "Siirrä pois tästä PDS:stä",
|
||||
"desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.",
|
||||
"warning": "Siirron jälkeen tilisi täällä deaktivoidaan.",
|
||||
"didWebNotice": "did:web-siirtoilmoitus",
|
||||
"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ä.",
|
||||
"understand": "Ymmärrän riskit ja haluan jatkaa"
|
||||
"title": "Palauta varmuuskopiosta",
|
||||
"desc": "Palauta tilisi CAR-tiedoston varmuuskopiolla ja rotaatioavaimella. Käytä tätä, kun edellinen PDS ei ole käytettävissä.",
|
||||
"warningTitle": "Milloin käyttää tätä menetelmää",
|
||||
"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.",
|
||||
"requirementsTitle": "Tarvitset",
|
||||
"requirement1": "CAR-tiedoston varmuuskopion tietovarastostasi",
|
||||
"requirement2": "Rotaatioavaimesi (DID:n yksityinen avain)",
|
||||
"requirement3": "DID:si (did:plc:xxx)",
|
||||
"understand": "Ymmärrän ja haluan jatkaa"
|
||||
},
|
||||
"targetPds": {
|
||||
"title": "Valitse kohde-PDS",
|
||||
"desc": "Syötä sen PDS:n URL, johon haluat siirtyä.",
|
||||
"url": "PDS URL",
|
||||
"urlPlaceholder": "https://pds.example.com",
|
||||
"validate": "Vahvista ja jatka",
|
||||
"validating": "Vahvistetaan...",
|
||||
"connected": "Yhdistetty: {name}",
|
||||
"inviteRequired": "Kutsukoodi vaaditaan",
|
||||
"privacyPolicy": "Tietosuojakäytäntö",
|
||||
"termsOfService": "Käyttöehdot"
|
||||
"provideDid": {
|
||||
"title": "Syötä DID:si",
|
||||
"desc": "Syötä palautettavan tilin DID.",
|
||||
"label": "DID:si",
|
||||
"hint": "Hajautettu tunnistesi (esim. did:plc:abc123)"
|
||||
},
|
||||
"newAccount": {
|
||||
"title": "Uuden tilin tiedot",
|
||||
"desc": "Määritä tilisi uudessa PDS:ssä.",
|
||||
"handle": "Käyttäjätunnus",
|
||||
"availableDomains": "Käytettävissä olevat verkkotunnukset",
|
||||
"email": "Sähköposti",
|
||||
"password": "Salasana",
|
||||
"confirmPassword": "Vahvista salasana",
|
||||
"inviteCode": "Kutsukoodi"
|
||||
"uploadCar": {
|
||||
"title": "Lataa CAR-tiedosto",
|
||||
"desc": "Lataa tietovaraston varmuuskopiotiedostosi.",
|
||||
"label": "CAR-tiedosto",
|
||||
"hint": "Valitse .car-tiedosto varmuuskopiostasi",
|
||||
"reuploadWarningTitle": "CAR-tiedosto vaaditaan",
|
||||
"reuploadWarning": "Istuntosi palautettiin, mutta sinun täytyy ladata CAR-tiedostosi uudelleen. Turvallisuussyistä tiedostosisältöä ei tallenneta istuntojen välillä."
|
||||
},
|
||||
"rotationKey": {
|
||||
"title": "Anna rotaatioavain",
|
||||
"desc": "Anna rotaatioavaimesi todistaaksesi tämän DID:n omistajuuden.",
|
||||
"securityWarningTitle": "Turvallisuusvaroitus",
|
||||
"securityWarning1": "Rotaatioavaimesi on erittäin arkaluonteinen - kohtele sitä kuten pääsalasanaa",
|
||||
"securityWarning2": "Syötä se vain luotetuilla laitteilla ja verkoilla",
|
||||
"securityWarning3": "Tätä avainta ei tallenneta siirron jälkeen",
|
||||
"label": "Rotaatioavain",
|
||||
"placeholder": "Syötä yksityinen avain (hex, base58 tai JWK)",
|
||||
"hint": "Yksityinen avain, joka vastaa yhtä DID-dokumentin rotaatioavaimista",
|
||||
"valid": "Avain on kelvollinen ja vastaa DID:si rotaatioavainta",
|
||||
"invalid": "Avain ei vastaa mitään DID-dokumentin rotaatioavainta",
|
||||
"validating": "Vahvistetaan avainta...",
|
||||
"validate": "Vahvista avain"
|
||||
},
|
||||
"chooseHandle": {
|
||||
"migratingDid": "Palautetaan DID"
|
||||
},
|
||||
"review": {
|
||||
"title": "Tarkista siirto",
|
||||
"desc": "Tarkista ja vahvista siirtotietosi.",
|
||||
"currentHandle": "Nykyinen käyttäjätunnus",
|
||||
"newHandle": "Uusi käyttäjätunnus",
|
||||
"sourcePds": "Tämä PDS",
|
||||
"targetPds": "Kohde-PDS",
|
||||
"confirm": "Vahvistan haluavani siirtää tilini",
|
||||
"startMigration": "Aloita siirto"
|
||||
"desc": "Tarkista offline-palautuksen tiedot.",
|
||||
"carFile": "CAR-tiedosto",
|
||||
"rotationKey": "Rotaatioavain",
|
||||
"warning": "Kun aloitat palautuksen, identiteettisi päivitetään osoittamaan tähän PDS:ään. Tätä ei voi helposti perua.",
|
||||
"plcWarningTitle": "Ei paluuta",
|
||||
"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."
|
||||
},
|
||||
"migrating": {
|
||||
"title": "Siirretään tiliäsi",
|
||||
"desc": "Odota, kun siirrämme tietojasi..."
|
||||
},
|
||||
"plcToken": {
|
||||
"title": "Vahvista henkilöllisyytesi",
|
||||
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi."
|
||||
},
|
||||
"finalizing": {
|
||||
"title": "Viimeistellään siirtoa",
|
||||
"desc": "Odota, kun viimeistelemme siirtoa...",
|
||||
"updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..."
|
||||
"title": "Palautetaan tiliä",
|
||||
"desc": "Odota, tiliäsi palautetaan...",
|
||||
"creating": "Luodaan tili",
|
||||
"importing": "Tuodaan tietovarastoa",
|
||||
"plcSigning": "Päivitetään identiteettiä",
|
||||
"activating": "Aktivoidaan tili"
|
||||
},
|
||||
"success": {
|
||||
"title": "Siirto valmis!",
|
||||
"desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.",
|
||||
"newHandle": "Uusi käyttäjätunnus",
|
||||
"newPds": "Uusi PDS",
|
||||
"nextSteps": "Seuraavat vaiheet",
|
||||
"nextSteps1": "Kirjaudu uuteen PDS:ääsi",
|
||||
"nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi",
|
||||
"nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi",
|
||||
"loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..."
|
||||
"desc": "Tilisi on palautettu onnistuneesti tähän PDS:ään."
|
||||
},
|
||||
"blobs": {
|
||||
"title": "Siirretään blob-tiedostoja",
|
||||
"desc": "Yritetään palauttaa kuvia ja mediaa vanhasta PDS:stäsi...",
|
||||
"migrating": "Siirretään blob-tiedostoja",
|
||||
"failedTitle": "Joitain blob-tiedostoja ei voitu siirtää",
|
||||
"failedDesc": "{count} blob-tiedostoa ei voitu hakea vanhasta PDS:stäsi. Tämä voi tapahtua, jos palvelin ei ole tavoitettavissa tai tiedostot on poistettu.",
|
||||
"sourceUnreachableTitle": "Lähde-PDS ei tavoitettavissa",
|
||||
"sourceUnreachable": "Ei voitu yhdistää vanhaan PDS:ääsi mediatiedostojen hakemiseksi. Tämä on yleistä siirrettäessä suljetulta palvelimelta. Julkaisusi toimivat, mutta joitain kuvia saattaa puuttua."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
|
||||
+147
-100
@@ -17,7 +17,46 @@
|
||||
"dashboard": "ダッシュボード",
|
||||
"backToDashboard": "← ダッシュボード",
|
||||
"copied": "コピーしました!",
|
||||
"copyToClipboard": "クリップボードにコピー"
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"verifying": "確認中...",
|
||||
"saving": "保存中...",
|
||||
"creating": "作成中...",
|
||||
"updating": "更新中...",
|
||||
"sending": "送信中...",
|
||||
"authenticating": "認証中...",
|
||||
"checking": "確認中...",
|
||||
"redirecting": "リダイレクト中...",
|
||||
"signIn": "サインイン",
|
||||
"verify": "確認",
|
||||
"remove": "削除",
|
||||
"revoke": "取り消し",
|
||||
"resendCode": "コードを再送信",
|
||||
"startOver": "最初からやり直す",
|
||||
"tryAgain": "再試行",
|
||||
"password": "パスワード",
|
||||
"email": "メール",
|
||||
"emailAddress": "メールアドレス",
|
||||
"handle": "ハンドル",
|
||||
"did": "DID",
|
||||
"verificationCode": "確認コード",
|
||||
"inviteCode": "招待コード",
|
||||
"newPassword": "新しいパスワード",
|
||||
"confirmPassword": "パスワードを確認",
|
||||
"enterSixDigitCode": "6桁のコードを入力",
|
||||
"passwordHint": "8文字以上",
|
||||
"enterPassword": "パスワードを入力",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"verified": "確認済み",
|
||||
"disabled": "無効",
|
||||
"available": "利用可能",
|
||||
"deactivated": "非アクティブ",
|
||||
"unverified": "未確認",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"backToSettings": "設定に戻る",
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
"createAccount": "アカウントを作成",
|
||||
"passwordsMismatch": "パスワードが一致しません",
|
||||
"passwordTooShort": "パスワードは8文字以上必要です"
|
||||
},
|
||||
"login": {
|
||||
"title": "サインイン",
|
||||
@@ -49,11 +88,7 @@
|
||||
"codeLabel": "確認コード",
|
||||
"codePlaceholder": "6桁のコードを入力",
|
||||
"verifyButton": "確認する",
|
||||
"verifying": "確認中...",
|
||||
"resendButton": "コードを再送信",
|
||||
"resending": "送信中...",
|
||||
"resent": "確認コードを再送信しました!",
|
||||
"backToLogin": "ログインに戻る"
|
||||
"resent": "確認コードを再送信しました!"
|
||||
},
|
||||
"register": {
|
||||
"title": "アカウント作成",
|
||||
@@ -124,7 +159,6 @@
|
||||
"inviteCodePlaceholder": "招待コードを入力",
|
||||
"inviteCodeRequired": "必須",
|
||||
"createButton": "アカウントを作成",
|
||||
"creating": "作成中...",
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
"signIn": "サインイン",
|
||||
"wantPasswordless": "パスワードレスをご希望ですか?",
|
||||
@@ -179,6 +213,11 @@
|
||||
"navAdminDesc": "サーバー統計と管理操作",
|
||||
"navDidDocument": "DID ドキュメント",
|
||||
"navDidDocumentDesc": "DID ドキュメントとキーを管理",
|
||||
"navDidDocumentDescActive": "DID ドキュメント設定を編集",
|
||||
"navBackup": "バックアップをダウンロード",
|
||||
"navBackupDesc": "リポジトリを CAR ファイルとしてダウンロード",
|
||||
"downloadingBackup": "ダウンロード中...",
|
||||
"backupFailed": "バックアップのダウンロードに失敗しました",
|
||||
"migrated": "移行済み",
|
||||
"migratedTitle": "アカウント移行済み",
|
||||
"migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。",
|
||||
@@ -208,7 +247,6 @@
|
||||
"serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。",
|
||||
"currentPds": "現在のPDS URL",
|
||||
"save": "変更を保存",
|
||||
"saving": "保存中...",
|
||||
"success": "DID ドキュメントを更新しました",
|
||||
"saveFailed": "DIDドキュメントの保存に失敗しました",
|
||||
"loadFailed": "DIDドキュメントの読み込みに失敗しました",
|
||||
@@ -246,7 +284,6 @@
|
||||
"yourDomain": "ドメイン",
|
||||
"yourDomainPlaceholder": "example.com",
|
||||
"verifyAndUpdate": "確認してハンドルを更新",
|
||||
"verifying": "確認中...",
|
||||
"newHandle": "新しいハンドル",
|
||||
"newHandlePlaceholder": "yourhandle",
|
||||
"changeHandleButton": "ハンドルを変更",
|
||||
@@ -262,7 +299,34 @@
|
||||
"exportData": "データエクスポート",
|
||||
"exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。",
|
||||
"downloadRepo": "リポジトリをダウンロード",
|
||||
"downloadBlobs": "メディアをダウンロード",
|
||||
"exporting": "エクスポート中...",
|
||||
"backups": {
|
||||
"title": "バックアップ",
|
||||
"description": "リポジトリは毎日自動的にバックアップされます。手動でバックアップを作成したり、以前のバックアップから復元することもできます。",
|
||||
"enableAutomatic": "自動バックアップを有効にする",
|
||||
"enabled": "自動バックアップが有効です",
|
||||
"disabled": "自動バックアップが無効です",
|
||||
"toggleFailed": "バックアップ設定の更新に失敗しました",
|
||||
"noBackups": "バックアップはまだありません。",
|
||||
"blocks": "ブロック",
|
||||
"download": "ダウンロード",
|
||||
"delete": "削除",
|
||||
"createNow": "今すぐバックアップを作成",
|
||||
"created": "バックアップが正常に作成されました",
|
||||
"createFailed": "バックアップの作成に失敗しました",
|
||||
"downloadFailed": "バックアップのダウンロードに失敗しました",
|
||||
"deleted": "バックアップが削除されました",
|
||||
"deleteFailed": "バックアップの削除に失敗しました",
|
||||
"restoreTitle": "バックアップから復元",
|
||||
"restoreDescription": "CARファイルをアップロードしてリポジトリを復元します。現在のデータは上書きされます。",
|
||||
"selectFile": "CARファイルを選択",
|
||||
"selectedFile": "選択されたファイル",
|
||||
"restore": "復元",
|
||||
"restoring": "復元中...",
|
||||
"restored": "リポジトリが正常に復元されました",
|
||||
"restoreFailed": "リポジトリの復元に失敗しました"
|
||||
},
|
||||
"deleteAccount": "アカウント削除",
|
||||
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
|
||||
"requestDeletion": "アカウント削除をリクエスト",
|
||||
@@ -291,7 +355,9 @@
|
||||
"deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。",
|
||||
"deletionFailed": "アカウントの削除に失敗しました",
|
||||
"repoExported": "リポジトリをエクスポートしました",
|
||||
"exportFailed": "リポジトリのエクスポートに失敗しました",
|
||||
"blobsExported": "メディアファイルをエクスポートしました",
|
||||
"noBlobsToExport": "エクスポートするメディアファイルがありません",
|
||||
"exportFailed": "エクスポートに失敗しました",
|
||||
"confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。"
|
||||
}
|
||||
},
|
||||
@@ -306,7 +372,6 @@
|
||||
"noPasswords": "アプリパスワードはまだありません",
|
||||
"revoke": "取り消す",
|
||||
"revoking": "取り消し中...",
|
||||
"creating": "作成中...",
|
||||
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
|
||||
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
|
||||
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
|
||||
@@ -354,7 +419,6 @@
|
||||
"used": "@{handle} が使用済み",
|
||||
"disabled": "無効",
|
||||
"usedBy": "使用者",
|
||||
"creating": "作成中...",
|
||||
"disableConfirm": "この招待コードを無効にしますか?使用できなくなります。",
|
||||
"created": "招待コードを作成しました",
|
||||
"copy": "コピー",
|
||||
@@ -482,7 +546,6 @@
|
||||
"verifyButton": "確認",
|
||||
"verifyCodePlaceholder": "確認コードを入力",
|
||||
"submit": "送信",
|
||||
"saving": "保存中...",
|
||||
"savePreferences": "設定を保存",
|
||||
"preferencesSaved": "連絡設定を保存しました",
|
||||
"verifiedSuccess": "{channel} を確認しました",
|
||||
@@ -521,13 +584,11 @@
|
||||
"noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。",
|
||||
"loadMore": "さらに読み込む",
|
||||
"recordJson": "レコード JSON",
|
||||
"saving": "保存中...",
|
||||
"updateRecord": "レコードを更新",
|
||||
"collectionNsid": "コレクション (NSID)",
|
||||
"recordKeyOptional": "レコードキー(任意)",
|
||||
"autoGenerated": "空白で自動生成 (TID)",
|
||||
"autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます",
|
||||
"creating": "作成中...",
|
||||
"demoPostText": "こんにちは、私の PDS からの初投稿です!",
|
||||
"demoDisplayName": "表示名",
|
||||
"demoBio": "自己紹介を書いてください。"
|
||||
@@ -548,7 +609,6 @@
|
||||
"primaryLight": "プライマリ(ライトモード)",
|
||||
"primaryDark": "プライマリ(ダークモード)",
|
||||
"configSaved": "サーバー設定を保存しました",
|
||||
"saving": "保存中...",
|
||||
"saveConfig": "設定を保存",
|
||||
"serverStats": "サーバー統計",
|
||||
"users": "ユーザー",
|
||||
@@ -639,16 +699,13 @@
|
||||
"title": "二要素認証",
|
||||
"subtitle": "追加の確認が必要です",
|
||||
"usePasskey": "パスキーを使用",
|
||||
"useTotp": "認証アプリを使用",
|
||||
"verifying": "確認中..."
|
||||
"useTotp": "認証アプリを使用"
|
||||
},
|
||||
"twoFactorCode": {
|
||||
"title": "二要素認証",
|
||||
"subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。",
|
||||
"codeLabel": "確認コード",
|
||||
"codePlaceholder": "6桁のコードを入力",
|
||||
"verify": "確認",
|
||||
"verifying": "確認中...",
|
||||
"errors": {
|
||||
"missingRequestUri": "request_uri パラメータがありません",
|
||||
"verificationFailed": "確認に失敗しました",
|
||||
@@ -660,8 +717,6 @@
|
||||
"title": "認証コードを入力",
|
||||
"subtitle": "認証アプリの6桁のコードを入力",
|
||||
"codePlaceholder": "6桁のコードを入力",
|
||||
"verify": "確認",
|
||||
"verifying": "確認中...",
|
||||
"useBackupCode": "バックアップコードを使用",
|
||||
"backupCodePlaceholder": "バックアップコードを入力",
|
||||
"trustDevice": "このデバイスを30日間信頼する",
|
||||
@@ -691,12 +746,7 @@
|
||||
"codeLabel": "確認コード",
|
||||
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
|
||||
"verifyButton": "アカウントを確認",
|
||||
"verify": "確認",
|
||||
"verifying": "確認中...",
|
||||
"pleaseWait": "お待ちください...",
|
||||
"sending": "送信中...",
|
||||
"resendCode": "コードを再送信",
|
||||
"resending": "送信中...",
|
||||
"codeResent": "確認コードを再送信しました!",
|
||||
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
|
||||
"verified": "確認完了!",
|
||||
@@ -706,14 +756,12 @@
|
||||
"identifierLabel": "メールまたは識別子",
|
||||
"identifierPlaceholder": "you@example.com",
|
||||
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"verifyingAccount": "確認中のアカウント: @{handle}",
|
||||
"startOver": "別のアカウントでやり直す",
|
||||
"noPending": "保留中の確認が見つかりません。",
|
||||
"noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。",
|
||||
"createAccount": "アカウントを作成",
|
||||
"signIn": "サインイン",
|
||||
"backToSettings": "設定に戻る",
|
||||
"emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました",
|
||||
"emailUpdateFailed": "メールアドレスの更新に失敗しました",
|
||||
"emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。",
|
||||
@@ -746,7 +794,6 @@
|
||||
"resetButton": "パスワードをリセット",
|
||||
"resetting": "リセット中...",
|
||||
"success": "パスワードをリセットしました!",
|
||||
"backToLogin": "サインインに戻る",
|
||||
"requestNewCode": "新しいコードをリクエスト",
|
||||
"passwordsMismatch": "パスワードが一致しません",
|
||||
"passwordLength": "パスワードは8文字以上である必要があります"
|
||||
@@ -790,8 +837,7 @@
|
||||
"howItWorks": "仕組み",
|
||||
"howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。",
|
||||
"sendRecoveryLink": "復旧リンクを送信",
|
||||
"sending": "送信中...",
|
||||
"backToLogin": "サインインに戻る"
|
||||
"sending": "送信中..."
|
||||
},
|
||||
"registerPasskey": {
|
||||
"title": "パスキーアカウントを作成",
|
||||
@@ -812,7 +858,6 @@
|
||||
"externalDid": "あなたの did:web",
|
||||
"externalDidPlaceholder": "did:web:yourdomain.com",
|
||||
"createButton": "アカウントを作成",
|
||||
"creating": "作成中...",
|
||||
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
|
||||
"signIn": "サインイン",
|
||||
"wantPassword": "パスワードを使用しますか?",
|
||||
@@ -911,8 +956,6 @@
|
||||
"useTotp": "認証アプリを使用",
|
||||
"passwordPlaceholder": "パスワードを入力",
|
||||
"totpPlaceholder": "6桁のコードを入力",
|
||||
"verify": "確認",
|
||||
"verifying": "確認中...",
|
||||
"authenticating": "認証中...",
|
||||
"passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。",
|
||||
"cancel": "キャンセル"
|
||||
@@ -985,7 +1028,6 @@
|
||||
"createAccount": "アカウントを作成",
|
||||
"createDelegatedAccount": "委任アカウントを作成",
|
||||
"createDelegatedAccountButton": "+ 委任アカウントを作成",
|
||||
"creating": "作成中...",
|
||||
"emailOptional": "メール(任意)",
|
||||
"failedToAddController": "コントローラーの追加に失敗しました",
|
||||
"failedToCreateAccount": "委任アカウントの作成に失敗しました",
|
||||
@@ -1059,15 +1101,9 @@
|
||||
"navDesc": "別のPDSへ、または別のPDSからアカウントを移動",
|
||||
"migrateHere": "ここに移行",
|
||||
"migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。",
|
||||
"migrateAway": "別の場所に移行",
|
||||
"migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。",
|
||||
"loginRequired": "ログインが必要です",
|
||||
"bringDid": "DIDとアイデンティティを持ち込む",
|
||||
"transferData": "すべてのデータを転送",
|
||||
"keepFollowers": "フォロワーを維持",
|
||||
"exportRepo": "リポジトリをエクスポート",
|
||||
"transferToPds": "新しいPDSに転送",
|
||||
"updateIdentity": "アイデンティティを更新",
|
||||
"whatIsMigration": "アカウント移行とは?",
|
||||
"whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。",
|
||||
"beforeMigrate": "移行前の確認事項",
|
||||
@@ -1077,7 +1113,11 @@
|
||||
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
|
||||
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
|
||||
"learnMore": "移行のリスクについて詳しく",
|
||||
"comingSoon": "近日公開",
|
||||
"offlineRestore": "オフライン復元",
|
||||
"offlineRestoreDesc": "旧PDSが利用できない場合にバックアップから復元します。",
|
||||
"offlineFeature1": "CARファイルバックアップを使用",
|
||||
"offlineFeature2": "ローテーションキーで所有権を証明",
|
||||
"offlineFeature3": "シャットダウンしたサーバーの復旧",
|
||||
"oauthCompleting": "認証を完了しています...",
|
||||
"oauthFailed": "認証に失敗しました",
|
||||
"tryAgain": "再試行",
|
||||
@@ -1086,7 +1126,6 @@
|
||||
"incomplete": "未完了の移行があります:",
|
||||
"direction": "方向",
|
||||
"migratingHere": "ここに移行中",
|
||||
"migratingAway": "別の場所に移行中",
|
||||
"from": "移行元",
|
||||
"to": "移行先",
|
||||
"progress": "進行状況",
|
||||
@@ -1229,7 +1268,8 @@
|
||||
"error": {
|
||||
"title": "移行エラー",
|
||||
"desc": "移行中にエラーが発生しました。",
|
||||
"startOver": "最初からやり直す"
|
||||
"startOver": "最初からやり直す",
|
||||
"unknown": "不明なエラーが発生しました。"
|
||||
},
|
||||
"common": {
|
||||
"back": "戻る",
|
||||
@@ -1247,70 +1287,77 @@
|
||||
"warning3": "移行後、古いアカウントは無効化されます"
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"offline": {
|
||||
"welcome": {
|
||||
"title": "このPDSから移行",
|
||||
"desc": "アカウントを別のパーソナルデータサーバーに移動します。",
|
||||
"warning": "移行後、ここでのアカウントは無効化されます。",
|
||||
"didWebNotice": "did:web移行のお知らせ",
|
||||
"didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。",
|
||||
"understand": "リスクを理解し、続行します"
|
||||
"title": "バックアップから復元",
|
||||
"desc": "CARファイルバックアップとローテーションキーを使用してアカウントを復元します。以前のPDSが利用できない場合に使用してください。",
|
||||
"warningTitle": "この方法を使用するタイミング",
|
||||
"warningDesc": "このオフライン復元は、古いPDSがシャットダウンした、アクセスできない、またはロックアウトされた場合の災害復旧用です。古いPDSがまだ利用可能な場合は、代わりに標準の移行を使用してください。",
|
||||
"requirementsTitle": "必要なもの",
|
||||
"requirement1": "リポジトリのCARファイルバックアップ",
|
||||
"requirement2": "ローテーションキー(DIDの秘密鍵)",
|
||||
"requirement3": "あなたのDID (did:plc:xxx)",
|
||||
"understand": "理解し、続行します"
|
||||
},
|
||||
"targetPds": {
|
||||
"title": "移行先PDSを選択",
|
||||
"desc": "移行先のPDSのURLを入力してください。",
|
||||
"url": "PDS URL",
|
||||
"urlPlaceholder": "https://pds.example.com",
|
||||
"validate": "検証して続行",
|
||||
"validating": "検証中...",
|
||||
"connected": "{name}に接続しました",
|
||||
"inviteRequired": "招待コードが必要です",
|
||||
"privacyPolicy": "プライバシーポリシー",
|
||||
"termsOfService": "利用規約"
|
||||
"provideDid": {
|
||||
"title": "DIDを入力",
|
||||
"desc": "復元するアカウントのDIDを入力してください。",
|
||||
"label": "あなたのDID",
|
||||
"hint": "分散型識別子(例:did:plc:abc123)"
|
||||
},
|
||||
"newAccount": {
|
||||
"title": "新しいアカウントの詳細",
|
||||
"desc": "新しいPDSでアカウントを設定します。",
|
||||
"handle": "ハンドル",
|
||||
"availableDomains": "利用可能なドメイン",
|
||||
"email": "メール",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワードを確認",
|
||||
"inviteCode": "招待コード"
|
||||
"uploadCar": {
|
||||
"title": "CARファイルをアップロード",
|
||||
"desc": "リポジトリバックアップファイルをアップロードしてください。",
|
||||
"label": "CARファイル",
|
||||
"hint": "バックアップから.carファイルを選択",
|
||||
"reuploadWarningTitle": "CARファイルが必要です",
|
||||
"reuploadWarning": "セッションは復元されましたが、CARファイルを再アップロードする必要があります。セキュリティ上の理由から、ファイルの内容はセッション間で保存されません。"
|
||||
},
|
||||
"rotationKey": {
|
||||
"title": "ローテーションキーを提供",
|
||||
"desc": "このDIDの所有権を証明するためにローテーションキーを入力してください。",
|
||||
"securityWarningTitle": "セキュリティ警告",
|
||||
"securityWarning1": "ローテーションキーは非常に機密性が高いです - マスターパスワードのように扱ってください",
|
||||
"securityWarning2": "信頼できるデバイスとネットワークでのみ入力してください",
|
||||
"securityWarning3": "このキーは移行完了後に保存されません",
|
||||
"label": "ローテーションキー",
|
||||
"placeholder": "秘密鍵を入力(hex、base58、またはJWK)",
|
||||
"hint": "DIDドキュメントのローテーションキーの1つに対応する秘密鍵",
|
||||
"valid": "キーは有効で、DIDのローテーションキーと一致します",
|
||||
"invalid": "キーはDIDドキュメントのどのローテーションキーとも一致しません",
|
||||
"validating": "キーを検証中...",
|
||||
"validate": "キーを検証"
|
||||
},
|
||||
"chooseHandle": {
|
||||
"migratingDid": "DIDを復元中"
|
||||
},
|
||||
"review": {
|
||||
"title": "移行の確認",
|
||||
"desc": "移行の詳細を確認してください。",
|
||||
"currentHandle": "現在のハンドル",
|
||||
"newHandle": "新しいハンドル",
|
||||
"sourcePds": "このPDS",
|
||||
"targetPds": "移行先PDS",
|
||||
"confirm": "アカウントを移行することを確認します",
|
||||
"startMigration": "移行を開始"
|
||||
"desc": "オフライン復元の詳細を確認してください。",
|
||||
"carFile": "CARファイル",
|
||||
"rotationKey": "ローテーションキー",
|
||||
"warning": "復元を開始すると、アイデンティティがこのPDSを指すように更新されます。これは簡単に元に戻すことができません。",
|
||||
"plcWarningTitle": "引き返せないポイント",
|
||||
"plcWarning": "開始すると、DIDドキュメントがこのPDSを指すように更新されます。問題が発生した場合はローテーションキーを使用して回復できますが、壊れたアイデンティティ状態を避けるために移行を完了する必要があります。"
|
||||
},
|
||||
"migrating": {
|
||||
"title": "アカウントを移行中",
|
||||
"desc": "データを転送しています..."
|
||||
},
|
||||
"plcToken": {
|
||||
"title": "本人確認",
|
||||
"desc": "確認コードがメールに送信されました。"
|
||||
},
|
||||
"finalizing": {
|
||||
"title": "移行を完了中",
|
||||
"desc": "移行を完了しています...",
|
||||
"updatingForwarding": "DIDドキュメントの転送先を更新中..."
|
||||
"title": "アカウントを復元中",
|
||||
"desc": "アカウントを復元しています...",
|
||||
"creating": "アカウントを作成中",
|
||||
"importing": "リポジトリをインポート中",
|
||||
"plcSigning": "アイデンティティを更新中",
|
||||
"activating": "アカウントをアクティベート中"
|
||||
},
|
||||
"success": {
|
||||
"title": "移行完了!",
|
||||
"desc": "アカウントは新しいPDSに正常に移行されました。",
|
||||
"newHandle": "新しいハンドル",
|
||||
"newPds": "新しいPDS",
|
||||
"nextSteps": "次のステップ",
|
||||
"nextSteps1": "新しいPDSにサインイン",
|
||||
"nextSteps2": "アプリの認証情報を更新",
|
||||
"nextSteps3": "フォロワーは自動的に新しい場所を確認できます",
|
||||
"loggingOut": "{seconds}秒後にログアウトします..."
|
||||
"desc": "アカウントはこのPDSに正常に復元されました。"
|
||||
},
|
||||
"blobs": {
|
||||
"title": "Blobを移行中",
|
||||
"desc": "古いPDSから画像とメディアの復元を試みています...",
|
||||
"migrating": "Blobを移行中",
|
||||
"failedTitle": "一部のBlobを移行できませんでした",
|
||||
"failedDesc": "{count}個のBlobを古いPDSから取得できませんでした。サーバーに接続できないか、ファイルが削除された可能性があります。",
|
||||
"sourceUnreachableTitle": "ソースPDSに接続できません",
|
||||
"sourceUnreachable": "古いPDSに接続してメディアファイルを取得できませんでした。シャットダウンしたサーバーからの移行ではよくあることです。投稿は機能しますが、一部の画像が欠落する可能性があります。"
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
|
||||
+147
-100
@@ -17,7 +17,46 @@
|
||||
"dashboard": "대시보드",
|
||||
"backToDashboard": "← 대시보드",
|
||||
"copied": "복사됨!",
|
||||
"copyToClipboard": "클립보드에 복사"
|
||||
"copyToClipboard": "클립보드에 복사",
|
||||
"verifying": "확인 중...",
|
||||
"saving": "저장 중...",
|
||||
"creating": "생성 중...",
|
||||
"updating": "업데이트 중...",
|
||||
"sending": "전송 중...",
|
||||
"authenticating": "인증 중...",
|
||||
"checking": "확인 중...",
|
||||
"redirecting": "리디렉션 중...",
|
||||
"signIn": "로그인",
|
||||
"verify": "확인",
|
||||
"remove": "삭제",
|
||||
"revoke": "취소",
|
||||
"resendCode": "코드 재전송",
|
||||
"startOver": "처음부터 다시",
|
||||
"tryAgain": "다시 시도",
|
||||
"password": "비밀번호",
|
||||
"email": "이메일",
|
||||
"emailAddress": "이메일 주소",
|
||||
"handle": "핸들",
|
||||
"did": "DID",
|
||||
"verificationCode": "인증 코드",
|
||||
"inviteCode": "초대 코드",
|
||||
"newPassword": "새 비밀번호",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"enterSixDigitCode": "6자리 코드 입력",
|
||||
"passwordHint": "8자 이상",
|
||||
"enterPassword": "비밀번호를 입력하세요",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"verified": "인증됨",
|
||||
"disabled": "비활성화됨",
|
||||
"available": "사용 가능",
|
||||
"deactivated": "비활성화됨",
|
||||
"unverified": "미인증",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"backToSettings": "설정으로 돌아가기",
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
"createAccount": "계정 만들기",
|
||||
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
|
||||
"passwordTooShort": "비밀번호는 8자 이상이어야 합니다"
|
||||
},
|
||||
"login": {
|
||||
"title": "로그인",
|
||||
@@ -49,11 +88,7 @@
|
||||
"codeLabel": "인증 코드",
|
||||
"codePlaceholder": "6자리 코드 입력",
|
||||
"verifyButton": "계정 인증",
|
||||
"verifying": "인증 중...",
|
||||
"resendButton": "코드 다시 보내기",
|
||||
"resending": "전송 중...",
|
||||
"resent": "인증 코드를 다시 보냈습니다!",
|
||||
"backToLogin": "로그인으로 돌아가기"
|
||||
"resent": "인증 코드를 다시 보냈습니다!"
|
||||
},
|
||||
"register": {
|
||||
"title": "계정 만들기",
|
||||
@@ -124,7 +159,6 @@
|
||||
"inviteCodePlaceholder": "초대 코드 입력",
|
||||
"inviteCodeRequired": "필수",
|
||||
"createButton": "계정 만들기",
|
||||
"creating": "계정 생성 중...",
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
"signIn": "로그인",
|
||||
"wantPasswordless": "비밀번호 없는 보안을 원하시나요?",
|
||||
@@ -179,6 +213,11 @@
|
||||
"navAdminDesc": "서버 통계 및 관리 작업",
|
||||
"navDidDocument": "DID 문서",
|
||||
"navDidDocumentDesc": "DID 문서 및 키 관리",
|
||||
"navDidDocumentDescActive": "DID 문서 설정 편집",
|
||||
"navBackup": "백업 다운로드",
|
||||
"navBackupDesc": "저장소를 CAR 파일로 다운로드",
|
||||
"downloadingBackup": "다운로드 중...",
|
||||
"backupFailed": "백업 다운로드 실패",
|
||||
"migrated": "마이그레이션됨",
|
||||
"migratedTitle": "계정 마이그레이션됨",
|
||||
"migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
|
||||
@@ -208,7 +247,6 @@
|
||||
"serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.",
|
||||
"currentPds": "현재 PDS URL",
|
||||
"save": "변경사항 저장",
|
||||
"saving": "저장 중...",
|
||||
"success": "DID 문서가 업데이트되었습니다",
|
||||
"saveFailed": "DID 문서 저장에 실패했습니다",
|
||||
"loadFailed": "DID 문서 로드에 실패했습니다",
|
||||
@@ -246,7 +284,6 @@
|
||||
"yourDomain": "도메인",
|
||||
"yourDomainPlaceholder": "example.com",
|
||||
"verifyAndUpdate": "확인 후 핸들 업데이트",
|
||||
"verifying": "확인 중...",
|
||||
"newHandle": "새 핸들",
|
||||
"newHandlePlaceholder": "yourhandle",
|
||||
"changeHandleButton": "핸들 변경",
|
||||
@@ -262,7 +299,34 @@
|
||||
"exportData": "데이터 내보내기",
|
||||
"exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.",
|
||||
"downloadRepo": "저장소 다운로드",
|
||||
"downloadBlobs": "미디어 다운로드",
|
||||
"exporting": "내보내기 중...",
|
||||
"backups": {
|
||||
"title": "백업",
|
||||
"description": "자동 백업을 관리하고 계정 데이터를 복원하세요. 백업에는 모든 기록과 blob이 포함됩니다.",
|
||||
"enableAutomatic": "자동 백업",
|
||||
"enabled": "활성화됨",
|
||||
"disabled": "비활성화됨",
|
||||
"toggleFailed": "백업 설정 변경 실패",
|
||||
"noBackups": "아직 백업이 없습니다",
|
||||
"blocks": "블록",
|
||||
"download": "다운로드",
|
||||
"delete": "삭제",
|
||||
"createNow": "지금 백업 생성",
|
||||
"created": "백업이 생성되었습니다",
|
||||
"createFailed": "백업 생성 실패",
|
||||
"downloadFailed": "백업 다운로드 실패",
|
||||
"deleted": "백업이 삭제되었습니다",
|
||||
"deleteFailed": "백업 삭제 실패",
|
||||
"restoreTitle": "백업에서 복원",
|
||||
"restoreDescription": "이전에 내보낸 CAR 파일에서 계정 데이터를 복원합니다. 이렇게 하면 현재 저장소가 업로드한 백업으로 교체됩니다.",
|
||||
"selectFile": "CAR 파일 선택",
|
||||
"selectedFile": "선택된 파일",
|
||||
"restore": "백업 복원",
|
||||
"restoring": "복원 중...",
|
||||
"restored": "백업이 성공적으로 복원되었습니다",
|
||||
"restoreFailed": "백업 복원 실패"
|
||||
},
|
||||
"deleteAccount": "계정 삭제",
|
||||
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
"requestDeletion": "계정 삭제 요청",
|
||||
@@ -291,7 +355,9 @@
|
||||
"deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"deletionFailed": "계정 삭제에 실패했습니다",
|
||||
"repoExported": "저장소를 내보냈습니다",
|
||||
"exportFailed": "저장소 내보내기에 실패했습니다",
|
||||
"blobsExported": "미디어 파일을 내보냈습니다",
|
||||
"noBlobsToExport": "내보낼 미디어 파일이 없습니다",
|
||||
"exportFailed": "내보내기에 실패했습니다",
|
||||
"confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
}
|
||||
},
|
||||
@@ -306,7 +372,6 @@
|
||||
"noPasswords": "앱 비밀번호가 아직 없습니다",
|
||||
"revoke": "취소",
|
||||
"revoking": "취소 중...",
|
||||
"creating": "생성 중...",
|
||||
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
|
||||
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
|
||||
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
|
||||
@@ -354,7 +419,6 @@
|
||||
"used": "@{handle}이(가) 사용함",
|
||||
"disabled": "비활성화됨",
|
||||
"usedBy": "사용자",
|
||||
"creating": "생성 중...",
|
||||
"disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.",
|
||||
"created": "초대 코드가 생성되었습니다",
|
||||
"copy": "복사",
|
||||
@@ -482,7 +546,6 @@
|
||||
"verifyButton": "인증",
|
||||
"verifyCodePlaceholder": "인증 코드 입력",
|
||||
"submit": "제출",
|
||||
"saving": "저장 중...",
|
||||
"savePreferences": "설정 저장",
|
||||
"preferencesSaved": "통신 설정이 저장되었습니다",
|
||||
"verifiedSuccess": "{channel} 인증 완료",
|
||||
@@ -521,13 +584,11 @@
|
||||
"noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.",
|
||||
"loadMore": "더 불러오기",
|
||||
"recordJson": "레코드 JSON",
|
||||
"saving": "저장 중...",
|
||||
"updateRecord": "레코드 업데이트",
|
||||
"collectionNsid": "컬렉션 (NSID)",
|
||||
"recordKeyOptional": "레코드 키 (선택사항)",
|
||||
"autoGenerated": "비워두면 자동 생성 (TID)",
|
||||
"autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다",
|
||||
"creating": "생성 중...",
|
||||
"demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!",
|
||||
"demoDisplayName": "표시 이름",
|
||||
"demoBio": "간단한 자기소개를 작성하세요."
|
||||
@@ -548,7 +609,6 @@
|
||||
"primaryLight": "기본 (라이트 모드)",
|
||||
"primaryDark": "기본 (다크 모드)",
|
||||
"configSaved": "서버 설정이 저장되었습니다",
|
||||
"saving": "저장 중...",
|
||||
"saveConfig": "설정 저장",
|
||||
"serverStats": "서버 통계",
|
||||
"users": "사용자",
|
||||
@@ -639,16 +699,13 @@
|
||||
"title": "2단계 인증",
|
||||
"subtitle": "추가 확인이 필요합니다",
|
||||
"usePasskey": "패스키 사용",
|
||||
"useTotp": "인증 앱 사용",
|
||||
"verifying": "확인 중..."
|
||||
"useTotp": "인증 앱 사용"
|
||||
},
|
||||
"twoFactorCode": {
|
||||
"title": "2단계 인증",
|
||||
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.",
|
||||
"codeLabel": "인증 코드",
|
||||
"codePlaceholder": "6자리 코드 입력",
|
||||
"verify": "확인",
|
||||
"verifying": "확인 중...",
|
||||
"errors": {
|
||||
"missingRequestUri": "request_uri 매개변수가 없습니다",
|
||||
"verificationFailed": "인증에 실패했습니다",
|
||||
@@ -660,8 +717,6 @@
|
||||
"title": "인증 코드 입력",
|
||||
"subtitle": "인증 앱의 6자리 코드를 입력하세요",
|
||||
"codePlaceholder": "6자리 코드 입력",
|
||||
"verify": "확인",
|
||||
"verifying": "확인 중...",
|
||||
"useBackupCode": "백업 코드 사용",
|
||||
"backupCodePlaceholder": "백업 코드 입력",
|
||||
"trustDevice": "이 기기를 30일간 신뢰",
|
||||
@@ -691,12 +746,7 @@
|
||||
"codeLabel": "인증 코드",
|
||||
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
|
||||
"verifyButton": "계정 인증",
|
||||
"verify": "인증",
|
||||
"verifying": "인증 중...",
|
||||
"pleaseWait": "잠시 기다려 주세요...",
|
||||
"sending": "전송 중...",
|
||||
"resendCode": "코드 다시 보내기",
|
||||
"resending": "전송 중...",
|
||||
"codeResent": "인증 코드를 다시 보냈습니다!",
|
||||
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
|
||||
"verified": "인증 완료!",
|
||||
@@ -706,14 +756,12 @@
|
||||
"identifierLabel": "이메일 또는 식별자",
|
||||
"identifierPlaceholder": "you@example.com",
|
||||
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"verifyingAccount": "인증 중인 계정: @{handle}",
|
||||
"startOver": "다른 계정으로 다시 시작",
|
||||
"noPending": "보류 중인 인증이 없습니다.",
|
||||
"noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.",
|
||||
"createAccount": "계정 만들기",
|
||||
"signIn": "로그인",
|
||||
"backToSettings": "설정으로 돌아가기",
|
||||
"emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다",
|
||||
"emailUpdateFailed": "이메일 주소 업데이트 실패",
|
||||
"emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.",
|
||||
@@ -746,7 +794,6 @@
|
||||
"resetButton": "비밀번호 재설정",
|
||||
"resetting": "재설정 중...",
|
||||
"success": "비밀번호가 재설정되었습니다!",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"requestNewCode": "새 코드 요청",
|
||||
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
|
||||
"passwordLength": "비밀번호는 8자 이상이어야 합니다"
|
||||
@@ -790,8 +837,7 @@
|
||||
"howItWorks": "작동 방식",
|
||||
"howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.",
|
||||
"sendRecoveryLink": "복구 링크 보내기",
|
||||
"sending": "전송 중...",
|
||||
"backToLogin": "로그인으로 돌아가기"
|
||||
"sending": "전송 중..."
|
||||
},
|
||||
"registerPasskey": {
|
||||
"title": "패스키 계정 만들기",
|
||||
@@ -812,7 +858,6 @@
|
||||
"externalDid": "귀하의 did:web",
|
||||
"externalDidPlaceholder": "did:web:yourdomain.com",
|
||||
"createButton": "계정 만들기",
|
||||
"creating": "생성 중...",
|
||||
"alreadyHaveAccount": "이미 계정이 있으신가요?",
|
||||
"signIn": "로그인",
|
||||
"wantPassword": "비밀번호를 사용하시겠습니까?",
|
||||
@@ -911,8 +956,6 @@
|
||||
"useTotp": "인증 앱 사용",
|
||||
"passwordPlaceholder": "비밀번호 입력",
|
||||
"totpPlaceholder": "6자리 코드 입력",
|
||||
"verify": "확인",
|
||||
"verifying": "확인 중...",
|
||||
"authenticating": "인증 중...",
|
||||
"passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.",
|
||||
"cancel": "취소"
|
||||
@@ -985,7 +1028,6 @@
|
||||
"createAccount": "계정 생성",
|
||||
"createDelegatedAccount": "위임 계정 생성",
|
||||
"createDelegatedAccountButton": "+ 위임 계정 생성",
|
||||
"creating": "생성 중...",
|
||||
"emailOptional": "이메일 (선택사항)",
|
||||
"failedToAddController": "컨트롤러 추가에 실패했습니다",
|
||||
"failedToCreateAccount": "위임 계정 생성에 실패했습니다",
|
||||
@@ -1059,15 +1101,9 @@
|
||||
"navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동",
|
||||
"migrateHere": "여기로 마이그레이션",
|
||||
"migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.",
|
||||
"migrateAway": "다른 곳으로 마이그레이션",
|
||||
"migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.",
|
||||
"loginRequired": "로그인 필요",
|
||||
"bringDid": "DID와 아이덴티티 가져오기",
|
||||
"transferData": "모든 데이터 전송",
|
||||
"keepFollowers": "팔로워 유지",
|
||||
"exportRepo": "저장소 내보내기",
|
||||
"transferToPds": "새 PDS로 전송",
|
||||
"updateIdentity": "아이덴티티 업데이트",
|
||||
"whatIsMigration": "계정 마이그레이션이란?",
|
||||
"whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.",
|
||||
"beforeMigrate": "마이그레이션 전 확인사항",
|
||||
@@ -1077,7 +1113,11 @@
|
||||
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
|
||||
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
|
||||
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
|
||||
"comingSoon": "곧 출시 예정",
|
||||
"offlineRestore": "오프라인 복원",
|
||||
"offlineRestoreDesc": "이전 PDS를 사용할 수 없을 때 백업에서 복원합니다.",
|
||||
"offlineFeature1": "CAR 파일 백업 사용",
|
||||
"offlineFeature2": "회전 키로 소유권 증명",
|
||||
"offlineFeature3": "종료된 서버 복구",
|
||||
"oauthCompleting": "인증 완료 중...",
|
||||
"oauthFailed": "인증 실패",
|
||||
"tryAgain": "다시 시도",
|
||||
@@ -1086,7 +1126,6 @@
|
||||
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
|
||||
"direction": "방향",
|
||||
"migratingHere": "여기로 마이그레이션 중",
|
||||
"migratingAway": "다른 곳으로 마이그레이션 중",
|
||||
"from": "출발지",
|
||||
"to": "목적지",
|
||||
"progress": "진행 상황",
|
||||
@@ -1229,7 +1268,8 @@
|
||||
"error": {
|
||||
"title": "마이그레이션 오류",
|
||||
"desc": "마이그레이션 중 오류가 발생했습니다.",
|
||||
"startOver": "처음부터 다시 시작"
|
||||
"startOver": "처음부터 다시 시작",
|
||||
"unknown": "알 수 없는 오류가 발생했습니다."
|
||||
},
|
||||
"common": {
|
||||
"back": "뒤로",
|
||||
@@ -1247,70 +1287,77 @@
|
||||
"warning3": "마이그레이션 후 이전 계정은 비활성화됩니다"
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"offline": {
|
||||
"welcome": {
|
||||
"title": "이 PDS에서 마이그레이션",
|
||||
"desc": "계정을 다른 개인 데이터 서버로 이동합니다.",
|
||||
"warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.",
|
||||
"didWebNotice": "did:web 마이그레이션 알림",
|
||||
"didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.",
|
||||
"understand": "위험을 이해하고 계속 진행합니다"
|
||||
"title": "백업에서 복원",
|
||||
"desc": "CAR 파일 백업과 회전 키를 사용하여 계정을 복원합니다. 이전 PDS를 사용할 수 없을 때 사용하세요.",
|
||||
"warningTitle": "이 방법을 사용해야 할 때",
|
||||
"warningDesc": "이 오프라인 복원은 이전 PDS가 종료되었거나, 접근할 수 없거나, 잠긴 경우의 재해 복구용입니다. 이전 PDS가 여전히 사용 가능하면 표준 마이그레이션을 사용하세요.",
|
||||
"requirementsTitle": "필요한 것",
|
||||
"requirement1": "저장소의 CAR 파일 백업",
|
||||
"requirement2": "회전 키 (DID의 개인 키)",
|
||||
"requirement3": "당신의 DID (did:plc:xxx)",
|
||||
"understand": "이해하고 계속 진행합니다"
|
||||
},
|
||||
"targetPds": {
|
||||
"title": "대상 PDS 선택",
|
||||
"desc": "마이그레이션할 PDS의 URL을 입력하세요.",
|
||||
"url": "PDS URL",
|
||||
"urlPlaceholder": "https://pds.example.com",
|
||||
"validate": "확인 및 계속",
|
||||
"validating": "확인 중...",
|
||||
"connected": "{name}에 연결됨",
|
||||
"inviteRequired": "초대 코드 필요",
|
||||
"privacyPolicy": "개인정보 처리방침",
|
||||
"termsOfService": "서비스 약관"
|
||||
"provideDid": {
|
||||
"title": "DID 입력",
|
||||
"desc": "복원할 계정의 DID를 입력하세요.",
|
||||
"label": "당신의 DID",
|
||||
"hint": "분산 식별자 (예: did:plc:abc123)"
|
||||
},
|
||||
"newAccount": {
|
||||
"title": "새 계정 세부 정보",
|
||||
"desc": "새 PDS에서 계정을 설정합니다.",
|
||||
"handle": "핸들",
|
||||
"availableDomains": "사용 가능한 도메인",
|
||||
"email": "이메일",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"inviteCode": "초대 코드"
|
||||
"uploadCar": {
|
||||
"title": "CAR 파일 업로드",
|
||||
"desc": "저장소 백업 파일을 업로드하세요.",
|
||||
"label": "CAR 파일",
|
||||
"hint": "백업에서 .car 파일을 선택하세요",
|
||||
"reuploadWarningTitle": "CAR 파일 필요",
|
||||
"reuploadWarning": "세션이 복원되었지만 CAR 파일을 다시 업로드해야 합니다. 보안상의 이유로 파일 내용은 세션 간에 저장되지 않습니다."
|
||||
},
|
||||
"rotationKey": {
|
||||
"title": "회전 키 제공",
|
||||
"desc": "이 DID의 소유권을 증명하기 위해 회전 키를 입력하세요.",
|
||||
"securityWarningTitle": "보안 경고",
|
||||
"securityWarning1": "회전 키는 매우 민감합니다 - 마스터 비밀번호처럼 취급하세요",
|
||||
"securityWarning2": "신뢰할 수 있는 장치와 네트워크에서만 입력하세요",
|
||||
"securityWarning3": "이 키는 마이그레이션 완료 후 저장되지 않습니다",
|
||||
"label": "회전 키",
|
||||
"placeholder": "개인 키 입력 (hex, base58 또는 JWK)",
|
||||
"hint": "DID 문서의 회전 키 중 하나에 해당하는 개인 키",
|
||||
"valid": "키가 유효하고 DID의 회전 키와 일치합니다",
|
||||
"invalid": "키가 DID 문서의 어떤 회전 키와도 일치하지 않습니다",
|
||||
"validating": "키 검증 중...",
|
||||
"validate": "키 검증"
|
||||
},
|
||||
"chooseHandle": {
|
||||
"migratingDid": "DID 복원 중"
|
||||
},
|
||||
"review": {
|
||||
"title": "마이그레이션 검토",
|
||||
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
|
||||
"currentHandle": "현재 핸들",
|
||||
"newHandle": "새 핸들",
|
||||
"sourcePds": "이 PDS",
|
||||
"targetPds": "대상 PDS",
|
||||
"confirm": "계정 마이그레이션을 확인합니다",
|
||||
"startMigration": "마이그레이션 시작"
|
||||
"desc": "오프라인 복원 세부 정보를 확인하세요.",
|
||||
"carFile": "CAR 파일",
|
||||
"rotationKey": "회전 키",
|
||||
"warning": "복원을 시작하면 아이덴티티가 이 PDS를 가리키도록 업데이트됩니다. 이것은 쉽게 되돌릴 수 없습니다.",
|
||||
"plcWarningTitle": "되돌릴 수 없는 지점",
|
||||
"plcWarning": "시작하면 DID 문서가 이 PDS를 가리키도록 업데이트됩니다. 문제가 발생하면 회전 키를 사용하여 복구할 수 있지만, 손상된 아이덴티티 상태를 피하려면 마이그레이션을 완료해야 합니다."
|
||||
},
|
||||
"migrating": {
|
||||
"title": "계정 마이그레이션 중",
|
||||
"desc": "데이터를 전송하는 중입니다..."
|
||||
},
|
||||
"plcToken": {
|
||||
"title": "신원 확인",
|
||||
"desc": "이메일로 인증 코드가 전송되었습니다."
|
||||
},
|
||||
"finalizing": {
|
||||
"title": "마이그레이션 완료 중",
|
||||
"desc": "마이그레이션을 완료하는 중입니다...",
|
||||
"updatingForwarding": "DID 문서 포워딩 업데이트 중..."
|
||||
"title": "계정 복원 중",
|
||||
"desc": "계정을 복원하는 중입니다...",
|
||||
"creating": "계정 생성 중",
|
||||
"importing": "저장소 가져오는 중",
|
||||
"plcSigning": "아이덴티티 업데이트 중",
|
||||
"activating": "계정 활성화 중"
|
||||
},
|
||||
"success": {
|
||||
"title": "마이그레이션 완료!",
|
||||
"desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.",
|
||||
"newHandle": "새 핸들",
|
||||
"newPds": "새 PDS",
|
||||
"nextSteps": "다음 단계",
|
||||
"nextSteps1": "새 PDS에 로그인",
|
||||
"nextSteps2": "새 인증 정보로 앱 업데이트",
|
||||
"nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다",
|
||||
"loggingOut": "{seconds}초 후 로그아웃됩니다..."
|
||||
"desc": "계정이 이 PDS에 성공적으로 복원되었습니다."
|
||||
},
|
||||
"blobs": {
|
||||
"title": "Blob 마이그레이션 중",
|
||||
"desc": "이전 PDS에서 이미지와 미디어를 복구하는 중...",
|
||||
"migrating": "Blob 마이그레이션 중",
|
||||
"failedTitle": "일부 Blob을 마이그레이션할 수 없음",
|
||||
"failedDesc": "{count}개의 Blob을 이전 PDS에서 가져올 수 없습니다. 서버에 연결할 수 없거나 파일이 삭제되었을 수 있습니다.",
|
||||
"sourceUnreachableTitle": "원본 PDS에 연결할 수 없음",
|
||||
"sourceUnreachable": "이전 PDS에 연결하여 미디어 파일을 가져올 수 없습니다. 종료된 서버에서 마이그레이션할 때 흔히 발생합니다. 게시물은 작동하지만 일부 이미지가 누락될 수 있습니다."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
|
||||
+147
-100
@@ -17,7 +17,46 @@
|
||||
"dashboard": "Kontrollpanel",
|
||||
"backToDashboard": "← Kontrollpanel",
|
||||
"copied": "Kopierat!",
|
||||
"copyToClipboard": "Kopiera"
|
||||
"copyToClipboard": "Kopiera",
|
||||
"verifying": "Verifierar...",
|
||||
"saving": "Sparar...",
|
||||
"creating": "Skapar...",
|
||||
"updating": "Uppdaterar...",
|
||||
"sending": "Skickar...",
|
||||
"authenticating": "Autentiserar...",
|
||||
"checking": "Kontrollerar...",
|
||||
"redirecting": "Omdirigerar...",
|
||||
"signIn": "Logga in",
|
||||
"verify": "Verifiera",
|
||||
"remove": "Ta bort",
|
||||
"revoke": "Återkalla",
|
||||
"resendCode": "Skicka kod igen",
|
||||
"startOver": "Börja om",
|
||||
"tryAgain": "Försök igen",
|
||||
"password": "Lösenord",
|
||||
"email": "E-post",
|
||||
"emailAddress": "E-postadress",
|
||||
"handle": "Användarnamn",
|
||||
"did": "DID",
|
||||
"verificationCode": "Verifieringskod",
|
||||
"inviteCode": "Inbjudningskod",
|
||||
"newPassword": "Nytt lösenord",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"enterSixDigitCode": "Ange 6-siffrig kod",
|
||||
"passwordHint": "Minst 8 tecken",
|
||||
"enterPassword": "Ange ditt lösenord",
|
||||
"emailPlaceholder": "du@exempel.se",
|
||||
"verified": "Verifierad",
|
||||
"disabled": "Inaktiverad",
|
||||
"available": "Tillgänglig",
|
||||
"deactivated": "Avaktiverad",
|
||||
"unverified": "Overifierad",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"backToSettings": "Tillbaka till inställningar",
|
||||
"alreadyHaveAccount": "Har du redan ett konto?",
|
||||
"createAccount": "Skapa konto",
|
||||
"passwordsMismatch": "Lösenorden matchar inte",
|
||||
"passwordTooShort": "Lösenordet måste vara minst 8 tecken"
|
||||
},
|
||||
"login": {
|
||||
"title": "Logga in",
|
||||
@@ -49,11 +88,7 @@
|
||||
"codeLabel": "Verifieringskod",
|
||||
"codePlaceholder": "Ange 6-siffrig kod",
|
||||
"verifyButton": "Verifiera konto",
|
||||
"verifying": "Verifierar...",
|
||||
"resendButton": "Skicka kod igen",
|
||||
"resending": "Skickar igen...",
|
||||
"resent": "Verifieringskod skickad igen!",
|
||||
"backToLogin": "Tillbaka till inloggning"
|
||||
"resent": "Verifieringskod skickad igen!"
|
||||
},
|
||||
"register": {
|
||||
"title": "Skapa konto",
|
||||
@@ -124,7 +159,6 @@
|
||||
"inviteCodePlaceholder": "Ange din inbjudningskod",
|
||||
"inviteCodeRequired": "krävs",
|
||||
"createButton": "Skapa konto",
|
||||
"creating": "Skapar konto...",
|
||||
"alreadyHaveAccount": "Har du redan ett konto?",
|
||||
"signIn": "Logga in",
|
||||
"wantPasswordless": "Vill du ha lösenordsfri säkerhet?",
|
||||
@@ -179,6 +213,11 @@
|
||||
"navAdminDesc": "Serverstatistik och administratörsoperationer",
|
||||
"navDidDocument": "DID-dokument",
|
||||
"navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
|
||||
"navDidDocumentDescActive": "Redigera dina DID-dokumentinställningar",
|
||||
"navBackup": "Ladda ner säkerhetskopia",
|
||||
"navBackupDesc": "Ladda ner ditt dataförvar som en CAR-fil",
|
||||
"downloadingBackup": "Laddar ner...",
|
||||
"backupFailed": "Kunde inte ladda ner säkerhetskopia",
|
||||
"migrated": "Flyttad",
|
||||
"migratedTitle": "Konto flyttat",
|
||||
"migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
|
||||
@@ -208,7 +247,6 @@
|
||||
"serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.",
|
||||
"currentPds": "Nuvarande PDS-URL",
|
||||
"save": "Spara ändringar",
|
||||
"saving": "Sparar...",
|
||||
"success": "DID-dokumentet har uppdaterats",
|
||||
"saveFailed": "Kunde inte spara DID-dokument",
|
||||
"loadFailed": "Kunde inte ladda DID-dokument",
|
||||
@@ -246,7 +284,6 @@
|
||||
"yourDomain": "Din domän",
|
||||
"yourDomainPlaceholder": "exempel.se",
|
||||
"verifyAndUpdate": "Verifiera och uppdatera användarnamn",
|
||||
"verifying": "Verifierar...",
|
||||
"newHandle": "Nytt användarnamn",
|
||||
"newHandlePlaceholder": "dittanvändarnamn",
|
||||
"changeHandleButton": "Ändra användarnamn",
|
||||
@@ -262,7 +299,34 @@
|
||||
"exportData": "Exportera data",
|
||||
"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.",
|
||||
"downloadRepo": "Ladda ner arkiv",
|
||||
"downloadBlobs": "Ladda ner media",
|
||||
"exporting": "Exporterar...",
|
||||
"backups": {
|
||||
"title": "Säkerhetskopior",
|
||||
"description": "Hantera automatiska säkerhetskopior och återställ din kontodata. Säkerhetskopior inkluderar alla poster och blobbar.",
|
||||
"enableAutomatic": "Automatiska säkerhetskopior",
|
||||
"enabled": "Aktiverad",
|
||||
"disabled": "Inaktiverad",
|
||||
"toggleFailed": "Kunde inte ändra säkerhetskopieringsinställning",
|
||||
"noBackups": "Inga säkerhetskopior ännu",
|
||||
"blocks": "block",
|
||||
"download": "Ladda ner",
|
||||
"delete": "Radera",
|
||||
"createNow": "Skapa säkerhetskopia nu",
|
||||
"created": "Säkerhetskopia skapad",
|
||||
"createFailed": "Kunde inte skapa säkerhetskopia",
|
||||
"downloadFailed": "Kunde inte ladda ner säkerhetskopia",
|
||||
"deleted": "Säkerhetskopia raderad",
|
||||
"deleteFailed": "Kunde inte radera säkerhetskopia",
|
||||
"restoreTitle": "Återställ från säkerhetskopia",
|
||||
"restoreDescription": "Återställ din kontodata från en tidigare exporterad CAR-fil. Detta ersätter ditt nuvarande dataförvar med den uppladdade säkerhetskopian.",
|
||||
"selectFile": "Välj CAR-fil",
|
||||
"selectedFile": "Vald fil",
|
||||
"restore": "Återställ säkerhetskopia",
|
||||
"restoring": "Återställer...",
|
||||
"restored": "Säkerhetskopia återställd",
|
||||
"restoreFailed": "Kunde inte återställa säkerhetskopia"
|
||||
},
|
||||
"deleteAccount": "Radera konto",
|
||||
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
|
||||
"requestDeletion": "Begär kontoradering",
|
||||
@@ -291,7 +355,9 @@
|
||||
"deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.",
|
||||
"deletionFailed": "Kunde inte radera kontot",
|
||||
"repoExported": "Arkiv exporterat",
|
||||
"exportFailed": "Kunde inte exportera arkiv",
|
||||
"blobsExported": "Mediafiler exporterade",
|
||||
"noBlobsToExport": "Inga mediafiler att exportera",
|
||||
"exportFailed": "Export misslyckades",
|
||||
"confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras."
|
||||
}
|
||||
},
|
||||
@@ -306,7 +372,6 @@
|
||||
"noPasswords": "Inga applösenord ännu",
|
||||
"revoke": "Återkalla",
|
||||
"revoking": "Återkallar...",
|
||||
"creating": "Skapar...",
|
||||
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
|
||||
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
|
||||
"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,7 +419,6 @@
|
||||
"used": "Använd av @{handle}",
|
||||
"disabled": "Inaktiverad",
|
||||
"usedBy": "Använd av",
|
||||
"creating": "Skapar...",
|
||||
"disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.",
|
||||
"created": "Inbjudningskod skapad",
|
||||
"copy": "Kopiera",
|
||||
@@ -482,7 +546,6 @@
|
||||
"verifyButton": "Verifiera",
|
||||
"verifyCodePlaceholder": "Ange verifieringskod",
|
||||
"submit": "Skicka",
|
||||
"saving": "Sparar...",
|
||||
"savePreferences": "Spara inställningar",
|
||||
"preferencesSaved": "Kommunikationsinställningar sparade",
|
||||
"verifiedSuccess": "{channel} verifierad",
|
||||
@@ -521,13 +584,11 @@
|
||||
"noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.",
|
||||
"loadMore": "Ladda fler",
|
||||
"recordJson": "Post-JSON",
|
||||
"saving": "Sparar...",
|
||||
"updateRecord": "Uppdatera post",
|
||||
"collectionNsid": "Samling (NSID)",
|
||||
"recordKeyOptional": "Postnyckel (valfri)",
|
||||
"autoGenerated": "Genereras automatiskt om tom (TID)",
|
||||
"autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel",
|
||||
"creating": "Skapar...",
|
||||
"demoPostText": "Hej från min PDS! Detta är mitt första inlägg.",
|
||||
"demoDisplayName": "Ditt visningsnamn",
|
||||
"demoBio": "En kort presentation om dig själv."
|
||||
@@ -548,7 +609,6 @@
|
||||
"primaryLight": "Primär (ljust läge)",
|
||||
"primaryDark": "Primär (mörkt läge)",
|
||||
"configSaved": "Serverkonfiguration sparad",
|
||||
"saving": "Sparar...",
|
||||
"saveConfig": "Spara konfiguration",
|
||||
"serverStats": "Serverstatistik",
|
||||
"users": "Användare",
|
||||
@@ -639,16 +699,13 @@
|
||||
"title": "Tvåfaktorsautentisering",
|
||||
"subtitle": "Ytterligare verifiering krävs",
|
||||
"usePasskey": "Använd nyckel",
|
||||
"useTotp": "Använd autentiseringsapp",
|
||||
"verifying": "Verifierar..."
|
||||
"useTotp": "Använd autentiseringsapp"
|
||||
},
|
||||
"twoFactorCode": {
|
||||
"title": "Tvåfaktorsautentisering",
|
||||
"subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.",
|
||||
"codeLabel": "Verifieringskod",
|
||||
"codePlaceholder": "Ange 6-siffrig kod",
|
||||
"verify": "Verifiera",
|
||||
"verifying": "Verifierar...",
|
||||
"errors": {
|
||||
"missingRequestUri": "Saknar request_uri-parameter",
|
||||
"verificationFailed": "Verifiering misslyckades",
|
||||
@@ -660,8 +717,6 @@
|
||||
"title": "Ange autentiseringskod",
|
||||
"subtitle": "Ange den 6-siffriga koden från din autentiseringsapp",
|
||||
"codePlaceholder": "Ange 6-siffrig kod",
|
||||
"verify": "Verifiera",
|
||||
"verifying": "Verifierar...",
|
||||
"useBackupCode": "Använd reservkod istället",
|
||||
"backupCodePlaceholder": "Ange reservkod",
|
||||
"trustDevice": "Lita på denna enhet i 30 dagar",
|
||||
@@ -691,12 +746,7 @@
|
||||
"codeLabel": "Verifieringskod",
|
||||
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
|
||||
"verifyButton": "Verifiera konto",
|
||||
"verify": "Verifiera",
|
||||
"verifying": "Verifierar...",
|
||||
"pleaseWait": "Vänta...",
|
||||
"sending": "Skickar...",
|
||||
"resendCode": "Skicka kod igen",
|
||||
"resending": "Skickar igen...",
|
||||
"codeResent": "Verifieringskod skickad igen!",
|
||||
"codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.",
|
||||
"verified": "Verifierad!",
|
||||
@@ -706,14 +756,12 @@
|
||||
"identifierLabel": "E-post eller identifierare",
|
||||
"identifierPlaceholder": "du@exempel.se",
|
||||
"identifierHelp": "E-postadressen eller identifieraren koden skickades till",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"verifyingAccount": "Verifierar konto: @{handle}",
|
||||
"startOver": "Börja om med ett annat konto",
|
||||
"noPending": "Ingen väntande verifiering hittades.",
|
||||
"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.",
|
||||
"createAccount": "Skapa konto",
|
||||
"signIn": "Logga in",
|
||||
"backToSettings": "Tillbaka till inställningar",
|
||||
"emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress",
|
||||
"emailUpdateFailed": "Kunde inte uppdatera e-postadress",
|
||||
"emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.",
|
||||
@@ -746,7 +794,6 @@
|
||||
"resetButton": "Återställ lösenord",
|
||||
"resetting": "Återställer...",
|
||||
"success": "Lösenord återställt!",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"requestNewCode": "Begär ny kod",
|
||||
"passwordsMismatch": "Lösenorden matchar inte",
|
||||
"passwordLength": "Lösenordet måste vara minst 8 tecken"
|
||||
@@ -790,8 +837,7 @@
|
||||
"howItWorks": "Så fungerar det",
|
||||
"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.",
|
||||
"sendRecoveryLink": "Skicka återställningslänk",
|
||||
"sending": "Skickar...",
|
||||
"backToLogin": "Tillbaka till inloggning"
|
||||
"sending": "Skickar..."
|
||||
},
|
||||
"registerPasskey": {
|
||||
"title": "Skapa nyckelkonto",
|
||||
@@ -812,7 +858,6 @@
|
||||
"externalDid": "Din did:web",
|
||||
"externalDidPlaceholder": "did:web:dindomän.se",
|
||||
"createButton": "Skapa konto",
|
||||
"creating": "Skapar...",
|
||||
"alreadyHaveAccount": "Har du redan ett konto?",
|
||||
"signIn": "Logga in",
|
||||
"wantPassword": "Vill du använda ett lösenord?",
|
||||
@@ -911,8 +956,6 @@
|
||||
"useTotp": "Använd autentiserare",
|
||||
"passwordPlaceholder": "Ange ditt lösenord",
|
||||
"totpPlaceholder": "Ange 6-siffrig kod",
|
||||
"verify": "Verifiera",
|
||||
"verifying": "Verifierar...",
|
||||
"authenticating": "Autentiserar...",
|
||||
"passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.",
|
||||
"cancel": "Avbryt"
|
||||
@@ -985,7 +1028,6 @@
|
||||
"createAccount": "Skapa konto",
|
||||
"createDelegatedAccount": "Skapa delegerat konto",
|
||||
"createDelegatedAccountButton": "+ Skapa delegerat konto",
|
||||
"creating": "Skapar...",
|
||||
"emailOptional": "E-post (valfritt)",
|
||||
"failedToAddController": "Kunde inte lägga till kontrollant",
|
||||
"failedToCreateAccount": "Kunde inte skapa delegerat konto",
|
||||
@@ -1059,15 +1101,9 @@
|
||||
"navDesc": "Flytta ditt konto till eller från en annan PDS",
|
||||
"migrateHere": "Flytta hit",
|
||||
"migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.",
|
||||
"migrateAway": "Flytta bort",
|
||||
"migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.",
|
||||
"loginRequired": "Inloggning krävs",
|
||||
"bringDid": "Ta med din DID och identitet",
|
||||
"transferData": "Överför all din data",
|
||||
"keepFollowers": "Behåll dina följare",
|
||||
"exportRepo": "Exportera ditt arkiv",
|
||||
"transferToPds": "Överför till ny PDS",
|
||||
"updateIdentity": "Uppdatera din identitet",
|
||||
"whatIsMigration": "Vad är kontoflyttning?",
|
||||
"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.",
|
||||
"beforeMigrate": "Innan du flyttar",
|
||||
@@ -1077,7 +1113,11 @@
|
||||
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
|
||||
"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.",
|
||||
"learnMore": "Läs mer om flyttningsrisker",
|
||||
"comingSoon": "Kommer snart",
|
||||
"offlineRestore": "Offline-återställning",
|
||||
"offlineRestoreDesc": "Återställ från backup när din gamla PDS inte är tillgänglig.",
|
||||
"offlineFeature1": "Använd en CAR-fil backup",
|
||||
"offlineFeature2": "Bevisa ägande med rotationsnyckel",
|
||||
"offlineFeature3": "Återställning för nedstängda servrar",
|
||||
"oauthCompleting": "Slutför autentisering...",
|
||||
"oauthFailed": "Autentisering misslyckades",
|
||||
"tryAgain": "Försök igen",
|
||||
@@ -1086,7 +1126,6 @@
|
||||
"incomplete": "Du har en ofullständig flytt pågående:",
|
||||
"direction": "Riktning",
|
||||
"migratingHere": "Flyttar hit",
|
||||
"migratingAway": "Flyttar bort",
|
||||
"from": "Från",
|
||||
"to": "Till",
|
||||
"progress": "Framsteg",
|
||||
@@ -1229,7 +1268,8 @@
|
||||
"error": {
|
||||
"title": "Flyttfel",
|
||||
"desc": "Ett fel uppstod under flytten.",
|
||||
"startOver": "Börja om"
|
||||
"startOver": "Börja om",
|
||||
"unknown": "Ett okänt fel uppstod."
|
||||
},
|
||||
"common": {
|
||||
"back": "Tillbaka",
|
||||
@@ -1247,70 +1287,77 @@
|
||||
"warning3": "Ditt gamla konto kommer att inaktiveras efter flytten"
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"offline": {
|
||||
"welcome": {
|
||||
"title": "Flytta från denna PDS",
|
||||
"desc": "Flytta ditt konto till en annan personlig dataserver.",
|
||||
"warning": "Efter flytten kommer ditt konto här att inaktiveras.",
|
||||
"didWebNotice": "did:web-flyttmeddelande",
|
||||
"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.",
|
||||
"understand": "Jag förstår riskerna och vill fortsätta"
|
||||
"title": "Återställ från backup",
|
||||
"desc": "Återställ ditt konto med en CAR-fil backup och rotationsnyckel. Använd detta när din tidigare PDS inte är tillgänglig.",
|
||||
"warningTitle": "När du ska använda denna metod",
|
||||
"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.",
|
||||
"requirementsTitle": "Du behöver",
|
||||
"requirement1": "En CAR-fil backup av ditt arkiv",
|
||||
"requirement2": "Din rotationsnyckel (privat nyckel för ditt DID)",
|
||||
"requirement3": "Ditt DID (did:plc:xxx)",
|
||||
"understand": "Jag förstår och vill fortsätta"
|
||||
},
|
||||
"targetPds": {
|
||||
"title": "Välj mål-PDS",
|
||||
"desc": "Ange URL:en för PDS du vill flytta till.",
|
||||
"url": "PDS URL",
|
||||
"urlPlaceholder": "https://pds.example.com",
|
||||
"validate": "Validera och fortsätt",
|
||||
"validating": "Validerar...",
|
||||
"connected": "Ansluten till {name}",
|
||||
"inviteRequired": "Inbjudningskod krävs",
|
||||
"privacyPolicy": "Integritetspolicy",
|
||||
"termsOfService": "Användarvillkor"
|
||||
"provideDid": {
|
||||
"title": "Ange ditt DID",
|
||||
"desc": "Ange DID för kontot du vill återställa.",
|
||||
"label": "Ditt DID",
|
||||
"hint": "Din decentraliserade identifierare (t.ex. did:plc:abc123)"
|
||||
},
|
||||
"newAccount": {
|
||||
"title": "Nya kontouppgifter",
|
||||
"desc": "Konfigurera ditt konto på den nya PDS.",
|
||||
"handle": "Användarnamn",
|
||||
"availableDomains": "Tillgängliga domäner",
|
||||
"email": "E-post",
|
||||
"password": "Lösenord",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"inviteCode": "Inbjudningskod"
|
||||
"uploadCar": {
|
||||
"title": "Ladda upp CAR-fil",
|
||||
"desc": "Ladda upp din arkiv-backupfil.",
|
||||
"label": "CAR-fil",
|
||||
"hint": "Välj .car-filen från din backup",
|
||||
"reuploadWarningTitle": "CAR-fil krävs",
|
||||
"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."
|
||||
},
|
||||
"rotationKey": {
|
||||
"title": "Ange rotationsnyckel",
|
||||
"desc": "Ange din rotationsnyckel för att bevisa ägande av detta DID.",
|
||||
"securityWarningTitle": "Säkerhetsvarning",
|
||||
"securityWarning1": "Din rotationsnyckel är extremt känslig - behandla den som ett huvudlösenord",
|
||||
"securityWarning2": "Ange den endast på betrodda enheter och nätverk",
|
||||
"securityWarning3": "Denna nyckel kommer inte att lagras efter att flytten slutförts",
|
||||
"label": "Rotationsnyckel",
|
||||
"placeholder": "Ange privat nyckel (hex, base58 eller JWK)",
|
||||
"hint": "Den privata nyckeln som motsvarar en av rotationsnycklarna i ditt DID-dokument",
|
||||
"valid": "Nyckeln är giltig och matchar en rotationsnyckel i ditt DID",
|
||||
"invalid": "Nyckeln matchar inte någon rotationsnyckel i ditt DID-dokument",
|
||||
"validating": "Validerar nyckel...",
|
||||
"validate": "Validera nyckel"
|
||||
},
|
||||
"chooseHandle": {
|
||||
"migratingDid": "Återställer DID"
|
||||
},
|
||||
"review": {
|
||||
"title": "Granska flytt",
|
||||
"desc": "Granska och bekräfta dina flyttdetaljer.",
|
||||
"currentHandle": "Nuvarande användarnamn",
|
||||
"newHandle": "Nytt användarnamn",
|
||||
"sourcePds": "Denna PDS",
|
||||
"targetPds": "Mål-PDS",
|
||||
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
|
||||
"startMigration": "Starta flytt"
|
||||
"desc": "Granska dina offline-återställningsuppgifter.",
|
||||
"carFile": "CAR-fil",
|
||||
"rotationKey": "Rotationsnyckel",
|
||||
"warning": "När du startar återställningen kommer din identitet att uppdateras för att peka på denna PDS. Detta kan inte enkelt ångras.",
|
||||
"plcWarningTitle": "Ingen återvändo",
|
||||
"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."
|
||||
},
|
||||
"migrating": {
|
||||
"title": "Flyttar ditt konto",
|
||||
"desc": "Vänta medan vi överför din data..."
|
||||
},
|
||||
"plcToken": {
|
||||
"title": "Verifiera din identitet",
|
||||
"desc": "En verifieringskod har skickats till din e-post."
|
||||
},
|
||||
"finalizing": {
|
||||
"title": "Slutför flytt",
|
||||
"desc": "Vänta medan vi slutför flytten...",
|
||||
"updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..."
|
||||
"title": "Återställer konto",
|
||||
"desc": "Vänta medan ditt konto återställs...",
|
||||
"creating": "Skapar konto",
|
||||
"importing": "Importerar arkiv",
|
||||
"plcSigning": "Uppdaterar identitet",
|
||||
"activating": "Aktiverar konto"
|
||||
},
|
||||
"success": {
|
||||
"title": "Flytt klar!",
|
||||
"desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.",
|
||||
"newHandle": "Nytt användarnamn",
|
||||
"newPds": "Ny PDS",
|
||||
"nextSteps": "Nästa steg",
|
||||
"nextSteps1": "Logga in på din nya PDS",
|
||||
"nextSteps2": "Uppdatera dina appar med nya uppgifter",
|
||||
"nextSteps3": "Dina följare kommer automatiskt se din nya plats",
|
||||
"loggingOut": "Loggar ut om {seconds} sekunder..."
|
||||
"desc": "Ditt konto har framgångsrikt återställts till denna PDS."
|
||||
},
|
||||
"blobs": {
|
||||
"title": "Flyttar blobbar",
|
||||
"desc": "Försöker återställa bilder och media från din gamla PDS...",
|
||||
"migrating": "Flyttar blobbar",
|
||||
"failedTitle": "Vissa blobbar kunde inte flyttas",
|
||||
"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.",
|
||||
"sourceUnreachableTitle": "Käll-PDS otillgänglig",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
|
||||
+147
-100
@@ -17,7 +17,46 @@
|
||||
"dashboard": "控制台",
|
||||
"backToDashboard": "← 返回控制台",
|
||||
"copied": "已复制!",
|
||||
"copyToClipboard": "复制"
|
||||
"copyToClipboard": "复制",
|
||||
"verifying": "验证中...",
|
||||
"saving": "保存中...",
|
||||
"creating": "创建中...",
|
||||
"updating": "更新中...",
|
||||
"sending": "发送中...",
|
||||
"authenticating": "认证中...",
|
||||
"checking": "检查中...",
|
||||
"redirecting": "跳转中...",
|
||||
"signIn": "登录",
|
||||
"verify": "验证",
|
||||
"remove": "移除",
|
||||
"revoke": "撤销",
|
||||
"resendCode": "重新发送验证码",
|
||||
"startOver": "重新开始",
|
||||
"tryAgain": "重试",
|
||||
"password": "密码",
|
||||
"email": "邮箱",
|
||||
"emailAddress": "邮箱地址",
|
||||
"handle": "用户名",
|
||||
"did": "DID",
|
||||
"verificationCode": "验证码",
|
||||
"inviteCode": "邀请码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"enterSixDigitCode": "输入6位验证码",
|
||||
"passwordHint": "至少8个字符",
|
||||
"enterPassword": "请输入密码",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"verified": "已验证",
|
||||
"disabled": "已禁用",
|
||||
"available": "可用",
|
||||
"deactivated": "已停用",
|
||||
"unverified": "未验证",
|
||||
"backToLogin": "返回登录",
|
||||
"backToSettings": "返回设置",
|
||||
"alreadyHaveAccount": "已有账户?",
|
||||
"createAccount": "立即注册",
|
||||
"passwordsMismatch": "密码不匹配",
|
||||
"passwordTooShort": "密码至少需要8个字符"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
@@ -49,11 +88,7 @@
|
||||
"codeLabel": "验证码",
|
||||
"codePlaceholder": "输入6位验证码",
|
||||
"verifyButton": "验证账户",
|
||||
"verifying": "验证中...",
|
||||
"resendButton": "重新发送验证码",
|
||||
"resending": "发送中...",
|
||||
"resent": "验证码已重新发送!",
|
||||
"backToLogin": "返回登录"
|
||||
"resent": "验证码已重新发送!"
|
||||
},
|
||||
"register": {
|
||||
"title": "创建账户",
|
||||
@@ -124,7 +159,6 @@
|
||||
"inviteCodePlaceholder": "输入您的邀请码",
|
||||
"inviteCodeRequired": "必填",
|
||||
"createButton": "创建账户",
|
||||
"creating": "正在创建...",
|
||||
"alreadyHaveAccount": "已有账户?",
|
||||
"signIn": "立即登录",
|
||||
"wantPasswordless": "想要无密码登录?",
|
||||
@@ -179,6 +213,11 @@
|
||||
"navAdminDesc": "服务器统计和管理操作",
|
||||
"navDidDocument": "DID 文档",
|
||||
"navDidDocumentDesc": "管理您的 DID 文档和密钥",
|
||||
"navDidDocumentDescActive": "编辑您的 DID 文档设置",
|
||||
"navBackup": "下载备份",
|
||||
"navBackupDesc": "将您的存储库下载为 CAR 文件",
|
||||
"downloadingBackup": "下载中...",
|
||||
"backupFailed": "下载备份失败",
|
||||
"migrated": "已迁移",
|
||||
"migratedTitle": "账户已迁移",
|
||||
"migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。",
|
||||
@@ -208,7 +247,6 @@
|
||||
"serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。",
|
||||
"currentPds": "当前 PDS URL",
|
||||
"save": "保存更改",
|
||||
"saving": "保存中...",
|
||||
"success": "DID 文档已更新",
|
||||
"saveFailed": "保存 DID 文档失败",
|
||||
"loadFailed": "加载 DID 文档失败",
|
||||
@@ -246,7 +284,6 @@
|
||||
"yourDomain": "您的域名",
|
||||
"yourDomainPlaceholder": "example.com",
|
||||
"verifyAndUpdate": "验证并更新用户名",
|
||||
"verifying": "验证中...",
|
||||
"newHandle": "新用户名",
|
||||
"newHandlePlaceholder": "yourhandle",
|
||||
"changeHandleButton": "更改用户名",
|
||||
@@ -262,7 +299,34 @@
|
||||
"exportData": "导出数据",
|
||||
"exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。",
|
||||
"downloadRepo": "下载数据",
|
||||
"downloadBlobs": "下载媒体文件",
|
||||
"exporting": "导出中...",
|
||||
"backups": {
|
||||
"title": "备份",
|
||||
"description": "管理自动备份并恢复账户数据。备份包括所有记录和文件。",
|
||||
"enableAutomatic": "自动备份",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"toggleFailed": "更改备份设置失败",
|
||||
"noBackups": "暂无备份",
|
||||
"blocks": "块",
|
||||
"download": "下载",
|
||||
"delete": "删除",
|
||||
"createNow": "立即创建备份",
|
||||
"created": "备份已创建",
|
||||
"createFailed": "创建备份失败",
|
||||
"downloadFailed": "下载备份失败",
|
||||
"deleted": "备份已删除",
|
||||
"deleteFailed": "删除备份失败",
|
||||
"restoreTitle": "从备份恢复",
|
||||
"restoreDescription": "从之前导出的 CAR 文件恢复账户数据。这将用上传的备份替换当前的存储库。",
|
||||
"selectFile": "选择 CAR 文件",
|
||||
"selectedFile": "已选文件",
|
||||
"restore": "恢复备份",
|
||||
"restoring": "恢复中...",
|
||||
"restored": "备份恢复成功",
|
||||
"restoreFailed": "备份恢复失败"
|
||||
},
|
||||
"deleteAccount": "删除账户",
|
||||
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
|
||||
"requestDeletion": "请求删除账户",
|
||||
@@ -291,7 +355,9 @@
|
||||
"deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。",
|
||||
"deletionFailed": "账户删除失败",
|
||||
"repoExported": "数据导出成功",
|
||||
"exportFailed": "数据导出失败",
|
||||
"blobsExported": "媒体文件导出成功",
|
||||
"noBlobsToExport": "没有可导出的媒体文件",
|
||||
"exportFailed": "导出失败",
|
||||
"confirmDelete": "您确定要删除账户吗?此操作无法撤销。"
|
||||
}
|
||||
},
|
||||
@@ -306,7 +372,6 @@
|
||||
"noPasswords": "暂无应用专用密码",
|
||||
"revoke": "撤销",
|
||||
"revoking": "撤销中...",
|
||||
"creating": "创建中...",
|
||||
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
|
||||
"saveWarningTitle": "重要:请保存此应用专用密码!",
|
||||
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
|
||||
@@ -354,7 +419,6 @@
|
||||
"used": "已被 @{handle} 使用",
|
||||
"disabled": "已禁用",
|
||||
"usedBy": "使用者",
|
||||
"creating": "创建中...",
|
||||
"disableConfirm": "禁用此邀请码?它将无法再被使用。",
|
||||
"created": "邀请码已创建",
|
||||
"copy": "复制",
|
||||
@@ -482,7 +546,6 @@
|
||||
"verifyButton": "验证",
|
||||
"verifyCodePlaceholder": "输入验证码",
|
||||
"submit": "提交",
|
||||
"saving": "保存中...",
|
||||
"savePreferences": "保存偏好设置",
|
||||
"preferencesSaved": "通讯偏好已保存",
|
||||
"verifiedSuccess": "{channel} 验证成功",
|
||||
@@ -521,13 +584,11 @@
|
||||
"noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。",
|
||||
"loadMore": "加载更多",
|
||||
"recordJson": "记录 JSON",
|
||||
"saving": "保存中...",
|
||||
"updateRecord": "更新记录",
|
||||
"collectionNsid": "集合 (NSID)",
|
||||
"recordKeyOptional": "记录键(可选)",
|
||||
"autoGenerated": "留空自动生成 (TID)",
|
||||
"autoGeneratedHint": "留空将自动生成基于 TID 的键",
|
||||
"creating": "创建中...",
|
||||
"demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。",
|
||||
"demoDisplayName": "你的显示名称",
|
||||
"demoBio": "写一段简短的自我介绍。"
|
||||
@@ -551,7 +612,6 @@
|
||||
"secondaryLight": "副色(浅色模式)",
|
||||
"secondaryDark": "副色(深色模式)",
|
||||
"configSaved": "服务器配置已保存",
|
||||
"saving": "保存中...",
|
||||
"saveConfig": "保存配置",
|
||||
"serverStats": "服务器统计",
|
||||
"users": "用户",
|
||||
@@ -639,16 +699,13 @@
|
||||
"title": "双重身份验证",
|
||||
"subtitle": "需要额外验证",
|
||||
"usePasskey": "使用通行密钥",
|
||||
"useTotp": "使用身份验证器",
|
||||
"verifying": "验证中..."
|
||||
"useTotp": "使用身份验证器"
|
||||
},
|
||||
"twoFactorCode": {
|
||||
"title": "双重身份验证",
|
||||
"subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。",
|
||||
"codeLabel": "验证码",
|
||||
"codePlaceholder": "输入6位验证码",
|
||||
"verify": "验证",
|
||||
"verifying": "验证中...",
|
||||
"errors": {
|
||||
"missingRequestUri": "缺少 request_uri 参数",
|
||||
"verificationFailed": "验证失败",
|
||||
@@ -660,8 +717,6 @@
|
||||
"title": "输入验证码",
|
||||
"subtitle": "请输入身份验证器应用中的6位验证码",
|
||||
"codePlaceholder": "输入6位验证码",
|
||||
"verify": "验证",
|
||||
"verifying": "验证中...",
|
||||
"useBackupCode": "使用备用验证码",
|
||||
"backupCodePlaceholder": "输入备用验证码",
|
||||
"trustDevice": "信任此设备30天",
|
||||
@@ -691,15 +746,9 @@
|
||||
"codeLabel": "验证码",
|
||||
"codeHelp": "复制消息中的完整验证码,包括横线",
|
||||
"verifyButton": "验证账户",
|
||||
"verify": "验证",
|
||||
"verifying": "验证中...",
|
||||
"pleaseWait": "请稍候...",
|
||||
"resendCode": "重新发送验证码",
|
||||
"resending": "发送中...",
|
||||
"sending": "发送中...",
|
||||
"codeResent": "验证码已重新发送!",
|
||||
"codeResentDetail": "验证码已发送!请查收。",
|
||||
"backToLogin": "返回登录",
|
||||
"verifyingAccount": "正在验证账户:@{handle}",
|
||||
"startOver": "使用其他账户重新开始",
|
||||
"noPending": "未找到待验证的账户",
|
||||
@@ -713,7 +762,6 @@
|
||||
"identifierLabel": "邮箱或标识符",
|
||||
"identifierPlaceholder": "you@example.com",
|
||||
"identifierHelp": "接收验证码的邮箱地址或标识符",
|
||||
"backToSettings": "返回设置",
|
||||
"emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址",
|
||||
"emailUpdateFailed": "更新邮箱地址失败",
|
||||
"emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。",
|
||||
@@ -746,7 +794,6 @@
|
||||
"resetButton": "重置密码",
|
||||
"resetting": "重置中...",
|
||||
"success": "密码重置成功!",
|
||||
"backToLogin": "返回登录",
|
||||
"requestNewCode": "重新获取验证码",
|
||||
"passwordsMismatch": "两次输入的密码不一致",
|
||||
"passwordLength": "密码至少需要8位字符"
|
||||
@@ -790,8 +837,7 @@
|
||||
"howItWorks": "如何恢复",
|
||||
"howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。",
|
||||
"sendRecoveryLink": "发送恢复链接",
|
||||
"sending": "发送中...",
|
||||
"backToLogin": "返回登录"
|
||||
"sending": "发送中..."
|
||||
},
|
||||
"registerPasskey": {
|
||||
"title": "创建通行密钥账户",
|
||||
@@ -814,7 +860,6 @@
|
||||
"inviteCode": "邀请码",
|
||||
"inviteCodePlaceholder": "输入您的邀请码",
|
||||
"createButton": "创建账户",
|
||||
"creating": "创建中...",
|
||||
"continue": "继续",
|
||||
"back": "返回",
|
||||
"alreadyHaveAccount": "已有账户?",
|
||||
@@ -911,8 +956,6 @@
|
||||
"useTotp": "使用身份验证器",
|
||||
"passwordPlaceholder": "输入您的密码",
|
||||
"totpPlaceholder": "输入6位验证码",
|
||||
"verify": "验证",
|
||||
"verifying": "验证中...",
|
||||
"authenticating": "正在验证...",
|
||||
"passkeyPrompt": "点击下方按钮使用通行密钥进行验证。",
|
||||
"cancel": "取消"
|
||||
@@ -986,7 +1029,6 @@
|
||||
"createAccount": "创建账户",
|
||||
"createDelegatedAccount": "创建委托账户",
|
||||
"createDelegatedAccountButton": "+ 创建委托账户",
|
||||
"creating": "创建中...",
|
||||
"emailOptional": "邮箱(可选)",
|
||||
"failedToAddController": "添加控制者失败",
|
||||
"failedToCreateAccount": "创建委托账户失败",
|
||||
@@ -1059,15 +1101,9 @@
|
||||
"navDesc": "将您的账户移至其他PDS或从其他PDS移入",
|
||||
"migrateHere": "迁移到此处",
|
||||
"migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。",
|
||||
"migrateAway": "迁移离开",
|
||||
"migrateAwayDesc": "将您的账户从此PDS移至其他服务器。",
|
||||
"loginRequired": "需要登录",
|
||||
"bringDid": "携带您的DID和身份",
|
||||
"transferData": "转移所有数据",
|
||||
"keepFollowers": "保留您的关注者",
|
||||
"exportRepo": "导出您的存储库",
|
||||
"transferToPds": "转移到新PDS",
|
||||
"updateIdentity": "更新您的身份",
|
||||
"whatIsMigration": "什么是账户迁移?",
|
||||
"whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。",
|
||||
"beforeMigrate": "迁移前须知",
|
||||
@@ -1077,7 +1113,11 @@
|
||||
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
|
||||
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
|
||||
"learnMore": "了解更多迁移风险",
|
||||
"comingSoon": "即将推出",
|
||||
"offlineRestore": "离线恢复",
|
||||
"offlineRestoreDesc": "当旧 PDS 不可用时从备份恢复。",
|
||||
"offlineFeature1": "使用 CAR 文件备份",
|
||||
"offlineFeature2": "使用轮换密钥证明所有权",
|
||||
"offlineFeature3": "用于已关闭服务器的恢复",
|
||||
"oauthCompleting": "正在完成身份验证...",
|
||||
"oauthFailed": "身份验证失败",
|
||||
"tryAgain": "重试",
|
||||
@@ -1086,7 +1126,6 @@
|
||||
"incomplete": "您有一个未完成的迁移:",
|
||||
"direction": "方向",
|
||||
"migratingHere": "正在迁移到此处",
|
||||
"migratingAway": "正在迁移离开",
|
||||
"from": "从",
|
||||
"to": "到",
|
||||
"progress": "进度",
|
||||
@@ -1229,7 +1268,8 @@
|
||||
"error": {
|
||||
"title": "迁移错误",
|
||||
"desc": "迁移过程中发生错误。",
|
||||
"startOver": "重新开始"
|
||||
"startOver": "重新开始",
|
||||
"unknown": "发生未知错误。"
|
||||
},
|
||||
"common": {
|
||||
"back": "返回",
|
||||
@@ -1247,70 +1287,77 @@
|
||||
"warning3": "迁移后您的旧账户将被停用"
|
||||
}
|
||||
},
|
||||
"outbound": {
|
||||
"offline": {
|
||||
"welcome": {
|
||||
"title": "从此PDS迁移离开",
|
||||
"desc": "将您的账户移至另一个个人数据服务器。",
|
||||
"warning": "迁移后,您在此处的账户将被停用。",
|
||||
"didWebNotice": "did:web迁移通知",
|
||||
"didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。",
|
||||
"understand": "我了解风险并希望继续"
|
||||
"title": "从备份恢复",
|
||||
"desc": "使用 CAR 文件备份和轮换密钥恢复您的账户。当您的旧 PDS 不可用时使用此方法。",
|
||||
"warningTitle": "何时使用此方法",
|
||||
"warningDesc": "此离线恢复用于灾难恢复,当您的旧 PDS 已关闭、无法访问或您被锁定时使用。如果您的旧 PDS 仍然可用,请使用标准迁移。",
|
||||
"requirementsTitle": "您需要",
|
||||
"requirement1": "您的存储库的 CAR 文件备份",
|
||||
"requirement2": "您的轮换密钥(DID 的私钥)",
|
||||
"requirement3": "您的 DID (did:plc:xxx)",
|
||||
"understand": "我了解并希望继续"
|
||||
},
|
||||
"targetPds": {
|
||||
"title": "选择目标PDS",
|
||||
"desc": "输入您要迁移到的PDS的URL。",
|
||||
"url": "PDS URL",
|
||||
"urlPlaceholder": "https://pds.example.com",
|
||||
"validate": "验证并继续",
|
||||
"validating": "验证中...",
|
||||
"connected": "已连接到 {name}",
|
||||
"inviteRequired": "需要邀请码",
|
||||
"privacyPolicy": "隐私政策",
|
||||
"termsOfService": "服务条款"
|
||||
"provideDid": {
|
||||
"title": "输入您的 DID",
|
||||
"desc": "输入您要恢复的账户的 DID。",
|
||||
"label": "您的 DID",
|
||||
"hint": "您的去中心化标识符(例如 did:plc:abc123)"
|
||||
},
|
||||
"newAccount": {
|
||||
"title": "新账户详情",
|
||||
"desc": "在新PDS上设置您的账户。",
|
||||
"handle": "用户名",
|
||||
"availableDomains": "可用域名",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"inviteCode": "邀请码"
|
||||
"uploadCar": {
|
||||
"title": "上传 CAR 文件",
|
||||
"desc": "上传您的存储库备份文件。",
|
||||
"label": "CAR 文件",
|
||||
"hint": "从您的备份中选择 .car 文件",
|
||||
"reuploadWarningTitle": "需要 CAR 文件",
|
||||
"reuploadWarning": "您的会话已恢复,但您需要重新上传 CAR 文件。出于安全原因,文件内容不会在会话之间保存。"
|
||||
},
|
||||
"rotationKey": {
|
||||
"title": "提供轮换密钥",
|
||||
"desc": "输入您的轮换密钥以证明此 DID 的所有权。",
|
||||
"securityWarningTitle": "安全警告",
|
||||
"securityWarning1": "您的轮换密钥极为敏感 - 请像对待主密码一样对待它",
|
||||
"securityWarning2": "仅在受信任的设备和网络上输入",
|
||||
"securityWarning3": "迁移完成后此密钥不会被存储",
|
||||
"label": "轮换密钥",
|
||||
"placeholder": "输入私钥(hex、base58 或 JWK)",
|
||||
"hint": "与您的 DID 文档中的轮换密钥之一对应的私钥",
|
||||
"valid": "密钥有效并匹配您的 DID 中的轮换密钥",
|
||||
"invalid": "密钥与您的 DID 文档中的任何轮换密钥都不匹配",
|
||||
"validating": "验证密钥...",
|
||||
"validate": "验证密钥"
|
||||
},
|
||||
"chooseHandle": {
|
||||
"migratingDid": "恢复 DID"
|
||||
},
|
||||
"review": {
|
||||
"title": "检查迁移",
|
||||
"desc": "请检查并确认您的迁移详情。",
|
||||
"currentHandle": "当前用户名",
|
||||
"newHandle": "新用户名",
|
||||
"sourcePds": "此PDS",
|
||||
"targetPds": "目标PDS",
|
||||
"confirm": "我确认要迁移我的账户",
|
||||
"startMigration": "开始迁移"
|
||||
"desc": "检查您的离线恢复详情。",
|
||||
"carFile": "CAR 文件",
|
||||
"rotationKey": "轮换密钥",
|
||||
"warning": "开始恢复后,您的身份将更新为指向此 PDS。此操作无法轻易撤销。",
|
||||
"plcWarningTitle": "不可逆转点",
|
||||
"plcWarning": "一旦开始,您的 DID 文档将更新为指向此 PDS。如果出现问题,您可以使用轮换密钥恢复,但您应该完成迁移以避免身份状态损坏。"
|
||||
},
|
||||
"migrating": {
|
||||
"title": "正在迁移您的账户",
|
||||
"desc": "请稍候,正在转移您的数据..."
|
||||
},
|
||||
"plcToken": {
|
||||
"title": "验证您的身份",
|
||||
"desc": "验证码已发送到您的邮箱。"
|
||||
},
|
||||
"finalizing": {
|
||||
"title": "正在完成迁移",
|
||||
"desc": "请稍候,正在完成迁移...",
|
||||
"updatingForwarding": "正在更新DID文档转发..."
|
||||
"title": "恢复账户",
|
||||
"desc": "请稍候,正在恢复您的账户...",
|
||||
"creating": "创建账户",
|
||||
"importing": "导入存储库",
|
||||
"plcSigning": "更新身份",
|
||||
"activating": "激活账户"
|
||||
},
|
||||
"success": {
|
||||
"title": "迁移完成!",
|
||||
"desc": "您的账户已成功迁移到新PDS。",
|
||||
"newHandle": "新用户名",
|
||||
"newPds": "新PDS",
|
||||
"nextSteps": "后续步骤",
|
||||
"nextSteps1": "登录到您的新PDS",
|
||||
"nextSteps2": "使用新凭据更新您的应用",
|
||||
"nextSteps3": "您的关注者将自动看到您的新位置",
|
||||
"loggingOut": "{seconds}秒后退出登录..."
|
||||
"desc": "您的账户已成功恢复到此 PDS。"
|
||||
},
|
||||
"blobs": {
|
||||
"title": "迁移 Blob",
|
||||
"desc": "正在尝试从您的旧 PDS 恢复图片和媒体...",
|
||||
"migrating": "正在迁移 blob",
|
||||
"failedTitle": "部分 blob 无法迁移",
|
||||
"failedDesc": "{count} 个 blob 无法从您的旧 PDS 获取。这可能是因为服务器无法访问或文件已被删除。",
|
||||
"sourceUnreachableTitle": "源 PDS 无法访问",
|
||||
"sourceUnreachable": "无法连接到您的旧 PDS 来获取媒体文件。从已关闭的服务器迁移时这很常见。您的帖子将正常工作,但部分图片可能会丢失。"
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/xrpc/com.tranquil.delegation.listControlledAccounts`,
|
||||
`/xrpc/_delegation.listControlledAccounts`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` }
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
<div class="message success">{$_('admin.configSaved')}</div>
|
||||
{/if}
|
||||
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
|
||||
{serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')}
|
||||
{serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={creating || !newPasswordName.trim()}>
|
||||
{creating ? $_('appPasswords.creating') : $_('common.create')}
|
||||
{creating ? $_('common.creating') : $_('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? $_('comms.saving') : $_('comms.savePreferences')}
|
||||
{saving ? $_('common.saving') : $_('comms.savePreferences')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
async function loadControllers() {
|
||||
if (!auth.session) return
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', {
|
||||
const response = await fetch('/xrpc/_delegation.listControllers', {
|
||||
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
@@ -90,7 +90,7 @@
|
||||
async function loadControlledAccounts() {
|
||||
if (!auth.session) return
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', {
|
||||
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
|
||||
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
async function loadScopePresets() {
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets')
|
||||
const response = await fetch('/xrpc/_delegation.getScopePresets')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
scopePresets = data.presets || []
|
||||
@@ -121,7 +121,7 @@
|
||||
success = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.addController', {
|
||||
const response = await fetch('/xrpc/_delegation.addController', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`,
|
||||
@@ -159,7 +159,7 @@
|
||||
success = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.removeController', {
|
||||
const response = await fetch('/xrpc/_delegation.removeController', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`,
|
||||
@@ -188,7 +188,7 @@
|
||||
success = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', {
|
||||
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`,
|
||||
@@ -407,7 +407,7 @@
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
|
||||
{creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')}
|
||||
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
let switching = $state(false)
|
||||
let inviteCodesEnabled = $state(false)
|
||||
|
||||
const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
@@ -176,6 +178,10 @@
|
||||
<h3>{$_('dashboard.navSecurity')}</h3>
|
||||
<p>{$_('dashboard.navSecurityDesc')}</p>
|
||||
</a>
|
||||
<a href="#/settings" class="nav-card">
|
||||
<h3>{$_('dashboard.navSettings')}</h3>
|
||||
<p>{$_('dashboard.navSettingsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<h3>{$_('dashboard.navMigrateAgain')}</h3>
|
||||
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
|
||||
@@ -215,6 +221,12 @@
|
||||
<h3>{$_('dashboard.navDelegation')}</h3>
|
||||
<p>{$_('dashboard.navDelegationDesc')}</p>
|
||||
</a>
|
||||
{#if isDidWeb}
|
||||
<a href="#/did-document" class="nav-card did-web-card">
|
||||
<h3>{$_('dashboard.navDidDocument')}</h3>
|
||||
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<h3>{$_('migration.navTitle')}</h3>
|
||||
<p>{$_('migration.navDesc')}</p>
|
||||
@@ -504,4 +516,13 @@
|
||||
.nav-card.migrated-card h3 {
|
||||
color: var(--info-text, #0369a1);
|
||||
}
|
||||
|
||||
.nav-card.did-web-card {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%);
|
||||
}
|
||||
|
||||
.nav-card.did-web-card:hover {
|
||||
box-shadow: 0 2px 12px var(--accent-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`,
|
||||
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
|
||||
<div class="actions">
|
||||
<button onclick={handleSave} disabled={saving}>
|
||||
{saving ? $_('didEditor.saving') : $_('didEditor.save')}
|
||||
{saving ? $_('common.saving') : $_('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -183,6 +183,11 @@
|
||||
<h3>Delegate without sharing passwords</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Automatic backups</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Everything in one place</h2>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
{#if auth.session?.isAdmin}
|
||||
<section class="create-section">
|
||||
<button onclick={handleCreate} disabled={creating}>
|
||||
{creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')}
|
||||
{creating ? $_('common.creating') : $_('inviteCodes.createNew')}
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -107,13 +107,13 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? $_('verification.verifying') : $_('verification.verifyButton')}
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? $_('verification.resending') : $_('verification.resendButton')}
|
||||
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
|
||||
</button>
|
||||
<button type="button" class="tertiary" onclick={backToLogin}>
|
||||
{$_('verification.backToLogin')}
|
||||
{$_('common.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
|
||||
import { setSession } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import {
|
||||
createInboundMigrationFlow,
|
||||
createOutboundMigrationFlow,
|
||||
createOfflineInboundMigrationFlow,
|
||||
hasPendingMigration,
|
||||
hasPendingOfflineMigration,
|
||||
getResumeInfo,
|
||||
getOfflineResumeInfo,
|
||||
clearMigrationState,
|
||||
clearOfflineState,
|
||||
loadMigrationState,
|
||||
} from '../lib/migration'
|
||||
import InboundWizard from '../components/migration/InboundWizard.svelte'
|
||||
import OutboundWizard from '../components/migration/OutboundWizard.svelte'
|
||||
import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
type Direction = 'select' | 'inbound' | 'outbound'
|
||||
type Direction = 'select' | 'inbound' | 'offline-inbound'
|
||||
let direction = $state<Direction>('select')
|
||||
let showResumeModal = $state(false)
|
||||
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
|
||||
@@ -23,7 +24,7 @@
|
||||
let oauthLoading = $state(false)
|
||||
|
||||
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
|
||||
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
|
||||
let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null)
|
||||
let oauthCallbackProcessed = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
@@ -66,20 +67,31 @@
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
|
||||
|
||||
if (!hasOAuthCallback && hasPendingMigration()) {
|
||||
resumeInfo = getResumeInfo()
|
||||
if (resumeInfo) {
|
||||
const stored = loadMigrationState()
|
||||
if (stored) {
|
||||
if (stored.direction === 'inbound') {
|
||||
direction = 'inbound'
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
inboundFlow.resumeFromState(stored)
|
||||
if (!hasOAuthCallback) {
|
||||
if (hasPendingMigration()) {
|
||||
resumeInfo = getResumeInfo()
|
||||
if (resumeInfo) {
|
||||
if (resumeInfo.step === 'success') {
|
||||
clearMigrationState()
|
||||
resumeInfo = null
|
||||
} else {
|
||||
direction = 'outbound'
|
||||
outboundFlow = createOutboundMigrationFlow()
|
||||
const stored = loadMigrationState()
|
||||
if (stored && stored.direction === 'inbound') {
|
||||
direction = 'inbound'
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
inboundFlow.resumeFromState(stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (hasPendingOfflineMigration()) {
|
||||
const offlineInfo = getOfflineResumeInfo()
|
||||
if (offlineInfo && offlineInfo.step === 'success') {
|
||||
clearOfflineState()
|
||||
} else {
|
||||
direction = 'offline-inbound'
|
||||
offlineFlow = createOfflineInboundMigrationFlow()
|
||||
offlineFlow.tryResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +100,9 @@
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
}
|
||||
|
||||
function selectOutbound() {
|
||||
if (!auth.session) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
direction = 'outbound'
|
||||
outboundFlow = createOutboundMigrationFlow()
|
||||
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
|
||||
function selectOfflineInbound() {
|
||||
direction = 'offline-inbound'
|
||||
offlineFlow = createOfflineInboundMigrationFlow()
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
@@ -108,14 +115,6 @@
|
||||
direction = 'inbound'
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
inboundFlow.resumeFromState(stored)
|
||||
} else {
|
||||
if (!auth.session) {
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
direction = 'outbound'
|
||||
outboundFlow = createOutboundMigrationFlow()
|
||||
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +129,9 @@
|
||||
inboundFlow.reset()
|
||||
inboundFlow = null
|
||||
}
|
||||
if (outboundFlow) {
|
||||
outboundFlow.reset()
|
||||
outboundFlow = null
|
||||
if (offlineFlow) {
|
||||
offlineFlow.reset()
|
||||
offlineFlow = null
|
||||
}
|
||||
direction = 'select'
|
||||
}
|
||||
@@ -150,9 +149,17 @@
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
async function handleOutboundComplete() {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
function handleOfflineComplete() {
|
||||
const session = offlineFlow?.getLocalSession()
|
||||
if (session) {
|
||||
setSession({
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: '',
|
||||
})
|
||||
}
|
||||
navigate('/dashboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -165,7 +172,7 @@
|
||||
<div class="resume-details">
|
||||
<div class="detail-row">
|
||||
<span class="label">{$_('migration.resume.direction')}:</span>
|
||||
<span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span>
|
||||
<span class="value">{$_('migration.resume.migratingHere')}</span>
|
||||
</div>
|
||||
{#if resumeInfo.sourceHandle}
|
||||
<div class="detail-row">
|
||||
@@ -212,7 +219,6 @@
|
||||
|
||||
<div class="direction-cards">
|
||||
<button class="direction-card ghost" onclick={selectInbound}>
|
||||
<div class="card-icon">↓</div>
|
||||
<h2>{$_('migration.migrateHere')}</h2>
|
||||
<p>{$_('migration.migrateHereDesc')}</p>
|
||||
<ul class="features">
|
||||
@@ -222,16 +228,14 @@
|
||||
</ul>
|
||||
</button>
|
||||
|
||||
<button class="direction-card ghost" onclick={selectOutbound} disabled>
|
||||
<div class="card-icon">↑</div>
|
||||
<h2>{$_('migration.migrateAway')}</h2>
|
||||
<p>{$_('migration.migrateAwayDesc')}</p>
|
||||
<button class="direction-card ghost offline-card" onclick={selectOfflineInbound}>
|
||||
<h2>{$_('migration.offlineRestore')}</h2>
|
||||
<p>{$_('migration.offlineRestoreDesc')}</p>
|
||||
<ul class="features">
|
||||
<li>{$_('migration.exportRepo')}</li>
|
||||
<li>{$_('migration.transferToPds')}</li>
|
||||
<li>{$_('migration.updateIdentity')}</li>
|
||||
<li>{$_('migration.offlineFeature1')}</li>
|
||||
<li>{$_('migration.offlineFeature2')}</li>
|
||||
<li>{$_('migration.offlineFeature3')}</li>
|
||||
</ul>
|
||||
<p class="login-required">{$_('migration.comingSoon')}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -263,11 +267,11 @@
|
||||
onComplete={handleInboundComplete}
|
||||
/>
|
||||
|
||||
{:else if direction === 'outbound' && outboundFlow}
|
||||
<OutboundWizard
|
||||
flow={outboundFlow}
|
||||
{:else if direction === 'offline-inbound' && offlineFlow}
|
||||
<OfflineInboundWizard
|
||||
flow={offlineFlow}
|
||||
onBack={handleBack}
|
||||
onComplete={handleOutboundComplete}
|
||||
onComplete={handleOfflineComplete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -302,6 +306,9 @@
|
||||
}
|
||||
|
||||
.direction-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -322,12 +329,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: var(--text-3xl);
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.direction-card h2 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-xl);
|
||||
@@ -351,12 +352,6 @@
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.login-required {
|
||||
color: var(--warning-text);
|
||||
font-weight: var(--font-medium);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -402,9 +397,8 @@
|
||||
}
|
||||
|
||||
.warning-box a {
|
||||
display: block;
|
||||
margin-top: var(--space-3);
|
||||
color: var(--accent);
|
||||
display: inline;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}>
|
||||
{submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')}
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
<h1>{$_('oauth.error.title')}</h1>
|
||||
<div class="error">{error}</div>
|
||||
<button type="button" onclick={() => navigate('/login')}>
|
||||
{$_('verify.backToLogin')}
|
||||
{$_('common.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if consentData}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !canSubmit}>
|
||||
{submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')}
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
case 'info': return $_('register.subtitle')
|
||||
case 'key-choice': return $_('register.subtitleKeyChoice')
|
||||
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
|
||||
case 'creating': return $_('register.creating')
|
||||
case 'creating': return $_('common.creating')
|
||||
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
|
||||
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
|
||||
case 'activating': return $_('register.subtitleActivating')
|
||||
@@ -375,7 +375,7 @@
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
|
||||
{flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'creating'}
|
||||
<p class="loading">{$_('register.creating')}</p>
|
||||
<p class="loading">{$_('common.creating')}</p>
|
||||
|
||||
{:else if flow.state.step === 'verify'}
|
||||
<VerificationStep {flow} />
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')}
|
||||
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary" disabled={saving || !!jsonError}>
|
||||
{saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')}
|
||||
{saving ? $_('common.saving') : $_('repoExplorer.updateRecord')}
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={handleDelete} disabled={saving}>
|
||||
{$_('common.delete')}
|
||||
@@ -464,7 +464,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
|
||||
{saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')}
|
||||
{saving ? $_('common.creating') : $_('repoExplorer.createRecord')}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={goBack}>
|
||||
{$_('common.cancel')}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<h1>{$_('requestPasskeyRecovery.successTitle')}</h1>
|
||||
<p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p>
|
||||
<p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p>
|
||||
<button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button>
|
||||
<button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>{$_('requestPasskeyRecovery.title')}</h1>
|
||||
@@ -71,7 +71,7 @@
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a>
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('resetPassword.backToLogin')}</a>
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
let deleteToken = $state('')
|
||||
let deleteTokenSent = $state(false)
|
||||
let exportLoading = $state(false)
|
||||
let exportBlobsLoading = $state(false)
|
||||
let passwordLoading = $state(false)
|
||||
let currentPassword = $state('')
|
||||
let newPassword = $state('')
|
||||
@@ -173,6 +174,172 @@
|
||||
exportLoading = false
|
||||
}
|
||||
}
|
||||
async function handleExportBlobs() {
|
||||
if (!auth.session) return
|
||||
exportBlobsLoading = true
|
||||
message = null
|
||||
try {
|
||||
const response = await fetch('/xrpc/_backup.exportBlobs', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.session.accessJwt}`
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: 'Export failed' }))
|
||||
throw new Error(err.message || 'Export failed')
|
||||
}
|
||||
const blob = await response.blob()
|
||||
if (blob.size === 0) {
|
||||
showMessage('success', $_('settings.messages.noBlobsToExport'))
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${auth.session.handle}-blobs.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showMessage('success', $_('settings.messages.blobsExported'))
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
|
||||
} finally {
|
||||
exportBlobsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupInfo {
|
||||
id: string
|
||||
repoRev: string
|
||||
repoRootCid: string
|
||||
blockCount: number
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
}
|
||||
let backups = $state<BackupInfo[]>([])
|
||||
let backupEnabled = $state(true)
|
||||
let backupsLoading = $state(false)
|
||||
let createBackupLoading = $state(false)
|
||||
let restoreFile = $state<File | null>(null)
|
||||
let restoreLoading = $state(false)
|
||||
|
||||
async function loadBackups() {
|
||||
if (!auth.session) return
|
||||
backupsLoading = true
|
||||
try {
|
||||
const result = await api.listBackups(auth.session.accessJwt)
|
||||
backups = result.backups
|
||||
backupEnabled = result.backupEnabled
|
||||
} catch (e) {
|
||||
console.error('Failed to load backups:', e)
|
||||
} finally {
|
||||
backupsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadBackups()
|
||||
})
|
||||
|
||||
async function handleToggleBackup() {
|
||||
if (!auth.session) return
|
||||
const newEnabled = !backupEnabled
|
||||
backupsLoading = true
|
||||
try {
|
||||
await api.setBackupEnabled(auth.session.accessJwt, newEnabled)
|
||||
backupEnabled = newEnabled
|
||||
showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
|
||||
} finally {
|
||||
backupsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateBackup() {
|
||||
if (!auth.session) return
|
||||
createBackupLoading = true
|
||||
message = null
|
||||
try {
|
||||
await api.createBackup(auth.session.accessJwt)
|
||||
await loadBackups()
|
||||
showMessage('success', $_('settings.backups.created'))
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
|
||||
} finally {
|
||||
createBackupLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadBackup(id: string, rev: string) {
|
||||
if (!auth.session) return
|
||||
try {
|
||||
const blob = await api.getBackup(auth.session.accessJwt, id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${auth.session.handle}-${rev}.car`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBackup(id: string) {
|
||||
if (!auth.session) return
|
||||
try {
|
||||
await api.deleteBackup(auth.session.accessJwt, id)
|
||||
await loadBackups()
|
||||
showMessage('success', $_('settings.backups.deleted'))
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files && input.files.length > 0) {
|
||||
restoreFile = input.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
if (!auth.session || !restoreFile) return
|
||||
restoreLoading = true
|
||||
message = null
|
||||
try {
|
||||
const buffer = await restoreFile.arrayBuffer()
|
||||
const car = new Uint8Array(buffer)
|
||||
await api.importRepo(auth.session.accessJwt, car)
|
||||
showMessage('success', $_('settings.backups.restored'))
|
||||
restoreFile = null
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
|
||||
} finally {
|
||||
restoreLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
|
||||
@@ -323,7 +490,7 @@
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={handleLoading || !newHandle}>
|
||||
{handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')}
|
||||
{handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -394,10 +561,78 @@
|
||||
<section>
|
||||
<h2>{$_('settings.exportData')}</h2>
|
||||
<p class="description">{$_('settings.exportDataDescription')}</p>
|
||||
<button onclick={handleExportRepo} disabled={exportLoading}>
|
||||
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
|
||||
<div class="export-buttons">
|
||||
<button onclick={handleExportRepo} disabled={exportLoading}>
|
||||
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
|
||||
</button>
|
||||
<button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary">
|
||||
{exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="backups-section">
|
||||
<h2>{$_('settings.backups.title')}</h2>
|
||||
<p class="description">{$_('settings.backups.description')}</p>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} />
|
||||
<span>{$_('settings.backups.enableAutomatic')}</span>
|
||||
</label>
|
||||
|
||||
{#if backupsLoading}
|
||||
<p class="loading">{$_('common.loading')}</p>
|
||||
{:else if backups.length > 0}
|
||||
<ul class="backup-list">
|
||||
{#each backups as backup}
|
||||
<li class="backup-item">
|
||||
<div class="backup-info">
|
||||
<span class="backup-date">{formatDate(backup.createdAt)}</span>
|
||||
<span class="backup-size">{formatBytes(backup.sizeBytes)}</span>
|
||||
<span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}>
|
||||
{$_('settings.backups.download')}
|
||||
</button>
|
||||
<button class="small danger" onclick={() => handleDeleteBackup(backup.id)}>
|
||||
{$_('settings.backups.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">{$_('settings.backups.noBackups')}</p>
|
||||
{/if}
|
||||
|
||||
<button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}>
|
||||
{createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')}
|
||||
</button>
|
||||
</section>
|
||||
<section class="restore-section">
|
||||
<h2>{$_('settings.backups.restoreTitle')}</h2>
|
||||
<p class="description">{$_('settings.backups.restoreDescription')}</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="restore-file">{$_('settings.backups.selectFile')}</label>
|
||||
<input
|
||||
id="restore-file"
|
||||
type="file"
|
||||
accept=".car"
|
||||
onchange={handleFileSelect}
|
||||
disabled={restoreLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if restoreFile}
|
||||
<div class="restore-preview">
|
||||
<p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p>
|
||||
<button onclick={handleRestore} disabled={restoreLoading} class="danger">
|
||||
{restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
<section class="danger-zone">
|
||||
<h2>{$_('settings.deleteAccount')}</h2>
|
||||
@@ -659,4 +894,107 @@
|
||||
border-left: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-date {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.backup-size,
|
||||
.backup-blocks {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.loading {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.restore-preview {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.restore-preview p {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.backup-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.backup-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -225,7 +225,7 @@
|
||||
<div class="verify-page">
|
||||
{#if autoSubmitting}
|
||||
<div class="loading-container">
|
||||
<h1>{$_('verify.verifying')}</h1>
|
||||
<h1>{$_('common.verifying')}</h1>
|
||||
<p class="subtitle">{$_('verify.pleaseWait')}</p>
|
||||
</div>
|
||||
{:else if success}
|
||||
@@ -235,7 +235,7 @@
|
||||
<p class="subtitle">{$_('verify.emailUpdated')}</p>
|
||||
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
|
||||
<div class="actions">
|
||||
<a href="#/settings" class="btn">{$_('verify.backToSettings')}</a>
|
||||
<a href="#/settings" class="btn">{$_('common.backToSettings')}</a>
|
||||
</div>
|
||||
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
|
||||
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
|
||||
@@ -301,7 +301,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/settings">{$_('verify.backToSettings')}</a>
|
||||
<a href="#/settings">{$_('common.backToSettings')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{:else if mode === 'token'}
|
||||
@@ -347,16 +347,16 @@
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
|
||||
{submitting ? $_('verify.verifying') : $_('verify.verify')}
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
|
||||
{resendingCode ? $_('verify.sending') : $_('verify.resendCode')}
|
||||
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('verify.backToLogin')}</a>
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
{:else if pendingVerification}
|
||||
<h1>{$_('verify.title')}</h1>
|
||||
@@ -390,11 +390,11 @@
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? $_('verify.verifying') : $_('verify.verifyButton')}
|
||||
{submitting ? $_('common.verifying') : $_('common.verify')}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? $_('verify.resending') : $_('verify.resendCode')}
|
||||
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -54,14 +54,13 @@ p {
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--secondary-hover);
|
||||
text-decoration: none;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -372,6 +371,7 @@ hr {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
|
||||
@@ -190,6 +190,12 @@
|
||||
|
||||
.current-info .value {
|
||||
font-weight: var(--font-medium);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.current-info .value.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.review-card {
|
||||
@@ -270,6 +276,31 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.blob-progress {
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.blob-progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.blob-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width var(--transition-slow);
|
||||
}
|
||||
|
||||
.blob-progress-text {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -567,3 +598,62 @@ label.auth-option {
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.file-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.step-content textarea {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.step-content textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Comms", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
@@ -37,7 +37,7 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
@@ -67,12 +67,12 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("shows loading text while fetching preferences", async () => {
|
||||
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
|
||||
mockEndpoint("_account.getNotificationPrefs", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse(mockData.notificationPrefs());
|
||||
});
|
||||
@@ -88,13 +88,13 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("displays all four channel options", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -111,7 +111,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("email channel is always selectable", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -122,7 +122,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("discord channel is disabled when not configured", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs({ discordId: null })),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -133,7 +133,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("discord channel is enabled when configured", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
|
||||
);
|
||||
@@ -145,7 +145,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("shows hint for disabled channels", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -156,7 +156,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("selects current preferred channel", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(
|
||||
mockData.notificationPrefs({ preferredChannel: "email" }),
|
||||
@@ -179,13 +179,13 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("displays email as readonly with current value", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -199,7 +199,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("displays all channel inputs with current values", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
@@ -231,13 +231,13 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("shows Primary badge for email", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -247,7 +247,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("shows Verified badge for verified discord", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
@@ -262,7 +262,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("shows Not verified badge for unverified discord", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
@@ -276,7 +276,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("does not show badge when channel not configured", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -294,18 +294,18 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("calls updateNotificationPrefs with correct data", async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
"_account.updateNotificationPrefs",
|
||||
(_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || "{}");
|
||||
return jsonResponse({ success: true });
|
||||
@@ -329,10 +329,10 @@ describe("Comms", () => {
|
||||
});
|
||||
it("shows loading state while saving", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => {
|
||||
mockEndpoint("_account.updateNotificationPrefs", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse({ success: true });
|
||||
});
|
||||
@@ -350,11 +350,11 @@ describe("Comms", () => {
|
||||
});
|
||||
it("shows success message after saving", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
"_account.updateNotificationPrefs",
|
||||
() => jsonResponse({ success: true }),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -372,11 +372,11 @@ describe("Comms", () => {
|
||||
});
|
||||
it("shows error when save fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
"_account.updateNotificationPrefs",
|
||||
() =>
|
||||
errorResponse("InvalidRequest", "Invalid channel configuration", 400),
|
||||
);
|
||||
@@ -400,12 +400,12 @@ describe("Comms", () => {
|
||||
});
|
||||
it("reloads preferences after successful save", async () => {
|
||||
let loadCount = 0;
|
||||
mockEndpoint("com.tranquil.account.getNotificationPrefs", () => {
|
||||
mockEndpoint("_account.getNotificationPrefs", () => {
|
||||
loadCount++;
|
||||
return jsonResponse(mockData.notificationPrefs());
|
||||
});
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
"_account.updateNotificationPrefs",
|
||||
() => jsonResponse({ success: true }),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -430,13 +430,13 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("enables discord channel after entering discord ID", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
@@ -453,7 +453,7 @@ describe("Comms", () => {
|
||||
});
|
||||
it("allows selecting a configured channel", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
@@ -480,13 +480,13 @@ describe("Comms", () => {
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("shows error when loading preferences fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => errorResponse("InternalError", "Database connection failed", 500),
|
||||
);
|
||||
render(Comms);
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
mockData,
|
||||
mockEndpoint,
|
||||
setupAuthenticatedUser,
|
||||
setupFetchMock,
|
||||
setupDefaultMocks,
|
||||
setupUnauthenticatedUser,
|
||||
} from "./mocks";
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
setupDefaultMocks();
|
||||
globalThis.confirm = vi.fn(() => true);
|
||||
});
|
||||
describe("authentication guard", () => {
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte";
|
||||
|
||||
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
|
||||
|
||||
describe("migration/offline-flow", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(OFFLINE_STORAGE_KEY);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createOfflineInboundMigrationFlow", () => {
|
||||
it("creates flow with initial state", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
expect(flow.state.direction).toBe("offline-inbound");
|
||||
expect(flow.state.step).toBe("welcome");
|
||||
expect(flow.state.userDid).toBe("");
|
||||
expect(flow.state.carFile).toBeNull();
|
||||
expect(flow.state.carFileName).toBe("");
|
||||
expect(flow.state.carSizeBytes).toBe(0);
|
||||
expect(flow.state.rotationKey).toBe("");
|
||||
expect(flow.state.rotationKeyDidKey).toBe("");
|
||||
expect(flow.state.targetHandle).toBe("");
|
||||
expect(flow.state.targetEmail).toBe("");
|
||||
expect(flow.state.targetPassword).toBe("");
|
||||
expect(flow.state.inviteCode).toBe("");
|
||||
expect(flow.state.localAccessToken).toBeNull();
|
||||
expect(flow.state.localRefreshToken).toBeNull();
|
||||
expect(flow.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("initializes progress correctly", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
expect(flow.state.progress.repoExported).toBe(false);
|
||||
expect(flow.state.progress.repoImported).toBe(false);
|
||||
expect(flow.state.progress.blobsTotal).toBe(0);
|
||||
expect(flow.state.progress.blobsMigrated).toBe(0);
|
||||
expect(flow.state.progress.blobsFailed).toEqual([]);
|
||||
expect(flow.state.progress.prefsMigrated).toBe(false);
|
||||
expect(flow.state.progress.plcSigned).toBe(false);
|
||||
expect(flow.state.progress.activated).toBe(false);
|
||||
expect(flow.state.progress.deactivated).toBe(false);
|
||||
expect(flow.state.progress.currentOperation).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserDid", () => {
|
||||
it("sets the user DID", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setUserDid("did:plc:abc123");
|
||||
|
||||
expect(flow.state.userDid).toBe("did:plc:abc123");
|
||||
});
|
||||
|
||||
it("saves state to localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setUserDid("did:plc:xyz789");
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
|
||||
expect(stored.userDid).toBe("did:plc:xyz789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCarFile", () => {
|
||||
it("sets CAR file data", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const carData = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
|
||||
flow.setCarFile(carData, "repo.car");
|
||||
|
||||
expect(flow.state.carFile).toEqual(carData);
|
||||
expect(flow.state.carFileName).toBe("repo.car");
|
||||
expect(flow.state.carSizeBytes).toBe(5);
|
||||
});
|
||||
|
||||
it("saves file metadata to localStorage (not file content)", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const carData = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
|
||||
flow.setCarFile(carData, "backup.car");
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
|
||||
expect(stored.carFileName).toBe("backup.car");
|
||||
expect(stored.carSizeBytes).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setRotationKey", () => {
|
||||
it("sets the rotation key", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setRotationKey("abc123privatekey");
|
||||
|
||||
expect(flow.state.rotationKey).toBe("abc123privatekey");
|
||||
});
|
||||
|
||||
it("does not save rotation key to localStorage (security)", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setRotationKey("supersecretkey");
|
||||
|
||||
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
expect(parsed.rotationKey).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTargetHandle", () => {
|
||||
it("sets the target handle", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setTargetHandle("alice.example.com");
|
||||
|
||||
expect(flow.state.targetHandle).toBe("alice.example.com");
|
||||
});
|
||||
|
||||
it("saves to localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setTargetHandle("bob.example.com");
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
|
||||
expect(stored.targetHandle).toBe("bob.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTargetEmail", () => {
|
||||
it("sets the target email", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setTargetEmail("alice@example.com");
|
||||
|
||||
expect(flow.state.targetEmail).toBe("alice@example.com");
|
||||
});
|
||||
|
||||
it("saves to localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setTargetEmail("bob@example.com");
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
|
||||
expect(stored.targetEmail).toBe("bob@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTargetPassword", () => {
|
||||
it("sets the target password", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setTargetPassword("securepassword123");
|
||||
|
||||
expect(flow.state.targetPassword).toBe("securepassword123");
|
||||
});
|
||||
|
||||
it("does not save password to localStorage (security)", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.setUserDid("did:plc:test");
|
||||
|
||||
flow.setTargetPassword("mypassword");
|
||||
|
||||
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
expect(parsed.targetPassword).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setInviteCode", () => {
|
||||
it("sets the invite code", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setInviteCode("invite-abc123");
|
||||
|
||||
expect(flow.state.inviteCode).toBe("invite-abc123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setStep", () => {
|
||||
it("changes the current step", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setStep("provide-did");
|
||||
|
||||
expect(flow.state.step).toBe("provide-did");
|
||||
});
|
||||
|
||||
it("clears error when changing step", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.setError("Previous error");
|
||||
|
||||
flow.setStep("upload-car");
|
||||
|
||||
expect(flow.state.error).toBeNull();
|
||||
});
|
||||
|
||||
it("saves step to localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setStep("provide-rotation-key");
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
|
||||
expect(stored.step).toBe("provide-rotation-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setError", () => {
|
||||
it("sets the error message", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setError("Something went wrong");
|
||||
|
||||
expect(flow.state.error).toBe("Something went wrong");
|
||||
});
|
||||
|
||||
it("saves error to localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setError("Connection failed");
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
|
||||
expect(stored.lastError).toBe("Connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setProgress", () => {
|
||||
it("updates progress fields", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
flow.setProgress({
|
||||
repoImported: true,
|
||||
currentOperation: "Importing...",
|
||||
});
|
||||
|
||||
expect(flow.state.progress.repoImported).toBe(true);
|
||||
expect(flow.state.progress.currentOperation).toBe("Importing...");
|
||||
});
|
||||
|
||||
it("preserves other progress fields", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.setProgress({ repoExported: true });
|
||||
|
||||
flow.setProgress({ repoImported: true });
|
||||
|
||||
expect(flow.state.progress.repoExported).toBe(true);
|
||||
expect(flow.state.progress.repoImported).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets state to initial values", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.setUserDid("did:plc:abc123");
|
||||
flow.setTargetHandle("alice.example.com");
|
||||
flow.setStep("review");
|
||||
|
||||
flow.reset();
|
||||
|
||||
expect(flow.state.step).toBe("welcome");
|
||||
expect(flow.state.userDid).toBe("");
|
||||
expect(flow.state.targetHandle).toBe("");
|
||||
});
|
||||
|
||||
it("clears localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.setUserDid("did:plc:abc123");
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
|
||||
|
||||
flow.reset();
|
||||
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearOfflineState", () => {
|
||||
it("removes state from localStorage", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.setUserDid("did:plc:abc123");
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
|
||||
|
||||
flow.clearOfflineState();
|
||||
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryResume", () => {
|
||||
it("returns false when no stored state", () => {
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
|
||||
const result = flow.tryResume();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("restores state from localStorage", () => {
|
||||
const storedState = {
|
||||
version: 1,
|
||||
step: "choose-handle",
|
||||
startedAt: new Date().toISOString(),
|
||||
userDid: "did:plc:restored123",
|
||||
carFileName: "backup.car",
|
||||
carSizeBytes: 12345,
|
||||
rotationKeyDidKey: "did:key:z123abc",
|
||||
targetHandle: "restored.example.com",
|
||||
targetEmail: "restored@example.com",
|
||||
progress: {
|
||||
accountCreated: true,
|
||||
repoImported: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const result = flow.tryResume();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(flow.state.step).toBe("choose-handle");
|
||||
expect(flow.state.userDid).toBe("did:plc:restored123");
|
||||
expect(flow.state.carFileName).toBe("backup.car");
|
||||
expect(flow.state.carSizeBytes).toBe(12345);
|
||||
expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc");
|
||||
expect(flow.state.targetHandle).toBe("restored.example.com");
|
||||
expect(flow.state.targetEmail).toBe("restored@example.com");
|
||||
expect(flow.state.progress.repoExported).toBe(true);
|
||||
});
|
||||
|
||||
it("restores error from stored state", () => {
|
||||
const storedState = {
|
||||
version: 1,
|
||||
step: "error",
|
||||
startedAt: new Date().toISOString(),
|
||||
userDid: "did:plc:abc",
|
||||
carFileName: "",
|
||||
carSizeBytes: 0,
|
||||
rotationKeyDidKey: "",
|
||||
targetHandle: "",
|
||||
targetEmail: "",
|
||||
progress: {
|
||||
accountCreated: false,
|
||||
repoImported: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
},
|
||||
lastError: "Previous migration failed",
|
||||
};
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
flow.tryResume();
|
||||
|
||||
expect(flow.state.error).toBe("Previous migration failed");
|
||||
});
|
||||
|
||||
it("returns false and clears for incompatible version", () => {
|
||||
const storedState = {
|
||||
version: 999,
|
||||
step: "review",
|
||||
userDid: "did:plc:abc",
|
||||
};
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const result = flow.tryResume();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false and clears for expired state (> 24 hours)", () => {
|
||||
const expiredState = {
|
||||
version: 1,
|
||||
step: "review",
|
||||
startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
|
||||
userDid: "did:plc:expired",
|
||||
carFileName: "old.car",
|
||||
carSizeBytes: 100,
|
||||
rotationKeyDidKey: "",
|
||||
targetHandle: "old.example.com",
|
||||
targetEmail: "old@example.com",
|
||||
progress: {
|
||||
accountCreated: false,
|
||||
repoImported: false,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState));
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const result = flow.tryResume();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false and clears for invalid JSON", () => {
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json");
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const result = flow.tryResume();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts state within 24 hours", () => {
|
||||
const recentState = {
|
||||
version: 1,
|
||||
step: "review",
|
||||
startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(),
|
||||
userDid: "did:plc:recent",
|
||||
carFileName: "recent.car",
|
||||
carSizeBytes: 500,
|
||||
rotationKeyDidKey: "did:key:zRecent",
|
||||
targetHandle: "recent.example.com",
|
||||
targetEmail: "recent@example.com",
|
||||
progress: {
|
||||
accountCreated: true,
|
||||
repoImported: true,
|
||||
plcSigned: false,
|
||||
activated: false,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState));
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const result = flow.tryResume();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(flow.state.userDid).toBe("did:plc:recent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadLocalServerInfo", () => {
|
||||
function createMockResponse(data: unknown) {
|
||||
const jsonStr = JSON.stringify(data);
|
||||
return new Response(jsonStr, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
it("fetches server description", async () => {
|
||||
const mockServerInfo = {
|
||||
did: "did:web:example.com",
|
||||
availableUserDomains: ["example.com"],
|
||||
inviteCodeRequired: false,
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse(mockServerInfo),
|
||||
);
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
const result = await flow.loadLocalServerInfo();
|
||||
|
||||
expect(result).toEqual(mockServerInfo);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("com.atproto.server.describeServer"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("caches server info", async () => {
|
||||
const mockServerInfo = {
|
||||
did: "did:web:example.com",
|
||||
availableUserDomains: ["example.com"],
|
||||
inviteCodeRequired: false,
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
createMockResponse(mockServerInfo),
|
||||
);
|
||||
|
||||
const flow = createOfflineInboundMigrationFlow();
|
||||
await flow.loadLocalServerInfo();
|
||||
await flow.loadLocalServerInfo();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlcOps, plcOps } from "../../lib/migration/plc-ops";
|
||||
|
||||
describe("migration/plc-ops", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PlcOps class", () => {
|
||||
it("uses default PLC directory URL", () => {
|
||||
const ops = new PlcOps();
|
||||
expect(ops).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts custom PLC directory URL", () => {
|
||||
const ops = new PlcOps("https://custom-plc.example.com");
|
||||
expect(ops).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plcOps singleton", () => {
|
||||
it("exports a singleton instance", () => {
|
||||
expect(plcOps).toBeInstanceOf(PlcOps);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlcAuditLogs", () => {
|
||||
it("throws on HTTP error", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow(
|
||||
"Failed to fetch PLC audit logs: 404",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastPlcOpFromPlc", () => {
|
||||
it("throws when empty array returned", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
plcOps.getLastPlcOpFromPlc("did:plc:empty"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNewSecp256k1Keypair", () => {
|
||||
it("generates a keypair with private and public keys", async () => {
|
||||
const result = await plcOps.createNewSecp256k1Keypair();
|
||||
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.publicKey.startsWith("did:key:")).toBe(true);
|
||||
});
|
||||
|
||||
it("generates different keypairs each time", async () => {
|
||||
const result1 = await plcOps.createNewSecp256k1Keypair();
|
||||
const result2 = await plcOps.createNewSecp256k1Keypair();
|
||||
|
||||
expect(result1.privateKey).not.toBe(result2.privateKey);
|
||||
expect(result1.publicKey).not.toBe(result2.publicKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getKeyPair", () => {
|
||||
it("parses 64-character hex private key", async () => {
|
||||
const hexKey = "a".repeat(64);
|
||||
|
||||
const result = await plcOps.getKeyPair(hexKey);
|
||||
|
||||
expect(result.type).toBe("private_key");
|
||||
expect(result.didPublicKey.startsWith("did:key:")).toBe(true);
|
||||
expect(result.keypair).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles whitespace in key input", async () => {
|
||||
const hexKey = " " + "b".repeat(64) + " ";
|
||||
|
||||
const result = await plcOps.getKeyPair(hexKey);
|
||||
|
||||
expect(result.type).toBe("private_key");
|
||||
});
|
||||
|
||||
it("throws for invalid key format", async () => {
|
||||
await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow(
|
||||
"Invalid key format",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for hex key with wrong length", async () => {
|
||||
await expect(plcOps.getKeyPair("abc123")).rejects.toThrow(
|
||||
"Invalid key format",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushPlcOperation", () => {
|
||||
it("posts operation to PLC directory", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const operation = {
|
||||
type: "plc_operation" as const,
|
||||
prev: "bafyreiabc",
|
||||
alsoKnownAs: ["at://alice.example.com"],
|
||||
rotationKeys: ["did:key:z123"],
|
||||
services: {
|
||||
atproto_pds: {
|
||||
type: "AtprotoPersonalDataServer",
|
||||
endpoint: "https://pds.example.com",
|
||||
},
|
||||
},
|
||||
verificationMethods: {
|
||||
atproto: "did:key:z456",
|
||||
},
|
||||
sig: "test-signature",
|
||||
};
|
||||
|
||||
await plcOps.pushPlcOperation("did:plc:abc123", operation);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://plc.directory/did:plc:abc123",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(operation),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with error message from PLC directory", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: new Map([["content-type", "application/json"]]),
|
||||
json: () => Promise.resolve({ message: "Invalid signature" }),
|
||||
});
|
||||
|
||||
const operation = {
|
||||
type: "plc_operation" as const,
|
||||
prev: "bafyreiabc",
|
||||
alsoKnownAs: [],
|
||||
rotationKeys: ["did:key:z123"],
|
||||
services: {},
|
||||
verificationMethods: {},
|
||||
sig: "bad-sig",
|
||||
};
|
||||
|
||||
await expect(
|
||||
plcOps.pushPlcOperation("did:plc:abc123", operation),
|
||||
).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
|
||||
it("throws generic error when no message in response", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: new Map([["content-type", "text/plain"]]),
|
||||
});
|
||||
|
||||
const operation = {
|
||||
type: "plc_operation" as const,
|
||||
prev: null,
|
||||
alsoKnownAs: [],
|
||||
rotationKeys: [],
|
||||
services: {},
|
||||
verificationMethods: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
plcOps.pushPlcOperation("did:plc:abc123", operation),
|
||||
).rejects.toThrow("PLC directory returned HTTP 500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createServiceAuthToken", () => {
|
||||
it("creates a valid JWT", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
const token = await plcOps.createServiceAuthToken(
|
||||
"did:plc:issuer",
|
||||
"did:web:audience.example.com",
|
||||
keypair.keypair,
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
const parts = token.split(".");
|
||||
expect(parts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("includes correct header", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
const token = await plcOps.createServiceAuthToken(
|
||||
"did:plc:issuer",
|
||||
"did:web:audience",
|
||||
keypair.keypair,
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
|
||||
const headerB64 = token.split(".")[0];
|
||||
const header = JSON.parse(
|
||||
atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")),
|
||||
);
|
||||
expect(header.typ).toBe("JWT");
|
||||
expect(header.alg).toBe("ES256K");
|
||||
});
|
||||
|
||||
it("includes correct payload claims", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const token = await plcOps.createServiceAuthToken(
|
||||
"did:plc:myissuer",
|
||||
"did:web:myaudience.com",
|
||||
keypair.keypair,
|
||||
"com.atproto.sync.getRepo",
|
||||
);
|
||||
const after = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payloadB64 = token.split(".")[1];
|
||||
const payload = JSON.parse(
|
||||
atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")),
|
||||
);
|
||||
|
||||
expect(payload.iss).toBe("did:plc:myissuer");
|
||||
expect(payload.aud).toBe("did:web:myaudience.com");
|
||||
expect(payload.lxm).toBe("com.atproto.sync.getRepo");
|
||||
expect(payload.iat).toBeGreaterThanOrEqual(before);
|
||||
expect(payload.iat).toBeLessThanOrEqual(after);
|
||||
expect(payload.exp).toBe(payload.iat + 60);
|
||||
expect(payload.jti).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signAndPublishNewOp", () => {
|
||||
it("throws when no rotation keys provided", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
await expect(
|
||||
plcOps.signAndPublishNewOp(
|
||||
"did:plc:test",
|
||||
keypair.keypair,
|
||||
["at://alice.example.com"],
|
||||
[],
|
||||
"https://pds.example.com",
|
||||
"did:key:zVerify",
|
||||
"bafyreiprev",
|
||||
),
|
||||
).rejects.toThrow("No rotation keys provided");
|
||||
});
|
||||
|
||||
it("throws when more than 5 unique rotation keys provided", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
const tooManyKeys = [
|
||||
"did:key:z1",
|
||||
"did:key:z2",
|
||||
"did:key:z3",
|
||||
"did:key:z4",
|
||||
"did:key:z5",
|
||||
"did:key:z6",
|
||||
];
|
||||
|
||||
await expect(
|
||||
plcOps.signAndPublishNewOp(
|
||||
"did:plc:test",
|
||||
keypair.keypair,
|
||||
[],
|
||||
tooManyKeys,
|
||||
"https://pds.example.com",
|
||||
"did:key:zVerify",
|
||||
"bafyreiprev",
|
||||
),
|
||||
).rejects.toThrow("Maximum 5 rotation keys allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("signPlcOperationWithCredentials", () => {
|
||||
it("throws when no rotation keys provided", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
await expect(
|
||||
plcOps.signPlcOperationWithCredentials(
|
||||
"did:plc:test",
|
||||
keypair.keypair,
|
||||
{
|
||||
rotationKeys: [],
|
||||
alsoKnownAs: [],
|
||||
verificationMethods: {},
|
||||
services: {},
|
||||
},
|
||||
[],
|
||||
"bafyreiprev",
|
||||
),
|
||||
).rejects.toThrow("No rotation keys provided");
|
||||
});
|
||||
|
||||
it("throws when more than 5 rotation keys provided", async () => {
|
||||
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
|
||||
const keypair = await plcOps.getKeyPair(privateKey);
|
||||
|
||||
await expect(
|
||||
plcOps.signPlcOperationWithCredentials(
|
||||
"did:plc:test",
|
||||
keypair.keypair,
|
||||
{
|
||||
rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"],
|
||||
alsoKnownAs: [],
|
||||
verificationMethods: {},
|
||||
services: {},
|
||||
},
|
||||
["did:key:z4", "did:key:z5", "did:key:z6"],
|
||||
"bafyreiprev",
|
||||
),
|
||||
).rejects.toThrow("Maximum 5 rotation keys allowed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -206,15 +206,15 @@ export function setupDefaultMocks(): void {
|
||||
() => jsonResponse({ code: "new-invite-" + Date.now() }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
"_account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
"_account.updateNotificationPrefs",
|
||||
() => jsonResponse({ success: true }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationHistory",
|
||||
"_account.getNotificationHistory",
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
mockEndpoint(
|
||||
@@ -241,6 +241,10 @@ export function setupDefaultMocks(): void {
|
||||
"com.atproto.repo.listRecords",
|
||||
() => jsonResponse({ records: [] }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"_backup.listBackups",
|
||||
() => jsonResponse({ backups: [] }),
|
||||
);
|
||||
}
|
||||
export function setupAuthenticatedUser(
|
||||
sessionOverrides?: Partial<Session>,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE users ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
CREATE TABLE account_backups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
storage_key TEXT NOT NULL,
|
||||
repo_root_cid TEXT NOT NULL,
|
||||
repo_rev TEXT NOT NULL,
|
||||
block_count INT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_account_backups_user_id ON account_backups(user_id);
|
||||
CREATE INDEX idx_account_backups_created_at ON account_backups(created_at);
|
||||
@@ -44,9 +44,10 @@ nuke_installation() {
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true
|
||||
sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true
|
||||
|
||||
log_info "Removing minio bucket..."
|
||||
log_info "Removing minio buckets..."
|
||||
if command -v mc &>/dev/null; then
|
||||
mc rb local/pds-blobs --force 2>/dev/null || true
|
||||
mc rb local/pds-backups --force 2>/dev/null || true
|
||||
mc alias remove local 2>/dev/null || true
|
||||
fi
|
||||
systemctl stop minio 2>/dev/null || true
|
||||
@@ -78,7 +79,7 @@ if [[ -f /etc/tranquil-pds/tranquil-pds.env ]] || [[ -d /opt/tranquil-pds ]] ||
|
||||
echo " - PostgreSQL database 'pds' and all data"
|
||||
echo " - All Tranquil PDS configuration and credentials"
|
||||
echo " - All source code in /opt/tranquil-pds"
|
||||
echo " - MinIO bucket 'pds-blobs' and all blobs"
|
||||
echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data"
|
||||
echo ""
|
||||
read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE
|
||||
if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then
|
||||
@@ -274,7 +275,8 @@ fi
|
||||
mc alias remove local 2>/dev/null || true
|
||||
mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4
|
||||
mc mb local/pds-blobs --ignore-existing
|
||||
log_success "minio bucket created"
|
||||
mc mb local/pds-backups --ignore-existing
|
||||
log_success "minio buckets created"
|
||||
|
||||
log_info "Installing rust..."
|
||||
if [[ -f "$HOME/.cargo/env" ]]; then
|
||||
@@ -382,6 +384,7 @@ DATABASE_MIN_CONNECTIONS=10
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
AWS_REGION=us-east-1
|
||||
S3_BUCKET=pds-blobs
|
||||
BACKUP_S3_BUCKET=pds-backups
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||
VALKEY_URL=redis://localhost:6379
|
||||
|
||||
@@ -83,15 +83,19 @@ start_infra() {
|
||||
echo "Waiting for Valkey... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
echo "Creating MinIO bucket..."
|
||||
echo "Creating MinIO buckets..."
|
||||
$CONTAINER_CMD run --rm --network host \
|
||||
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
|
||||
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
|
||||
$CONTAINER_CMD run --rm --network host \
|
||||
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
|
||||
minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true
|
||||
cat > "$INFRA_FILE" << EOF
|
||||
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
|
||||
export TEST_DB_PORT="${PG_PORT}"
|
||||
export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}"
|
||||
export S3_BUCKET="test-bucket"
|
||||
export BACKUP_S3_BUCKET="test-backups"
|
||||
export AWS_ACCESS_KEY_ID="minioadmin"
|
||||
export AWS_SECRET_ACCESS_KEY="minioadmin"
|
||||
export AWS_REGION="us-east-1"
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::scheduled::generate_full_backup;
|
||||
use crate::state::AppState;
|
||||
use crate::storage::BackupStorage;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use cid::Cid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupInfo {
|
||||
pub id: String,
|
||||
pub repo_rev: String,
|
||||
pub repo_root_cid: String,
|
||||
pub block_count: i32,
|
||||
pub size_bytes: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListBackupsOutput {
|
||||
pub backups: Vec<BackupInfo>,
|
||||
pub backup_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, backup_enabled FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backups = match sqlx::query!(
|
||||
r#"
|
||||
SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at
|
||||
FROM account_backups
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
user.id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("DB error fetching backups: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup_list: Vec<BackupInfo> = backups
|
||||
.into_iter()
|
||||
.map(|b| BackupInfo {
|
||||
id: b.id.to_string(),
|
||||
repo_rev: b.repo_rev,
|
||||
repo_root_cid: b.repo_root_cid,
|
||||
block_count: b.block_count,
|
||||
size_bytes: b.size_bytes,
|
||||
created_at: b.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ListBackupsOutput {
|
||||
backups: backup_list,
|
||||
backup_enabled: user.backup_enabled,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetBackupQuery {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn get_backup(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Query(query): Query<GetBackupQuery>,
|
||||
) -> Response {
|
||||
let backup_id = match uuid::Uuid::parse_str(&query.id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup = match sqlx::query!(
|
||||
r#"
|
||||
SELECT ab.storage_key, ab.repo_rev
|
||||
FROM account_backups ab
|
||||
JOIN users u ON u.id = ab.user_id
|
||||
WHERE ab.id = $1 AND u.did = $2
|
||||
"#,
|
||||
backup_id,
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup_storage = match state.backup_storage.as_ref() {
|
||||
Some(storage) => storage,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(
|
||||
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let car_bytes = match backup_storage.get_backup(&backup.storage_key).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to fetch backup from storage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
&format!("attachment; filename=\"{}.car\"", backup.repo_rev),
|
||||
),
|
||||
],
|
||||
car_bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateBackupOutput {
|
||||
pub id: String,
|
||||
pub repo_rev: String,
|
||||
pub size_bytes: i64,
|
||||
pub block_count: i32,
|
||||
}
|
||||
|
||||
pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let backup_storage = match state.backup_storage.as_ref() {
|
||||
Some(storage) => storage,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(
|
||||
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user = match sqlx::query!(
|
||||
r#"
|
||||
SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev
|
||||
FROM users u
|
||||
JOIN repos r ON r.user_id = u.id
|
||||
WHERE u.did = $1
|
||||
"#,
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.deactivated_at.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let repo_rev = match &user.repo_rev {
|
||||
Some(rev) => rev.clone(),
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let head_cid = match Cid::from_str(&user.repo_root_cid) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let car_bytes = match generate_full_backup(&state.block_store, &head_cid).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to generate CAR: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to generate backup"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let block_count = crate::scheduled::count_car_blocks(&car_bytes);
|
||||
let size_bytes = car_bytes.len() as i64;
|
||||
|
||||
let storage_key = match backup_storage
|
||||
.put_backup(&user.did, &repo_rev, &car_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
error!("Failed to upload backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to store backup"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup_id = match sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
"#,
|
||||
user.id,
|
||||
storage_key,
|
||||
user.repo_root_cid,
|
||||
repo_rev,
|
||||
block_count,
|
||||
size_bytes
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!("DB error inserting backup: {:?}", e);
|
||||
if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await {
|
||||
error!(
|
||||
storage_key = %storage_key,
|
||||
error = %rollback_err,
|
||||
"Failed to rollback orphaned backup from S3"
|
||||
);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to record backup"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
did = %user.did,
|
||||
rev = %repo_rev,
|
||||
size_bytes,
|
||||
"Created manual backup"
|
||||
);
|
||||
|
||||
let retention = BackupStorage::retention_count();
|
||||
if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await {
|
||||
warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup");
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateBackupOutput {
|
||||
id: backup_id.to_string(),
|
||||
repo_rev,
|
||||
size_bytes,
|
||||
block_count,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn cleanup_old_backups(
|
||||
db: &sqlx::PgPool,
|
||||
backup_storage: &BackupStorage,
|
||||
user_id: uuid::Uuid,
|
||||
retention_count: u32,
|
||||
) -> Result<(), String> {
|
||||
let old_backups = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, storage_key
|
||||
FROM account_backups
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $2
|
||||
"#,
|
||||
user_id,
|
||||
retention_count as i64
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
|
||||
|
||||
for backup in old_backups {
|
||||
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
|
||||
warn!(
|
||||
storage_key = %backup.storage_key,
|
||||
error = %e,
|
||||
"Failed to delete old backup from storage, skipping DB cleanup to avoid orphan"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteBackupQuery {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn delete_backup(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Query(query): Query<DeleteBackupQuery>,
|
||||
) -> Response {
|
||||
let backup_id = match uuid::Uuid::parse_str(&query.id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let backup = match sqlx::query!(
|
||||
r#"
|
||||
SELECT ab.id, ab.storage_key, u.deactivated_at
|
||||
FROM account_backups ab
|
||||
JOIN users u ON u.id = ab.user_id
|
||||
WHERE ab.id = $1 AND u.did = $2
|
||||
"#,
|
||||
backup_id,
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if backup.deactivated_at.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(backup_storage) = state.backup_storage.as_ref()
|
||||
&& let Err(e) = backup_storage.delete_backup(&backup.storage_key).await
|
||||
{
|
||||
warn!(
|
||||
storage_key = %backup.storage_key,
|
||||
error = %e,
|
||||
"Failed to delete backup from storage (continuing anyway)"
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting backup: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete backup"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup");
|
||||
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetBackupEnabledInput {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn set_backup_enabled(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<SetBackupEnabledInput>,
|
||||
) -> Response {
|
||||
let user = match sqlx::query!(
|
||||
"SELECT deactivated_at FROM users WHERE did = $1",
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if user.deactivated_at.is_some() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET backup_enabled = $1 WHERE did = $2",
|
||||
input.enabled,
|
||||
auth.0.did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error updating backup_enabled: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update setting"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting");
|
||||
|
||||
(StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response()
|
||||
}
|
||||
|
||||
pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let blobs = match sqlx::query!(
|
||||
r#"
|
||||
SELECT DISTINCT b.cid, b.storage_key, b.mime_type
|
||||
FROM blobs b
|
||||
JOIN record_blobs rb ON rb.blob_cid = b.cid
|
||||
WHERE rb.repo_id = $1
|
||||
"#,
|
||||
user.id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
error!("DB error fetching blobs: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if blobs.is_empty() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "application/zip"),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"blobs.zip\"",
|
||||
),
|
||||
],
|
||||
Vec::<u8>::new(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut zip_buffer = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut zip = zip::ZipWriter::new(&mut zip_buffer);
|
||||
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let mut exported: Vec<serde_json::Value> = Vec::new();
|
||||
let mut skipped: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for blob in &blobs {
|
||||
let blob_data = match state.blob_store.get(&blob.storage_key).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping");
|
||||
skipped.push(json!({
|
||||
"cid": blob.cid,
|
||||
"mimeType": blob.mime_type,
|
||||
"reason": "fetch_failed"
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let extension = mime_to_extension(&blob.mime_type);
|
||||
let filename = format!("{}{}", blob.cid, extension);
|
||||
|
||||
if let Err(e) = zip.start_file(&filename, options) {
|
||||
warn!(filename = %filename, error = %e, "Failed to start zip file entry");
|
||||
skipped.push(json!({
|
||||
"cid": blob.cid,
|
||||
"mimeType": blob.mime_type,
|
||||
"reason": "zip_entry_failed"
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) {
|
||||
warn!(filename = %filename, error = %e, "Failed to write blob to zip");
|
||||
skipped.push(json!({
|
||||
"cid": blob.cid,
|
||||
"mimeType": blob.mime_type,
|
||||
"reason": "write_failed"
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
exported.push(json!({
|
||||
"cid": blob.cid,
|
||||
"filename": filename,
|
||||
"mimeType": blob.mime_type,
|
||||
"sizeBytes": blob_data.len()
|
||||
}));
|
||||
}
|
||||
|
||||
let manifest = json!({
|
||||
"exportedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"totalBlobs": blobs.len(),
|
||||
"exportedCount": exported.len(),
|
||||
"skippedCount": skipped.len(),
|
||||
"exported": exported,
|
||||
"skipped": skipped
|
||||
});
|
||||
|
||||
if zip.start_file("manifest.json", options).is_ok() {
|
||||
let _ = std::io::Write::write_all(
|
||||
&mut zip,
|
||||
serde_json::to_string_pretty(&manifest)
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = zip.finish() {
|
||||
error!("Failed to finish zip: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to create zip file"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let zip_bytes = zip_buffer.into_inner();
|
||||
|
||||
info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs");
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "application/zip"),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"blobs.zip\"",
|
||||
),
|
||||
],
|
||||
zip_bytes,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn mime_to_extension(mime_type: &str) -> &'static str {
|
||||
match mime_type {
|
||||
"application/font-sfnt" => ".otf",
|
||||
"application/font-tdpfr" => ".pfr",
|
||||
"application/font-woff" => ".woff",
|
||||
"application/gzip" => ".gz",
|
||||
"application/json" => ".json",
|
||||
"application/json5" => ".json5",
|
||||
"application/jsonml+json" => ".jsonml",
|
||||
"application/octet-stream" => ".bin",
|
||||
"application/pdf" => ".pdf",
|
||||
"application/zip" => ".zip",
|
||||
"audio/aac" => ".aac",
|
||||
"audio/ac3" => ".ac3",
|
||||
"audio/aiff" => ".aiff",
|
||||
"audio/annodex" => ".axa",
|
||||
"audio/audible" => ".aa",
|
||||
"audio/basic" => ".au",
|
||||
"audio/flac" => ".flac",
|
||||
"audio/m4a" => ".m4a",
|
||||
"audio/m4b" => ".m4b",
|
||||
"audio/m4p" => ".m4p",
|
||||
"audio/mid" => ".mid",
|
||||
"audio/midi" => ".midi",
|
||||
"audio/mp4" => ".mp4a",
|
||||
"audio/mpeg" => ".mp3",
|
||||
"audio/ogg" => ".ogg",
|
||||
"audio/s3m" => ".s3m",
|
||||
"audio/scpls" => ".pls",
|
||||
"audio/silk" => ".sil",
|
||||
"audio/vnd.audible.aax" => ".aax",
|
||||
"audio/vnd.dece.audio" => ".uva",
|
||||
"audio/vnd.digital-winds" => ".eol",
|
||||
"audio/vnd.dlna.adts" => ".adt",
|
||||
"audio/vnd.dra" => ".dra",
|
||||
"audio/vnd.dts" => ".dts",
|
||||
"audio/vnd.dts.hd" => ".dtshd",
|
||||
"audio/vnd.lucent.voice" => ".lvp",
|
||||
"audio/vnd.ms-playready.media.pya" => ".pya",
|
||||
"audio/vnd.nuera.ecelp4800" => ".ecelp4800",
|
||||
"audio/vnd.nuera.ecelp7470" => ".ecelp7470",
|
||||
"audio/vnd.nuera.ecelp9600" => ".ecelp9600",
|
||||
"audio/vnd.rip" => ".rip",
|
||||
"audio/wav" => ".wav",
|
||||
"audio/webm" => ".weba",
|
||||
"audio/x-caf" => ".caf",
|
||||
"audio/x-gsm" => ".gsm",
|
||||
"audio/x-m4r" => ".m4r",
|
||||
"audio/x-matroska" => ".mka",
|
||||
"audio/x-mpegurl" => ".m3u",
|
||||
"audio/x-ms-wax" => ".wax",
|
||||
"audio/x-ms-wma" => ".wma",
|
||||
"audio/x-pn-realaudio" => ".ra",
|
||||
"audio/x-pn-realaudio-plugin" => ".rpm",
|
||||
"audio/x-sd2" => ".sd2",
|
||||
"audio/x-smd" => ".smd",
|
||||
"audio/xm" => ".xm",
|
||||
"font/collection" => ".ttc",
|
||||
"font/ttf" => ".ttf",
|
||||
"font/woff" => ".woff",
|
||||
"font/woff2" => ".woff2",
|
||||
"image/apng" => ".apng",
|
||||
"image/avif" => ".avif",
|
||||
"image/avif-sequence" => ".avifs",
|
||||
"image/bmp" => ".bmp",
|
||||
"image/cgm" => ".cgm",
|
||||
"image/cis-cod" => ".cod",
|
||||
"image/g3fax" => ".g3",
|
||||
"image/gif" => ".gif",
|
||||
"image/heic" => ".heic",
|
||||
"image/heic-sequence" => ".heics",
|
||||
"image/heif" => ".heif",
|
||||
"image/heif-sequence" => ".heifs",
|
||||
"image/ief" => ".ief",
|
||||
"image/jp2" => ".jp2",
|
||||
"image/jpeg" => ".jpg",
|
||||
"image/jpm" => ".jpm",
|
||||
"image/jpx" => ".jpf",
|
||||
"image/jxl" => ".jxl",
|
||||
"image/ktx" => ".ktx",
|
||||
"image/pict" => ".pct",
|
||||
"image/png" => ".png",
|
||||
"image/prs.btif" => ".btif",
|
||||
"image/qoi" => ".qoi",
|
||||
"image/sgi" => ".sgi",
|
||||
"image/svg+xml" => ".svg",
|
||||
"image/tiff" => ".tiff",
|
||||
"image/vnd.dece.graphic" => ".uvg",
|
||||
"image/vnd.djvu" => ".djv",
|
||||
"image/vnd.fastbidsheet" => ".fbs",
|
||||
"image/vnd.fpx" => ".fpx",
|
||||
"image/vnd.fst" => ".fst",
|
||||
"image/vnd.fujixerox.edmics-mmr" => ".mmr",
|
||||
"image/vnd.fujixerox.edmics-rlc" => ".rlc",
|
||||
"image/vnd.ms-modi" => ".mdi",
|
||||
"image/vnd.ms-photo" => ".wdp",
|
||||
"image/vnd.net-fpx" => ".npx",
|
||||
"image/vnd.radiance" => ".hdr",
|
||||
"image/vnd.rn-realflash" => ".rf",
|
||||
"image/vnd.wap.wbmp" => ".wbmp",
|
||||
"image/vnd.xiff" => ".xif",
|
||||
"image/webp" => ".webp",
|
||||
"image/x-3ds" => ".3ds",
|
||||
"image/x-adobe-dng" => ".dng",
|
||||
"image/x-canon-cr2" => ".cr2",
|
||||
"image/x-canon-cr3" => ".cr3",
|
||||
"image/x-canon-crw" => ".crw",
|
||||
"image/x-cmu-raster" => ".ras",
|
||||
"image/x-cmx" => ".cmx",
|
||||
"image/x-epson-erf" => ".erf",
|
||||
"image/x-freehand" => ".fh",
|
||||
"image/x-fuji-raf" => ".raf",
|
||||
"image/x-icon" => ".ico",
|
||||
"image/x-jg" => ".art",
|
||||
"image/x-jng" => ".jng",
|
||||
"image/x-kodak-dcr" => ".dcr",
|
||||
"image/x-kodak-k25" => ".k25",
|
||||
"image/x-kodak-kdc" => ".kdc",
|
||||
"image/x-macpaint" => ".mac",
|
||||
"image/x-minolta-mrw" => ".mrw",
|
||||
"image/x-mrsid-image" => ".sid",
|
||||
"image/x-nikon-nef" => ".nef",
|
||||
"image/x-nikon-nrw" => ".nrw",
|
||||
"image/x-olympus-orf" => ".orf",
|
||||
"image/x-panasonic-rw" => ".raw",
|
||||
"image/x-panasonic-rw2" => ".rw2",
|
||||
"image/x-pentax-pef" => ".pef",
|
||||
"image/x-portable-anymap" => ".pnm",
|
||||
"image/x-portable-bitmap" => ".pbm",
|
||||
"image/x-portable-graymap" => ".pgm",
|
||||
"image/x-portable-pixmap" => ".ppm",
|
||||
"image/x-qoi" => ".qoi",
|
||||
"image/x-quicktime" => ".qti",
|
||||
"image/x-rgb" => ".rgb",
|
||||
"image/x-sigma-x3f" => ".x3f",
|
||||
"image/x-sony-arw" => ".arw",
|
||||
"image/x-sony-sr2" => ".sr2",
|
||||
"image/x-sony-srf" => ".srf",
|
||||
"image/x-tga" => ".tga",
|
||||
"image/x-xbitmap" => ".xbm",
|
||||
"image/x-xcf" => ".xcf",
|
||||
"image/x-xpixmap" => ".xpm",
|
||||
"image/x-xwindowdump" => ".xwd",
|
||||
"model/gltf+json" => ".gltf",
|
||||
"model/gltf-binary" => ".glb",
|
||||
"model/iges" => ".igs",
|
||||
"model/mesh" => ".msh",
|
||||
"model/vnd.collada+xml" => ".dae",
|
||||
"model/vnd.gdl" => ".gdl",
|
||||
"model/vnd.gtw" => ".gtw",
|
||||
"model/vnd.vtu" => ".vtu",
|
||||
"model/vrml" => ".vrml",
|
||||
"model/x3d+binary" => ".x3db",
|
||||
"model/x3d+vrml" => ".x3dv",
|
||||
"model/x3d+xml" => ".x3d",
|
||||
"text/css" => ".css",
|
||||
"text/html" => ".html",
|
||||
"text/plain" => ".txt",
|
||||
"video/3gpp" => ".3gp",
|
||||
"video/3gpp2" => ".3g2",
|
||||
"video/annodex" => ".axv",
|
||||
"video/divx" => ".divx",
|
||||
"video/h261" => ".h261",
|
||||
"video/h263" => ".h263",
|
||||
"video/h264" => ".h264",
|
||||
"video/jpeg" => ".jpgv",
|
||||
"video/jpm" => ".jpgm",
|
||||
"video/mj2" => ".mj2",
|
||||
"video/mp4" => ".mp4",
|
||||
"video/mpeg" => ".mpg",
|
||||
"video/ogg" => ".ogv",
|
||||
"video/quicktime" => ".mov",
|
||||
"video/vnd.dece.hd" => ".uvh",
|
||||
"video/vnd.dece.mobile" => ".uvm",
|
||||
"video/vnd.dece.pd" => ".uvp",
|
||||
"video/vnd.dece.sd" => ".uvs",
|
||||
"video/vnd.dece.video" => ".uvv",
|
||||
"video/vnd.dlna.mpeg-tts" => ".ts",
|
||||
"video/vnd.dvb.file" => ".dvb",
|
||||
"video/vnd.fvt" => ".fvt",
|
||||
"video/vnd.mpegurl" => ".m4u",
|
||||
"video/vnd.ms-playready.media.pyv" => ".pyv",
|
||||
"video/vnd.uvvu.mp4" => ".uvu",
|
||||
"video/vnd.vivo" => ".viv",
|
||||
"video/webm" => ".webm",
|
||||
"video/x-dv" => ".dv",
|
||||
"video/x-f4v" => ".f4v",
|
||||
"video/x-fli" => ".fli",
|
||||
"video/x-flv" => ".flv",
|
||||
"video/x-ivf" => ".ivf",
|
||||
"video/x-la-asf" => ".lsf",
|
||||
"video/x-m4v" => ".m4v",
|
||||
"video/x-matroska" => ".mkv",
|
||||
"video/x-mng" => ".mng",
|
||||
"video/x-ms-asf" => ".asf",
|
||||
"video/x-ms-vob" => ".vob",
|
||||
"video/x-ms-wm" => ".wm",
|
||||
"video/x-ms-wmp" => ".wmp",
|
||||
"video/x-ms-wmv" => ".wmv",
|
||||
"video/x-ms-wmx" => ".wmx",
|
||||
"video/x-ms-wvx" => ".wvx",
|
||||
"video/x-msvideo" => ".avi",
|
||||
"video/x-sgi-movie" => ".movie",
|
||||
"video/x-smv" => ".smv",
|
||||
_ => ".bin",
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod actor;
|
||||
pub mod admin;
|
||||
pub mod age_assurance;
|
||||
pub mod backup;
|
||||
pub mod delegation;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
|
||||
@@ -182,15 +182,34 @@ pub async fn get_notification_history(
|
||||
.into_response(),
|
||||
};
|
||||
|
||||
let sensitive_types = [
|
||||
"email_verification",
|
||||
"password_reset",
|
||||
"email_update",
|
||||
"two_factor_code",
|
||||
"passkey_recovery",
|
||||
"migration_verification",
|
||||
"plc_operation",
|
||||
"channel_verification",
|
||||
"signup_verification",
|
||||
];
|
||||
|
||||
let notifications = rows
|
||||
.iter()
|
||||
.map(|row| NotificationHistoryEntry {
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
channel: row.channel.clone(),
|
||||
comms_type: row.comms_type.clone(),
|
||||
status: row.status.clone(),
|
||||
subject: row.subject.clone(),
|
||||
body: row.body.clone(),
|
||||
.map(|row| {
|
||||
let body = if sensitive_types.contains(&row.comms_type.as_str()) {
|
||||
"[Code redacted for security]".to_string()
|
||||
} else {
|
||||
row.body.clone()
|
||||
};
|
||||
NotificationHistoryEntry {
|
||||
created_at: row.created_at.to_rfc3339(),
|
||||
channel: row.channel.clone(),
|
||||
comms_type: row.comms_type.clone(),
|
||||
status: row.status.clone(),
|
||||
subject: row.subject.clone(),
|
||||
body,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ pub async fn list_missing_blobs(
|
||||
r#"
|
||||
SELECT rb.blob_cid, rb.record_uri
|
||||
FROM record_blobs rb
|
||||
LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id
|
||||
LEFT JOIN blobs b ON rb.blob_cid = b.cid
|
||||
WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2
|
||||
ORDER BY rb.blob_cid
|
||||
LIMIT $3
|
||||
|
||||
@@ -345,8 +345,9 @@ pub async fn apply_writes(
|
||||
let rkey = rkey
|
||||
.clone()
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
let record_ipld = crate::util::json_to_ipld(value);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
@@ -409,8 +410,9 @@ pub async fn apply_writes(
|
||||
}
|
||||
};
|
||||
all_blob_cids.extend(extract_blob_cids(value));
|
||||
let record_ipld = crate::util::json_to_ipld(value);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
|
||||
@@ -382,8 +382,9 @@ pub async fn create_record_internal(
|
||||
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
|
||||
.map_err(|e| format!("Failed to parse commit: {:?}", e))?;
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let record_ipld = crate::util::json_to_ipld(record);
|
||||
let mut record_bytes = Vec::new();
|
||||
serde_ipld_dagcbor::to_writer(&mut record_bytes, record)
|
||||
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
|
||||
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
|
||||
let record_cid = tracking_store
|
||||
.put(&record_bytes)
|
||||
|
||||
@@ -297,8 +297,9 @@ pub async fn create_record(
|
||||
let rkey = input
|
||||
.rkey
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
let record_ipld = crate::util::json_to_ipld(&input.record);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
|
||||
@@ -550,8 +551,9 @@ pub async fn put_record(
|
||||
}
|
||||
}
|
||||
let existing_cid = mst.get(&key).await.ok().flatten();
|
||||
let record_ipld = crate::util::json_to_ipld(&input.record);
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
|
||||
|
||||
@@ -567,7 +567,6 @@ pub async fn activate_account(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeactivateAccountInput {
|
||||
pub delete_after: Option<String>,
|
||||
pub migrating_to: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn deactivate_account(
|
||||
@@ -618,62 +617,34 @@ pub async fn deactivate_account(
|
||||
|
||||
let did = auth_user.did;
|
||||
|
||||
let migrating_to = if let Some(ref url) = input.migrating_to {
|
||||
let url = url.trim().trim_end_matches('/');
|
||||
if url.is_empty() || !did.starts_with("did:web:") {
|
||||
None
|
||||
} else {
|
||||
if !url.starts_with("https://") {
|
||||
return ApiError::InvalidRequest("migratingTo must start with https://".into())
|
||||
.into_response();
|
||||
}
|
||||
Some(url.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let result = if let Some(ref pds_url) = migrating_to {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
|
||||
did,
|
||||
delete_after,
|
||||
pds_url
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
|
||||
did,
|
||||
delete_after
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
};
|
||||
|
||||
let status = if migrating_to.is_some() {
|
||||
"migrated"
|
||||
} else {
|
||||
"deactivated"
|
||||
};
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
|
||||
did,
|
||||
delete_after
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Some(ref h) = handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", h)).await;
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status))
|
||||
.await
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
&did,
|
||||
false,
|
||||
Some("deactivated"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account {} event: {}", status, e);
|
||||
warn!("Failed to sequence account deactivated event: {}", e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -476,3 +476,57 @@ pub async fn update_email(
|
||||
info!("Email updated for user {}", user_id);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckEmailVerifiedInput {
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
pub async fn check_email_verified(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<CheckEmailVerifiedInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
|
||||
input.identifier
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match user {
|
||||
Ok(Some(row)) => (
|
||||
StatusCode::OK,
|
||||
Json(json!({ "verified": row.email_verified })),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "AccountNotFound", "message": "Account not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error checking email verified: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "InternalError" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-241
@@ -6,238 +6,10 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetMigrationStatusOutput {
|
||||
pub did: String,
|
||||
pub did_type: String,
|
||||
pub migrated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub migrated_to_pds: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub migrated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn get_migration_status(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.tranquil.account.getMigrationStatus",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let auth_user = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
extracted.is_dpop,
|
||||
dpop_proof,
|
||||
"GET",
|
||||
&http_uri,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
let user = match sqlx::query!(
|
||||
"SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
|
||||
auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return ApiError::AccountNotFound.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("DB error getting migration status: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let did_type = if user.did.starts_with("did:plc:") {
|
||||
"plc"
|
||||
} else if user.did.starts_with("did:web:") {
|
||||
"web"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
let migrated = user.migrated_to_pds.is_some();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(GetMigrationStatusOutput {
|
||||
did: user.did,
|
||||
did_type: did_type.to_string(),
|
||||
migrated,
|
||||
migrated_to_pds: user.migrated_to_pds,
|
||||
migrated_at: user.migrated_at,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateMigrationForwardingInput {
|
||||
pub pds_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateMigrationForwardingOutput {
|
||||
pub success: bool,
|
||||
pub migrated_to_pds: String,
|
||||
pub migrated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn update_migration_forwarding(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<UpdateMigrationForwardingInput>,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.tranquil.account.updateMigrationForwarding",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let auth_user = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
extracted.is_dpop,
|
||||
dpop_proof,
|
||||
"POST",
|
||||
&http_uri,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
if !auth_user.did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let pds_url = input.pds_url.trim();
|
||||
if pds_url.is_empty() {
|
||||
return ApiError::InvalidRequest("pds_url is required".into()).into_response();
|
||||
}
|
||||
if !pds_url.starts_with("https://") {
|
||||
return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response();
|
||||
}
|
||||
let pds_url_clean = pds_url.trim_end_matches('/');
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
|
||||
pds_url_clean,
|
||||
now,
|
||||
auth_user.did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"Updated migration forwarding for {} to {}",
|
||||
auth_user.did,
|
||||
pds_url_clean
|
||||
);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(UpdateMigrationForwardingOutput {
|
||||
success: true,
|
||||
migrated_to_pds: pds_url_clean.to_string(),
|
||||
migrated_at: now,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DB error updating migration forwarding: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_migration_forwarding(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.tranquil.account.clearMigrationForwarding",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let auth_user = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
extracted.is_dpop,
|
||||
dpop_proof,
|
||||
"POST",
|
||||
&http_uri,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
if !auth_user.did.starts_with("did:web:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Migration forwarding is only available for did:web accounts"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
|
||||
auth_user.did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!("Cleared migration forwarding for {}", auth_user.did);
|
||||
(StatusCode::OK, Json(json!({ "success": true }))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DB error clearing migration forwarding: {:?}", e);
|
||||
ApiError::InternalError.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VerificationMethod {
|
||||
@@ -275,7 +47,7 @@ pub async fn update_did_document(
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.tranquil.account.updateDidDocument",
|
||||
"https://{}/xrpc/_account.updateDidDocument",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let auth_user = match crate::auth::validate_token_with_dpop(
|
||||
@@ -305,7 +77,7 @@ pub async fn update_did_document(
|
||||
}
|
||||
|
||||
let user = match sqlx::query!(
|
||||
"SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
|
||||
"SELECT id, handle, deactivated_at FROM users WHERE did = $1",
|
||||
auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -319,15 +91,8 @@ pub async fn update_did_document(
|
||||
}
|
||||
};
|
||||
|
||||
if user.migrated_to_pds.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
if user.deactivated_at.is_some() {
|
||||
return ApiError::AccountDeactivated.into_response();
|
||||
}
|
||||
|
||||
if let Some(ref methods) = input.verification_methods {
|
||||
@@ -452,7 +217,7 @@ pub async fn get_did_document(
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.tranquil.account.getDidDocument",
|
||||
"https://{}/xrpc/_account.getDidDocument",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let auth_user = match crate::auth::validate_token_with_dpop(
|
||||
|
||||
@@ -22,14 +22,11 @@ pub use account_status::{
|
||||
request_account_delete,
|
||||
};
|
||||
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
||||
pub use email::{confirm_email, request_email_update, update_email};
|
||||
pub use email::{check_email_verified, confirm_email, request_email_update, update_email};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use logo::get_logo;
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use migration::{
|
||||
clear_migration_forwarding, get_did_document, get_migration_status, update_did_document,
|
||||
update_migration_forwarding,
|
||||
};
|
||||
pub use migration::{get_did_document, update_did_document};
|
||||
pub use passkey_account::{
|
||||
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
||||
request_passkey_recovery, start_passkey_registration_for_setup,
|
||||
|
||||
+59
-55
@@ -57,15 +57,15 @@ pub fn app(state: AppState) -> Router {
|
||||
get(api::server::get_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.listSessions",
|
||||
"/xrpc/_account.listSessions",
|
||||
get(api::server::list_sessions),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.revokeSession",
|
||||
"/xrpc/_account.revokeSession",
|
||||
post(api::server::revoke_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.revokeAllSessions",
|
||||
"/xrpc/_account.revokeAllSessions",
|
||||
post(api::server::revoke_all_sessions),
|
||||
)
|
||||
.route(
|
||||
@@ -208,105 +208,94 @@ pub fn app(state: AppState) -> Router {
|
||||
post(api::server::reset_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.changePassword",
|
||||
"/xrpc/_account.changePassword",
|
||||
post(api::server::change_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.removePassword",
|
||||
"/xrpc/_account.removePassword",
|
||||
post(api::server::remove_password),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getPasswordStatus",
|
||||
"/xrpc/_account.getPasswordStatus",
|
||||
get(api::server::get_password_status),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getReauthStatus",
|
||||
"/xrpc/_account.getReauthStatus",
|
||||
get(api::server::get_reauth_status),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthPassword",
|
||||
"/xrpc/_account.reauthPassword",
|
||||
post(api::server::reauth_password),
|
||||
)
|
||||
.route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp))
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthTotp",
|
||||
post(api::server::reauth_totp),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthPasskeyStart",
|
||||
"/xrpc/_account.reauthPasskeyStart",
|
||||
post(api::server::reauth_passkey_start),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
|
||||
"/xrpc/_account.reauthPasskeyFinish",
|
||||
post(api::server::reauth_passkey_finish),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getLegacyLoginPreference",
|
||||
"/xrpc/_account.getLegacyLoginPreference",
|
||||
get(api::server::get_legacy_login_preference),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateLegacyLoginPreference",
|
||||
"/xrpc/_account.updateLegacyLoginPreference",
|
||||
post(api::server::update_legacy_login_preference),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateLocale",
|
||||
"/xrpc/_account.updateLocale",
|
||||
post(api::server::update_locale),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.listTrustedDevices",
|
||||
"/xrpc/_account.listTrustedDevices",
|
||||
get(api::server::list_trusted_devices),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.revokeTrustedDevice",
|
||||
"/xrpc/_account.revokeTrustedDevice",
|
||||
post(api::server::revoke_trusted_device),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateTrustedDevice",
|
||||
"/xrpc/_account.updateTrustedDevice",
|
||||
post(api::server::update_trusted_device),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.createPasskeyAccount",
|
||||
"/xrpc/_account.createPasskeyAccount",
|
||||
post(api::server::create_passkey_account),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup",
|
||||
"/xrpc/_account.startPasskeyRegistrationForSetup",
|
||||
post(api::server::start_passkey_registration_for_setup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.completePasskeySetup",
|
||||
"/xrpc/_account.completePasskeySetup",
|
||||
post(api::server::complete_passkey_setup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.requestPasskeyRecovery",
|
||||
"/xrpc/_account.requestPasskeyRecovery",
|
||||
post(api::server::request_passkey_recovery),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.recoverPasskeyAccount",
|
||||
"/xrpc/_account.recoverPasskeyAccount",
|
||||
post(api::server::recover_passkey_account),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getMigrationStatus",
|
||||
get(api::server::get_migration_status),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateMigrationForwarding",
|
||||
post(api::server::update_migration_forwarding),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.clearMigrationForwarding",
|
||||
post(api::server::clear_migration_forwarding),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateDidDocument",
|
||||
"/xrpc/_account.updateDidDocument",
|
||||
post(api::server::update_did_document),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getDidDocument",
|
||||
"/xrpc/_account.getDidDocument",
|
||||
get(api::server::get_did_document),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.requestEmailUpdate",
|
||||
post(api::server::request_email_update),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/_checkEmailVerified",
|
||||
post(api::server::check_email_verified),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.confirmEmail",
|
||||
post(api::server::confirm_email),
|
||||
@@ -432,15 +421,15 @@ pub fn app(state: AppState) -> Router {
|
||||
get(api::admin::get_invite_codes),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.admin.getServerStats",
|
||||
"/xrpc/_admin.getServerStats",
|
||||
get(api::admin::get_server_stats),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.server.getConfig",
|
||||
"/xrpc/_server.getConfig",
|
||||
get(api::admin::get_server_config),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.admin.updateServerConfig",
|
||||
"/xrpc/_admin.updateServerConfig",
|
||||
post(api::admin::update_server_config),
|
||||
)
|
||||
.route(
|
||||
@@ -575,57 +564,72 @@ pub fn app(state: AppState) -> Router {
|
||||
post(api::temp::dereference_scope),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getNotificationPrefs",
|
||||
"/xrpc/_account.getNotificationPrefs",
|
||||
get(api::notification_prefs::get_notification_prefs),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateNotificationPrefs",
|
||||
"/xrpc/_account.updateNotificationPrefs",
|
||||
post(api::notification_prefs::update_notification_prefs),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getNotificationHistory",
|
||||
"/xrpc/_account.getNotificationHistory",
|
||||
get(api::notification_prefs::get_notification_history),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.confirmChannelVerification",
|
||||
"/xrpc/_account.confirmChannelVerification",
|
||||
post(api::verification::confirm_channel_verification),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.verifyToken",
|
||||
"/xrpc/_account.verifyToken",
|
||||
post(api::server::verify_token),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.listControllers",
|
||||
"/xrpc/_delegation.listControllers",
|
||||
get(api::delegation::list_controllers),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.addController",
|
||||
"/xrpc/_delegation.addController",
|
||||
post(api::delegation::add_controller),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.removeController",
|
||||
"/xrpc/_delegation.removeController",
|
||||
post(api::delegation::remove_controller),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.updateControllerScopes",
|
||||
"/xrpc/_delegation.updateControllerScopes",
|
||||
post(api::delegation::update_controller_scopes),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.listControlledAccounts",
|
||||
"/xrpc/_delegation.listControlledAccounts",
|
||||
get(api::delegation::list_controlled_accounts),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.getAuditLog",
|
||||
"/xrpc/_delegation.getAuditLog",
|
||||
get(api::delegation::get_audit_log),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.getScopePresets",
|
||||
"/xrpc/_delegation.getScopePresets",
|
||||
get(api::delegation::get_scope_presets),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.delegation.createDelegatedAccount",
|
||||
"/xrpc/_delegation.createDelegatedAccount",
|
||||
post(api::delegation::create_delegated_account),
|
||||
)
|
||||
.route("/xrpc/_backup.listBackups", get(api::backup::list_backups))
|
||||
.route("/xrpc/_backup.getBackup", get(api::backup::get_backup))
|
||||
.route(
|
||||
"/xrpc/_backup.createBackup",
|
||||
post(api::backup::create_backup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/_backup.deleteBackup",
|
||||
post(api::backup::delete_backup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/_backup.setEnabled",
|
||||
post(api::backup::set_backup_enabled),
|
||||
)
|
||||
.route("/xrpc/_backup.exportBlobs", get(api::backup::export_blobs))
|
||||
.route(
|
||||
"/xrpc/app.bsky.ageassurance.getState",
|
||||
get(api::age_assurance::get_state),
|
||||
|
||||
+18
-1
@@ -7,7 +7,7 @@ use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender
|
||||
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
|
||||
use tranquil_pds::scheduled::{
|
||||
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
|
||||
start_scheduled_tasks,
|
||||
start_backup_tasks, start_scheduled_tasks,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
@@ -83,6 +83,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
|
||||
let backup_handle = if let Some(backup_storage) = state.backup_storage.clone() {
|
||||
info!("Backup service enabled");
|
||||
Some(tokio::spawn(start_backup_tasks(
|
||||
state.db.clone(),
|
||||
state.block_store.clone(),
|
||||
backup_storage,
|
||||
shutdown_rx.clone(),
|
||||
)))
|
||||
} else {
|
||||
warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)");
|
||||
None
|
||||
};
|
||||
|
||||
let scheduled_handle = tokio::spawn(start_scheduled_tasks(
|
||||
state.db.clone(),
|
||||
state.blob_store.clone(),
|
||||
@@ -117,6 +130,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
handle.await.ok();
|
||||
}
|
||||
|
||||
if let Some(handle) = backup_handle {
|
||||
handle.await.ok();
|
||||
}
|
||||
|
||||
scheduled_handle.await.ok();
|
||||
|
||||
if let Err(e) = server_result {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct RateLimiters {
|
||||
pub totp_verify: Arc<KeyedRateLimiter>,
|
||||
pub handle_update: Arc<KeyedRateLimiter>,
|
||||
pub handle_update_daily: Arc<KeyedRateLimiter>,
|
||||
pub verification_check: Arc<KeyedRateLimiter>,
|
||||
}
|
||||
|
||||
impl Default for RateLimiters {
|
||||
@@ -91,6 +92,9 @@ impl RateLimiters {
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(50).unwrap()),
|
||||
)),
|
||||
verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(60).unwrap(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+311
-1
@@ -11,7 +11,8 @@ use tokio::time::interval;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::repo::PostgresBlockStore;
|
||||
use crate::storage::BlobStorage;
|
||||
use crate::storage::{BackupStorage, BlobStorage};
|
||||
use crate::sync::car::encode_car_header;
|
||||
|
||||
pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) {
|
||||
let broken_genesis_commits = match sqlx::query!(
|
||||
@@ -563,3 +564,312 @@ async fn delete_account_data(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_backup_tasks(
|
||||
db: PgPool,
|
||||
block_store: PostgresBlockStore,
|
||||
backup_storage: Arc<BackupStorage>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let backup_interval = Duration::from_secs(BackupStorage::interval_secs());
|
||||
|
||||
info!(
|
||||
interval_secs = backup_interval.as_secs(),
|
||||
retention_count = BackupStorage::retention_count(),
|
||||
"Starting backup service"
|
||||
);
|
||||
|
||||
let mut ticker = interval(backup_interval);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("Backup service shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = ticker.tick() => {
|
||||
if let Err(e) = process_scheduled_backups(&db, &block_store, &backup_storage).await {
|
||||
error!("Error processing scheduled backups: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_scheduled_backups(
|
||||
db: &PgPool,
|
||||
block_store: &PostgresBlockStore,
|
||||
backup_storage: &BackupStorage,
|
||||
) -> Result<(), String> {
|
||||
let backup_interval_secs = BackupStorage::interval_secs() as i64;
|
||||
let retention_count = BackupStorage::retention_count();
|
||||
|
||||
let users_needing_backup = sqlx::query!(
|
||||
r#"
|
||||
SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev
|
||||
FROM users u
|
||||
JOIN repos r ON r.user_id = u.id
|
||||
WHERE u.backup_enabled = true
|
||||
AND u.deactivated_at IS NULL
|
||||
AND (
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id
|
||||
)
|
||||
OR (
|
||||
SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id
|
||||
) < NOW() - make_interval(secs => $1)
|
||||
)
|
||||
LIMIT 50
|
||||
"#,
|
||||
backup_interval_secs as f64
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error fetching users for backup: {}", e))?;
|
||||
|
||||
if users_needing_backup.is_empty() {
|
||||
debug!("No accounts need backup");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
count = users_needing_backup.len(),
|
||||
"Processing scheduled backups"
|
||||
);
|
||||
|
||||
for user in users_needing_backup {
|
||||
let repo_root_cid = user.repo_root_cid.clone();
|
||||
|
||||
let repo_rev = match &user.repo_rev {
|
||||
Some(rev) => rev.clone(),
|
||||
None => {
|
||||
warn!(did = %user.did, "User has no repo_rev, skipping backup");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let head_cid = match Cid::from_str(&repo_root_cid) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(did = %user.did, error = %e, "Invalid repo_root_cid, skipping backup");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let car_result = generate_full_backup(block_store, &head_cid).await;
|
||||
let car_bytes = match car_result {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
warn!(did = %user.did, error = %e, "Failed to generate CAR for backup");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let block_count = count_car_blocks(&car_bytes);
|
||||
let size_bytes = car_bytes.len() as i64;
|
||||
|
||||
let storage_key = match backup_storage
|
||||
.put_backup(&user.did, &repo_rev, &car_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
warn!(did = %user.did, error = %e, "Failed to upload backup to storage");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"#,
|
||||
user.user_id,
|
||||
storage_key,
|
||||
repo_root_cid,
|
||||
repo_rev,
|
||||
block_count,
|
||||
size_bytes
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
warn!(did = %user.did, error = %e, "Failed to insert backup record, rolling back S3 upload");
|
||||
if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await {
|
||||
error!(
|
||||
did = %user.did,
|
||||
storage_key = %storage_key,
|
||||
error = %rollback_err,
|
||||
"Failed to rollback orphaned backup from S3"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
did = %user.did,
|
||||
rev = %repo_rev,
|
||||
size_bytes,
|
||||
block_count,
|
||||
"Created backup"
|
||||
);
|
||||
|
||||
if let Err(e) = cleanup_old_backups(db, backup_storage, user.user_id, retention_count).await
|
||||
{
|
||||
warn!(did = %user.did, error = %e, "Failed to cleanup old backups");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn generate_repo_car(
|
||||
block_store: &PostgresBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use std::io::Write;
|
||||
|
||||
let mut car_bytes =
|
||||
encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?;
|
||||
|
||||
let mut stack = vec![*head_cid];
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
|
||||
while let Some(cid) = stack.pop() {
|
||||
if visited.contains(&cid) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(cid);
|
||||
|
||||
if let Ok(Some(block)) = block_store.get(&cid).await {
|
||||
let cid_bytes = cid.to_bytes();
|
||||
let total_len = cid_bytes.len() + block.len();
|
||||
let mut writer = Vec::new();
|
||||
crate::sync::car::write_varint(&mut writer, total_len as u64)
|
||||
.expect("Writing to Vec<u8> should never fail");
|
||||
writer
|
||||
.write_all(&cid_bytes)
|
||||
.expect("Writing to Vec<u8> should never fail");
|
||||
writer
|
||||
.write_all(&block)
|
||||
.expect("Writing to Vec<u8> should never fail");
|
||||
car_bytes.extend_from_slice(&writer);
|
||||
|
||||
if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
|
||||
extract_links(&value, &mut stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(car_bytes)
|
||||
}
|
||||
|
||||
pub async fn generate_full_backup(
|
||||
block_store: &PostgresBlockStore,
|
||||
head_cid: &Cid,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
generate_repo_car(block_store, head_cid).await
|
||||
}
|
||||
|
||||
fn extract_links(value: &Ipld, stack: &mut Vec<Cid>) {
|
||||
match value {
|
||||
Ipld::Link(cid) => {
|
||||
stack.push(*cid);
|
||||
}
|
||||
Ipld::Map(map) => {
|
||||
for v in map.values() {
|
||||
extract_links(v, stack);
|
||||
}
|
||||
}
|
||||
Ipld::List(arr) => {
|
||||
for v in arr {
|
||||
extract_links(v, stack);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count_car_blocks(car_bytes: &[u8]) -> i32 {
|
||||
let mut count = 0;
|
||||
let mut pos = 0;
|
||||
|
||||
if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) {
|
||||
pos += header_varint_len + header_len as usize;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
|
||||
while pos < car_bytes.len() {
|
||||
if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) {
|
||||
pos += varint_len + block_len as usize;
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
fn read_varint(data: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift = 0;
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < data.len() && pos < 10 {
|
||||
let byte = data[pos];
|
||||
value |= ((byte & 0x7f) as u64) << shift;
|
||||
pos += 1;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some((value, pos));
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn cleanup_old_backups(
|
||||
db: &PgPool,
|
||||
backup_storage: &BackupStorage,
|
||||
user_id: uuid::Uuid,
|
||||
retention_count: u32,
|
||||
) -> Result<(), String> {
|
||||
let old_backups = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, storage_key
|
||||
FROM account_backups
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
OFFSET $2
|
||||
"#,
|
||||
user_id,
|
||||
retention_count as i64
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
|
||||
|
||||
for backup in old_backups {
|
||||
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
|
||||
warn!(
|
||||
storage_key = %backup.storage_key,
|
||||
error = %e,
|
||||
"Failed to delete old backup from storage, skipping DB cleanup to avoid orphan"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
|
||||
.execute(db)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+8
-1
@@ -4,7 +4,7 @@ use crate::circuit_breaker::CircuitBreakers;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::rate_limit::RateLimiters;
|
||||
use crate::repo::PostgresBlockStore;
|
||||
use crate::storage::{BlobStorage, S3BlobStorage};
|
||||
use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage};
|
||||
use crate::sync::firehose::SequencedEvent;
|
||||
use sqlx::PgPool;
|
||||
use std::error::Error;
|
||||
@@ -16,6 +16,7 @@ pub struct AppState {
|
||||
pub db: PgPool,
|
||||
pub block_store: PostgresBlockStore,
|
||||
pub blob_store: Arc<dyn BlobStorage>,
|
||||
pub backup_storage: Option<Arc<BackupStorage>>,
|
||||
pub firehose_tx: broadcast::Sender<SequencedEvent>,
|
||||
pub rate_limiters: Arc<RateLimiters>,
|
||||
pub circuit_breakers: Arc<CircuitBreakers>,
|
||||
@@ -39,6 +40,7 @@ pub enum RateLimitKind {
|
||||
TotpVerify,
|
||||
HandleUpdate,
|
||||
HandleUpdateDaily,
|
||||
VerificationCheck,
|
||||
}
|
||||
|
||||
impl RateLimitKind {
|
||||
@@ -58,6 +60,7 @@ impl RateLimitKind {
|
||||
Self::TotpVerify => "totp_verify",
|
||||
Self::HandleUpdate => "handle_update",
|
||||
Self::HandleUpdateDaily => "handle_update_daily",
|
||||
Self::VerificationCheck => "verification_check",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ impl RateLimitKind {
|
||||
Self::TotpVerify => (5, 300_000),
|
||||
Self::HandleUpdate => (10, 300_000),
|
||||
Self::HandleUpdateDaily => (50, 86_400_000),
|
||||
Self::VerificationCheck => (60, 60_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +135,7 @@ impl AppState {
|
||||
|
||||
let block_store = PostgresBlockStore::new(db.clone());
|
||||
let blob_store = S3BlobStorage::new().await;
|
||||
let backup_storage = BackupStorage::new().await.map(Arc::new);
|
||||
|
||||
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
|
||||
.ok()
|
||||
@@ -147,6 +152,7 @@ impl AppState {
|
||||
db,
|
||||
block_store,
|
||||
blob_store: Arc::new(blob_store),
|
||||
backup_storage,
|
||||
firehose_tx,
|
||||
rate_limiters,
|
||||
circuit_breakers,
|
||||
@@ -199,6 +205,7 @@ impl AppState {
|
||||
RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify,
|
||||
RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update,
|
||||
RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily,
|
||||
RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check,
|
||||
};
|
||||
|
||||
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
|
||||
|
||||
+121
-18
@@ -32,29 +32,132 @@ pub struct S3BlobStorage {
|
||||
|
||||
impl S3BlobStorage {
|
||||
pub async fn new() -> Self {
|
||||
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
|
||||
|
||||
let config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(region_provider)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
|
||||
|
||||
let client = if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
|
||||
let s3_config = aws_sdk_s3::config::Builder::from(&config)
|
||||
.endpoint_url(endpoint)
|
||||
.force_path_style(true)
|
||||
.build();
|
||||
Client::from_conf(s3_config)
|
||||
} else {
|
||||
Client::new(&config)
|
||||
};
|
||||
|
||||
let client = create_s3_client().await;
|
||||
Self { client, bucket }
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_s3_client() -> Client {
|
||||
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
|
||||
|
||||
let config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(region_provider)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
|
||||
let s3_config = aws_sdk_s3::config::Builder::from(&config)
|
||||
.endpoint_url(endpoint)
|
||||
.force_path_style(true)
|
||||
.build();
|
||||
Client::from_conf(s3_config)
|
||||
} else {
|
||||
Client::new(&config)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BackupStorage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl BackupStorage {
|
||||
pub async fn new() -> Option<Self> {
|
||||
let backup_enabled = std::env::var("BACKUP_ENABLED")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
|
||||
if !backup_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?;
|
||||
let client = create_s3_client().await;
|
||||
Some(Self { client, bucket })
|
||||
}
|
||||
|
||||
pub fn retention_count() -> u32 {
|
||||
std::env::var("BACKUP_RETENTION_COUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(7)
|
||||
}
|
||||
|
||||
pub fn interval_secs() -> u64 {
|
||||
std::env::var("BACKUP_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(86400)
|
||||
}
|
||||
|
||||
pub async fn put_backup(
|
||||
&self,
|
||||
did: &str,
|
||||
rev: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, StorageError> {
|
||||
let key = format!("{}/{}.car", did, rev);
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(Bytes::copy_from_slice(data)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::metrics::record_s3_operation("backup_put", "error");
|
||||
StorageError::S3(e.to_string())
|
||||
})?;
|
||||
|
||||
crate::metrics::record_s3_operation("backup_put", "success");
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(storage_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::metrics::record_s3_operation("backup_get", "error");
|
||||
StorageError::S3(e.to_string())
|
||||
})?;
|
||||
|
||||
let data = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::metrics::record_s3_operation("backup_get", "error");
|
||||
StorageError::S3(e.to_string())
|
||||
})?
|
||||
.into_bytes();
|
||||
|
||||
crate::metrics::record_s3_operation("backup_get", "success");
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> {
|
||||
self.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(storage_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::metrics::record_s3_operation("backup_delete", "error");
|
||||
StorageError::S3(e.to_string())
|
||||
})?;
|
||||
|
||||
crate::metrics::record_s3_operation("backup_delete", "success");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlobStorage for S3BlobStorage {
|
||||
async fn put(&self, key: &str, data: &[u8]) -> Result<(), StorageError> {
|
||||
|
||||
+23
-12
@@ -77,19 +77,30 @@ pub fn find_blob_refs_ipld(value: &Ipld, depth: usize) -> Vec<BlobRef> {
|
||||
Ipld::Map(obj) => {
|
||||
if let Some(Ipld::String(type_str)) = obj.get("$type")
|
||||
&& type_str == "blob"
|
||||
&& let Some(Ipld::Link(link_cid)) = obj.get("ref")
|
||||
{
|
||||
let mime = obj.get("mimeType").and_then(|v| {
|
||||
if let Ipld::String(s) = v {
|
||||
Some(s.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
return vec![BlobRef {
|
||||
cid: link_cid.to_string(),
|
||||
mime_type: mime,
|
||||
}];
|
||||
let cid_str = if let Some(Ipld::Link(link_cid)) = obj.get("ref") {
|
||||
Some(link_cid.to_string())
|
||||
} else if let Some(Ipld::Map(ref_obj)) = obj.get("ref")
|
||||
&& let Some(Ipld::String(link)) = ref_obj.get("$link")
|
||||
{
|
||||
Some(link.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(cid) = cid_str {
|
||||
let mime = obj.get("mimeType").and_then(|v| {
|
||||
if let Ipld::String(s) = v {
|
||||
Some(s.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
return vec![BlobRef {
|
||||
cid,
|
||||
mime_type: mime,
|
||||
}];
|
||||
}
|
||||
}
|
||||
obj.values()
|
||||
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
|
||||
|
||||
+129
@@ -1,6 +1,11 @@
|
||||
use axum::http::HeaderMap;
|
||||
use cid::Cid;
|
||||
use ipld_core::ipld::Ipld;
|
||||
use rand::Rng;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -150,6 +155,37 @@ pub fn build_full_url(path: &str) -> String {
|
||||
format!("{}{}", pds_public_url(), path)
|
||||
}
|
||||
|
||||
pub fn json_to_ipld(value: &JsonValue) -> Ipld {
|
||||
match value {
|
||||
JsonValue::Null => Ipld::Null,
|
||||
JsonValue::Bool(b) => Ipld::Bool(*b),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ipld::Integer(i as i128)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ipld::Float(f)
|
||||
} else {
|
||||
Ipld::Null
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => Ipld::String(s.clone()),
|
||||
JsonValue::Array(arr) => Ipld::List(arr.iter().map(json_to_ipld).collect()),
|
||||
JsonValue::Object(obj) => {
|
||||
if let Some(JsonValue::String(link)) = obj.get("$link")
|
||||
&& obj.len() == 1
|
||||
&& let Ok(cid) = Cid::from_str(link)
|
||||
{
|
||||
return Ipld::Link(cid);
|
||||
}
|
||||
let map: BTreeMap<String, Ipld> = obj
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), json_to_ipld(v)))
|
||||
.collect();
|
||||
Ipld::Map(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -224,4 +260,97 @@ mod tests {
|
||||
assert_eq!(part.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_ipld_cid_link() {
|
||||
let json = serde_json::json!({
|
||||
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
});
|
||||
let ipld = json_to_ipld(&json);
|
||||
match ipld {
|
||||
Ipld::Link(cid) => {
|
||||
assert_eq!(
|
||||
cid.to_string(),
|
||||
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Ipld::Link, got {:?}", ipld),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_ipld_blob_ref() {
|
||||
let json = serde_json::json!({
|
||||
"$type": "blob",
|
||||
"ref": {
|
||||
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
},
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 12345
|
||||
});
|
||||
let ipld = json_to_ipld(&json);
|
||||
match ipld {
|
||||
Ipld::Map(map) => {
|
||||
assert_eq!(map.get("$type"), Some(&Ipld::String("blob".to_string())));
|
||||
assert_eq!(
|
||||
map.get("mimeType"),
|
||||
Some(&Ipld::String("image/jpeg".to_string()))
|
||||
);
|
||||
assert_eq!(map.get("size"), Some(&Ipld::Integer(12345)));
|
||||
match map.get("ref") {
|
||||
Some(Ipld::Link(cid)) => {
|
||||
assert_eq!(
|
||||
cid.to_string(),
|
||||
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Ipld::Link in ref field, got {:?}", map.get("ref")),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Ipld::Map, got {:?}", ipld),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_to_ipld_nested_blob_refs_serializes_correctly() {
|
||||
let record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello world",
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.images",
|
||||
"images": [
|
||||
{
|
||||
"alt": "Test image",
|
||||
"image": {
|
||||
"$type": "blob",
|
||||
"ref": {
|
||||
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
},
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 12345
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
let ipld = json_to_ipld(&record);
|
||||
let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed");
|
||||
assert!(!cbor_bytes.is_empty());
|
||||
let parsed: Ipld =
|
||||
serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed");
|
||||
if let Ipld::Map(map) = &parsed
|
||||
&& let Some(Ipld::Map(embed)) = map.get("embed")
|
||||
&& let Some(Ipld::List(images)) = embed.get("images")
|
||||
&& let Some(Ipld::Map(img)) = images.first()
|
||||
&& let Some(Ipld::Map(blob)) = img.get("image")
|
||||
&& let Some(Ipld::Link(cid)) = blob.get("ref")
|
||||
{
|
||||
assert_eq!(
|
||||
cid.to_string(),
|
||||
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
);
|
||||
return;
|
||||
}
|
||||
panic!("Failed to find CID link in parsed CBOR");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ async fn test_get_notification_history() {
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.tranquil.account.getNotificationHistory",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/_account.getNotificationHistory", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
@@ -56,10 +53,7 @@ async fn test_verify_channel_discord() {
|
||||
"discordId": "123456789"
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&prefs)
|
||||
.send()
|
||||
@@ -101,10 +95,7 @@ async fn test_verify_channel_discord() {
|
||||
"code": code
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&input)
|
||||
.send()
|
||||
@@ -113,10 +104,7 @@ async fn test_verify_channel_discord() {
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
@@ -136,10 +124,7 @@ async fn test_verify_channel_invalid_code() {
|
||||
"telegramUsername": "testuser"
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&prefs)
|
||||
.send()
|
||||
@@ -153,10 +138,7 @@ async fn test_verify_channel_invalid_code() {
|
||||
"code": "XXXX-XXXX-XXXX-XXXX"
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&input)
|
||||
.send()
|
||||
@@ -181,10 +163,7 @@ async fn test_verify_channel_not_set() {
|
||||
"code": "XXXX-XXXX-XXXX-XXXX"
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&input)
|
||||
.send()
|
||||
@@ -209,10 +188,7 @@ async fn test_update_email_via_notification_prefs() {
|
||||
"email": unique_email
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&prefs)
|
||||
.send()
|
||||
@@ -263,10 +239,7 @@ async fn test_update_email_via_notification_prefs() {
|
||||
"code": code
|
||||
});
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
|
||||
base
|
||||
))
|
||||
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&input)
|
||||
.send()
|
||||
@@ -275,10 +248,7 @@ async fn test_update_email_via_notification_prefs() {
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
|
||||
base
|
||||
))
|
||||
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user