From 7b6807c316687a85f757a33dc616ee639923dee0 Mon Sep 17 00:00:00 2001 From: lewis Date: Fri, 12 Dec 2025 23:48:37 +0200 Subject: [PATCH] First UI idea done --- .gitignore | 4 + ...6fad5a538dbfc442625ef306272d2530ddc3a.json | 2 +- ...ce81f8079a29e40b07ba58adc4380d58068c8.json | 2 +- ...4e889c615bb4dab0895863fd59c913f7895fd.json | 76 + ...2c1f84c5db138fdef9a8e8756771c30b66810.json | 2 +- ...8cd77405ae6246656c8698a7618a5a29a4ccb.json | 52 - ...ec1130ea91911d7d187cafcb4573be12bfcf4.json | 25 - ...77c98667913225305df559559e36110516cfb.json | 2 +- ...e4b6f8ac0b712c63421989faa413556bef6f1.json | 16 + ...37b846628ed20ffa70b81fa2e416d5776185a.json | 2 +- ...20c3c10badee74f78722d3cea58d183734bf6.json | 94 ++ ...66f56fed0edd0f9f154c1786f2b0cdbe39508.json | 2 +- ...2d214cc26b4794e0b922b1dae3dad18a7ddc0.json | 2 +- ...676b3822629d505e84d51b60162e80a43d190.json | 76 + ...e3358bfd252e68502e5b8ccc9821d479d3c67.json | 2 +- Cargo.lock | 26 + Cargo.toml | 1 + Dockerfile | 10 + README.md | 21 + TODO.md | 37 +- frontend/deno.json | 13 + frontend/deno.lock | 1309 +++++++++++++++++ frontend/index.html | 16 + frontend/package.json | 24 + frontend/src/App.svelte | 129 ++ frontend/src/lib/api.ts | 341 +++++ frontend/src/lib/auth.svelte.ts | 172 +++ frontend/src/lib/router.svelte.ts | 13 + frontend/src/main.ts | 8 + frontend/src/routes/AppPasswords.svelte | 333 +++++ frontend/src/routes/Dashboard.svelte | 201 +++ frontend/src/routes/InviteCodes.svelte | 326 ++++ frontend/src/routes/Login.svelte | 289 ++++ frontend/src/routes/Notifications.svelte | 453 ++++++ frontend/src/routes/Register.svelte | 523 +++++++ frontend/src/routes/RepoExplorer.svelte | 940 ++++++++++++ frontend/src/routes/Settings.svelte | 409 +++++ frontend/src/tests/AppPasswords.test.ts | 453 ++++++ frontend/src/tests/Dashboard.test.ts | 138 ++ frontend/src/tests/Login.test.ts | 167 +++ frontend/src/tests/Notifications.test.ts | 443 ++++++ frontend/src/tests/Settings.test.ts | 516 +++++++ frontend/src/tests/mocks.ts | 264 ++++ frontend/src/tests/setup.ts | 35 + frontend/src/tests/utils.ts | 86 ++ frontend/svelte.config.js | 7 + frontend/vite.config.ts | 19 + frontend/vitest.config.ts | 22 + justfile | 29 + ...schema.sql => 20251211_initial_schema.sql} | 64 +- .../202512211406_plc_operation_tokens.sql | 10 - ...07_add_plc_operation_notification_type.sql | 1 - .../202512211500_account_preferences.sql | 12 - migrations/202512211600_add_repo_rev.sql | 2 - migrations/202512211700_add_2fa.sql | 16 - src/api/admin/account/email.rs | 14 +- src/api/admin/account/info.rs | 4 +- src/api/identity/account.rs | 162 +- src/api/mod.rs | 1 + src/api/notification_prefs.rs | 248 ++++ src/api/repo/record/batch.rs | 23 + src/api/repo/record/write.rs | 51 + src/api/server/email.rs | 6 +- src/api/server/meta.rs | 11 +- src/api/server/mod.rs | 2 +- src/api/server/session.rs | 253 +++- src/crawlers.rs | 6 +- src/lib.rs | 33 +- src/notifications/mod.rs | 2 +- src/notifications/service.rs | 53 +- src/oauth/db/device.rs | 2 +- src/oauth/templates.rs | 5 +- 72 files changed, 8880 insertions(+), 233 deletions(-) create mode 100644 .sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json delete mode 100644 .sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json delete mode 100644 .sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json create mode 100644 .sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json create mode 100644 .sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json create mode 100644 .sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json create mode 100644 frontend/deno.json create mode 100644 frontend/deno.lock create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.svelte.ts create mode 100644 frontend/src/lib/router.svelte.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/routes/AppPasswords.svelte create mode 100644 frontend/src/routes/Dashboard.svelte create mode 100644 frontend/src/routes/InviteCodes.svelte create mode 100644 frontend/src/routes/Login.svelte create mode 100644 frontend/src/routes/Notifications.svelte create mode 100644 frontend/src/routes/Register.svelte create mode 100644 frontend/src/routes/RepoExplorer.svelte create mode 100644 frontend/src/routes/Settings.svelte create mode 100644 frontend/src/tests/AppPasswords.test.ts create mode 100644 frontend/src/tests/Dashboard.test.ts create mode 100644 frontend/src/tests/Login.test.ts create mode 100644 frontend/src/tests/Notifications.test.ts create mode 100644 frontend/src/tests/Settings.test.ts create mode 100644 frontend/src/tests/mocks.ts create mode 100644 frontend/src/tests/setup.ts create mode 100644 frontend/src/tests/utils.ts create mode 100644 frontend/svelte.config.js create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts rename migrations/{202512211400_initial_schema.sql => 20251211_initial_schema.sql} (79%) delete mode 100644 migrations/202512211406_plc_operation_tokens.sql delete mode 100644 migrations/202512211407_add_plc_operation_notification_type.sql delete mode 100644 migrations/202512211500_account_preferences.sql delete mode 100644 migrations/202512211600_add_repo_rev.sql delete mode 100644 migrations/202512211700_add_2fa.sql create mode 100644 src/api/notification_prefs.rs diff --git a/.gitignore b/.gitignore index 8562ef2..39ac4a5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ reference-pds-hailey/ reference-pds-bsky/ + +# Frontend build artifacts +frontend/node_modules/ +frontend/dist/ diff --git a/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json b/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json index fed143b..a7f927b 100644 --- a/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json +++ b/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json @@ -21,7 +21,7 @@ }, "nullable": [ false, - false + true ] }, "hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a" diff --git a/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json b/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json index 18627c1..e9b7308 100644 --- a/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json +++ b/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json @@ -32,7 +32,7 @@ "nullable": [ false, false, - false, + true, false ] }, diff --git a/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json b/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json new file mode 100644 index 0000000..dbd6434 --- /dev/null +++ b/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_confirmed", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "discord_verified", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "telegram_verified", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "signal_verified", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 9, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd" +} diff --git a/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json b/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json index 7601810..99dc9cf 100644 --- a/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json +++ b/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json @@ -64,7 +64,7 @@ "nullable": [ false, false, - false, + true, false, false, false, diff --git a/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json b/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json deleted file mode 100644 index ea16568..0000000 --- a/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "handle", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "password_hash", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 5, - "name": "encryption_version", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb" -} diff --git a/.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json b/.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json deleted file mode 100644 index 6084872..0000000 --- a/.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4" -} diff --git a/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json b/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json index 04c9297..c421a3c 100644 --- a/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json +++ b/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json @@ -32,7 +32,7 @@ "nullable": [ false, false, - false, + true, false ] }, diff --git a/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json b/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json new file mode 100644 index 0000000..e176d54 --- /dev/null +++ b/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1" +} diff --git a/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json index ea0c2f1..31e7026 100644 --- a/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json +++ b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json @@ -36,7 +36,7 @@ }, "nullable": [ false, - false, + true, true, true, true diff --git a/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json b/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json new file mode 100644 index 0000000..c494da4 --- /dev/null +++ b/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id, handle, email,\n preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n discord_id, telegram_username, signal_number,\n email_confirmed, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "channel: crate::notifications::NotificationChannel", + "type_info": { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "discord_id", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "telegram_username", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "signal_number", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "email_confirmed", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "discord_verified", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "telegram_verified", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "signal_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + false, + false, + false + ] + }, + "hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6" +} diff --git a/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json b/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json index d8c81be..b0a5d1b 100644 --- a/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json +++ b/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json @@ -37,7 +37,7 @@ ] }, "nullable": [ - false, + true, false, false ] diff --git a/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json b/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json index e9631fc..813ef1a 100644 --- a/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json +++ b/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json @@ -32,7 +32,7 @@ "nullable": [ false, false, - false, + true, false ] }, diff --git a/.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json b/.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json new file mode 100644 index 0000000..bd79fb2 --- /dev/null +++ b/.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n u.id, u.did, u.handle,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "handle", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "email_confirmation_code", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_confirmation_code_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "channel: crate::notifications::NotificationChannel", + "type_info": { + "Custom": { + "name": "notification_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 7, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190" +} diff --git a/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json b/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json index 8dd467e..59be9a8 100644 --- a/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json +++ b/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json @@ -26,7 +26,7 @@ }, "nullable": [ false, - false, + true, false ] }, diff --git a/Cargo.lock b/Cargo.lock index fe625cf..1ae7493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -960,6 +960,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-tungstenite", + "tower-http", "tracing", "tracing-subscriber", "urlencoding", @@ -2558,6 +2559,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3580,6 +3587,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mini-moka" version = "0.10.3" @@ -6009,11 +6026,20 @@ checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 03d00b9..d4e21e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } iroh-car = "0.5.1" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } +tower-http = { version = "0.6", features = ["fs"] } [features] external-infra = [] diff --git a/Dockerfile b/Dockerfile index 3cbb9c0..d2c6487 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +# Stage 1: Build frontend with Deno +FROM denoland/deno:alpine AS frontend-builder +WORKDIR /frontend +COPY frontend/ ./ +RUN deno task build + +# Stage 2: Build Rust backend FROM rust:1.91.1-alpine AS builder RUN apk add ca-certificates openssl openssl-dev pkgconfig @@ -13,15 +20,18 @@ COPY migrations ./migrations COPY .sqlx ./.sqlx RUN touch src/main.rs && cargo build --release +# Stage 3: Final image FROM alpine:3.23 COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds COPY --from=builder /app/migrations /app/migrations +COPY --from=frontend-builder /frontend/dist /app/frontend/dist WORKDIR /app ENV SERVER_HOST=0.0.0.0 ENV SERVER_PORT=3000 +ENV FRONTEND_DIR=/app/frontend/dist EXPOSE 3000 diff --git a/README.md b/README.md index 547bc26..cd6406a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Uses PostgreSQL instead of SQLite, S3-compatible blob storage, and is designed t - Crawler notifications via `requestCrawl` - Multi-channel notifications: email, discord, telegram, signal - Per-IP rate limiting on sensitive endpoints +- Built-in web UI for account management ## Running Locally @@ -77,6 +78,25 @@ just lint # Clippy + fmt check just db-reset # Drop and recreate local database ``` +## Web UI + +BSPDS includes a built-in web frontend for users to manage their accounts. Users can: + +- Sign in and register new accounts +- Manage app passwords +- View and create invite codes +- Update email and handle +- Configure notification preferences +- Browse their repository data + +The frontend is built with svelte and deno, and is served directly by the PDS. + +```bash +just frontend-dev # Run frontend dev server +just frontend-build # Build for production +just frontend-test # Run frontend tests +``` + ## Project Structure ``` @@ -94,6 +114,7 @@ src/ plc/ PLC directory client circuit_breaker/ Circuit breaker for external services rate_limit/ Per-IP rate limiting +frontend/ Svelte web UI (deno) tests/ Integration tests migrations/ SQLx migrations ``` diff --git a/TODO.md b/TODO.md index 30139bd..de347d2 100644 --- a/TODO.md +++ b/TODO.md @@ -258,16 +258,16 @@ These are implemented at PDS level to enable local-first reads (read-after-write A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers. ### Architecture -- [ ] Static SPA served from PDS (or separate static host) +- [x] Static SPA served from PDS (or separate static host) - [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client) -- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below) -- [ ] No server-side sessions or CSRF - pure API client +- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below) +- [x] No server-side sessions or CSRF - pure API client ### PDS-Specific XRPC Endpoints (new) Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D Anyway... endpoints for PDS settings not covered by standard ATProto: -- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels -- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel +- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels +- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel - [ ] `com.bspds.account.getNotificationHistory` - list past notifications - [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal - [ ] `com.bspds.account.confirmChannelVerification` - confirm with code @@ -276,23 +276,32 @@ Anyway... endpoints for PDS settings not covered by standard ATProto: ### Frontend Views Uses existing ATProto endpoints where possible: +Authentication +- [x] Login page (uses `com.atproto.server.createSession`) +- [x] Registration page (uses `com.atproto.server.createAccount`) +- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`) +- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`) + User Dashboard -- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) +- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) - [ ] Active sessions view (needs new endpoint or extend existing) -- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) -- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) +- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) +- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) Notification Preferences -- [ ] Channel selector (uses `com.bspds.account.*` endpoints above) +- [x] Channel selector (uses `com.bspds.account.*` endpoints above) - [ ] Verification flows for Discord/Telegram/Signal - [ ] Notification history view Account Settings -- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) -- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`) -- [ ] Handle change (uses `com.atproto.identity.updateHandle`) -- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`) -- [ ] Data export (uses `com.atproto.sync.getRepo`) +- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) +- [ ] Password change while logged in (needs new endpoint - change password with current password) +- [x] Handle change (uses `com.atproto.identity.updateHandle`) +- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`) + +Data Management +- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`) +- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`) Admin Dashboard (privileged users only) - [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination) diff --git a/frontend/deno.json b/frontend/deno.json new file mode 100644 index 0000000..2d7690d --- /dev/null +++ b/frontend/deno.json @@ -0,0 +1,13 @@ +{ + "tasks": { + "dev": "deno run -A npm:vite", + "build": "deno run -A npm:vite build", + "preview": "deno run -A npm:vite preview", + "test": "deno run -A npm:vitest", + "test:run": "deno run -A npm:vitest run", + "test:watch": "deno run -A npm:vitest watch", + "test:ui": "deno run -A npm:vitest --ui", + "test:coverage": "deno run -A npm:vitest run --coverage" + }, + "nodeModulesDir": "auto" +} diff --git a/frontend/deno.lock b/frontend/deno.lock new file mode 100644 index 0000000..0736697 --- /dev/null +++ b/frontend/deno.lock @@ -0,0 +1,1309 @@ +{ + "version": "5", + "specifiers": { + "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", + "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1", + "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", + "npm:jsdom@^25.0.1": "25.0.1", + "npm:svelte@5": "5.45.10_acorn@8.15.0", + "npm:vite@*": "6.4.1_picomatch@4.0.3", + "npm:vite@6": "6.4.1_picomatch@4.0.3", + "npm:vitest@*": "2.1.9_jsdom@25.0.1_vite@5.4.21", + "npm:vitest@^2.1.8": "2.1.9_jsdom@25.0.1_vite@5.4.21" + }, + "npm": { + "@adobe/css-tools@4.4.4": { + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==" + }, + "@asamuzakjp/css-color@3.2.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dependencies": [ + "@csstools/css-calc", + "@csstools/css-color-parser", + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer", + "lru-cache" + ] + }, + "@babel/code-frame@7.27.1": { + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens", + "picocolors" + ] + }, + "@babel/helper-validator-identifier@7.28.5": { + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" + }, + "@babel/runtime@7.28.4": { + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" + }, + "@csstools/color-helpers@5.1.0": { + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" + }, + "@csstools/css-calc@2.1.4_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dependencies": [ + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-color-parser@3.1.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dependencies": [ + "@csstools/color-helpers", + "@csstools/css-calc", + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-parser-algorithms@3.0.5_@csstools+css-tokenizer@3.0.4": { + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dependencies": [ + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-tokenizer@3.0.4": { + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==" + }, + "@esbuild/aix-ppc64@0.21.5": { + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/aix-ppc64@0.25.12": { + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.21.5": { + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm64@0.25.12": { + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.21.5": { + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-arm@0.25.12": { + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.21.5": { + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/android-x64@0.25.12": { + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.21.5": { + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-arm64@0.25.12": { + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.21.5": { + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/darwin-x64@0.25.12": { + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.21.5": { + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-arm64@0.25.12": { + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.21.5": { + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-x64@0.25.12": { + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.21.5": { + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm64@0.25.12": { + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.21.5": { + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-arm@0.25.12": { + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.21.5": { + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-ia32@0.25.12": { + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.21.5": { + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-loong64@0.25.12": { + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.21.5": { + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-mips64el@0.25.12": { + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.21.5": { + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-ppc64@0.25.12": { + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.21.5": { + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-riscv64@0.25.12": { + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.21.5": { + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-s390x@0.25.12": { + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.21.5": { + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/linux-x64@0.25.12": { + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.12": { + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.21.5": { + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-x64@0.25.12": { + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.12": { + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.21.5": { + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-x64@0.25.12": { + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.12": { + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.21.5": { + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/sunos-x64@0.25.12": { + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.21.5": { + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-arm64@0.25.12": { + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.21.5": { + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-ia32@0.25.12": { + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.21.5": { + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@esbuild/win32-x64@0.25.12": { + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@rollup/rollup-android-arm-eabi@4.53.3": { + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.53.3": { + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.53.3": { + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.53.3": { + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.53.3": { + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.53.3": { + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.53.3": { + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.53.3": { + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.53.3": { + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.53.3": { + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loong64-gnu@4.53.3": { + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-ppc64-gnu@4.53.3": { + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.53.3": { + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.53.3": { + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.53.3": { + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.53.3": { + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.53.3": { + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-openharmony-arm64@4.53.3": { + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.53.3": { + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.53.3": { + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-gnu@4.53.3": { + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-x64-msvc@4.53.3": { + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dependencies": [ + "acorn" + ] + }, + "@sveltejs/vite-plugin-svelte-inspector@4.0.1_@sveltejs+vite-plugin-svelte@5.1.1__svelte@5.45.10___acorn@8.15.0__vite@6.4.1___picomatch@4.0.3_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": { + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dependencies": [ + "@sveltejs/vite-plugin-svelte", + "debug", + "svelte", + "vite@6.4.1_picomatch@4.0.3" + ] + }, + "@sveltejs/vite-plugin-svelte@5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": { + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dependencies": [ + "@sveltejs/vite-plugin-svelte-inspector", + "debug", + "deepmerge", + "kleur", + "magic-string", + "svelte", + "vite@6.4.1_picomatch@4.0.3", + "vitefu" + ] + }, + "@testing-library/dom@10.4.1": { + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dependencies": [ + "@babel/code-frame", + "@babel/runtime", + "@types/aria-query", + "aria-query@5.3.0", + "dom-accessibility-api@0.5.16", + "lz-string", + "picocolors", + "pretty-format" + ] + }, + "@testing-library/jest-dom@6.9.1": { + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dependencies": [ + "@adobe/css-tools", + "aria-query@5.3.2", + "css.escape", + "dom-accessibility-api@0.6.3", + "picocolors", + "redent" + ] + }, + "@testing-library/svelte@5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1": { + "integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==", + "dependencies": [ + "@testing-library/dom", + "svelte", + "vite@6.4.1_picomatch@4.0.3", + "vitest" + ], + "optionalPeers": [ + "vite@6.4.1_picomatch@4.0.3", + "vitest" + ] + }, + "@testing-library/user-event@14.6.1_@testing-library+dom@10.4.1": { + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dependencies": [ + "@testing-library/dom" + ] + }, + "@types/aria-query@5.0.4": { + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "@vitest/expect@2.1.9": { + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dependencies": [ + "@vitest/spy", + "@vitest/utils", + "chai", + "tinyrainbow" + ] + }, + "@vitest/mocker@2.1.9_vite@5.4.21": { + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dependencies": [ + "@vitest/spy", + "estree-walker", + "magic-string", + "vite@5.4.21" + ], + "optionalPeers": [ + "vite@5.4.21" + ] + }, + "@vitest/pretty-format@2.1.9": { + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dependencies": [ + "tinyrainbow" + ] + }, + "@vitest/runner@2.1.9": { + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dependencies": [ + "@vitest/utils", + "pathe" + ] + }, + "@vitest/snapshot@2.1.9": { + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dependencies": [ + "@vitest/pretty-format", + "magic-string", + "pathe" + ] + }, + "@vitest/spy@2.1.9": { + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dependencies": [ + "tinyspy" + ] + }, + "@vitest/utils@2.1.9": { + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dependencies": [ + "@vitest/pretty-format", + "loupe", + "tinyrainbow" + ] + }, + "acorn@8.15.0": { + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": true + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@5.2.0": { + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + }, + "aria-query@5.3.0": { + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dependencies": [ + "dequal" + ] + }, + "aria-query@5.3.2": { + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" + }, + "assertion-error@2.0.1": { + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" + }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axobject-query@4.1.0": { + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" + }, + "cac@6.7.14": { + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==" + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "chai@5.3.3": { + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dependencies": [ + "assertion-error", + "check-error", + "deep-eql", + "loupe", + "pathval" + ] + }, + "check-error@2.1.1": { + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==" + }, + "clsx@2.1.1": { + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, + "css.escape@1.5.1": { + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + }, + "cssstyle@4.6.0": { + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dependencies": [ + "@asamuzakjp/css-color", + "rrweb-cssom@0.8.0" + ] + }, + "data-urls@5.0.0": { + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dependencies": [ + "whatwg-mimetype", + "whatwg-url" + ] + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "decimal.js@10.6.0": { + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" + }, + "deep-eql@5.0.2": { + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" + }, + "deepmerge@4.3.1": { + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "dequal@2.0.3": { + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, + "devalue@5.6.1": { + "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==" + }, + "dom-accessibility-api@0.5.16": { + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + }, + "dom-accessibility-api@0.6.3": { + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "entities@6.0.1": { + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-module-lexer@1.7.0": { + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==" + }, + "es-object-atoms@1.1.1": { + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": [ + "es-errors" + ] + }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, + "esbuild@0.21.5": { + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.21.5", + "@esbuild/android-arm@0.21.5", + "@esbuild/android-arm64@0.21.5", + "@esbuild/android-x64@0.21.5", + "@esbuild/darwin-arm64@0.21.5", + "@esbuild/darwin-x64@0.21.5", + "@esbuild/freebsd-arm64@0.21.5", + "@esbuild/freebsd-x64@0.21.5", + "@esbuild/linux-arm@0.21.5", + "@esbuild/linux-arm64@0.21.5", + "@esbuild/linux-ia32@0.21.5", + "@esbuild/linux-loong64@0.21.5", + "@esbuild/linux-mips64el@0.21.5", + "@esbuild/linux-ppc64@0.21.5", + "@esbuild/linux-riscv64@0.21.5", + "@esbuild/linux-s390x@0.21.5", + "@esbuild/linux-x64@0.21.5", + "@esbuild/netbsd-x64@0.21.5", + "@esbuild/openbsd-x64@0.21.5", + "@esbuild/sunos-x64@0.21.5", + "@esbuild/win32-arm64@0.21.5", + "@esbuild/win32-ia32@0.21.5", + "@esbuild/win32-x64@0.21.5" + ], + "scripts": true, + "bin": true + }, + "esbuild@0.25.12": { + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.25.12", + "@esbuild/android-arm@0.25.12", + "@esbuild/android-arm64@0.25.12", + "@esbuild/android-x64@0.25.12", + "@esbuild/darwin-arm64@0.25.12", + "@esbuild/darwin-x64@0.25.12", + "@esbuild/freebsd-arm64@0.25.12", + "@esbuild/freebsd-x64@0.25.12", + "@esbuild/linux-arm@0.25.12", + "@esbuild/linux-arm64@0.25.12", + "@esbuild/linux-ia32@0.25.12", + "@esbuild/linux-loong64@0.25.12", + "@esbuild/linux-mips64el@0.25.12", + "@esbuild/linux-ppc64@0.25.12", + "@esbuild/linux-riscv64@0.25.12", + "@esbuild/linux-s390x@0.25.12", + "@esbuild/linux-x64@0.25.12", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64@0.25.12", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64@0.25.12", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64@0.25.12", + "@esbuild/win32-arm64@0.25.12", + "@esbuild/win32-ia32@0.25.12", + "@esbuild/win32-x64@0.25.12" + ], + "scripts": true, + "bin": true + }, + "esm-env@1.2.2": { + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" + }, + "esrap@2.2.1": { + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "estree-walker@3.0.3": { + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": [ + "@types/estree" + ] + }, + "expect-type@1.3.0": { + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==" + }, + "fdir@6.5.0_picomatch@4.0.3": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "form-data@4.0.5": { + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": [ + "asynckit", + "combined-stream", + "es-set-tostringtag", + "hasown", + "mime-types" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "html-encoding-sniffer@4.0.0": { + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dependencies": [ + "whatwg-encoding" + ] + }, + "http-proxy-agent@7.0.2": { + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "iconv-lite@0.6.3": { + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": [ + "safer-buffer" + ] + }, + "indent-string@4.0.0": { + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + }, + "is-potential-custom-element-name@1.0.1": { + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, + "is-reference@3.0.3": { + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dependencies": [ + "@types/estree" + ] + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsdom@25.0.1": { + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dependencies": [ + "cssstyle", + "data-urls", + "decimal.js", + "form-data", + "html-encoding-sniffer", + "http-proxy-agent", + "https-proxy-agent", + "is-potential-custom-element-name", + "nwsapi", + "parse5", + "rrweb-cssom@0.7.1", + "saxes", + "symbol-tree", + "tough-cookie", + "w3c-xmlserializer", + "webidl-conversions", + "whatwg-encoding", + "whatwg-mimetype", + "whatwg-url", + "ws", + "xml-name-validator" + ] + }, + "kleur@4.1.5": { + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" + }, + "locate-character@3.0.0": { + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "loupe@3.2.1": { + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==" + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "lz-string@1.5.0": { + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": true + }, + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, + "min-indent@1.0.1": { + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "nwsapi@2.2.23": { + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==" + }, + "parse5@7.3.0": { + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": [ + "entities" + ] + }, + "pathe@1.1.2": { + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==" + }, + "pathval@2.0.1": { + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + }, + "postcss@8.5.6": { + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "pretty-format@27.5.1": { + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": [ + "ansi-regex", + "ansi-styles", + "react-is" + ] + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "react-is@17.0.2": { + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "redent@3.0.0": { + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dependencies": [ + "indent-string", + "strip-indent" + ] + }, + "rollup@4.53.3": { + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dependencies": [ + "@types/estree" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loong64-gnu", + "@rollup/rollup-linux-ppc64-gnu", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-openharmony-arm64", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-gnu", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, + "rrweb-cssom@0.7.1": { + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==" + }, + "rrweb-cssom@0.8.0": { + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "saxes@6.0.0": { + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": [ + "xmlchars" + ] + }, + "siginfo@2.0.0": { + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==" + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "stackback@0.0.2": { + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==" + }, + "std-env@3.10.0": { + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==" + }, + "strip-indent@3.0.0": { + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dependencies": [ + "min-indent" + ] + }, + "svelte@5.45.10_acorn@8.15.0": { + "integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==", + "dependencies": [ + "@jridgewell/remapping", + "@jridgewell/sourcemap-codec", + "@sveltejs/acorn-typescript", + "@types/estree", + "acorn", + "aria-query@5.3.2", + "axobject-query", + "clsx", + "devalue", + "esm-env", + "esrap", + "is-reference", + "locate-character", + "magic-string", + "zimmerframe" + ] + }, + "symbol-tree@3.2.4": { + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + }, + "tinybench@2.9.0": { + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" + }, + "tinyexec@0.3.2": { + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + }, + "tinyglobby@0.2.15_picomatch@4.0.3": { + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "tinypool@1.1.1": { + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==" + }, + "tinyrainbow@1.2.0": { + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==" + }, + "tinyspy@3.0.2": { + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==" + }, + "tldts-core@6.1.86": { + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==" + }, + "tldts@6.1.86": { + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dependencies": [ + "tldts-core" + ], + "bin": true + }, + "tough-cookie@5.1.2": { + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dependencies": [ + "tldts" + ] + }, + "tr46@5.1.1": { + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": [ + "punycode" + ] + }, + "vite-node@2.1.9": { + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dependencies": [ + "cac", + "debug", + "es-module-lexer", + "pathe", + "vite@5.4.21" + ], + "bin": true + }, + "vite@5.4.21": { + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dependencies": [ + "esbuild@0.21.5", + "postcss", + "rollup" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "vite@6.4.1_picomatch@4.0.3": { + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dependencies": [ + "esbuild@0.25.12", + "fdir", + "picomatch", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "vitefu@1.1.1_vite@6.4.1__picomatch@4.0.3": { + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dependencies": [ + "vite@6.4.1_picomatch@4.0.3" + ], + "optionalPeers": [ + "vite@6.4.1_picomatch@4.0.3" + ] + }, + "vitest@2.1.9_jsdom@25.0.1_vite@5.4.21": { + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dependencies": [ + "@vitest/expect", + "@vitest/mocker", + "@vitest/pretty-format", + "@vitest/runner", + "@vitest/snapshot", + "@vitest/spy", + "@vitest/utils", + "chai", + "debug", + "expect-type", + "jsdom", + "magic-string", + "pathe", + "std-env", + "tinybench", + "tinyexec", + "tinypool", + "tinyrainbow", + "vite@5.4.21", + "vite-node", + "why-is-node-running" + ], + "optionalPeers": [ + "jsdom" + ], + "bin": true + }, + "w3c-xmlserializer@5.0.0": { + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dependencies": [ + "xml-name-validator" + ] + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-encoding@3.1.1": { + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": [ + "iconv-lite" + ] + }, + "whatwg-mimetype@4.0.0": { + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, + "whatwg-url@14.2.0": { + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "why-is-node-running@2.3.0": { + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dependencies": [ + "siginfo", + "stackback" + ], + "bin": true + }, + "ws@8.18.3": { + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" + }, + "xml-name-validator@5.0.0": { + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==" + }, + "xmlchars@2.2.0": { + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, + "zimmerframe@1.1.4": { + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@sveltejs/vite-plugin-svelte@5", + "npm:@testing-library/jest-dom@^6.6.3", + "npm:@testing-library/svelte@^5.2.6", + "npm:@testing-library/user-event@^14.5.2", + "npm:jsdom@^25.0.1", + "npm:svelte@5", + "npm:vite@6", + "npm:vitest@^2.1.8" + ] + } + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d5301b5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + BSPDS + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..26e6c21 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "bspds-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/svelte": "^5.2.6", + "@testing-library/user-event": "^14.5.2", + "jsdom": "^25.0.1", + "svelte": "^5.0.0", + "vite": "^6.0.0", + "vitest": "^2.1.8" + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..2691ebb --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,129 @@ + + +
+ {#if auth.loading} +
+

Loading...

+
+ {:else} + + {/if} +
+ + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..e038a0d --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,341 @@ +const API_BASE = '/xrpc' + +export class ApiError extends Error { + public did?: string + + constructor(public status: number, public error: string, message: string, did?: string) { + super(message) + this.name = 'ApiError' + this.did = did + } +} + +async function xrpc(method: string, options?: { + method?: 'GET' | 'POST' + params?: Record + body?: unknown + token?: string +}): Promise { + const { method: httpMethod = 'GET', params, body, token } = options ?? {} + + let url = `${API_BASE}/${method}` + if (params) { + const searchParams = new URLSearchParams(params) + url += `?${searchParams}` + } + + const headers: Record = {} + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + if (body) { + headers['Content-Type'] = 'application/json' + } + + const res = await fetch(url, { + method: httpMethod, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) + throw new ApiError(res.status, err.error, err.message, err.did) + } + + return res.json() +} + +export interface Session { + did: string + handle: string + email?: string + emailConfirmed?: boolean + accessJwt: string + refreshJwt: string +} + +export interface AppPassword { + name: string + createdAt: string +} + +export interface InviteCode { + code: string + available: number + disabled: boolean + forAccount: string + createdBy: string + createdAt: string + uses: { usedBy: string; usedAt: string }[] +} + +export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal' + +export interface CreateAccountParams { + handle: string + email: string + password: string + inviteCode?: string + verificationChannel?: VerificationChannel + discordId?: string + telegramUsername?: string + signalNumber?: string +} + +export interface CreateAccountResult { + handle: string + did: string + verificationRequired: boolean + verificationChannel: string +} + +export interface ConfirmSignupResult { + accessJwt: string + refreshJwt: string + handle: string + did: string +} + +export const api = { + async createAccount(params: CreateAccountParams): Promise { + return xrpc('com.atproto.server.createAccount', { + method: 'POST', + body: { + handle: params.handle, + email: params.email, + password: params.password, + inviteCode: params.inviteCode, + verificationChannel: params.verificationChannel, + discordId: params.discordId, + telegramUsername: params.telegramUsername, + signalNumber: params.signalNumber, + }, + }) + }, + + async confirmSignup(did: string, verificationCode: string): Promise { + return xrpc('com.atproto.server.confirmSignup', { + method: 'POST', + body: { did, verificationCode }, + }) + }, + + async resendVerification(did: string): Promise<{ success: boolean }> { + return xrpc('com.atproto.server.resendVerification', { + method: 'POST', + body: { did }, + }) + }, + + async createSession(identifier: string, password: string): Promise { + return xrpc('com.atproto.server.createSession', { + method: 'POST', + body: { identifier, password }, + }) + }, + + async getSession(token: string): Promise { + return xrpc('com.atproto.server.getSession', { token }) + }, + + async refreshSession(refreshJwt: string): Promise { + return xrpc('com.atproto.server.refreshSession', { + method: 'POST', + token: refreshJwt, + }) + }, + + async deleteSession(token: string): Promise { + await xrpc('com.atproto.server.deleteSession', { + method: 'POST', + token, + }) + }, + + async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { + return xrpc('com.atproto.server.listAppPasswords', { token }) + }, + + async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> { + return xrpc('com.atproto.server.createAppPassword', { + method: 'POST', + token, + body: { name }, + }) + }, + + async revokeAppPassword(token: string, name: string): Promise { + await xrpc('com.atproto.server.revokeAppPassword', { + method: 'POST', + token, + body: { name }, + }) + }, + + async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { + return xrpc('com.atproto.server.getAccountInviteCodes', { token }) + }, + + async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> { + return xrpc('com.atproto.server.createInviteCode', { + method: 'POST', + token, + body: { useCount }, + }) + }, + + async requestPasswordReset(email: string): Promise { + await xrpc('com.atproto.server.requestPasswordReset', { + method: 'POST', + body: { email }, + }) + }, + + async resetPassword(token: string, password: string): Promise { + await xrpc('com.atproto.server.resetPassword', { + method: 'POST', + body: { token, password }, + }) + }, + + async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> { + return xrpc('com.atproto.server.requestEmailUpdate', { + method: 'POST', + token, + }) + }, + + async updateEmail(token: string, email: string, emailToken?: string): Promise { + await xrpc('com.atproto.server.updateEmail', { + method: 'POST', + token, + body: { email, token: emailToken }, + }) + }, + + async updateHandle(token: string, handle: string): Promise { + await xrpc('com.atproto.identity.updateHandle', { + method: 'POST', + token, + body: { handle }, + }) + }, + + async requestAccountDelete(token: string): Promise { + await xrpc('com.atproto.server.requestAccountDelete', { + method: 'POST', + token, + }) + }, + + async deleteAccount(did: string, password: string, deleteToken: string): Promise { + await xrpc('com.atproto.server.deleteAccount', { + method: 'POST', + body: { did, password, token: deleteToken }, + }) + }, + + async describeServer(): Promise<{ + availableUserDomains: string[] + inviteCodeRequired: boolean + links?: { privacyPolicy?: string; termsOfService?: string } + }> { + return xrpc('com.atproto.server.describeServer') + }, + + async getNotificationPrefs(token: string): Promise<{ + preferredChannel: string + email: string + discordId: string | null + discordVerified: boolean + telegramUsername: string | null + telegramVerified: boolean + signalNumber: string | null + signalVerified: boolean + }> { + return xrpc('com.bspds.account.getNotificationPrefs', { token }) + }, + + async updateNotificationPrefs(token: string, prefs: { + preferredChannel?: string + discordId?: string + telegramUsername?: string + signalNumber?: string + }): Promise<{ success: boolean }> { + return xrpc('com.bspds.account.updateNotificationPrefs', { + method: 'POST', + token, + body: prefs, + }) + }, + + async describeRepo(token: string, repo: string): Promise<{ + handle: string + did: string + didDoc: unknown + collections: string[] + handleIsCorrect: boolean + }> { + return xrpc('com.atproto.repo.describeRepo', { + token, + params: { repo }, + }) + }, + + async listRecords(token: string, repo: string, collection: string, options?: { + limit?: number + cursor?: string + reverse?: boolean + }): Promise<{ + records: Array<{ uri: string; cid: string; value: unknown }> + cursor?: string + }> { + const params: Record = { repo, collection } + if (options?.limit) params.limit = String(options.limit) + if (options?.cursor) params.cursor = options.cursor + if (options?.reverse) params.reverse = 'true' + return xrpc('com.atproto.repo.listRecords', { token, params }) + }, + + async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{ + uri: string + cid: string + value: unknown + }> { + return xrpc('com.atproto.repo.getRecord', { + token, + params: { repo, collection, rkey }, + }) + }, + + async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{ + uri: string + cid: string + }> { + return xrpc('com.atproto.repo.createRecord', { + method: 'POST', + token, + body: { repo, collection, record, rkey }, + }) + }, + + async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{ + uri: string + cid: string + }> { + return xrpc('com.atproto.repo.putRecord', { + method: 'POST', + token, + body: { repo, collection, rkey, record }, + }) + }, + + async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise { + await xrpc('com.atproto.repo.deleteRecord', { + method: 'POST', + token, + body: { repo, collection, rkey }, + }) + }, +} diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts new file mode 100644 index 0000000..5f8ed54 --- /dev/null +++ b/frontend/src/lib/auth.svelte.ts @@ -0,0 +1,172 @@ +import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api' + +const STORAGE_KEY = 'bspds_session' + +interface AuthState { + session: Session | null + loading: boolean + error: string | null +} + +let state = $state({ + session: null, + loading: true, + error: null, +}) + +function saveSession(session: Session | null) { + if (session) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) + } else { + localStorage.removeItem(STORAGE_KEY) + } +} + +function loadSession(): Session | null { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + try { + return JSON.parse(stored) + } catch { + return null + } + } + return null +} + +export async function initAuth() { + state.loading = true + state.error = null + + const stored = loadSession() + if (stored) { + try { + const session = await api.getSession(stored.accessJwt) + state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + try { + const refreshed = await api.refreshSession(stored.refreshJwt) + state.session = refreshed + saveSession(refreshed) + } catch { + saveSession(null) + state.session = null + } + } else { + saveSession(null) + state.session = null + } + } + } + + state.loading = false +} + +export async function login(identifier: string, password: string): Promise { + state.loading = true + state.error = null + + try { + const session = await api.createSession(identifier, password) + state.session = session + saveSession(session) + } catch (e) { + if (e instanceof ApiError) { + state.error = e.message + } else { + state.error = 'Login failed' + } + throw e + } finally { + state.loading = false + } +} + +export async function register(params: CreateAccountParams): Promise { + try { + const result = await api.createAccount(params) + return result + } catch (e) { + if (e instanceof ApiError) { + state.error = e.message + } else { + state.error = 'Registration failed' + } + throw e + } +} + +export async function confirmSignup(did: string, verificationCode: string): Promise { + state.loading = true + state.error = null + + try { + const result = await api.confirmSignup(did, verificationCode) + const session: Session = { + did: result.did, + handle: result.handle, + accessJwt: result.accessJwt, + refreshJwt: result.refreshJwt, + } + state.session = session + saveSession(session) + } catch (e) { + if (e instanceof ApiError) { + state.error = e.message + } else { + state.error = 'Verification failed' + } + throw e + } finally { + state.loading = false + } +} + +export async function resendVerification(did: string): Promise { + try { + await api.resendVerification(did) + } catch (e) { + if (e instanceof ApiError) { + throw e + } + throw new Error('Failed to resend verification code') + } +} + +export async function logout(): Promise { + if (state.session) { + try { + await api.deleteSession(state.session.accessJwt) + } catch { + // Ignore errors on logout + } + } + state.session = null + saveSession(null) +} + +export function getAuthState() { + return state +} + +export function getToken(): string | null { + return state.session?.accessJwt ?? null +} + +export function isAuthenticated(): boolean { + return state.session !== null +} + +export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) { + state.session = newState.session + state.loading = newState.loading + state.error = newState.error +} + +export function _testReset() { + state.session = null + state.loading = true + state.error = null + localStorage.removeItem(STORAGE_KEY) +} diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts new file mode 100644 index 0000000..1d113b0 --- /dev/null +++ b/frontend/src/lib/router.svelte.ts @@ -0,0 +1,13 @@ +let currentPath = $state(window.location.hash.slice(1) || '/') + +window.addEventListener('hashchange', () => { + currentPath = window.location.hash.slice(1) || '/' +}) + +export function navigate(path: string) { + window.location.hash = path +} + +export function getCurrentPath() { + return currentPath +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..1a85b5f --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,8 @@ +import App from './App.svelte' +import { mount } from 'svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/frontend/src/routes/AppPasswords.svelte b/frontend/src/routes/AppPasswords.svelte new file mode 100644 index 0000000..0c2a2a3 --- /dev/null +++ b/frontend/src/routes/AppPasswords.svelte @@ -0,0 +1,333 @@ + + +
+
+ ← Dashboard +

App Passwords

+
+ +

+ App passwords let you sign in to third-party apps without giving them your main password. + Each app password can be revoked individually. +

+ + {#if error} +
{error}
+ {/if} + + {#if createdPassword} +
+

App Password Created

+

Copy this password now. You won't be able to see it again.

+
+ {createdPassword.password} +
+

Name: {createdPassword.name}

+ +
+ {/if} + +
+

Create New App Password

+
+ + +
+
+ +
+

Your App Passwords

+ + {#if loading} +

Loading...

+ {:else if passwords.length === 0} +

No app passwords yet

+ {:else} +
    + {#each passwords as pw} +
  • +
    + {pw.name} + Created {new Date(pw.createdAt).toLocaleDateString()} +
    + +
  • + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte new file mode 100644 index 0000000..5c848f5 --- /dev/null +++ b/frontend/src/routes/Dashboard.svelte @@ -0,0 +1,201 @@ + + +{#if auth.session} +
+
+

Dashboard

+ +
+ + + + +
+{:else if auth.loading} +
Loading...
+{/if} + + diff --git a/frontend/src/routes/InviteCodes.svelte b/frontend/src/routes/InviteCodes.svelte new file mode 100644 index 0000000..6b94aa2 --- /dev/null +++ b/frontend/src/routes/InviteCodes.svelte @@ -0,0 +1,326 @@ + + +
+
+ ← Dashboard +

Invite Codes

+
+ +

+ Invite codes let you invite friends to join. Each code can be used once. +

+ + {#if error} +
{error}
+ {/if} + + {#if createdCode} +
+

Invite Code Created

+
+ {createdCode} + +
+ +
+ {/if} + +
+ +
+ +
+

Your Invite Codes

+ + {#if loading} +

Loading...

+ {:else if codes.length === 0} +

No invite codes yet

+ {:else} +
    + {#each codes as code} +
  • 0 && code.available === 0}> +
    + {code.code} + +
    +
    + Created {new Date(code.createdAt).toLocaleDateString()} + {#if code.disabled} + Disabled + {:else if code.uses.length > 0} + Used by @{code.uses[0].usedBy.split(':').pop()} + {:else} + Available + {/if} +
    +
  • + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte new file mode 100644 index 0000000..3af89ca --- /dev/null +++ b/frontend/src/routes/Login.svelte @@ -0,0 +1,289 @@ + + + + + diff --git a/frontend/src/routes/Notifications.svelte b/frontend/src/routes/Notifications.svelte new file mode 100644 index 0000000..568eabb --- /dev/null +++ b/frontend/src/routes/Notifications.svelte @@ -0,0 +1,453 @@ + + +
+
+ ← Dashboard +

Notification Preferences

+
+ +

+ Choose how you want to receive important notifications like password resets, + security alerts, and account updates. +

+ + {#if loading} +

Loading...

+ {:else} + {#if error} +
{error}
+ {/if} + + {#if success} +
{success}
+ {/if} + +
+
+

Preferred Channel

+

+ Select your preferred way to receive notifications. You must configure a channel before you can select it. +

+ +
+ {#each channels as channel} + + {/each} +
+
+ +
+

Channel Configuration

+ +
+
+ +
+ + Primary +
+

Your email is managed in Account Settings

+
+ +
+ +
+ + {#if discordId} + {#if discordVerified} + Verified + {:else} + Not verified + {/if} + {/if} +
+

Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.

+
+ +
+ +
+ + {#if telegramUsername} + {#if telegramVerified} + Verified + {:else} + Not verified + {/if} + {/if} +
+

Your Telegram username without the @ symbol

+
+ +
+ +
+ + {#if signalNumber} + {#if signalVerified} + Verified + {:else} + Not verified + {/if} + {/if} +
+

Your Signal phone number with country code

+
+
+
+ +
+ +
+
+ {/if} +
+ + diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte new file mode 100644 index 0000000..640b398 --- /dev/null +++ b/frontend/src/routes/Register.svelte @@ -0,0 +1,523 @@ + + +
+ {#if error} +
{error}
+ {/if} + + {#if pendingVerification} +

Verify Your Account

+

+ We've sent a verification code to your {channelLabel(pendingVerification.channel)}. + Enter it below to complete registration. +

+ + {#if resendMessage} +
{resendMessage}
+ {/if} + +
{ e.preventDefault(); handleVerification(e); }}> + +
+ + +
+ + + + +
+ {:else} +

Create Account

+

Create a new account on this PDS

+ + {#if loadingServerInfo} +

Loading...

+ {:else} +
{ e.preventDefault(); handleSubmit(e); }}> +
+ + + {#if fullHandle()} +

Your full handle will be: @{fullHandle()}

+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ Contact Method +

Choose how you'd like to verify your account and receive notifications. You only need one.

+ +
+ + +
+ + {#if verificationChannel === 'email'} +
+ + +
+ {:else if verificationChannel === 'discord'} +
+ + +

Your numeric Discord user ID (enable Developer Mode to find it)

+
+ {:else if verificationChannel === 'telegram'} +
+ + +
+ {:else if verificationChannel === 'signal'} +
+ + +

Include country code (e.g., +1 for US)

+
+ {/if} +
+ + {#if serverInfo?.inviteCodeRequired} +
+ + +
+ {:else} +
+ + +
+ {/if} + + +
+ + + {/if} + {/if} +
+ + diff --git a/frontend/src/routes/RepoExplorer.svelte b/frontend/src/routes/RepoExplorer.svelte new file mode 100644 index 0000000..69f9f5a --- /dev/null +++ b/frontend/src/routes/RepoExplorer.svelte @@ -0,0 +1,940 @@ + + +
+
+ +

+ {#if view === 'collections'} + Repository Explorer + {:else if view === 'records'} + {selectedCollection} + {:else if view === 'record'} + Record Detail + {:else} + Create Record + {/if} +

+ {#if auth.session} +

{auth.session.did}

+ {/if} +
+ + {#if error} +
+ {#if error.code} + {error.code} + {/if} + {error.message} +
+ {/if} + + {#if success} +
{success}
+ {/if} + + {#if loading} +

Loading...

+ {:else if view === 'collections'} +
+ + +
+ + {#if collections.length === 0} +

No collections yet. Create your first record to get started.

+ {:else} +
+ {#each [...groupedCollections.entries()] as [authority, nsids]} +
+

{authority}

+
    + {#each nsids as nsid} +
  • + +
  • + {/each} +
+
+ {/each} +
+ {/if} + + {:else if view === 'records'} +
+ + +
+ + {#if records.length === 0} +

No records in this collection.

+ {:else} +
    + {#each filteredRecords as record} +
  • + +
  • + {/each} +
+ + {#if recordsCursor} +
+ +
+ {/if} + {/if} + + {:else if view === 'record' && selectedRecord} +
+
+
+
URI
+
{selectedRecord.uri}
+
CID
+
{selectedRecord.cid}
+
+
+ +
+
+ + + {#if jsonError} +

{jsonError}

+ {/if} +
+ +
+ + +
+
+
+ + {:else if view === 'create'} +
+
+ + +
+ +
+ + +

Leave empty to auto-generate a TID-based key

+
+ +
+ + + {#if jsonError} +

{jsonError}

+ {/if} +
+ +
+ + +
+
+ {/if} +
+ + diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte new file mode 100644 index 0000000..4103032 --- /dev/null +++ b/frontend/src/routes/Settings.svelte @@ -0,0 +1,409 @@ + + +
+
+ ← Dashboard +

Account Settings

+
+ + {#if message} +
{message.text}
+ {/if} + +
+

Change Email

+ {#if auth.session?.email} +

Current: {auth.session.email}

+ {/if} + + {#if emailTokenRequired} +
+
+ + +
+
+ + +
+
+ {:else} +
+
+ + +
+ +
+ {/if} +
+ +
+

Change Handle

+ {#if auth.session} +

Current: @{auth.session.handle}

+ {/if} + +
+
+ + +
+ +
+
+ +
+

Delete Account

+

This action is irreversible. All your data will be permanently deleted.

+ + {#if deleteTokenSent} +
+
+ + +
+
+ + +
+
+ + +
+
+ {:else} + + {/if} +
+
+ + diff --git a/frontend/src/tests/AppPasswords.test.ts b/frontend/src/tests/AppPasswords.test.ts new file mode 100644 index 0000000..a4cc6ad --- /dev/null +++ b/frontend/src/tests/AppPasswords.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import AppPasswords from '../routes/AppPasswords.svelte' +import { + setupFetchMock, + mockEndpoint, + jsonResponse, + errorResponse, + mockData, + clearMocks, + setupAuthenticatedUser, + setupUnauthenticatedUser, +} from './mocks' + +describe('AppPasswords', () => { + beforeEach(() => { + clearMocks() + setupFetchMock() + window.confirm = vi.fn(() => true) + }) + + describe('authentication guard', () => { + it('redirects to login when not authenticated', async () => { + setupUnauthenticatedUser() + render(AppPasswords) + + await waitFor(() => { + expect(window.location.hash).toBe('#/login') + }) + }) + }) + + describe('page structure', () => { + beforeEach(() => { + setupAuthenticatedUser() + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [] }) + ) + }) + + it('displays all page elements', async () => { + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') + expect(screen.getByText(/third-party apps/i)).toBeInTheDocument() + }) + }) + }) + + describe('loading state', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('shows loading text while fetching passwords', async () => { + mockEndpoint('com.atproto.server.listAppPasswords', async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + return jsonResponse({ passwords: [] }) + }) + + render(AppPasswords) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + }) + + describe('empty state', () => { + beforeEach(() => { + setupAuthenticatedUser() + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [] }) + ) + }) + + it('shows empty message when no passwords exist', async () => { + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() + }) + }) + }) + + describe('password list', () => { + const testPasswords = [ + mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }), + mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }), + ] + + beforeEach(() => { + setupAuthenticatedUser() + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: testPasswords }) + ) + }) + + it('displays all app passwords with dates and revoke buttons', async () => { + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('Graysky')).toBeInTheDocument() + expect(screen.getByText('Skeets')).toBeInTheDocument() + expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument() + expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2) + }) + }) + }) + + describe('create app password', () => { + beforeEach(() => { + setupAuthenticatedUser() + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [] }) + ) + }) + + it('displays create form with input and button', async () => { + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument() + }) + }) + + it('disables create button when input is empty', async () => { + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create/i })).toBeDisabled() + }) + }) + + it('enables create button when input has value', async () => { + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } }) + + expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled() + }) + + it('calls createAppPassword with correct name', async () => { + let capturedName: string | null = null + + mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => { + const body = JSON.parse((options?.body as string) || '{}') + capturedName = body.name + return jsonResponse({ + name: body.name, + password: 'xxxx-xxxx-xxxx-xxxx', + createdAt: new Date().toISOString(), + }) + }) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } }) + await fireEvent.click(screen.getByRole('button', { name: /create/i })) + + await waitFor(() => { + expect(capturedName).toBe('Graysky') + }) + }) + + it('shows loading state while creating', async () => { + mockEndpoint('com.atproto.server.createAppPassword', async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + return jsonResponse({ + name: 'Test', + password: 'xxxx-xxxx-xxxx-xxxx', + createdAt: new Date().toISOString(), + }) + }) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } }) + await fireEvent.click(screen.getByRole('button', { name: /create/i })) + + expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled() + }) + + it('displays created password in success box and clears input', async () => { + mockEndpoint('com.atproto.server.createAppPassword', () => + jsonResponse({ + name: 'MyApp', + password: 'abcd-efgh-ijkl-mnop', + createdAt: new Date().toISOString(), + }) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement + await fireEvent.input(input, { target: { value: 'MyApp' } }) + await fireEvent.click(screen.getByRole('button', { name: /create/i })) + + await waitFor(() => { + expect(screen.getByText(/app password created/i)).toBeInTheDocument() + expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument() + expect(screen.getByText(/name: myapp/i)).toBeInTheDocument() + expect(input.value).toBe('') + }) + }) + + it('dismisses created password box when clicking Done', async () => { + mockEndpoint('com.atproto.server.createAppPassword', () => + jsonResponse({ + name: 'Test', + password: 'xxxx-xxxx-xxxx-xxxx', + createdAt: new Date().toISOString(), + }) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } }) + await fireEvent.click(screen.getByRole('button', { name: /create/i })) + + await waitFor(() => { + expect(screen.getByText(/app password created/i)).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /done/i })) + + await waitFor(() => { + expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument() + }) + }) + + it('shows error when creation fails', async () => { + mockEndpoint('com.atproto.server.createAppPassword', () => + errorResponse('InvalidRequest', 'Name already exists', 400) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } }) + await fireEvent.click(screen.getByRole('button', { name: /create/i })) + + await waitFor(() => { + expect(screen.getByText(/name already exists/i)).toBeInTheDocument() + expect(screen.getByText(/name already exists/i)).toHaveClass('error') + }) + }) + }) + + describe('revoke app password', () => { + const testPassword = mockData.appPassword({ name: 'TestApp' }) + + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('shows confirmation dialog before revoking', async () => { + const confirmSpy = vi.fn(() => false) + window.confirm = confirmSpy + + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [testPassword] }) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('TestApp')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) + + expect(confirmSpy).toHaveBeenCalledWith( + expect.stringContaining('TestApp') + ) + }) + + it('does not revoke when confirmation is cancelled', async () => { + window.confirm = vi.fn(() => false) + let revokeCalled = false + + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [testPassword] }) + ) + + mockEndpoint('com.atproto.server.revokeAppPassword', () => { + revokeCalled = true + return jsonResponse({}) + }) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('TestApp')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) + + expect(revokeCalled).toBe(false) + }) + + it('calls revokeAppPassword with correct name', async () => { + window.confirm = vi.fn(() => true) + let capturedName: string | null = null + + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [testPassword] }) + ) + + mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => { + const body = JSON.parse((options?.body as string) || '{}') + capturedName = body.name + return jsonResponse({}) + }) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('TestApp')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) + + await waitFor(() => { + expect(capturedName).toBe('TestApp') + }) + }) + + it('shows loading state while revoking', async () => { + window.confirm = vi.fn(() => true) + + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [testPassword] }) + ) + + mockEndpoint('com.atproto.server.revokeAppPassword', async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + return jsonResponse({}) + }) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('TestApp')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) + + expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled() + }) + + it('reloads password list after successful revocation', async () => { + window.confirm = vi.fn(() => true) + let listCallCount = 0 + + mockEndpoint('com.atproto.server.listAppPasswords', () => { + listCallCount++ + if (listCallCount === 1) { + return jsonResponse({ passwords: [testPassword] }) + } + return jsonResponse({ passwords: [] }) + }) + + mockEndpoint('com.atproto.server.revokeAppPassword', () => + jsonResponse({}) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('TestApp')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) + + await waitFor(() => { + expect(screen.queryByText('TestApp')).not.toBeInTheDocument() + expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() + }) + }) + + it('shows error when revocation fails', async () => { + window.confirm = vi.fn(() => true) + + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [testPassword] }) + ) + + mockEndpoint('com.atproto.server.revokeAppPassword', () => + errorResponse('InternalError', 'Server error', 500) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText('TestApp')).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) + + await waitFor(() => { + expect(screen.getByText(/server error/i)).toBeInTheDocument() + expect(screen.getByText(/server error/i)).toHaveClass('error') + }) + }) + }) + + describe('error handling', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('shows error when loading passwords fails', async () => { + mockEndpoint('com.atproto.server.listAppPasswords', () => + errorResponse('InternalError', 'Database connection failed', 500) + ) + + render(AppPasswords) + + await waitFor(() => { + expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() + expect(screen.getByText(/database connection failed/i)).toHaveClass('error') + }) + }) + }) +}) diff --git a/frontend/src/tests/Dashboard.test.ts b/frontend/src/tests/Dashboard.test.ts new file mode 100644 index 0000000..85b977a --- /dev/null +++ b/frontend/src/tests/Dashboard.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import Dashboard from '../routes/Dashboard.svelte' +import { + setupFetchMock, + mockEndpoint, + jsonResponse, + mockData, + clearMocks, + setupAuthenticatedUser, + setupUnauthenticatedUser, +} from './mocks' + +const STORAGE_KEY = 'bspds_session' + +describe('Dashboard', () => { + beforeEach(() => { + clearMocks() + setupFetchMock() + }) + + describe('authentication guard', () => { + it('redirects to login when not authenticated', async () => { + setupUnauthenticatedUser() + render(Dashboard) + + await waitFor(() => { + expect(window.location.hash).toBe('#/login') + }) + }) + + it('shows loading state while checking auth', () => { + render(Dashboard) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + }) + + describe('authenticated view', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('displays user account info and page structure', async () => { + render(Dashboard) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument() + expect(screen.getByText(/@testuser\.test\.bspds\.dev/)).toBeInTheDocument() + expect(screen.getByText(/did:web:test\.bspds\.dev:u:testuser/)).toBeInTheDocument() + expect(screen.getByText('test@example.com')).toBeInTheDocument() + expect(screen.getByText('Verified')).toBeInTheDocument() + expect(screen.getByText('Verified')).toHaveClass('badge', 'success') + }) + }) + + it('displays unverified badge when email not confirmed', async () => { + setupAuthenticatedUser({ emailConfirmed: false }) + render(Dashboard) + + await waitFor(() => { + expect(screen.getByText('Unverified')).toBeInTheDocument() + expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning') + }) + }) + + it('displays all navigation cards', async () => { + render(Dashboard) + + await waitFor(() => { + const navCards = [ + { name: /app passwords/i, href: '#/app-passwords' }, + { name: /invite codes/i, href: '#/invite-codes' }, + { name: /account settings/i, href: '#/settings' }, + { name: /notification preferences/i, href: '#/notifications' }, + { name: /repository explorer/i, href: '#/repo' }, + ] + + for (const { name, href } of navCards) { + const card = screen.getByRole('link', { name }) + expect(card).toBeInTheDocument() + expect(card).toHaveAttribute('href', href) + } + }) + }) + }) + + describe('logout functionality', () => { + beforeEach(() => { + setupAuthenticatedUser() + localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session())) + + mockEndpoint('com.atproto.server.deleteSession', () => + jsonResponse({}) + ) + }) + + it('calls deleteSession and navigates to login on logout', async () => { + let deleteSessionCalled = false + + mockEndpoint('com.atproto.server.deleteSession', () => { + deleteSessionCalled = true + return jsonResponse({}) + }) + + render(Dashboard) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /sign out/i })) + + await waitFor(() => { + expect(deleteSessionCalled).toBe(true) + expect(window.location.hash).toBe('#/login') + }) + }) + + it('clears session from localStorage after logout', async () => { + const storedSession = localStorage.getItem(STORAGE_KEY) + expect(storedSession).not.toBeNull() + + render(Dashboard) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /sign out/i })) + + await waitFor(() => { + expect(localStorage.getItem(STORAGE_KEY)).toBeNull() + }) + }) + }) +}) diff --git a/frontend/src/tests/Login.test.ts b/frontend/src/tests/Login.test.ts new file mode 100644 index 0000000..41ea5f1 --- /dev/null +++ b/frontend/src/tests/Login.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import Login from '../routes/Login.svelte' +import { + setupFetchMock, + mockEndpoint, + jsonResponse, + errorResponse, + mockData, + clearMocks, +} from './mocks' + +describe('Login', () => { + beforeEach(() => { + clearMocks() + setupFetchMock() + window.location.hash = '' + }) + + describe('initial render', () => { + it('renders login form with all elements and correct initial state', () => { + render(Login) + + expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument() + expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument() + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled() + expect(screen.getByText(/don't have an account/i)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register') + }) + }) + + describe('form validation', () => { + it('enables submit button only when both fields are filled', async () => { + render(Login) + + const identifierInput = screen.getByLabelText(/handle or email/i) + const passwordInput = screen.getByLabelText(/password/i) + const submitButton = screen.getByRole('button', { name: /sign in/i }) + + await fireEvent.input(identifierInput, { target: { value: 'testuser' } }) + expect(submitButton).toBeDisabled() + + await fireEvent.input(identifierInput, { target: { value: '' } }) + await fireEvent.input(passwordInput, { target: { value: 'password123' } }) + expect(submitButton).toBeDisabled() + + await fireEvent.input(identifierInput, { target: { value: 'testuser' } }) + expect(submitButton).not.toBeDisabled() + }) + }) + + describe('login submission', () => { + it('calls createSession with correct credentials', async () => { + let capturedBody: Record | null = null + + mockEndpoint('com.atproto.server.createSession', (_url, options) => { + capturedBody = JSON.parse((options?.body as string) || '{}') + return jsonResponse(mockData.session()) + }) + + render(Login) + + await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } }) + await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } }) + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(capturedBody).toEqual({ + identifier: 'testuser@example.com', + password: 'mypassword', + }) + }) + }) + + it('shows styled error message on invalid credentials', async () => { + mockEndpoint('com.atproto.server.createSession', () => + errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) + ) + + render(Login) + + await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } }) + await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } }) + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + const errorDiv = screen.getByText(/invalid identifier or password/i) + expect(errorDiv).toBeInTheDocument() + expect(errorDiv).toHaveClass('error') + }) + }) + + it('navigates to dashboard on successful login', async () => { + mockEndpoint('com.atproto.server.createSession', () => + jsonResponse(mockData.session()) + ) + + render(Login) + + await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } }) + await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } }) + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(window.location.hash).toBe('#/dashboard') + }) + }) + }) + + describe('account verification flow', () => { + it('shows verification form with all controls when account is not verified', async () => { + mockEndpoint('com.atproto.server.createSession', () => ({ + ok: false, + status: 401, + json: async () => ({ + error: 'AccountNotVerified', + message: 'Account not verified', + did: 'did:web:test.bspds.dev:u:testuser', + }), + })) + + render(Login) + + await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } }) + await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } }) + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument() + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument() + }) + }) + + it('returns to login form when clicking back', async () => { + mockEndpoint('com.atproto.server.createSession', () => ({ + ok: false, + status: 401, + json: async () => ({ + error: 'AccountNotVerified', + message: 'Account not verified', + did: 'did:web:test.bspds.dev:u:testuser', + }), + })) + + render(Login) + + await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } }) + await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } }) + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /back to login/i })) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument() + expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/src/tests/Notifications.test.ts b/frontend/src/tests/Notifications.test.ts new file mode 100644 index 0000000..46d4dbe --- /dev/null +++ b/frontend/src/tests/Notifications.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import Notifications from '../routes/Notifications.svelte' +import { + setupFetchMock, + mockEndpoint, + jsonResponse, + errorResponse, + mockData, + clearMocks, + setupAuthenticatedUser, + setupUnauthenticatedUser, +} from './mocks' + +describe('Notifications', () => { + beforeEach(() => { + clearMocks() + setupFetchMock() + }) + + describe('authentication guard', () => { + it('redirects to login when not authenticated', async () => { + setupUnauthenticatedUser() + render(Notifications) + + await waitFor(() => { + expect(window.location.hash).toBe('#/login') + }) + }) + }) + + describe('page structure', () => { + beforeEach(() => { + setupAuthenticatedUser() + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + }) + + it('displays all page elements and sections', async () => { + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') + expect(screen.getByText(/password resets/i)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument() + }) + }) + }) + + describe('loading state', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('shows loading text while fetching preferences', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + return jsonResponse(mockData.notificationPrefs()) + }) + + render(Notifications) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + }) + + describe('channel options', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('displays all four channel options', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument() + }) + }) + + it('email channel is always selectable', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + const emailRadio = screen.getByRole('radio', { name: /email/i }) + expect(emailRadio).not.toBeDisabled() + }) + }) + + it('discord channel is disabled when not configured', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ discordId: null })) + ) + + render(Notifications) + + await waitFor(() => { + const discordRadio = screen.getByRole('radio', { name: /discord/i }) + expect(discordRadio).toBeDisabled() + }) + }) + + it('discord channel is enabled when configured', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ discordId: '123456789' })) + ) + + render(Notifications) + + await waitFor(() => { + const discordRadio = screen.getByRole('radio', { name: /discord/i }) + expect(discordRadio).not.toBeDisabled() + }) + }) + + it('shows hint for disabled channels', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0) + }) + }) + + it('selects current preferred channel', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' })) + ) + + render(Notifications) + + await waitFor(() => { + const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement + expect(emailRadio.checked).toBe(true) + }) + }) + }) + + describe('channel configuration', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('displays email as readonly with current value', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement + expect(emailInput).toBeDisabled() + expect(emailInput.value).toBe('test@example.com') + }) + }) + + it('displays all channel inputs with current values', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ + discordId: '123456789', + telegramUsername: 'testuser', + signalNumber: '+1234567890', + })) + ) + + render(Notifications) + + await waitFor(() => { + expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789') + expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser') + expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890') + }) + }) + }) + + describe('verification status badges', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('shows Primary badge for email', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByText('Primary')).toBeInTheDocument() + }) + }) + + it('shows Verified badge for verified discord', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ + discordId: '123456789', + discordVerified: true, + })) + ) + + render(Notifications) + + await waitFor(() => { + const verifiedBadges = screen.getAllByText('Verified') + expect(verifiedBadges.length).toBeGreaterThan(0) + }) + }) + + it('shows Not verified badge for unverified discord', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ + discordId: '123456789', + discordVerified: false, + })) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByText('Not verified')).toBeInTheDocument() + }) + }) + + it('does not show badge when channel not configured', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByText('Primary')).toBeInTheDocument() + expect(screen.queryByText('Not verified')).not.toBeInTheDocument() + }) + }) + }) + + describe('save preferences', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('calls updateNotificationPrefs with correct data', async () => { + let capturedBody: Record | null = null + + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + mockEndpoint('com.bspds.account.updateNotificationPrefs', (_url, options) => { + capturedBody = JSON.parse((options?.body as string) || '{}') + return jsonResponse({ success: true }) + }) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } }) + await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) + + await waitFor(() => { + expect(capturedBody).not.toBeNull() + expect(capturedBody?.discordId).toBe('999888777') + expect(capturedBody?.preferredChannel).toBe('email') + }) + }) + + it('shows loading state while saving', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + mockEndpoint('com.bspds.account.updateNotificationPrefs', async () => { + await new Promise(resolve => setTimeout(resolve, 100)) + return jsonResponse({ success: true }) + }) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) + + expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled() + }) + + it('shows success message after saving', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + mockEndpoint('com.bspds.account.updateNotificationPrefs', () => + jsonResponse({ success: true }) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) + + await waitFor(() => { + expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument() + }) + }) + + it('shows error when save fails', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + mockEndpoint('com.bspds.account.updateNotificationPrefs', () => + errorResponse('InvalidRequest', 'Invalid channel configuration', 400) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) + + await waitFor(() => { + expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument() + expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error') + }) + }) + + it('reloads preferences after successful save', async () => { + let loadCount = 0 + + mockEndpoint('com.bspds.account.getNotificationPrefs', () => { + loadCount++ + return jsonResponse(mockData.notificationPrefs()) + }) + + mockEndpoint('com.bspds.account.updateNotificationPrefs', () => + jsonResponse({ success: true }) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() + }) + + const initialLoadCount = loadCount + await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) + + await waitFor(() => { + expect(loadCount).toBeGreaterThan(initialLoadCount) + }) + }) + }) + + describe('channel selection interaction', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('enables discord channel after entering discord ID', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled() + }) + + await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } }) + + await waitFor(() => { + expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled() + }) + }) + + it('allows selecting a configured channel', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs({ + discordId: '123456789', + discordVerified: true, + })) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled() + }) + + await fireEvent.click(screen.getByRole('radio', { name: /discord/i })) + + const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement + expect(discordRadio.checked).toBe(true) + }) + }) + + describe('error handling', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('shows error when loading preferences fails', async () => { + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + errorResponse('InternalError', 'Database connection failed', 500) + ) + + render(Notifications) + + await waitFor(() => { + expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/src/tests/Settings.test.ts b/frontend/src/tests/Settings.test.ts new file mode 100644 index 0000000..d17fe8f --- /dev/null +++ b/frontend/src/tests/Settings.test.ts @@ -0,0 +1,516 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' +import Settings from '../routes/Settings.svelte' +import { + setupFetchMock, + mockEndpoint, + jsonResponse, + errorResponse, + clearMocks, + setupAuthenticatedUser, + setupUnauthenticatedUser, +} from './mocks' + +describe('Settings', () => { + beforeEach(() => { + clearMocks() + setupFetchMock() + window.confirm = vi.fn(() => true) + }) + + describe('authentication guard', () => { + it('redirects to login when not authenticated', async () => { + setupUnauthenticatedUser() + render(Settings) + + await waitFor(() => { + expect(window.location.hash).toBe('#/login') + }) + }) + }) + + describe('page structure', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('displays all page elements and sections', async () => { + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') + expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument() + }) + }) + }) + + describe('email change', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('displays current email and input field', async () => { + render(Settings) + + await waitFor(() => { + expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument() + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + }) + + it('calls requestEmailUpdate when submitting', async () => { + let requestCalled = false + + mockEndpoint('com.atproto.server.requestEmailUpdate', () => { + requestCalled = true + return jsonResponse({ tokenRequired: true }) + }) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } }) + await fireEvent.click(screen.getByRole('button', { name: /change email/i })) + + await waitFor(() => { + expect(requestCalled).toBe(true) + }) + }) + + it('shows verification code input when token is required', async () => { + mockEndpoint('com.atproto.server.requestEmailUpdate', () => + jsonResponse({ tokenRequired: true }) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } }) + await fireEvent.click(screen.getByRole('button', { name: /change email/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument() + }) + }) + + it('calls updateEmail with token when confirming', async () => { + let updateCalled = false + let capturedBody: Record | null = null + + mockEndpoint('com.atproto.server.requestEmailUpdate', () => + jsonResponse({ tokenRequired: true }) + ) + + mockEndpoint('com.atproto.server.updateEmail', (_url, options) => { + updateCalled = true + capturedBody = JSON.parse((options?.body as string) || '{}') + return jsonResponse({}) + }) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } }) + await fireEvent.click(screen.getByRole('button', { name: /change email/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } }) + await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i })) + + await waitFor(() => { + expect(updateCalled).toBe(true) + expect(capturedBody?.email).toBe('newemail@example.com') + expect(capturedBody?.token).toBe('123456') + }) + }) + + it('shows success message after email update', async () => { + mockEndpoint('com.atproto.server.requestEmailUpdate', () => + jsonResponse({ tokenRequired: true }) + ) + + mockEndpoint('com.atproto.server.updateEmail', () => + jsonResponse({}) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } }) + await fireEvent.click(screen.getByRole('button', { name: /change email/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } }) + await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i })) + + await waitFor(() => { + expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument() + }) + }) + + it('shows cancel button to return to email form', async () => { + mockEndpoint('com.atproto.server.requestEmailUpdate', () => + jsonResponse({ tokenRequired: true }) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } }) + await fireEvent.click(screen.getByRole('button', { name: /change email/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument() + }) + }) + + it('shows error when email update fails', async () => { + mockEndpoint('com.atproto.server.requestEmailUpdate', () => + errorResponse('InvalidEmail', 'Invalid email format', 400) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } }) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled() + }) + + await fireEvent.click(screen.getByRole('button', { name: /change email/i })) + + await waitFor(() => { + expect(screen.getByText(/invalid email format/i)).toBeInTheDocument() + }) + }) + }) + + describe('handle change', () => { + beforeEach(() => { + setupAuthenticatedUser() + }) + + it('displays current handle', async () => { + render(Settings) + + await waitFor(() => { + expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument() + }) + }) + + it('calls updateHandle with new handle', async () => { + let capturedHandle: string | null = null + + mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => { + const body = JSON.parse((options?.body as string) || '{}') + capturedHandle = body.handle + return jsonResponse({}) + }) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } }) + await fireEvent.click(screen.getByRole('button', { name: /change handle/i })) + + await waitFor(() => { + expect(capturedHandle).toBe('newhandle.bsky.social') + }) + }) + + it('shows success message after handle change', async () => { + mockEndpoint('com.atproto.identity.updateHandle', () => + jsonResponse({}) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } }) + await fireEvent.click(screen.getByRole('button', { name: /change handle/i })) + + await waitFor(() => { + expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument() + }) + }) + + it('shows error when handle change fails', async () => { + mockEndpoint('com.atproto.identity.updateHandle', () => + errorResponse('HandleNotAvailable', 'Handle is already taken', 400) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } }) + await fireEvent.click(screen.getByRole('button', { name: /change handle/i })) + + await waitFor(() => { + expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument() + }) + }) + }) + + describe('account deletion', () => { + beforeEach(() => { + setupAuthenticatedUser() + mockEndpoint('com.atproto.server.deleteSession', () => + jsonResponse({}) + ) + }) + + it('displays delete section with warning and request button', async () => { + render(Settings) + + await waitFor(() => { + expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + }) + + it('calls requestAccountDelete when clicking request', async () => { + let requestCalled = false + + mockEndpoint('com.atproto.server.requestAccountDelete', () => { + requestCalled = true + return jsonResponse({}) + }) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + expect(requestCalled).toBe(true) + }) + }) + + it('shows confirmation form after requesting deletion', async () => { + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() + expect(screen.getByLabelText(/your password/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument() + }) + }) + + it('shows confirmation dialog before final deletion', async () => { + const confirmSpy = vi.fn(() => false) + window.confirm = confirmSpy + + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } }) + await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } }) + await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) + + expect(confirmSpy).toHaveBeenCalledWith( + expect.stringContaining('absolutely sure') + ) + }) + + it('calls deleteAccount with correct parameters', async () => { + window.confirm = vi.fn(() => true) + let capturedBody: Record | null = null + + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => { + capturedBody = JSON.parse((options?.body as string) || '{}') + return jsonResponse({}) + }) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } }) + await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } }) + await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) + + await waitFor(() => { + expect(capturedBody?.token).toBe('DEL123') + expect(capturedBody?.password).toBe('mypassword') + expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser') + }) + }) + + it('navigates to login after successful deletion', async () => { + window.confirm = vi.fn(() => true) + + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.deleteAccount', () => + jsonResponse({}) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } }) + await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } }) + await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) + + await waitFor(() => { + expect(window.location.hash).toBe('#/login') + }) + }) + + it('shows cancel button to return to request state', async () => { + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }) + expect(cancelButtons.length).toBeGreaterThan(0) + }) + + const deleteHeading = screen.getByRole('heading', { name: /delete account/i }) + const deleteSection = deleteHeading.closest('section') + const cancelButton = deleteSection?.querySelector('button.secondary') + if (cancelButton) { + await fireEvent.click(cancelButton) + } + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + }) + + it('shows error when deletion fails', async () => { + window.confirm = vi.fn(() => true) + + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.deleteAccount', () => + errorResponse('InvalidToken', 'Invalid confirmation code', 400) + ) + + render(Settings) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() + }) + + await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) + + await waitFor(() => { + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() + }) + + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } }) + await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } }) + await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) + + await waitFor(() => { + expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/src/tests/mocks.ts b/frontend/src/tests/mocks.ts new file mode 100644 index 0000000..2377302 --- /dev/null +++ b/frontend/src/tests/mocks.ts @@ -0,0 +1,264 @@ +import { vi } from 'vitest' +import type { Session, AppPassword, InviteCode } from '../lib/api' +import { _testSetState } from '../lib/auth.svelte' + +export interface MockResponse { + ok: boolean + status: number + json: () => Promise +} + +export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise + +const mockHandlers: Map = new Map() + +export function mockEndpoint(endpoint: string, handler: MockHandler): void { + mockHandlers.set(endpoint, handler) +} + +export function mockEndpointOnce(endpoint: string, handler: MockHandler): void { + const originalHandler = mockHandlers.get(endpoint) + mockHandlers.set(endpoint, (url, options) => { + mockHandlers.set(endpoint, originalHandler!) + return handler(url, options) + }) +} + +export function clearMocks(): void { + mockHandlers.clear() +} + +function extractEndpoint(url: string): string { + const match = url.match(/\/xrpc\/([^?]+)/) + return match ? match[1] : url +} + +export function setupFetchMock(): void { + global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input.toString() + const endpoint = extractEndpoint(url) + + const handler = mockHandlers.get(endpoint) + if (handler) { + const result = await handler(url, init) + return { + ok: result.ok, + status: result.status, + json: result.json, + text: async () => JSON.stringify(await result.json()), + headers: new Headers(), + redirected: false, + statusText: result.ok ? 'OK' : 'Error', + type: 'basic', + url, + clone: () => ({ ...result }) as Response, + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + } as Response + } + + return { + ok: false, + status: 404, + json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }), + text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }), + headers: new Headers(), + redirected: false, + statusText: 'Not Found', + type: 'basic', + url, + clone: function() { return this }, + body: null, + bodyUsed: false, + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + } as Response + }) +} + +export function jsonResponse(data: T, status = 200): MockResponse { + return { + ok: status >= 200 && status < 300, + status, + json: async () => data, + } +} + +export function errorResponse(error: string, message: string, status = 400): MockResponse { + return { + ok: false, + status, + json: async () => ({ error, message }), + } +} + +export const mockData = { + session: (overrides?: Partial): Session => ({ + did: 'did:web:test.bspds.dev:u:testuser', + handle: 'testuser.test.bspds.dev', + email: 'test@example.com', + emailConfirmed: true, + accessJwt: 'mock-access-jwt-token', + refreshJwt: 'mock-refresh-jwt-token', + ...overrides, + }), + + appPassword: (overrides?: Partial): AppPassword => ({ + name: 'Test App', + createdAt: new Date().toISOString(), + ...overrides, + }), + + inviteCode: (overrides?: Partial): InviteCode => ({ + code: 'test-invite-123', + available: 1, + disabled: false, + forAccount: 'did:web:test.bspds.dev:u:testuser', + createdBy: 'did:web:test.bspds.dev:u:testuser', + createdAt: new Date().toISOString(), + uses: [], + ...overrides, + }), + + notificationPrefs: (overrides?: Record) => ({ + preferredChannel: 'email', + email: 'test@example.com', + discordId: null, + discordVerified: false, + telegramUsername: null, + telegramVerified: false, + signalNumber: null, + signalVerified: false, + ...overrides, + }), + + describeServer: () => ({ + availableUserDomains: ['test.bspds.dev'], + inviteCodeRequired: false, + links: { + privacyPolicy: 'https://example.com/privacy', + termsOfService: 'https://example.com/tos', + }, + }), + + describeRepo: (did: string) => ({ + handle: 'testuser.test.bspds.dev', + did, + didDoc: {}, + collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'], + handleIsCorrect: true, + }), +} + +export function setupDefaultMocks(): void { + setupFetchMock() + + mockEndpoint('com.atproto.server.getSession', () => + jsonResponse(mockData.session()) + ) + + mockEndpoint('com.atproto.server.createSession', (_url, options) => { + const body = JSON.parse((options?.body as string) || '{}') + if (body.identifier && body.password === 'correctpassword') { + return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') })) + } + return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) + }) + + mockEndpoint('com.atproto.server.refreshSession', () => + jsonResponse(mockData.session()) + ) + + mockEndpoint('com.atproto.server.deleteSession', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.listAppPasswords', () => + jsonResponse({ passwords: [mockData.appPassword()] }) + ) + + mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => { + const body = JSON.parse((options?.body as string) || '{}') + return jsonResponse({ + name: body.name, + password: 'xxxx-xxxx-xxxx-xxxx', + createdAt: new Date().toISOString(), + }) + }) + + mockEndpoint('com.atproto.server.revokeAppPassword', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.getAccountInviteCodes', () => + jsonResponse({ codes: [mockData.inviteCode()] }) + ) + + mockEndpoint('com.atproto.server.createInviteCode', () => + jsonResponse({ code: 'new-invite-' + Date.now() }) + ) + + mockEndpoint('com.bspds.account.getNotificationPrefs', () => + jsonResponse(mockData.notificationPrefs()) + ) + + mockEndpoint('com.bspds.account.updateNotificationPrefs', () => + jsonResponse({ success: true }) + ) + + mockEndpoint('com.atproto.server.requestEmailUpdate', () => + jsonResponse({ tokenRequired: true }) + ) + + mockEndpoint('com.atproto.server.updateEmail', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.identity.updateHandle', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.requestAccountDelete', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.deleteAccount', () => + jsonResponse({}) + ) + + mockEndpoint('com.atproto.server.describeServer', () => + jsonResponse(mockData.describeServer()) + ) + + mockEndpoint('com.atproto.repo.describeRepo', (url) => { + const params = new URLSearchParams(url.split('?')[1]) + const repo = params.get('repo') || 'did:web:test' + return jsonResponse(mockData.describeRepo(repo)) + }) + + mockEndpoint('com.atproto.repo.listRecords', () => + jsonResponse({ records: [] }) + ) +} + +export function setupAuthenticatedUser(sessionOverrides?: Partial): Session { + const session = mockData.session(sessionOverrides) + _testSetState({ + session, + loading: false, + error: null, + }) + return session +} + +export function setupUnauthenticatedUser(): void { + _testSetState({ + session: null, + loading: false, + error: null, + }) +} diff --git a/frontend/src/tests/setup.ts b/frontend/src/tests/setup.ts new file mode 100644 index 0000000..b419348 --- /dev/null +++ b/frontend/src/tests/setup.ts @@ -0,0 +1,35 @@ +import '@testing-library/jest-dom/vitest' +import { vi, beforeEach, afterEach } from 'vitest' +import { _testReset } from '../lib/auth.svelte' + +let locationHash = '' + +Object.defineProperty(window, 'location', { + value: { + get hash() { return locationHash }, + set hash(value: string) { + locationHash = value.startsWith('#') ? value : `#${value}` + }, + href: 'http://localhost:3000/', + origin: 'http://localhost:3000', + pathname: '/', + search: '', + assign: vi.fn(), + replace: vi.fn(), + reload: vi.fn(), + }, + writable: true, + configurable: true, +}) + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + sessionStorage.clear() + locationHash = '' + _testReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) diff --git a/frontend/src/tests/utils.ts b/frontend/src/tests/utils.ts new file mode 100644 index 0000000..0a80b2b --- /dev/null +++ b/frontend/src/tests/utils.ts @@ -0,0 +1,86 @@ +import { render, type RenderResult } from '@testing-library/svelte' +import { tick } from 'svelte' +import type { ComponentType } from 'svelte' + +export async function renderAndWait( + component: T, + options?: Parameters[1] +): Promise> { + const result = render(component, options) + await tick() + await new Promise(resolve => setTimeout(resolve, 0)) + return result +} + +export async function waitForElement( + queryFn: () => HTMLElement | null, + timeout = 1000 +): Promise { + const start = Date.now() + while (Date.now() - start < timeout) { + const element = queryFn() + if (element) return element + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('Element not found within timeout') +} + +export async function waitForElementToDisappear( + queryFn: () => HTMLElement | null, + timeout = 1000 +): Promise { + const start = Date.now() + while (Date.now() - start < timeout) { + const element = queryFn() + if (!element) return + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error('Element still present after timeout') +} + +export async function waitForText( + container: HTMLElement, + text: string | RegExp, + timeout = 1000 +): Promise { + const start = Date.now() + while (Date.now() - start < timeout) { + const content = container.textContent || '' + if (typeof text === 'string' ? content.includes(text) : text.test(content)) { + return + } + await new Promise(resolve => setTimeout(resolve, 10)) + } + throw new Error(`Text "${text}" not found within timeout`) +} + +export function mockLocalStorage(initialData: Record = {}): void { + const store: Record = { ...initialData } + + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { Object.keys(store).forEach(key => delete store[key]) }, + key: (index: number) => Object.keys(store)[index] || null, + get length() { return Object.keys(store).length }, + }, + writable: true, + }) +} + +export function setAuthState(session: { + did: string + handle: string + email?: string + emailConfirmed?: boolean + accessJwt: string + refreshJwt: string +}): void { + localStorage.setItem('session', JSON.stringify(session)) +} + +export function clearAuthState(): void { + localStorage.removeItem('session') +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..0c05c28 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +const isTest = process.env.VITEST === 'true' || process.env.VITEST === true + +export default { + preprocess: isTest ? [] : vitePreprocess(), +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0c3ed1d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: 'dist', + }, + server: { + port: 5173, + proxy: { + '/xrpc': 'http://localhost:3000', + '/oauth': 'http://localhost:3000', + '/.well-known': 'http://localhost:3000', + '/health': 'http://localhost:3000', + '/u': 'http://localhost:3000', + } + } +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..562481a --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [ + svelte({ + hot: false, + }), + ], + resolve: { + conditions: ['browser', 'development'], + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/tests/setup.ts'], + include: ['src/**/*.{test,spec}.{js,ts}'], + alias: { + 'svelte': 'svelte', + }, + }, +}) diff --git a/justfile b/justfile index 05e253f..16c9f0b 100644 --- a/justfile +++ b/justfile @@ -77,3 +77,32 @@ docker-logs: docker-build: docker compose build + +# Frontend commands (Deno) +frontend-dev: + . ~/.deno/env && cd frontend && deno task dev + +frontend-build: + . ~/.deno/env && cd frontend && deno task build + +frontend-clean: + rm -rf frontend/dist frontend/node_modules + +# Frontend tests +frontend-test *args: + . ~/.deno/env && cd frontend && VITEST=true deno task test:run {{args}} + +frontend-test-watch: + . ~/.deno/env && cd frontend && VITEST=true deno task test:watch + +frontend-test-ui: + . ~/.deno/env && cd frontend && VITEST=true deno task test:ui + +frontend-test-coverage: + . ~/.deno/env && cd frontend && VITEST=true deno task test:run --coverage + +# Build all (frontend + backend) +build-all: frontend-build build + +# Test all (backend + frontend) +test-all: test frontend-test diff --git a/migrations/202512211400_initial_schema.sql b/migrations/20251211_initial_schema.sql similarity index 79% rename from migrations/202512211400_initial_schema.sql rename to migrations/20251211_initial_schema.sql index 093e4a7..58f34d3 100644 --- a/migrations/202512211400_initial_schema.sql +++ b/migrations/20251211_initial_schema.sql @@ -6,13 +6,15 @@ CREATE TYPE notification_type AS ENUM ( 'password_reset', 'email_update', 'account_deletion', - 'admin_email' + 'admin_email', + 'plc_operation', + 'two_factor_code' ); CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), handle TEXT NOT NULL UNIQUE, - email TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, did TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -29,11 +31,26 @@ CREATE TABLE IF NOT EXISTS users ( email_pending_verification TEXT, email_confirmation_code TEXT, - email_confirmation_code_expires_at TIMESTAMPTZ + email_confirmation_code_expires_at TIMESTAMPTZ, + email_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + + two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, + + discord_id TEXT, + discord_verified BOOLEAN NOT NULL DEFAULT FALSE, + + telegram_username TEXT, + telegram_verified BOOLEAN NOT NULL DEFAULT FALSE, + + signal_number TEXT, + signal_verified BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL; CREATE TABLE IF NOT EXISTS invite_codes ( code TEXT PRIMARY KEY, @@ -62,6 +79,7 @@ CREATE TABLE IF NOT EXISTS user_keys ( CREATE TABLE IF NOT EXISTS repos ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, repo_root_cid TEXT NOT NULL, + repo_rev TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -79,10 +97,13 @@ CREATE TABLE IF NOT EXISTS records ( rkey TEXT NOT NULL, record_cid TEXT NOT NULL, takedown_ref TEXT, + repo_rev TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(repo_id, collection, rkey) ); +CREATE INDEX idx_records_repo_rev ON records(repo_rev); + CREATE TABLE IF NOT EXISTS blobs ( cid TEXT PRIMARY KEY, mime_type TEXT NOT NULL, @@ -265,3 +286,40 @@ CREATE TABLE oauth_dpop_jti ( ); CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at); + +CREATE TABLE plc_operation_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id); +CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at); + +CREATE TABLE IF NOT EXISTS account_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + value_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id); +CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name); + +CREATE TABLE oauth_2fa_challenge ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + request_uri TEXT NOT NULL, + code TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes' +); + +CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri); +CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at); diff --git a/migrations/202512211406_plc_operation_tokens.sql b/migrations/202512211406_plc_operation_tokens.sql deleted file mode 100644 index 730bae9..0000000 --- a/migrations/202512211406_plc_operation_tokens.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE plc_operation_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id); -CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at); diff --git a/migrations/202512211407_add_plc_operation_notification_type.sql b/migrations/202512211407_add_plc_operation_notification_type.sql deleted file mode 100644 index d1cda80..0000000 --- a/migrations/202512211407_add_plc_operation_notification_type.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE notification_type ADD VALUE 'plc_operation'; diff --git a/migrations/202512211500_account_preferences.sql b/migrations/202512211500_account_preferences.sql deleted file mode 100644 index 56c1b4e..0000000 --- a/migrations/202512211500_account_preferences.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS account_preferences ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - value_json JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, name) -); - -CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id); -CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name); diff --git a/migrations/202512211600_add_repo_rev.sql b/migrations/202512211600_add_repo_rev.sql deleted file mode 100644 index 6e81b79..0000000 --- a/migrations/202512211600_add_repo_rev.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE records ADD COLUMN repo_rev TEXT; -CREATE INDEX idx_records_repo_rev ON records(repo_rev); diff --git a/migrations/202512211700_add_2fa.sql b/migrations/202512211700_add_2fa.sql deleted file mode 100644 index 8a6c332..0000000 --- a/migrations/202512211700_add_2fa.sql +++ /dev/null @@ -1,16 +0,0 @@ -ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TYPE notification_type ADD VALUE 'two_factor_code'; - -CREATE TABLE oauth_2fa_challenge ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, - request_uri TEXT NOT NULL, - code TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes' -); - -CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri); -CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at); diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs index e38556e..7d1198c 100644 --- a/src/api/admin/account/email.rs +++ b/src/api/admin/account/email.rs @@ -65,7 +65,19 @@ pub async fn send_email( .await; let (user_id, email, handle) = match user { - Ok(Some(row)) => (row.id, row.email, row.handle), + Ok(Some(row)) => { + let email = match row.email { + Some(e) => e, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})), + ) + .into_response(); + } + }; + (row.id, email, row.handle) + } Ok(None) => { return ( StatusCode::NOT_FOUND, diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 3b5e3ba..c129362 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -74,7 +74,7 @@ pub async fn get_account_info( Json(AccountInfo { did: row.did, handle: row.handle, - email: Some(row.email), + email: row.email, indexed_at: row.created_at.to_rfc3339(), invite_note: None, invites_disabled: false, @@ -150,7 +150,7 @@ pub async fn get_account_infos( infos.push(AccountInfo { did: row.did, handle: row.handle, - email: Some(row.email), + email: row.email, indexed_at: row.created_at.to_rfc3339(), invite_note: None, invites_disabled: false, diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 0dbf59f..53c75e9 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -36,20 +36,24 @@ fn extract_client_ip(headers: &HeaderMap) -> String { #[serde(rename_all = "camelCase")] pub struct CreateAccountInput { pub handle: String, - pub email: String, + pub email: Option, pub password: String, pub invite_code: Option, pub did: Option, pub signing_key: Option, + pub verification_channel: Option, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateAccountOutput { - pub access_jwt: String, - pub refresh_jwt: String, pub handle: String, pub did: String, + pub verification_required: bool, + pub verification_channel: String, } pub async fn create_account( @@ -82,12 +86,17 @@ pub async fn create_account( .into_response(); } - if !crate::api::validation::is_valid_email(&input.email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), - ) - .into_response(); + let email: Option = input.email.as_ref() + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); + if let Some(ref email) = email { + if !crate::api::validation::is_valid_email(email) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + ) + .into_response(); + } } let did = if let Some(d) = &input.did { @@ -202,18 +211,77 @@ pub async fn create_account( } }; - let user_insert = sqlx::query!( - "INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id", - input.handle, - input.email, - did, - password_hash + let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); + let valid_channels = ["email", "discord", "telegram", "signal"]; + if !valid_channels.contains(&verification_channel) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})), + ) + .into_response(); + } + + let verification_recipient = match verification_channel { + "email" => match &input.email { + Some(email) if !email.trim().is_empty() => email.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), + ).into_response(), + }, + "discord" => match &input.discord_id { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), + ).into_response(), + }, + "telegram" => match &input.telegram_username { + Some(username) if !username.trim().is_empty() => username.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), + ).into_response(), + }, + "signal" => match &input.signal_number { + Some(number) if !number.trim().is_empty() => number.trim().to_string(), + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), + ).into_response(), + }, + _ => return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), + ).into_response(), + }; + + let verification_code = format!("{:06}", rand::random::() % 1_000_000); + let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30); + + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( + r#"INSERT INTO users ( + handle, email, did, password_hash, + email_confirmation_code, email_confirmation_code_expires_at, + preferred_notification_channel, + discord_id, telegram_username, signal_number + ) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#, ) + .bind(&input.handle) + .bind(&email) + .bind(&did) + .bind(&password_hash) + .bind(&verification_code) + .bind(&code_expires_at) + .bind(verification_channel) + .bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())) + .bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())) + .bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())) .fetch_one(&mut *tx) .await; let user_id = match user_insert { - Ok(row) => row.id, + Ok((id,)) => id, Err(e) => { if let Some(db_err) = e.as_database_error() { if db_err.code().as_deref() == Some("23505") { @@ -453,53 +521,6 @@ pub async fn create_account( } } - let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| { - error!("Error creating access token: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - }); - let access_meta = match access_meta { - Ok(m) => m, - Err(r) => return r, - }; - - let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| { - error!("Error creating refresh token: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - }); - let refresh_meta = match refresh_meta { - Ok(m) => m, - Err(r) => return r, - }; - - let session_insert = - sqlx::query!( - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", - did, - access_meta.jti, - refresh_meta.jti, - access_meta.expires_at, - refresh_meta.expires_at - ) - .execute(&mut *tx) - .await; - - if let Err(e) = session_insert { - error!("Error inserting session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - if let Err(e) = tx.commit().await { error!("Error committing transaction: {:?}", e); return ( @@ -509,18 +530,23 @@ pub async fn create_account( .into_response(); } - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - if let Err(e) = crate::notifications::enqueue_welcome(&state.db, user_id, &hostname).await { - warn!("Failed to enqueue welcome notification: {:?}", e); + if let Err(e) = crate::notifications::enqueue_signup_verification( + &state.db, + user_id, + verification_channel, + &verification_recipient, + &verification_code, + ).await { + warn!("Failed to enqueue signup verification notification: {:?}", e); } ( StatusCode::OK, Json(CreateAccountOutput { - access_jwt: access_meta.token, - refresh_jwt: refresh_meta.token, handle: input.handle, did, + verification_required: true, + verification_channel: verification_channel.to_string(), }), ) .into_response() diff --git a/src/api/mod.rs b/src/api/mod.rs index 018c563..7ed9b42 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,6 +5,7 @@ pub mod feed; pub mod identity; pub mod moderation; pub mod notification; +pub mod notification_prefs; pub mod proxy; pub mod proxy_client; pub mod read_after_write; diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs new file mode 100644 index 0000000..232ac1e --- /dev/null +++ b/src/api/notification_prefs.rs @@ -0,0 +1,248 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::Row; +use tracing::info; + +use crate::auth::validate_bearer_token; +use crate::state::AppState; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationPrefsResponse { + pub preferred_channel: String, + pub email: String, + pub discord_id: Option, + pub discord_verified: bool, + pub telegram_username: Option, + pub telegram_verified: bool, + pub signal_number: Option, + pub signal_verified: bool, +} + +pub async fn get_notification_prefs( + State(state): State, + headers: HeaderMap, +) -> Response { + let token = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), + ) + .into_response() + } + }; + + let user = match validate_bearer_token(&state.db, &token).await { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), + ) + .into_response() + } + }; + + let row = match sqlx::query( + r#" + SELECT + email, + preferred_notification_channel::text as channel, + discord_id, + discord_verified, + telegram_username, + telegram_verified, + signal_number, + signal_verified + FROM users + WHERE did = $1 + "# + ) + .bind(&user.did) + .fetch_one(&state.db) + .await + { + Ok(r) => r, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response() + } + }; + + let email: String = row.get("email"); + let channel: String = row.get("channel"); + let discord_id: Option = row.get("discord_id"); + let discord_verified: bool = row.get("discord_verified"); + let telegram_username: Option = row.get("telegram_username"); + let telegram_verified: bool = row.get("telegram_verified"); + let signal_number: Option = row.get("signal_number"); + let signal_verified: bool = row.get("signal_verified"); + + Json(NotificationPrefsResponse { + preferred_channel: channel, + email, + discord_id, + discord_verified, + telegram_username, + telegram_verified, + signal_number, + signal_verified, + }) + .into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateNotificationPrefsInput { + pub preferred_channel: Option, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, +} + +pub async fn update_notification_prefs( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let token = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), + ) + .into_response() + } + }; + + let user = match validate_bearer_token(&state.db, &token).await { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), + ) + .into_response() + } + }; + + if let Some(ref channel) = input.preferred_channel { + let valid_channels = ["email", "discord", "telegram", "signal"]; + if !valid_channels.contains(&channel.as_str()) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Invalid channel. Must be one of: email, discord, telegram, signal" + })), + ) + .into_response(); + } + + if let Err(e) = sqlx::query( + r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"# + ) + .bind(channel) + .bind(&user.did) + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + + info!(did = %user.did, channel = %channel, "Updated preferred notification channel"); + } + + if let Some(ref discord_id) = input.discord_id { + let discord_id_clean: Option<&str> = if discord_id.is_empty() { + None + } else { + Some(discord_id.as_str()) + }; + + if let Err(e) = sqlx::query( + r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"# + ) + .bind(discord_id_clean) + .bind(&user.did) + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + + info!(did = %user.did, "Updated Discord ID"); + } + + if let Some(ref telegram) = input.telegram_username { + let telegram_clean: Option<&str> = if telegram.is_empty() { + None + } else { + Some(telegram.trim_start_matches('@')) + }; + + if let Err(e) = sqlx::query( + r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"# + ) + .bind(telegram_clean) + .bind(&user.did) + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + + info!(did = %user.did, "Updated Telegram username"); + } + + if let Some(ref signal) = input.signal_number { + let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) }; + + if let Err(e) = sqlx::query( + r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"# + ) + .bind(signal_clean) + .bind(&user.did) + .execute(&state.db) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), + ) + .into_response(); + } + + info!(did = %user.did, "Updated Signal number"); + } + + Json(json!({"success": true})).into_response() +} diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index e0c242f..48891fb 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -1,4 +1,5 @@ use super::validation::validate_record; +use super::write::has_verified_notification_channel; use crate::api::repo::record::utils::{commit_and_log, RecordOp}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -110,6 +111,28 @@ pub async fn apply_writes( .into_response(); } + match has_verified_notification_channel(&state.db, &did).await { + Ok(true) => {} + Ok(false) => { + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "AccountNotVerified", + "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" + })), + ) + .into_response(); + } + Err(e) => { + error!("DB error checking notification channels: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + if input.writes.is_empty() { return ( StatusCode::BAD_REQUEST, diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index ff827b2..539d1de 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -14,11 +14,40 @@ use jacquard::types::string::Nsid; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; +use sqlx::{PgPool, Row}; use std::str::FromStr; use std::sync::Arc; use tracing::error; use uuid::Uuid; +pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result { + let row = sqlx::query( + r#" + SELECT + email_confirmed, + discord_verified, + telegram_verified, + signal_verified + FROM users + WHERE did = $1 + "# + ) + .bind(did) + .fetch_optional(db) + .await?; + + match row { + Some(r) => { + let email_confirmed: bool = r.get("email_confirmed"); + let discord_verified: bool = r.get("discord_verified"); + let telegram_verified: bool = r.get("telegram_verified"); + let signal_verified: bool = r.get("signal_verified"); + Ok(email_confirmed || discord_verified || telegram_verified || signal_verified) + } + None => Ok(false), + } +} + pub async fn prepare_repo_write( state: &AppState, headers: &HeaderMap, @@ -52,6 +81,28 @@ pub async fn prepare_repo_write( .into_response()); } + match has_verified_notification_channel(&state.db, &auth_user.did).await { + Ok(true) => {} + Ok(false) => { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "AccountNotVerified", + "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" + })), + ) + .into_response()); + } + Err(e) => { + error!("DB error checking notification channels: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response()); + } + } + let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) .fetch_optional(&state.db) .await diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 382124e..af47514 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -343,8 +343,10 @@ pub async fn update_email( .into_response(); } - if new_email == current_email.to_lowercase() { - return (StatusCode::OK, Json(json!({}))).into_response(); + if let Some(ref current) = current_email { + if new_email == current.to_lowercase() { + return (StatusCode::OK, Json(json!({}))).into_response(); + } } let email_confirmed = stored_code.is_some() && email_pending_verification.is_some(); diff --git a/src/api/server/meta.rs b/src/api/server/meta.rs index 6e52070..cf6eed2 100644 --- a/src/api/server/meta.rs +++ b/src/api/server/meta.rs @@ -13,12 +13,19 @@ pub async fn robots_txt() -> impl IntoResponse { } pub async fn describe_server() -> impl IntoResponse { + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let domains_str = - std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string()); + std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone()); let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); + let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + Json(json!({ - "availableUserDomains": domains + "availableUserDomains": domains, + "inviteCodeRequired": invite_code_required, + "did": format!("did:web:{}", pds_hostname) })) } diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 475010e..0b3acc7 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -18,5 +18,5 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod pub use meta::{describe_server, health, robots_txt}; pub use password::{request_password_reset, reset_password}; pub use service_auth::get_service_auth; -pub use session::{create_session, delete_session, get_session, refresh_session}; +pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification}; pub use signing_key::reserve_signing_key; diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 4129e6f..39160a3 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -8,6 +8,7 @@ use axum::{ response::{IntoResponse, Response}, }; use bcrypt::verify; +use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; @@ -64,7 +65,13 @@ pub async fn create_session( } let row = match sqlx::query!( - "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", + r#"SELECT + u.id, u.did, u.handle, u.password_hash, + u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified, + k.key_bytes, k.encryption_version + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.handle = $1 OR u.email = $1"#, input.identifier ) .fetch_optional(&state.db) @@ -103,6 +110,23 @@ pub async fn create_session( return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response(); } + let is_verified = row.email_confirmed + || row.discord_verified + || row.telegram_verified + || row.signal_verified; + + if !is_verified { + warn!("Login attempt for unverified account: {}", row.did); + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "AccountNotVerified", + "message": "Please verify your account before logging in", + "did": row.did + })), + ).into_response(); + } + let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { @@ -361,3 +385,230 @@ pub async fn refresh_session( } } } + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmSignupInput { + pub did: String, + pub verification_code: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmSignupOutput { + pub access_jwt: String, + pub refresh_jwt: String, + pub handle: String, + pub did: String, +} + +pub async fn confirm_signup( + State(state): State, + Json(input): Json, +) -> Response { + info!("confirm_signup called for DID: {}", input.did); + + let row = match sqlx::query!( + r#"SELECT + u.id, u.did, u.handle, + u.email_confirmation_code, + u.email_confirmation_code_expires_at, + u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel", + k.key_bytes, k.encryption_version + FROM users u + JOIN user_keys k ON u.id = k.user_id + WHERE u.did = $1"#, + input.did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + warn!("User not found for confirm_signup: {}", input.did); + return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response(); + } + Err(e) => { + error!("Database error in confirm_signup: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let stored_code = match &row.email_confirmation_code { + Some(code) => code, + None => { + warn!("No verification code found for user: {}", input.did); + return ApiError::InvalidRequest("No pending verification".into()).into_response(); + } + }; + + if stored_code != &input.verification_code { + warn!("Invalid verification code for user: {}", input.did); + return ApiError::InvalidRequest("Invalid verification code".into()).into_response(); + } + + if let Some(expires_at) = row.email_confirmation_code_expires_at { + if expires_at < Utc::now() { + warn!("Verification code expired for user: {}", input.did); + return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response(); + } + } + + let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let verified_column = match row.channel { + crate::notifications::NotificationChannel::Email => "email_confirmed", + crate::notifications::NotificationChannel::Discord => "discord_verified", + crate::notifications::NotificationChannel::Telegram => "telegram_verified", + crate::notifications::NotificationChannel::Signal => "signal_verified", + }; + + let update_query = format!( + "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1", + verified_column + ); + + if let Err(e) = sqlx::query(&update_query) + .bind(&input.did) + .execute(&state.db) + .await + { + error!("Failed to update verification status: {:?}", e); + return ApiError::InternalError.into_response(); + } + + let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create access token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create refresh token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + row.did, + access_meta.jti, + refresh_meta.jti, + access_meta.expires_at, + refresh_meta.expires_at + ) + .execute(&state.db) + .await + { + error!("Failed to insert session: {:?}", e); + return ApiError::InternalError.into_response(); + } + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await { + warn!("Failed to enqueue welcome notification: {:?}", e); + } + + Json(ConfirmSignupOutput { + access_jwt: access_meta.token, + refresh_jwt: refresh_meta.token, + handle: row.handle, + did: row.did, + }).into_response() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResendVerificationInput { + pub did: String, +} + +pub async fn resend_verification( + State(state): State, + Json(input): Json, +) -> Response { + info!("resend_verification called for DID: {}", input.did); + + let row = match sqlx::query!( + r#"SELECT + id, handle, email, + preferred_notification_channel as "channel: crate::notifications::NotificationChannel", + discord_id, telegram_username, signal_number, + email_confirmed, discord_verified, telegram_verified, signal_verified + FROM users + WHERE did = $1"#, + input.did + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + return ApiError::InvalidRequest("User not found".into()).into_response(); + } + Err(e) => { + error!("Database error in resend_verification: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let is_verified = row.email_confirmed + || row.discord_verified + || row.telegram_verified + || row.signal_verified; + + if is_verified { + return ApiError::InvalidRequest("Account is already verified".into()).into_response(); + } + + let verification_code = format!("{:06}", rand::random::() % 1_000_000); + let code_expires_at = Utc::now() + chrono::Duration::minutes(30); + + if let Err(e) = sqlx::query!( + "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3", + verification_code, + code_expires_at, + input.did + ) + .execute(&state.db) + .await + { + error!("Failed to update verification code: {:?}", e); + return ApiError::InternalError.into_response(); + } + + let (channel_str, recipient) = match row.channel { + crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()), + crate::notifications::NotificationChannel::Discord => { + ("discord", row.discord_id.unwrap_or_default()) + } + crate::notifications::NotificationChannel::Telegram => { + ("telegram", row.telegram_username.unwrap_or_default()) + } + crate::notifications::NotificationChannel::Signal => { + ("signal", row.signal_number.unwrap_or_default()) + } + }; + + if let Err(e) = crate::notifications::enqueue_signup_verification( + &state.db, + row.id, + channel_str, + &recipient, + &verification_code, + ).await { + warn!("Failed to enqueue verification notification: {:?}", e); + } + + Json(json!({"success": true})).into_response() +} diff --git a/src/crawlers.rs b/src/crawlers.rs index 46b4414..8ad8f4b 100644 --- a/src/crawlers.rs +++ b/src/crawlers.rs @@ -106,9 +106,13 @@ impl Crawlers { cb.record_success().await; } } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); warn!( crawler = %url, - status = %response.status(), + status = %status, + body = %body, + hostname = %hostname, "Crawler notification returned non-success status" ); if let Some(cb) = cb { diff --git a/src/lib.rs b/src/lib.rs index 3ced835..9e8f192 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,9 +21,10 @@ use axum::{ routing::{any, get, post}, }; use state::AppState; +use tower_http::services::{ServeDir, ServeFile}; pub fn app(state: AppState) -> Router { - Router::new() + let router = Router::new() .route("/health", get(api::server::health)) .route("/xrpc/_health", get(api::server::health)) .route("/robots.txt", get(api::server::robots_txt)) @@ -51,6 +52,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.server.refreshSession", post(api::server::refresh_session), ) + .route( + "/xrpc/com.atproto.server.confirmSignup", + post(api::server::confirm_signup), + ) + .route( + "/xrpc/com.atproto.server.resendVerification", + post(api::server::resend_verification), + ) .route( "/xrpc/com.atproto.server.getServiceAuth", get(api::server::get_service_auth), @@ -364,6 +373,26 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.atproto.temp.checkSignupQueue", get(api::temp::check_signup_queue), ) + .route( + "/xrpc/com.bspds.account.getNotificationPrefs", + get(api::notification_prefs::get_notification_prefs), + ) + .route( + "/xrpc/com.bspds.account.updateNotificationPrefs", + post(api::notification_prefs::update_notification_prefs), + ) .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) - .with_state(state) + .with_state(state); + + let frontend_dir = std::env::var("FRONTEND_DIR") + .unwrap_or_else(|_| "./frontend/dist".to_string()); + + if std::path::Path::new(&frontend_dir).join("index.html").exists() { + let index_path = format!("{}/index.html", frontend_dir); + let serve_dir = ServeDir::new(&frontend_dir) + .not_found_service(ServeFile::new(index_path)); + router.fallback_service(serve_dir) + } else { + router + } } diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index 7c54d1f..a775865 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -9,7 +9,7 @@ pub use sender::{ pub use service::{ channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset, - enqueue_plc_operation, enqueue_welcome, NotificationService, + enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService, }; pub use types::{ NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification, diff --git a/src/notifications/service.rs b/src/notifications/service.rs index 0180bb1..2f4d80b 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -256,7 +256,7 @@ pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) -> pub struct UserNotificationPrefs { pub channel: NotificationChannel, - pub email: String, + pub email: Option, pub handle: String, } @@ -303,7 +303,7 @@ pub async fn enqueue_welcome( user_id, prefs.channel, super::types::NotificationType::Welcome, - prefs.email.clone(), + prefs.email.clone().unwrap_or_default(), Some(format!("Welcome to {}", hostname)), body, ), @@ -356,7 +356,7 @@ pub async fn enqueue_password_reset( user_id, prefs.channel, super::types::NotificationType::PasswordReset, - prefs.email.clone(), + prefs.email.clone().unwrap_or_default(), Some(format!("Password Reset - {}", hostname)), body, ), @@ -409,7 +409,7 @@ pub async fn enqueue_account_deletion( user_id, prefs.channel, super::types::NotificationType::AccountDeletion, - prefs.email.clone(), + prefs.email.clone().unwrap_or_default(), Some(format!("Account Deletion Request - {}", hostname)), body, ), @@ -436,7 +436,7 @@ pub async fn enqueue_plc_operation( user_id, prefs.channel, super::types::NotificationType::PlcOperation, - prefs.email.clone(), + prefs.email.clone().unwrap_or_default(), Some(format!("{} - PLC Operation Token", hostname)), body, ), @@ -463,7 +463,7 @@ pub async fn enqueue_2fa_code( user_id, prefs.channel, super::types::NotificationType::TwoFactorCode, - prefs.email.clone(), + prefs.email.clone().unwrap_or_default(), Some(format!("Sign-in Verification - {}", hostname)), body, ), @@ -479,3 +479,44 @@ pub fn channel_display_name(channel: NotificationChannel) -> &'static str { NotificationChannel::Signal => "Signal", } } + +pub async fn enqueue_signup_verification( + db: &PgPool, + user_id: Uuid, + channel: &str, + recipient: &str, + code: &str, +) -> Result { + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + + let notification_channel = match channel { + "email" => NotificationChannel::Email, + "discord" => NotificationChannel::Discord, + "telegram" => NotificationChannel::Telegram, + "signal" => NotificationChannel::Signal, + _ => NotificationChannel::Email, + }; + + let body = format!( + "Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.", + code, hostname + ); + + let subject = match notification_channel { + NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)), + _ => None, + }; + + enqueue_notification( + db, + NewNotification::new( + user_id, + notification_channel, + super::types::NotificationType::EmailVerification, + recipient.to_string(), + subject, + body, + ), + ) + .await +} diff --git a/src/oauth/db/device.rs b/src/oauth/db/device.rs index c60c422..8551f3b 100644 --- a/src/oauth/db/device.rs +++ b/src/oauth/db/device.rs @@ -6,7 +6,7 @@ use super::super::{DeviceData, OAuthError}; pub struct DeviceAccountRow { pub did: String, pub handle: String, - pub email: String, + pub email: Option, pub last_used_at: DateTime, } diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs index 6f8c80b..c4ce311 100644 --- a/src/oauth/templates.rs +++ b/src/oauth/templates.rs @@ -477,7 +477,7 @@ pub fn login_page( pub struct DeviceAccount { pub did: String, pub handle: String, - pub email: String, + pub email: Option, pub last_used_at: DateTime, } @@ -493,6 +493,7 @@ pub fn account_selector_page( .iter() .map(|account| { let initials = get_initials(&account.handle); + let email_display = account.email.as_deref().unwrap_or(""); format!( r#"
@@ -510,7 +511,7 @@ pub fn account_selector_page( did = html_escape(&account.did), initials = html_escape(&initials), handle = html_escape(&account.handle), - email = html_escape(&account.email), + email = html_escape(email_display), ) }) .collect();