From ead4e3b76a94e701eb4bff243d1dfea9ad5da1af Mon Sep 17 00:00:00 2001 From: crthpl Date: Mon, 2 Mar 2026 22:26:36 -0800 Subject: [PATCH 01/21] Implement multi-cohort support - Add global SQLite DB for users, cohorts, membership, and config - Per-cohort WebSocket routes at /api/ws/:cohort_name - REST endpoints for cohort listing and admin management - Restructure frontend routes under /[cohort_name]/ - Cohort selection page with auto-redirect for single cohort - Admin page for cohort/member/config management - Public auction access with auction-only mode - Cohort-scoped localStorage keys - Legacy DB auto-migration support --- .gitignore | 1 + ...c7bff1d4b96b4d5b9aa586f268a89624155b.json} | 4 +- ...81af976ef2bf5b773cab191bfcb3c9b6249f.json} | 4 +- ...12404d773129357a3845da91d0ec752fd143a.json | 26 + ...a78a546129d2e9bf4e99993b4c92e07f6ad9.json} | 4 +- ...6c1c1646d5f224b97ba88985192518773f92.json} | 4 +- ...0166defea7deb41560c9bcd35a8c60b7bb59.json} | 4 +- ...9dbf1743f978837d9bb971868ed5dd17f0db.json} | 4 +- ...ad09b7ca596147544f719c4b09d6020e8a01b.json | 20 + ...de89a23ba7b39ae38ca815a9274572f38ecd5.json | 20 + backend/global_migrations/001_initial.sql | 31 ++ .../20260302000000_add_global_user_id.sql | 4 + backend/src/airtable_users.rs | 24 +- backend/src/auth.rs | 5 + backend/src/db.rs | 216 ++++++++- backend/src/global_db.rs | 454 ++++++++++++++++++ backend/src/handle_socket.rs | 175 ++++++- backend/src/lib.rs | 137 +++++- backend/src/main.rs | 447 ++++++++++++++++- backend/src/test_utils.rs | 46 +- backend/tests/websocket_sudo.rs | 62 +-- frontend/src/lib/adminApi.ts | 134 ++++++ frontend/src/lib/api.svelte.ts | 82 +++- frontend/src/lib/cohortApi.ts | 23 + frontend/src/lib/components/appSideBar.svelte | 206 ++++---- .../src/lib/components/selectMarket.svelte | 11 +- frontend/src/lib/starPinnedMarkets.svelte.ts | 10 +- frontend/src/lib/starredMarkets.svelte.ts | 10 +- frontend/src/routes/+layout.svelte | 218 +-------- frontend/src/routes/+page.svelte | 98 ++++ frontend/src/routes/+page.ts | 5 - .../src/routes/[cohort_name]/+layout.svelte | 230 +++++++++ frontend/src/routes/[cohort_name]/+page.ts | 5 + .../{ => [cohort_name]}/accounts/+page.svelte | 0 .../{ => [cohort_name]}/auction/+page.svelte | 12 +- .../{ => [cohort_name]}/docs/+page.svelte | 5 +- .../docs/[slug]/+page.svelte | 6 +- .../{ => [cohort_name]}/home/+page.svelte | 0 .../{ => [cohort_name]}/market/+layout.svelte | 0 .../{ => [cohort_name]}/market/+page.svelte | 16 +- .../market/[id]/+page.svelte | 0 .../{ => [cohort_name]}/options/+page.svelte | 0 .../transfers/+page.svelte | 0 frontend/src/routes/admin/+page.svelte | 303 ++++++++++++ frontend/src/routes/auction/+page.ts | 3 - schema-js/index.d.ts | 6 + schema-js/index.js | 26 +- schema/server-message.proto | 1 + 48 files changed, 2622 insertions(+), 480 deletions(-) rename backend/.sqlx/{query-dd14586228dca7e2a5586fae1a7843c59e859ae11fa1e81cf411064be03822d6.json => query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json} (73%) rename backend/.sqlx/{query-5bb80a40f93c647af291b36f950243bfc43acd2e8f38f0d760b203a4a4bad351.json => query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json} (67%) create mode 100644 backend/.sqlx/query-433242ec70fe924887b6fde177112404d773129357a3845da91d0ec752fd143a.json rename backend/.sqlx/{query-943427a7ba64285bf8dc6f35a61f4e025b5c2ec12c2448768425245ffe49290d.json => query-6026fe9a76ae8dd30ead15b3c809a78a546129d2e9bf4e99993b4c92e07f6ad9.json} (62%) rename backend/.sqlx/{query-0c7c90c8753c66208ee11c0cc2826e7044881937d5d1aadccb5dc412dec9f55a.json => query-62de5a5e7713956efa9ee7f382a36c1c1646d5f224b97ba88985192518773f92.json} (64%) rename backend/.sqlx/{query-573c8349f125b65d642b439cacd5e900b4fad9fb3a396c3a625c2adbfdb30a7f.json => query-98aba038f48d4dc2f929955a9fa90166defea7deb41560c9bcd35a8c60b7bb59.json} (64%) rename backend/.sqlx/{query-78b6fbf503baf041f4ebf76c310e39c63ca56bb86a64d001e0853014cf71f340.json => query-b20cceac140cfcdb62468aa6a2d19dbf1743f978837d9bb971868ed5dd17f0db.json} (64%) create mode 100644 backend/.sqlx/query-c4f5b6e260992d4e2df494d9ba3ad09b7ca596147544f719c4b09d6020e8a01b.json create mode 100644 backend/.sqlx/query-fdcbfffc320184b5fc9c55e1892de89a23ba7b39ae38ca815a9274572f38ecd5.json create mode 100644 backend/global_migrations/001_initial.sql create mode 100644 backend/migrations/20260302000000_add_global_user_id.sql create mode 100644 backend/src/global_db.rs create mode 100644 frontend/src/lib/adminApi.ts create mode 100644 frontend/src/lib/cohortApi.ts create mode 100644 frontend/src/routes/+page.svelte delete mode 100644 frontend/src/routes/+page.ts create mode 100644 frontend/src/routes/[cohort_name]/+layout.svelte create mode 100644 frontend/src/routes/[cohort_name]/+page.ts rename frontend/src/routes/{ => [cohort_name]}/accounts/+page.svelte (100%) rename frontend/src/routes/{ => [cohort_name]}/auction/+page.svelte (81%) rename frontend/src/routes/{ => [cohort_name]}/docs/+page.svelte (93%) rename frontend/src/routes/{ => [cohort_name]}/docs/[slug]/+page.svelte (95%) rename frontend/src/routes/{ => [cohort_name]}/home/+page.svelte (100%) rename frontend/src/routes/{ => [cohort_name]}/market/+layout.svelte (100%) rename frontend/src/routes/{ => [cohort_name]}/market/+page.svelte (97%) rename frontend/src/routes/{ => [cohort_name]}/market/[id]/+page.svelte (100%) rename frontend/src/routes/{ => [cohort_name]}/options/+page.svelte (100%) rename frontend/src/routes/{ => [cohort_name]}/transfers/+page.svelte (100%) create mode 100644 frontend/src/routes/admin/+page.svelte delete mode 100644 frontend/src/routes/auction/+page.ts diff --git a/.gitignore b/.gitignore index 39af7a91..b9017b54 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules backend/uploads .dev-ports .playwright-cli +.claude/worktrees/ .claude/worktrees/ diff --git a/backend/.sqlx/query-dd14586228dca7e2a5586fae1a7843c59e859ae11fa1e81cf411064be03822d6.json b/backend/.sqlx/query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json similarity index 73% rename from backend/.sqlx/query-dd14586228dca7e2a5586fae1a7843c59e859ae11fa1e81cf411064be03822d6.json rename to backend/.sqlx/query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json index b35c36b1..992885eb 100644 --- a/backend/.sqlx/query-dd14586228dca7e2a5586fae1a7843c59e859ae11fa1e81cf411064be03822d6.json +++ b/backend/.sqlx/query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, name, kinde_id IS NOT NULL as \"is_user: bool\", universe_id FROM account", + "query": "SELECT id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as \"is_user: bool\", universe_id FROM account", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "dd14586228dca7e2a5586fae1a7843c59e859ae11fa1e81cf411064be03822d6" + "hash": "20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b" } diff --git a/backend/.sqlx/query-5bb80a40f93c647af291b36f950243bfc43acd2e8f38f0d760b203a4a4bad351.json b/backend/.sqlx/query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json similarity index 67% rename from backend/.sqlx/query-5bb80a40f93c647af291b36f950243bfc43acd2e8f38f0d760b203a4a4bad351.json rename to backend/.sqlx/query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json index 5905ed29..f629564c 100644 --- a/backend/.sqlx/query-5bb80a40f93c647af291b36f950243bfc43acd2e8f38f0d760b203a4a4bad351.json +++ b/backend/.sqlx/query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id,\n name,\n kinde_id IS NOT NULL AS \"is_user: bool\",\n universe_id\n FROM account\n WHERE id = ?\n ", + "query": "\n SELECT\n id,\n name,\n (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) AS \"is_user: bool\",\n universe_id\n FROM account\n WHERE id = ?\n ", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "5bb80a40f93c647af291b36f950243bfc43acd2e8f38f0d760b203a4a4bad351" + "hash": "32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f" } diff --git a/backend/.sqlx/query-433242ec70fe924887b6fde177112404d773129357a3845da91d0ec752fd143a.json b/backend/.sqlx/query-433242ec70fe924887b6fde177112404d773129357a3845da91d0ec752fd143a.json new file mode 100644 index 00000000..12ce415c --- /dev/null +++ b/backend/.sqlx/query-433242ec70fe924887b6fde177112404d773129357a3845da91d0ec752fd143a.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id AS \"id!\", name\n FROM account\n WHERE global_user_id = ?\n ", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false + ] + }, + "hash": "433242ec70fe924887b6fde177112404d773129357a3845da91d0ec752fd143a" +} diff --git a/backend/.sqlx/query-943427a7ba64285bf8dc6f35a61f4e025b5c2ec12c2448768425245ffe49290d.json b/backend/.sqlx/query-6026fe9a76ae8dd30ead15b3c809a78a546129d2e9bf4e99993b4c92e07f6ad9.json similarity index 62% rename from backend/.sqlx/query-943427a7ba64285bf8dc6f35a61f4e025b5c2ec12c2448768425245ffe49290d.json rename to backend/.sqlx/query-6026fe9a76ae8dd30ead15b3c809a78a546129d2e9bf4e99993b4c92e07f6ad9.json index 46a73e4b..8e0991c5 100644 --- a/backend/.sqlx/query-943427a7ba64285bf8dc6f35a61f4e025b5c2ec12c2448768425245ffe49290d.json +++ b/backend/.sqlx/query-6026fe9a76ae8dd30ead15b3c809a78a546129d2e9bf4e99993b4c92e07f6ad9.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT EXISTS(\n SELECT 1 FROM account WHERE id = ? AND kinde_id IS NOT NULL\n ) as \"exists!: bool\"", + "query": "SELECT EXISTS(\n SELECT 1 FROM account WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL)\n ) as \"exists!: bool\"", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "943427a7ba64285bf8dc6f35a61f4e025b5c2ec12c2448768425245ffe49290d" + "hash": "6026fe9a76ae8dd30ead15b3c809a78a546129d2e9bf4e99993b4c92e07f6ad9" } diff --git a/backend/.sqlx/query-0c7c90c8753c66208ee11c0cc2826e7044881937d5d1aadccb5dc412dec9f55a.json b/backend/.sqlx/query-62de5a5e7713956efa9ee7f382a36c1c1646d5f224b97ba88985192518773f92.json similarity index 64% rename from backend/.sqlx/query-0c7c90c8753c66208ee11c0cc2826e7044881937d5d1aadccb5dc412dec9f55a.json rename to backend/.sqlx/query-62de5a5e7713956efa9ee7f382a36c1c1646d5f224b97ba88985192518773f92.json index e2532a22..b42f459b 100644 --- a/backend/.sqlx/query-0c7c90c8753c66208ee11c0cc2826e7044881937d5d1aadccb5dc412dec9f55a.json +++ b/backend/.sqlx/query-62de5a5e7713956efa9ee7f382a36c1c1646d5f224b97ba88985192518773f92.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT EXISTS(\n SELECT 1\n FROM account\n WHERE id = ? AND kinde_id IS NOT NULL\n ) as \"exists!: bool\"\n ", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM account\n WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL)\n ) AS \"exists!: bool\"\n ", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "0c7c90c8753c66208ee11c0cc2826e7044881937d5d1aadccb5dc412dec9f55a" + "hash": "62de5a5e7713956efa9ee7f382a36c1c1646d5f224b97ba88985192518773f92" } diff --git a/backend/.sqlx/query-573c8349f125b65d642b439cacd5e900b4fad9fb3a396c3a625c2adbfdb30a7f.json b/backend/.sqlx/query-98aba038f48d4dc2f929955a9fa90166defea7deb41560c9bcd35a8c60b7bb59.json similarity index 64% rename from backend/.sqlx/query-573c8349f125b65d642b439cacd5e900b4fad9fb3a396c3a625c2adbfdb30a7f.json rename to backend/.sqlx/query-98aba038f48d4dc2f929955a9fa90166defea7deb41560c9bcd35a8c60b7bb59.json index 0c19932e..56de5698 100644 --- a/backend/.sqlx/query-573c8349f125b65d642b439cacd5e900b4fad9fb3a396c3a625c2adbfdb30a7f.json +++ b/backend/.sqlx/query-98aba038f48d4dc2f929955a9fa90166defea7deb41560c9bcd35a8c60b7bb59.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT EXISTS(\n SELECT 1\n FROM account\n WHERE id = ? AND kinde_id IS NOT NULL\n ) AS \"exists!: bool\"\n ", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM account\n WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL)\n ) as \"exists!: bool\"\n ", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "573c8349f125b65d642b439cacd5e900b4fad9fb3a396c3a625c2adbfdb30a7f" + "hash": "98aba038f48d4dc2f929955a9fa90166defea7deb41560c9bcd35a8c60b7bb59" } diff --git a/backend/.sqlx/query-78b6fbf503baf041f4ebf76c310e39c63ca56bb86a64d001e0853014cf71f340.json b/backend/.sqlx/query-b20cceac140cfcdb62468aa6a2d19dbf1743f978837d9bb971868ed5dd17f0db.json similarity index 64% rename from backend/.sqlx/query-78b6fbf503baf041f4ebf76c310e39c63ca56bb86a64d001e0853014cf71f340.json rename to backend/.sqlx/query-b20cceac140cfcdb62468aa6a2d19dbf1743f978837d9bb971868ed5dd17f0db.json index 97a1b543..1f3149a7 100644 --- a/backend/.sqlx/query-78b6fbf503baf041f4ebf76c310e39c63ca56bb86a64d001e0853014cf71f340.json +++ b/backend/.sqlx/query-b20cceac140cfcdb62468aa6a2d19dbf1743f978837d9bb971868ed5dd17f0db.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT EXISTS (\n SELECT 1\n FROM account\n WHERE id = ? AND kinde_id IS NOT NULL\n ) AS \"exists!: bool\"\n ", + "query": "\n SELECT EXISTS (\n SELECT 1\n FROM account\n WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL)\n ) AS \"exists!: bool\"\n ", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "78b6fbf503baf041f4ebf76c310e39c63ca56bb86a64d001e0853014cf71f340" + "hash": "b20cceac140cfcdb62468aa6a2d19dbf1743f978837d9bb971868ed5dd17f0db" } diff --git a/backend/.sqlx/query-c4f5b6e260992d4e2df494d9ba3ad09b7ca596147544f719c4b09d6020e8a01b.json b/backend/.sqlx/query-c4f5b6e260992d4e2df494d9ba3ad09b7ca596147544f719c4b09d6020e8a01b.json new file mode 100644 index 00000000..055da3fd --- /dev/null +++ b/backend/.sqlx/query-c4f5b6e260992d4e2df494d9ba3ad09b7ca596147544f719c4b09d6020e8a01b.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id\n FROM account\n WHERE name = ? AND (global_user_id != ? OR global_user_id IS NULL)\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "c4f5b6e260992d4e2df494d9ba3ad09b7ca596147544f719c4b09d6020e8a01b" +} diff --git a/backend/.sqlx/query-fdcbfffc320184b5fc9c55e1892de89a23ba7b39ae38ca815a9274572f38ecd5.json b/backend/.sqlx/query-fdcbfffc320184b5fc9c55e1892de89a23ba7b39ae38ca815a9274572f38ecd5.json new file mode 100644 index 00000000..774bdea6 --- /dev/null +++ b/backend/.sqlx/query-fdcbfffc320184b5fc9c55e1892de89a23ba7b39ae38ca815a9274572f38ecd5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO account (global_user_id, name, balance)\n VALUES (?, ?, ?)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false + ] + }, + "hash": "fdcbfffc320184b5fc9c55e1892de89a23ba7b39ae38ca815a9274572f38ecd5" +} diff --git a/backend/global_migrations/001_initial.sql b/backend/global_migrations/001_initial.sql new file mode 100644 index 00000000..6ec8ab01 --- /dev/null +++ b/backend/global_migrations/001_initial.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS "global_user" ( + "id" INTEGER PRIMARY KEY, + "kinde_id" TEXT UNIQUE NOT NULL, + "display_name" TEXT NOT NULL, + "is_admin" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "cohort" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL UNIQUE, + "display_name" TEXT NOT NULL, + "db_path" TEXT NOT NULL UNIQUE, + "is_read_only" BOOLEAN NOT NULL DEFAULT FALSE, + "created_at" DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS "cohort_member" ( + "id" INTEGER PRIMARY KEY, + "cohort_id" INTEGER NOT NULL REFERENCES "cohort", + "global_user_id" INTEGER REFERENCES "global_user", + "email" TEXT, + "created_at" DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE("cohort_id", "global_user_id"), + UNIQUE("cohort_id", "email") +); + +CREATE TABLE IF NOT EXISTS "global_config" ( + "key" TEXT PRIMARY KEY, + "value" TEXT NOT NULL +); diff --git a/backend/migrations/20260302000000_add_global_user_id.sql b/backend/migrations/20260302000000_add_global_user_id.sql new file mode 100644 index 00000000..26694ee2 --- /dev/null +++ b/backend/migrations/20260302000000_add_global_user_id.sql @@ -0,0 +1,4 @@ +-- Add global_user_id column to account table for multi-cohort support +ALTER TABLE "account" ADD COLUMN "global_user_id" INTEGER DEFAULT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS "idx_account_global_user_id" + ON "account" ("global_user_id") WHERE "global_user_id" IS NOT NULL; diff --git a/backend/src/airtable_users.rs b/backend/src/airtable_users.rs index 239a530b..1f5cda30 100644 --- a/backend/src/airtable_users.rs +++ b/backend/src/airtable_users.rs @@ -153,6 +153,11 @@ struct AirtableUpdateFields { /// # Errors /// Returns an error if API calls fail or environment variables are missing pub async fn sync_airtable_users_to_kinde_and_db(app_state: AppState) -> anyhow::Result<()> { + // Get the first available cohort for airtable sync + let cohort = app_state.cohorts.iter().next() + .ok_or_else(|| anyhow::anyhow!("No cohorts available for airtable sync"))?; + let cohort = std::sync::Arc::clone(&cohort); + let airtable_base_id = env::var("AIRTABLE_BASE_ID").context("Missing AIRTABLE_BASE_ID environment variable")?; let airtable_token = @@ -176,7 +181,7 @@ pub async fn sync_airtable_users_to_kinde_and_db(app_state: AppState) -> anyhow: .records .iter() .filter(|record| !record.fields.initialized_correctly.is_some_and(|b| b)) - .map(|record| process_user(app_state.clone(), record, &kinde_token, &client)); + .map(|record| process_user(&cohort.db, &cohort.subscriptions, record, &kinde_token, &client)); let results = join_all(futures).await; @@ -200,7 +205,8 @@ pub async fn sync_airtable_users_to_kinde_and_db(app_state: AppState) -> anyhow: /// Helper function to process each user async fn process_user( - app_state: AppState, + db: &crate::db::DB, + subscriptions: &crate::subscriptions::Subscriptions, record: &AirtableRecord, kinde_token: &str, client: &Client, @@ -217,8 +223,7 @@ async fn process_user( }; let name = format!("{first_name} {last_name}"); - let result = app_state - .db + let result = db .ensure_user_created(&kinde_id, Some(&name), dec!(0)) .await?; @@ -233,7 +238,7 @@ async fn process_user( universe_id: 0, })), }; - app_state.subscriptions.send_public(msg); + subscriptions.send_public(msg); tracing::info!("User {name} created"); id } @@ -263,8 +268,7 @@ async fn process_user( } }; - let transfer = app_state - .db + let transfer = db .ensure_arbor_pixie_transfer(id, initial_clips) .await? .map_err(|e| anyhow::anyhow!("Couldn't transfer initial clips to user {name}: {e:?}"))?; @@ -274,10 +278,8 @@ async fn process_user( request_id: String::new(), message: Some(SM::TransferCreated(transfer.into())), }; - app_state - .subscriptions - .send_private(id, msg.encode_to_vec().into()); - app_state.subscriptions.notify_portfolio(id); + subscriptions.send_private(id, msg.encode_to_vec().into()); + subscriptions.notify_portfolio(id); tracing::info!("Pixie transfer created for user {name}"); // Update Airtable to indicate successful initialization diff --git a/backend/src/auth.rs b/backend/src/auth.rs index bc96df57..31482b5c 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -43,6 +43,8 @@ pub struct AccessClaims { struct IdClaims { pub name: String, pub sub: String, + #[serde(default)] + pub email: Option, } static AUTH_CONFIG: OnceCell = OnceCell::new(); @@ -112,6 +114,7 @@ pub struct ValidatedClient { pub id: String, pub roles: Vec, pub name: Option, + pub email: Option, } /// # Errors @@ -135,6 +138,7 @@ pub async fn validate_access_and_id( Ok(ValidatedClient { id: access_claims.sub, roles: access_claims.roles, + email: id_claims.as_ref().and_then(|c| c.email.clone()), name: id_claims.map(|c| c.name), }) } @@ -170,6 +174,7 @@ pub fn validate_test_token(token: &str) -> anyhow::Result { id: kinde_id, roles, name: Some(name), + email: None, }) } diff --git a/backend/src/db.rs b/backend/src/db.rs index 2e06b971..979c2ad9 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -152,6 +152,111 @@ impl DB { } } + /// Initialize a DB from a specific file path (for multi-cohort support). + /// Does NOT run seed data. + #[instrument(err)] + pub async fn init_with_path(db_path: &str) -> anyhow::Result { + let connection_options = SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(std::time::Duration::from_secs(5)) + .optimize_on_close(true, None) + .pragma("optimize", "0x10002") + .pragma("wal_autocheckpoint", "0"); + + // Create pool first, then run migrations through it. + // This avoids holding a raw SqliteConnection across await points, + // which would make this future !Send. + let (release_tx, release_rx) = tokio::sync::broadcast::channel(1); + + let pool = SqlitePoolOptions::new() + .min_connections(8) + .max_connections(64) + .after_release(move |_, _| { + let release_tx = release_tx.clone(); + Box::pin(async move { + if let Err(e) = release_tx.send(()) { + tracing::error!("release_tx.send failed: {:?}", e); + } + Ok(true) + }) + }) + .connect_with(connection_options.clone()) + .await?; + + let mut migrator = sqlx::migrate::Migrator::new(Path::new("./migrations")).await?; + migrator + .set_ignore_missing(true) + .run(&pool) + .await?; + + let arbor_pixie_account_id: i64 = sqlx::query_scalar( + r#"SELECT id FROM account WHERE name = ?"#, + ) + .bind(ARBOR_PIXIE_ACCOUNT_NAME) + .fetch_one(&pool) + .await?; + + // Checkpoint task: create a dedicated connection inside the spawned task + let checkpoint_options = connection_options; + tokio::spawn(async move { + let mut management_conn = + match SqliteConnection::connect_with(&checkpoint_options).await { + Ok(conn) => conn, + Err(e) => { + tracing::error!("Failed to create checkpoint connection: {e}"); + return; + } + }; + let mut release_rx = release_rx; + let mut released_connections: i64 = 0; + let mut remaining_pages: i64 = 0; + loop { + match release_rx.recv().await { + Ok(()) => { + released_connections += 1; + } + #[allow(clippy::cast_possible_wrap)] + Err(RecvError::Lagged(n)) => { + released_connections += n as i64; + } + Err(RecvError::Closed) => { + break; + } + } + let approx_wal_pages = remaining_pages + released_connections * 8; + if approx_wal_pages < CHECKPOINT_PAGE_LIMIT { + continue; + } + match sqlx::query_as::<_, WalCheckPointRow>("PRAGMA wal_checkpoint(PASSIVE)") + .fetch_one(&mut management_conn) + .await + { + Err(e) => { + tracing::error!("wal_checkpoint failed: {:?}", e); + } + Ok(row) => { + released_connections = 0; + remaining_pages = row.log - row.checkpointed; + tracing::info!( + "wal_checkpoint: busy={} log={} checkpointed={}", + row.busy, + row.log, + row.checkpointed + ); + } + } + } + }); + + Ok(Self { + arbor_pixie_account_id, + pool, + }) + } + #[instrument(err, skip(self))] pub async fn get_account(&self, account_id: i64) -> SqlxResult> { sqlx::query_as!( @@ -160,7 +265,7 @@ impl DB { SELECT id, name, - kinde_id IS NOT NULL AS "is_user: bool", + (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) AS "is_user: bool", universe_id FROM account WHERE id = ? @@ -420,7 +525,7 @@ impl DB { // Owner must be in universe 0 or in the same universe as the new account let (owner_universe, owner_is_user) = sqlx::query_as::<_, (i64, bool)>( - r#"SELECT universe_id, kinde_id IS NOT NULL as "is_user" FROM account WHERE id = ?"#, + r#"SELECT universe_id, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as "is_user" FROM account WHERE id = ?"#, ) .bind(create_account.owner_id) .fetch_one(transaction.as_mut()) @@ -577,7 +682,7 @@ impl DB { SELECT EXISTS ( SELECT 1 FROM account - WHERE id = ? AND kinde_id IS NOT NULL + WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) AS "exists!: bool" "#, existing_owner_id @@ -612,7 +717,7 @@ impl DB { SELECT EXISTS( SELECT 1 FROM account - WHERE id = ? AND kinde_id IS NOT NULL + WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) as "exists!: bool" "#, to_account_id @@ -652,7 +757,7 @@ impl DB { SELECT EXISTS( SELECT 1 FROM account - WHERE id = ? AND kinde_id IS NOT NULL + WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) as "exists!: bool" "#, from_account_id @@ -804,6 +909,73 @@ impl DB { })) } + /// Ensure a user exists in this cohort DB by `global_user_id`. + /// Used in multi-cohort mode where the global DB tracks the user identity. + #[instrument(err, skip(self))] + pub async fn ensure_user_created_by_global_id( + &self, + global_user_id: i64, + requested_name: &str, + initial_balance: Decimal, + ) -> SqlxResult> { + let balance = Text(initial_balance); + + // First try to find user by global_user_id + let existing_user = sqlx::query!( + r#" + SELECT id AS "id!", name + FROM account + WHERE global_user_id = ? + "#, + global_user_id + ) + .fetch_optional(&self.pool) + .await?; + + if let Some(user) = existing_user { + return Ok(Ok(EnsureUserCreatedSuccess { + id: user.id, + name: None, + })); + } + + // Check for name conflicts + let conflicting_account = sqlx::query!( + r#" + SELECT id + FROM account + WHERE name = ? AND (global_user_id != ? OR global_user_id IS NULL) + "#, + requested_name, + global_user_id + ) + .fetch_optional(&self.pool) + .await?; + + let final_name = if conflicting_account.is_some() { + format!("{}-g{}", requested_name, global_user_id) + } else { + requested_name.to_string() + }; + + let id = sqlx::query_scalar!( + r#" + INSERT INTO account (global_user_id, name, balance) + VALUES (?, ?, ?) + RETURNING id + "#, + global_user_id, + final_name, + balance, + ) + .fetch_one(&self.pool) + .await?; + Ok(Ok(EnsureUserCreatedSuccess { + id, + name: Some(final_name), + })) + } + /// # Errors /// Fails is there's a database error pub async fn get_portfolio(&self, account_id: i64) -> SqlxResult> { @@ -833,11 +1005,35 @@ impl DB { pub fn get_all_accounts(&self) -> BoxStream<'_, SqlxResult> { sqlx::query_as!( Account, - r#"SELECT id, name, kinde_id IS NOT NULL as "is_user: bool", universe_id FROM account"# + r#"SELECT id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as "is_user: bool", universe_id FROM account"# ) .fetch(&self.pool) } + /// Get all accounts with kinde_id but without global_user_id (legacy accounts). + /// Used during migration from single-DB to multi-cohort mode. + pub async fn get_legacy_kinde_users(&self) -> SqlxResult> { + sqlx::query_as::<_, (i64, String, String)>( + r#"SELECT id, kinde_id, name FROM account WHERE kinde_id IS NOT NULL AND global_user_id IS NULL"#, + ) + .fetch_all(&self.pool) + .await + } + + /// Set the global_user_id for an account (used during migration). + pub async fn set_global_user_id( + &self, + account_id: i64, + global_user_id: i64, + ) -> SqlxResult<()> { + sqlx::query("UPDATE account SET global_user_id = ? WHERE id = ?") + .bind(global_user_id) + .bind(account_id) + .execute(&self.pool) + .await?; + Ok(()) + } + #[instrument(err, skip(self))] pub async fn get_all_markets(&self) -> SqlxResult> { let market_rows: Vec = sqlx::query_as( @@ -1465,7 +1661,7 @@ impl DB { let initiator_is_user = sqlx::query_scalar!( r#"SELECT EXISTS( - SELECT 1 FROM account WHERE id = ? AND kinde_id IS NOT NULL + SELECT 1 FROM account WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) as "exists!: bool""#, initiator_id ) @@ -1630,7 +1826,7 @@ impl DB { let is_user = sqlx::query_scalar!( r#"SELECT EXISTS( - SELECT 1 FROM account WHERE id = ? AND kinde_id IS NOT NULL + SELECT 1 FROM account WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) as "exists!: bool""#, to_account_id ) @@ -3355,7 +3551,7 @@ impl DB { SELECT EXISTS( SELECT 1 FROM account - WHERE id = ? AND kinde_id IS NOT NULL + WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) AS "exists!: bool" "#, user_id @@ -3417,7 +3613,7 @@ impl DB { SELECT EXISTS( SELECT 1 FROM account - WHERE id = ? AND kinde_id IS NOT NULL + WHERE id = ? AND (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) ) AS "exists!: bool" "#, user_id diff --git a/backend/src/global_db.rs b/backend/src/global_db.rs new file mode 100644 index 00000000..51c9543d --- /dev/null +++ b/backend/src/global_db.rs @@ -0,0 +1,454 @@ +use std::{env, path::Path}; + +use serde::Serialize; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}, + Connection, FromRow, SqliteConnection, SqlitePool, +}; + +#[derive(Clone, Debug)] +pub struct GlobalDB { + pool: SqlitePool, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct GlobalUser { + pub id: i64, + pub kinde_id: String, + pub display_name: String, + pub is_admin: bool, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct CohortInfo { + pub id: i64, + pub name: String, + pub display_name: String, + pub db_path: String, + pub is_read_only: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CohortMember { + pub id: i64, + pub cohort_id: i64, + pub global_user_id: Option, + pub email: Option, + pub display_name: Option, +} + +impl GlobalDB { + /// Initialize the global database, creating it if necessary and running migrations. + /// + /// # Errors + /// Returns an error if database creation or migration fails. + pub async fn init() -> anyhow::Result { + let db_url = env::var("GLOBAL_DATABASE_URL") + .unwrap_or_else(|_| "sqlite:///data/global.sqlite".to_string()); + let db_path = db_url.trim_start_matches("sqlite://"); + Self::init_with_path(db_path).await + } + + /// Initialize a `GlobalDB` from a specific file path. + /// + /// # Errors + /// Returns an error if database initialization fails. + pub async fn init_with_path(db_path: &str) -> anyhow::Result { + let connection_options = SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(std::time::Duration::from_secs(5)); + + let mut management_conn = SqliteConnection::connect_with(&connection_options).await?; + + // Run global migrations + let mut migrator = + sqlx::migrate::Migrator::new(Path::new("./global_migrations")).await?; + migrator + .set_ignore_missing(true) + .run(&mut management_conn) + .await?; + + let pool = SqlitePool::connect_with(connection_options).await?; + + tracing::info!("Global database initialized"); + Ok(Self { pool }) + } + + /// Create or find a global user by kinde_id. Updates display_name if user already exists. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn ensure_global_user( + &self, + kinde_id: &str, + name: &str, + ) -> Result { + // Try to find existing user + let existing = sqlx::query_as::<_, GlobalUser>( + r#"SELECT id, kinde_id, display_name, is_admin FROM global_user WHERE kinde_id = ?"#, + ) + .bind(kinde_id) + .fetch_optional(&self.pool) + .await?; + + if let Some(mut user) = existing { + // Update display_name if changed + if user.display_name != name { + sqlx::query("UPDATE global_user SET display_name = ? WHERE id = ?") + .bind(name) + .bind(user.id) + .execute(&self.pool) + .await?; + user.display_name = name.to_string(); + } + return Ok(user); + } + + // Create new user + let id = sqlx::query_scalar::<_, i64>( + r#"INSERT INTO global_user (kinde_id, display_name) VALUES (?, ?) RETURNING id"#, + ) + .bind(kinde_id) + .bind(name) + .fetch_one(&self.pool) + .await?; + + // Try to link any pre-authorized email-based cohort memberships + // (This would require knowing the user's email, which comes from the ID token) + + Ok(GlobalUser { + id, + kinde_id: kinde_id.to_string(), + display_name: name.to_string(), + is_admin: false, + }) + } + + /// Get a global user by kinde_id. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_global_user_by_kinde_id( + &self, + kinde_id: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, GlobalUser>( + r#"SELECT id, kinde_id, display_name, is_admin FROM global_user WHERE kinde_id = ?"#, + ) + .bind(kinde_id) + .fetch_optional(&self.pool) + .await + } + + /// Get all cohorts a user is a member of. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_user_cohorts( + &self, + global_user_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, CohortInfo>( + r#" + SELECT c.id, c.name, c.display_name, c.db_path, c.is_read_only + FROM cohort c + INNER JOIN cohort_member cm ON cm.cohort_id = c.id + WHERE cm.global_user_id = ? + ORDER BY c.created_at DESC + "#, + ) + .bind(global_user_id) + .fetch_all(&self.pool) + .await + } + + /// Get all cohorts. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_all_cohorts(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, CohortInfo>( + r#"SELECT id, name, display_name, db_path, is_read_only FROM cohort ORDER BY created_at DESC"#, + ) + .fetch_all(&self.pool) + .await + } + + /// Check if a user is a member of a cohort. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn is_cohort_member( + &self, + global_user_id: i64, + cohort_id: i64, + ) -> Result { + let count = sqlx::query_scalar::<_, i64>( + r#"SELECT COUNT(*) FROM cohort_member WHERE global_user_id = ? AND cohort_id = ?"#, + ) + .bind(global_user_id) + .bind(cohort_id) + .fetch_one(&self.pool) + .await?; + Ok(count > 0) + } + + /// Get a config value by key. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_config(&self, key: &str) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, String>( + r#"SELECT value FROM global_config WHERE key = ?"#, + ) + .bind(key) + .fetch_optional(&self.pool) + .await + } + + /// Set a config value. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn set_config(&self, key: &str, value: &str) -> Result<(), sqlx::Error> { + sqlx::query( + r#"INSERT INTO global_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"#, + ) + .bind(key) + .bind(value) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Create a new cohort. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn create_cohort( + &self, + name: &str, + display_name: &str, + db_path: &str, + ) -> Result { + let id = sqlx::query_scalar::<_, i64>( + r#"INSERT INTO cohort (name, display_name, db_path) VALUES (?, ?, ?) RETURNING id"#, + ) + .bind(name) + .bind(display_name) + .bind(db_path) + .fetch_one(&self.pool) + .await?; + + Ok(CohortInfo { + id, + name: name.to_string(), + display_name: display_name.to_string(), + db_path: db_path.to_string(), + is_read_only: false, + }) + } + + /// Update a cohort's display_name and/or read-only status. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn update_cohort( + &self, + id: i64, + display_name: Option<&str>, + is_read_only: Option, + ) -> Result<(), sqlx::Error> { + if let Some(dn) = display_name { + sqlx::query("UPDATE cohort SET display_name = ? WHERE id = ?") + .bind(dn) + .bind(id) + .execute(&self.pool) + .await?; + } + if let Some(ro) = is_read_only { + sqlx::query("UPDATE cohort SET is_read_only = ? WHERE id = ?") + .bind(ro) + .bind(id) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + /// Get a cohort by name. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_cohort_by_name(&self, name: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, CohortInfo>( + r#"SELECT id, name, display_name, db_path, is_read_only FROM cohort WHERE name = ?"#, + ) + .bind(name) + .fetch_optional(&self.pool) + .await + } + + /// Batch add members by email. Returns count of newly added members. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn batch_add_members( + &self, + cohort_id: i64, + emails: &[String], + ) -> Result { + let mut added = 0; + for email in emails { + let email = email.trim().to_lowercase(); + if email.is_empty() { + continue; + } + + // Check if this email matches an existing global user + // (we don't have email in global_user, so just store the email for now) + let result = sqlx::query( + r#"INSERT INTO cohort_member (cohort_id, email) VALUES (?, ?) ON CONFLICT DO NOTHING"#, + ) + .bind(cohort_id) + .bind(&email) + .execute(&self.pool) + .await?; + + if result.rows_affected() > 0 { + added += 1; + } + } + Ok(added) + } + + /// Add a member by global_user_id. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn add_member_by_user_id( + &self, + cohort_id: i64, + global_user_id: i64, + ) -> Result<(), sqlx::Error> { + sqlx::query( + r#"INSERT INTO cohort_member (cohort_id, global_user_id) VALUES (?, ?) ON CONFLICT DO NOTHING"#, + ) + .bind(cohort_id) + .bind(global_user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Remove a member from a cohort. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn remove_member( + &self, + cohort_id: i64, + member_id: i64, + ) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM cohort_member WHERE id = ? AND cohort_id = ?") + .bind(member_id) + .bind(cohort_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Get all members of a cohort. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_cohort_members( + &self, + cohort_id: i64, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, CohortMemberRow>( + r#" + SELECT cm.id, cm.cohort_id, cm.global_user_id, cm.email, gu.display_name + FROM cohort_member cm + LEFT JOIN global_user gu ON gu.id = cm.global_user_id + WHERE cm.cohort_id = ? + ORDER BY cm.created_at + "#, + ) + .bind(cohort_id) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| CohortMember { + id: r.id, + cohort_id: r.cohort_id, + global_user_id: r.global_user_id, + email: r.email, + display_name: r.display_name, + }) + .collect()) + } + + /// Set a user's admin status. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn set_user_admin( + &self, + global_user_id: i64, + is_admin: bool, + ) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE global_user SET is_admin = ? WHERE id = ?") + .bind(is_admin) + .bind(global_user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Link a pre-authorized email to a global user. When a user signs up and their email + /// matches a pre-authorized cohort_member row, this links them. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn link_email_to_user( + &self, + email: &str, + global_user_id: i64, + ) -> Result<(), sqlx::Error> { + sqlx::query( + r#"UPDATE cohort_member SET global_user_id = ? WHERE email = ? AND global_user_id IS NULL"#, + ) + .bind(global_user_id) + .bind(email.trim().to_lowercase()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Get all global users (for admin UI). + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_all_users(&self) -> Result, sqlx::Error> { + sqlx::query_as::<_, GlobalUser>( + r#"SELECT id, kinde_id, display_name, is_admin FROM global_user ORDER BY created_at"#, + ) + .fetch_all(&self.pool) + .await + } +} + +#[derive(Debug, FromRow)] +struct CohortMemberRow { + id: i64, + cohort_id: i64, + global_user_id: Option, + email: Option, + display_name: Option, +} diff --git a/backend/src/handle_socket.rs b/backend/src/handle_socket.rs index f00eed06..b7e3dbce 100644 --- a/backend/src/handle_socket.rs +++ b/backend/src/handle_socket.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use crate::{ auth::{validate_access_and_id_or_test, Role}, db::{self, EnsureUserCreatedSuccess, DB}, @@ -12,7 +14,7 @@ use crate::{ ServerMessage, SettleAuction, SudoStatus, Trade, Trades, Transfer, Transfers, Universe, Universes, }, - AppState, + AppState, CohortState, }; use anyhow::{anyhow, bail}; use async_stream::stream; @@ -24,8 +26,8 @@ use rust_decimal_macros::dec; use tokio::sync::broadcast::error::RecvError; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; -pub async fn handle_socket(socket: WebSocket, app_state: AppState) { - if let Err(e) = handle_socket_fallible(socket, app_state).await { +pub async fn handle_socket(socket: WebSocket, app_state: AppState, cohort: Arc) { + if let Err(e) = handle_socket_fallible(socket, app_state, cohort).await { tracing::error!("Error handling socket: {e}"); } else { tracing::info!("Client disconnected"); @@ -33,19 +35,21 @@ pub async fn handle_socket(socket: WebSocket, app_state: AppState) { } #[allow(clippy::too_many_lines, unused_assignments)] -async fn handle_socket_fallible(mut socket: WebSocket, app_state: AppState) -> anyhow::Result<()> { +async fn handle_socket_fallible(mut socket: WebSocket, app_state: AppState, cohort: Arc) -> anyhow::Result<()> { + let is_read_only = cohort.info.is_read_only; let AuthenticatedClient { id: mut user_id, is_admin, act_as, mut owned_accounts, - } = authenticate(&app_state, &mut socket).await?; + auction_only, + } = authenticate(&app_state, &cohort, &mut socket).await?; let admin_id = is_admin.then_some(user_id); let mut acting_as = act_as.unwrap_or(user_id); let mut sudo_enabled = false; - let mut subscription_receivers = app_state.subscriptions.subscribe_all(&owned_accounts); - let db = &app_state.db; + let mut subscription_receivers = cohort.subscriptions.subscribe_all(&owned_accounts); + let db = &cohort.db; let mut current_universe_id = db.get_account_universe_id(acting_as).await?.unwrap_or(0); send_initial_private_data(db, &owned_accounts, &mut socket, false).await?; @@ -59,7 +63,7 @@ async fn handle_socket_fallible(mut socket: WebSocket, app_state: AppState) -> a .collect(); for &account_id in &added_owned_accounts { owned_accounts.push(account_id); - app_state + cohort .subscriptions .add_owned_subscription(&mut subscription_receivers, account_id); } @@ -71,7 +75,7 @@ async fn handle_socket_fallible(mut socket: WebSocket, app_state: AppState) -> a .collect(); owned_accounts.retain(|account_id| !removed_owned_accounts.contains(account_id)); for &account_id in &removed_owned_accounts { - app_state + cohort .subscriptions .remove_owned_subscription(&mut subscription_receivers, account_id); } @@ -160,10 +164,13 @@ async fn handle_socket_fallible(mut socket: WebSocket, app_state: AppState) -> a if let Some(result) = handle_client_message( &mut socket, &app_state, + &cohort, effective_admin_id, user_id, acting_as, &owned_accounts, + is_read_only, + auction_only, msg, ) .await? { @@ -203,7 +210,7 @@ async fn handle_socket_fallible(mut socket: WebSocket, app_state: AppState) -> a if act_as.admin_as_user { user_id = act_as.account_id; owned_accounts = db.get_owned_accounts(user_id).await?; - subscription_receivers = app_state.subscriptions.subscribe_all(&owned_accounts); + subscription_receivers = cohort.subscriptions.subscribe_all(&owned_accounts); // TODO: somehow notify the client to get rid of existing portfolios send_initial_private_data(db, &owned_accounts, &mut socket, false).await?; update_owned_accounts!(); @@ -523,14 +530,17 @@ enum HandleResult { async fn handle_client_message( socket: &mut WebSocket, app_state: &AppState, + cohort: &CohortState, admin_id: Option, user_id: i64, acting_as: i64, owned_accounts: &[i64], + is_read_only: bool, + auction_only: bool, msg: ws::Message, ) -> anyhow::Result> { - let db = &app_state.db; - let subscriptions = &app_state.subscriptions; + let db = &cohort.db; + let subscriptions = &cohort.subscriptions; let ws::Message::Binary(msg) = msg else { let resp = request_failed(String::new(), "Unknown", "Expected Binary message"); @@ -588,6 +598,18 @@ async fn handle_client_message( }; }; } + // Check read-only and auction-only restrictions + macro_rules! check_mutation_allowed { + ($msg_type:expr) => { + if is_read_only { + fail!($msg_type, "Cohort is read-only"); + } + if auction_only { + fail!($msg_type, "Auction access only"); + } + }; + } + match msg { CM::GetFullTradeHistory(GetFullTradeHistory { market_id }) => { check_expensive_rate_limit!("GetFullTradeHistory"); @@ -632,6 +654,7 @@ async fn handle_client_message( socket.send(msg.encode_to_vec().into()).await?; } CM::CreateMarket(create_market) => { + check_mutation_allowed!("CreateMarket"); check_expensive_rate_limit!("CreateMarket"); // Get the universe_id of the acting_as account let universe_id = db @@ -659,6 +682,7 @@ async fn handle_client_message( }; } CM::SettleMarket(settle_market) => { + check_mutation_allowed!("SettleMarket"); check_expensive_rate_limit!("SettleMarket"); match db.settle_market(user_id, admin_id, settle_market).await? { Ok(db::MarketSettledWithAffectedAccounts { @@ -684,6 +708,7 @@ async fn handle_client_message( } } CM::CreateOrder(create_order) => { + check_mutation_allowed!("CreateOrder"); check_mutate_rate_limit!("CreateOrder"); match db.create_order(acting_as, create_order).await? { Ok(order_created) => { @@ -700,6 +725,7 @@ async fn handle_client_message( } } CM::CancelOrder(cancel_order) => { + check_mutation_allowed!("CancelOrder"); check_mutate_rate_limit!("CancelOrder"); match db.cancel_order(acting_as, cancel_order).await? { Ok(order_cancelled) => { @@ -714,6 +740,7 @@ async fn handle_client_message( } } CM::MakeTransfer(make_transfer) => { + check_mutation_allowed!("MakeTransfer"); check_mutate_rate_limit!("MakeTransfer"); let from_account_id = make_transfer.from_account_id; let to_account_id = make_transfer.to_account_id; @@ -734,6 +761,7 @@ async fn handle_client_message( } } CM::Out(out) => { + check_mutation_allowed!("Out"); check_mutate_rate_limit!("Out"); match db.out(acting_as, out.clone()).await? { Ok(orders_cancelled_list) => { @@ -753,6 +781,7 @@ async fn handle_client_message( } } CM::CreateAccount(create_account) => { + check_mutation_allowed!("CreateAccount"); check_mutate_rate_limit!("CreateAccount"); let owner_id = create_account.owner_id; let status = db.create_account(user_id, create_account).await?; @@ -768,6 +797,7 @@ async fn handle_client_message( } } CM::ShareOwnership(share_ownership) => { + check_mutation_allowed!("ShareOwnership"); check_mutate_rate_limit!("ShareOwnership"); let to_account_id = share_ownership.to_account_id; match db.share_ownership(user_id, share_ownership).await? { @@ -783,6 +813,7 @@ async fn handle_client_message( } } CM::RevokeOwnership(revoke_ownership) => { + check_mutation_allowed!("RevokeOwnership"); check_mutate_rate_limit!("RevokeOwnership"); let from_account_id = revoke_ownership.from_account_id; if admin_id.is_none() { @@ -806,6 +837,7 @@ async fn handle_client_message( } } CM::Redeem(redeem) => { + check_mutation_allowed!("Redeem"); check_mutate_rate_limit!("Redeem"); match db.redeem(acting_as, redeem).await? { Ok(redeemed) => { @@ -855,6 +887,7 @@ async fn handle_client_message( })); } CM::CreateUniverse(create_universe) => { + check_mutation_allowed!("CreateUniverse"); check_expensive_rate_limit!("CreateUniverse"); match db .create_universe(user_id, create_universe.name, create_universe.description) @@ -870,6 +903,7 @@ async fn handle_client_message( } } CM::EditMarket(edit_market) => { + check_mutation_allowed!("EditMarket"); // Check if user is admin or owner of the market let Some((owner_id, status)) = db.get_market_owner_and_status(edit_market.id).await? else { @@ -917,6 +951,9 @@ async fn handle_client_message( }; } CM::CreateAuction(create_auction) => { + if is_read_only { + fail!("CreateAuction", "Cohort is read-only"); + } check_expensive_rate_limit!("CreateMarket"); match db .create_auction(admin_id.unwrap_or(user_id), create_auction) @@ -932,6 +969,9 @@ async fn handle_client_message( }; } CM::SettleAuction(settle_auction) => { + if is_read_only { + fail!("SettleAuction", "Cohort is read-only"); + } check_expensive_rate_limit!("SettleAuction"); match admin_id { None => { @@ -964,6 +1004,9 @@ async fn handle_client_message( } } CM::BuyAuction(buy_auction) => { + if is_read_only { + fail!("BuyAuction", "Cohort is read-only"); + } check_expensive_rate_limit!("SettleAuction"); match db .settle_auction( @@ -997,6 +1040,9 @@ async fn handle_client_message( }; } CM::DeleteAuction(delete_auction) => { + if is_read_only { + fail!("DeleteAuction", "Cohort is read-only"); + } check_expensive_rate_limit!("DeleteAuction"); match db .delete_auction(user_id, delete_auction, admin_id) @@ -1015,6 +1061,9 @@ async fn handle_client_message( } } CM::EditAuction(edit_auction) => { + if is_read_only { + fail!("EditAuction", "Cohort is read-only"); + } check_expensive_rate_limit!("EditAuction"); match db .edit_auction(user_id, edit_auction, admin_id) @@ -1132,14 +1181,17 @@ struct AuthenticatedClient { is_admin: bool, act_as: Option, owned_accounts: Vec, + auction_only: bool, } #[allow(clippy::too_many_lines)] async fn authenticate( app_state: &AppState, + cohort: &CohortState, socket: &mut WebSocket, ) -> anyhow::Result { - let db = &app_state.db; + let db = &cohort.db; + let global_db = &app_state.global_db; loop { match socket.recv().await { Some(Ok(ws::Message::Binary(msg))) => { @@ -1166,12 +1218,96 @@ async fn authenticate( continue; } }; - let is_admin = valid_client.roles.contains(&Role::Admin); + + // Get or create global user + let display_name = valid_client.name.as_deref().unwrap_or("Unknown"); + let global_user = match global_db + .ensure_global_user(&valid_client.id, display_name) + .await + { + Ok(user) => user, + Err(e) => { + tracing::error!("Failed to ensure global user: {e}"); + let resp = request_failed( + request_id, + "Authenticate", + "Failed to create global user", + ); + socket.send(resp).await?; + continue; + } + }; + + // Link email-based pre-authorizations if we have an email + if let Some(email) = &valid_client.email { + if let Err(e) = global_db + .link_email_to_user(email, global_user.id) + .await + { + tracing::warn!("Failed to link email to user: {e}"); + } + } + + // Check admin status (Kinde role OR global DB flag) + let is_admin = + valid_client.roles.contains(&Role::Admin) || global_user.is_admin; + + // Check cohort access + let mut is_member = global_db + .is_cohort_member(global_user.id, cohort.info.id) + .await + .unwrap_or(false); + + // In dev-mode, auto-add users as cohort members + #[cfg(feature = "dev-mode")] + if !is_member { + if let Err(e) = global_db + .add_member_by_user_id(cohort.info.id, global_user.id) + .await + { + tracing::warn!("Failed to auto-add user as cohort member: {e}"); + } else { + is_member = true; + } + } + + let mut auction_only = false; + + if !is_admin && !is_member { + // Check if this is the active auction cohort with public auction enabled + let public_auction_enabled = global_db + .get_config("public_auction_enabled") + .await + .unwrap_or(None) + .map_or(false, |v| v == "true"); + let active_auction_cohort_id = global_db + .get_config("active_auction_cohort_id") + .await + .unwrap_or(None) + .and_then(|v| v.parse::().ok()); + + if public_auction_enabled + && active_auction_cohort_id == Some(cohort.info.id) + { + auction_only = true; + } else { + let resp = request_failed( + request_id, + "Authenticate", + "You are not authorized for this cohort", + ); + socket.send(resp).await?; + continue; + } + } + let initial_balance = if is_admin { dec!(100_000_000) } else { dec!(0) }; + + // Create/find user in cohort DB using global_user_id let result = db - .ensure_user_created( - &valid_client.id, - valid_client.name.as_deref(), + .ensure_user_created_by_global_id( + global_user.id, + display_name, initial_balance, ) .await?; @@ -1190,7 +1326,7 @@ async fn authenticate( universe_id: 0, }), ); - app_state.subscriptions.send_public(msg); + cohort.subscriptions.send_public(msg); id } Ok(EnsureUserCreatedSuccess { id, name: None }) => id, @@ -1216,7 +1352,7 @@ async fn authenticate( } let resp = encode_server_message( request_id, - SM::Authenticated(Authenticated { account_id: id }), + SM::Authenticated(Authenticated { account_id: id, auction_only }), ); socket.send(resp).await?; return Ok(AuthenticatedClient { @@ -1224,6 +1360,7 @@ async fn authenticate( is_admin, act_as, owned_accounts, + auction_only, }); } Some(Ok(ws::Message::Ping(payload))) => { diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 279276f6..39347b32 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,8 @@ use std::{path::PathBuf, sync::Arc}; +use dashmap::DashMap; use db::DB; +use global_db::{CohortInfo, GlobalDB}; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use nonzero_ext::nonzero; use subscriptions::Subscriptions; @@ -10,10 +12,17 @@ pub mod websocket_api { include!(concat!(env!("OUT_DIR"), "/websocket_api.rs")); } -#[derive(Clone)] -pub struct AppState { +/// State for a single cohort (its own DB + pub/sub system). +pub struct CohortState { pub db: DB, pub subscriptions: Subscriptions, + pub info: CohortInfo, +} + +#[derive(Clone)] +pub struct AppState { + pub global_db: GlobalDB, + pub cohorts: Arc>>, pub expensive_ratelimit: Arc>, pub admin_expensive_ratelimit: Arc>, pub mutate_ratelimit: Arc>, @@ -31,16 +40,76 @@ impl AppState { /// # Errors /// Returns an error if initializing the database failed. pub async fn new() -> anyhow::Result { - let db = DB::init().await?; - let subscriptions = Subscriptions::new(); + let global_db = GlobalDB::init().await?; + + let cohorts = Arc::new(DashMap::new()); + + // Load all cohorts from global DB and initialize their databases + let cohort_list = global_db.get_all_cohorts().await?; + for cohort_info in cohort_list { + match DB::init_with_path(&cohort_info.db_path).await { + Ok(db) => { + let cohort_state = Arc::new(CohortState { + db, + subscriptions: Subscriptions::new(), + info: cohort_info.clone(), + }); + cohorts.insert(cohort_info.name.clone(), cohort_state); + tracing::info!("Loaded cohort: {}", cohort_info.name); + } + Err(e) => { + tracing::error!( + "Failed to load cohort '{}' at '{}': {e}", + cohort_info.name, + cohort_info.db_path + ); + } + } + } + + // If no cohorts exist, check for legacy DATABASE_URL and auto-migrate + if cohorts.is_empty() { + if let Ok(legacy_db_url) = std::env::var("DATABASE_URL") { + tracing::info!("No cohorts found, migrating legacy database"); + let db_path = legacy_db_url.trim_start_matches("sqlite://").to_string(); + let db_path_str = if db_path.starts_with("//") { + db_path.trim_start_matches('/').to_string() + } else { + db_path + }; + + match DB::init_with_path(&db_path_str).await { + Ok(db) => { + let cohort_info = global_db + .create_cohort("main", "Main", &db_path_str) + .await?; + + // Migrate existing kinde_id users to global users + Self::migrate_legacy_users(&global_db, &db, &cohort_info).await?; + + let cohort_state = Arc::new(CohortState { + db, + subscriptions: Subscriptions::new(), + info: cohort_info, + }); + cohorts.insert("main".to_string(), cohort_state); + tracing::info!("Legacy database migrated as 'main' cohort"); + } + Err(e) => { + tracing::error!("Failed to load legacy database: {e}"); + } + } + } + } + let expensive_ratelimit = Arc::new(RateLimiter::keyed(LARGE_REQUEST_QUOTA)); let admin_expensive_ratelimit = Arc::new(RateLimiter::keyed(ADMIN_LARGE_REQUEST_QUOTA)); let mutate_ratelimit = Arc::new(RateLimiter::keyed(MUTATE_QUOTA)); let admin_mutate_ratelimit = Arc::new(RateLimiter::keyed(ADMIN_MUTATE_QUOTA)); let uploads_dir = PathBuf::from("/data/uploads"); // Default value, overridden in main.rs Ok(Self { - db, - subscriptions, + global_db, + cohorts, expensive_ratelimit, admin_expensive_ratelimit, mutate_ratelimit, @@ -48,12 +117,68 @@ impl AppState { uploads_dir, }) } + + /// Migrate legacy users from a cohort DB to the global DB. + async fn migrate_legacy_users( + global_db: &GlobalDB, + cohort_db: &DB, + cohort_info: &CohortInfo, + ) -> anyhow::Result<()> { + let legacy_users = cohort_db.get_legacy_kinde_users().await?; + for (account_id, kinde_id, name) in legacy_users { + let global_user = global_db.ensure_global_user(&kinde_id, &name).await?; + cohort_db + .set_global_user_id(account_id, global_user.id) + .await?; + // Also add as cohort member + global_db + .add_member_by_user_id(cohort_info.id, global_user.id) + .await?; + } + Ok(()) + } + + /// Add a new cohort at runtime (e.g., from admin API). + /// If the DB is a legacy DB (has kinde_id but no global_user_id), migrates users. + /// + /// # Errors + /// Returns an error if database initialization fails. + pub async fn add_cohort(&self, cohort_info: CohortInfo) -> anyhow::Result<()> { + let db = DB::init_with_path(&cohort_info.db_path).await?; + + // Check if this is a legacy DB that needs migration + let legacy_users = db.get_legacy_kinde_users().await?; + if !legacy_users.is_empty() { + tracing::info!( + "Migrating {} legacy users in cohort '{}'", + legacy_users.len(), + cohort_info.name + ); + for (account_id, kinde_id, name) in legacy_users { + let global_user = self.global_db.ensure_global_user(&kinde_id, &name).await?; + db.set_global_user_id(account_id, global_user.id).await?; + self.global_db + .add_member_by_user_id(cohort_info.id, global_user.id) + .await?; + } + } + + let cohort_state = Arc::new(CohortState { + db, + subscriptions: Subscriptions::new(), + info: cohort_info.clone(), + }); + self.cohorts + .insert(cohort_info.name.clone(), cohort_state); + Ok(()) + } } pub mod airtable_users; pub mod auth; pub mod convert; pub mod db; +pub mod global_db; pub mod handle_socket; pub mod subscriptions; diff --git a/backend/src/main.rs b/backend/src/main.rs index 9ca99618..999a051c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,12 +1,16 @@ +use std::sync::Arc; + use axum::{ self, extract::{Multipart, Path as AxumPath, State, WebSocketUpgrade}, http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN}, + http::StatusCode, response::{IntoResponse, Response}, - routing::{get, post}, - Router, + routing::{delete, get, post, put}, + Json, Router, }; -use backend::{airtable_users, AppState}; +use backend::{airtable_users, auth::AccessClaims, global_db::CohortInfo, AppState}; +use serde::{Deserialize, Serialize}; use std::{env, path::Path, str::FromStr}; use tokio::{fs::create_dir_all, net::TcpListener}; use tower_http::{ @@ -38,12 +42,29 @@ async fn main() -> anyhow::Result<()> { } let app = Router::new() - .route("/api", get(api)) + // Per-cohort WebSocket route + .route("/api/ws/:cohort_name", get(cohort_ws)) + // REST endpoints + .route("/api/cohorts", get(list_cohorts)) + // Admin REST endpoints + .route("/api/admin/cohorts", get(admin_list_cohorts).post(create_cohort)) + .route("/api/admin/cohorts/:name", put(update_cohort)) + .route( + "/api/admin/cohorts/:name/members", + get(list_members).post(batch_add_members), + ) + .route( + "/api/admin/cohorts/:name/members/:id", + delete(remove_member), + ) + .route("/api/admin/config", get(get_config).put(update_config)) + .route("/api/admin/users", get(list_users)) + .route("/api/admin/users/:id/admin", put(toggle_admin)) + // Legacy / utility routes .route("/sync-airtable-users", get(sync_airtable_users)) .route("/api/upload-image", post(upload_image)) .route("/api/images/:filename", get(serve_image)) .layer(TraceLayer::new_for_http()) - // Limit file uploads to 10MB .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) .layer(SetResponseHeaderLayer::if_not_present( ACCESS_CONTROL_ALLOW_ORIGIN, @@ -81,17 +102,403 @@ async fn main() -> anyhow::Result<()> { Ok(axum::serve(listener, app).await?) } +// --- WebSocket Handler --- + +#[axum::debug_handler] +async fn cohort_ws( + ws: WebSocketUpgrade, + AxumPath(cohort_name): AxumPath, + State(state): State, +) -> Response { + let Some(cohort) = state.cohorts.get(&cohort_name).map(|c| Arc::clone(&c)) else { + return (StatusCode::NOT_FOUND, "Cohort not found").into_response(); + }; + ws.on_upgrade(move |socket| backend::handle_socket::handle_socket(socket, state, cohort)) +} + +// --- REST Endpoints --- + +#[derive(Serialize)] +struct CohortsResponse { + cohorts: Vec, + active_auction_cohort: Option, + public_auction_enabled: bool, +} + +#[axum::debug_handler] +async fn list_cohorts( + claims: AccessClaims, + State(state): State, +) -> Result, (StatusCode, String)> { + let global_user = state + .global_db + .get_global_user_by_kinde_id(&claims.sub) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let Some(global_user) = global_user else { + // User hasn't connected via WebSocket yet, so no global user record. + // Return empty cohorts. + let public_auction_enabled = state + .global_db + .get_config("public_auction_enabled") + .await + .unwrap_or(None) + .is_some_and(|v| v == "true"); + + let active_auction_cohort = get_active_auction_cohort_name(&state).await; + + return Ok(Json(CohortsResponse { + cohorts: vec![], + active_auction_cohort, + public_auction_enabled, + })); + }; + + let is_admin = global_user.is_admin + || claims + .roles + .contains(&backend::auth::Role::Admin); + + let cohorts = if is_admin { + state + .global_db + .get_all_cohorts() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + } else { + state + .global_db + .get_user_cohorts(global_user.id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + }; + + let public_auction_enabled = state + .global_db + .get_config("public_auction_enabled") + .await + .unwrap_or(None) + .is_some_and(|v| v == "true"); + + let active_auction_cohort = get_active_auction_cohort_name(&state).await; + + Ok(Json(CohortsResponse { + cohorts, + active_auction_cohort, + public_auction_enabled, + })) +} + +async fn get_active_auction_cohort_name(state: &AppState) -> Option { + let cohort_id = state + .global_db + .get_config("active_auction_cohort_id") + .await + .ok() + .flatten() + .and_then(|v| v.parse::().ok())?; + + let all_cohorts = state.global_db.get_all_cohorts().await.ok()?; + all_cohorts + .into_iter() + .find(|c| c.id == cohort_id) + .map(|c| c.name) +} + +// --- Admin Endpoints --- + +fn check_admin(claims: &AccessClaims) -> Result<(), (StatusCode, String)> { + if !claims + .roles + .contains(&backend::auth::Role::Admin) + { + return Err((StatusCode::FORBIDDEN, "Admin access required".to_string())); + } + Ok(()) +} + +#[axum::debug_handler] +async fn admin_list_cohorts( + claims: AccessClaims, + State(state): State, +) -> Result>, (StatusCode, String)> { + check_admin(&claims)?; + state + .global_db + .get_all_cohorts() + .await + .map(Json) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +#[derive(Deserialize)] +struct CreateCohortRequest { + name: String, + display_name: String, + db_path: String, +} + +#[axum::debug_handler] +async fn create_cohort( + claims: AccessClaims, + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + check_admin(&claims)?; + + let cohort_info = state + .global_db + .create_cohort(&body.name, &body.display_name, &body.db_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Initialize and add the cohort at runtime + state + .add_cohort(cohort_info.clone()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(cohort_info)) +} + +#[derive(Deserialize)] +struct UpdateCohortRequest { + display_name: Option, + is_read_only: Option, +} + +#[axum::debug_handler] +async fn update_cohort( + claims: AccessClaims, + AxumPath(name): AxumPath, + State(state): State, + Json(body): Json, +) -> Result { + check_admin(&claims)?; + + let cohort = state + .global_db + .get_cohort_by_name(&name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; + + state + .global_db + .update_cohort( + cohort.id, + body.display_name.as_deref(), + body.is_read_only, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Update in-memory cohort info if read-only changed + if let Some(is_read_only) = body.is_read_only { + if let Some(_cohort_state) = state.cohorts.get_mut(&name) { + // Read-only enforcement is checked per-request from DB. + // In-memory CohortInfo will be updated on next restart. + let _ = is_read_only; + } + } + + Ok(StatusCode::OK) +} + #[axum::debug_handler] -async fn api(ws: WebSocketUpgrade, State(state): State) -> Response { - ws.on_upgrade(move |socket| backend::handle_socket::handle_socket(socket, state)) +async fn list_members( + claims: AccessClaims, + AxumPath(name): AxumPath, + State(state): State, +) -> Result>, (StatusCode, String)> { + check_admin(&claims)?; + + let cohort = state + .global_db + .get_cohort_by_name(&name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; + + state + .global_db + .get_cohort_members(cohort.id) + .await + .map(Json) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +#[derive(Deserialize)] +struct BatchAddMembersRequest { + emails: Vec, } +#[derive(Serialize)] +struct BatchAddMembersResponse { + added: usize, +} + +#[axum::debug_handler] +async fn batch_add_members( + claims: AccessClaims, + AxumPath(name): AxumPath, + State(state): State, + Json(body): Json, +) -> Result, (StatusCode, String)> { + check_admin(&claims)?; + + let cohort = state + .global_db + .get_cohort_by_name(&name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; + + let added = state + .global_db + .batch_add_members(cohort.id, &body.emails) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(BatchAddMembersResponse { added })) +} + +#[axum::debug_handler] +async fn remove_member( + claims: AccessClaims, + AxumPath((name, member_id)): AxumPath<(String, i64)>, + State(state): State, +) -> Result { + check_admin(&claims)?; + + let cohort = state + .global_db + .get_cohort_by_name(&name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; + + state + .global_db + .remove_member(cohort.id, member_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +#[derive(Serialize)] +struct GlobalConfig { + active_auction_cohort_id: Option, + public_auction_enabled: bool, +} + +#[axum::debug_handler] +async fn get_config( + claims: AccessClaims, + State(state): State, +) -> Result, (StatusCode, String)> { + check_admin(&claims)?; + + let active_auction_cohort_id = state + .global_db + .get_config("active_auction_cohort_id") + .await + .unwrap_or(None) + .and_then(|v| v.parse().ok()); + + let public_auction_enabled = state + .global_db + .get_config("public_auction_enabled") + .await + .unwrap_or(None) + .is_some_and(|v| v == "true"); + + Ok(Json(GlobalConfig { + active_auction_cohort_id, + public_auction_enabled, + })) +} + +#[derive(Deserialize)] +struct UpdateConfigRequest { + active_auction_cohort_id: Option, + public_auction_enabled: Option, +} + +#[axum::debug_handler] +async fn update_config( + claims: AccessClaims, + State(state): State, + Json(body): Json, +) -> Result { + check_admin(&claims)?; + + if let Some(id) = body.active_auction_cohort_id { + state + .global_db + .set_config("active_auction_cohort_id", &id.to_string()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + if let Some(enabled) = body.public_auction_enabled { + state + .global_db + .set_config("public_auction_enabled", &enabled.to_string()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + Ok(StatusCode::OK) +} + +#[axum::debug_handler] +async fn list_users( + claims: AccessClaims, + State(state): State, +) -> Result>, (StatusCode, String)> { + check_admin(&claims)?; + + state + .global_db + .get_all_users() + .await + .map(Json) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +#[derive(Deserialize)] +struct ToggleAdminRequest { + is_admin: bool, +} + +#[axum::debug_handler] +async fn toggle_admin( + claims: AccessClaims, + AxumPath(user_id): AxumPath, + State(state): State, + Json(body): Json, +) -> Result { + check_admin(&claims)?; + + state + .global_db + .set_user_admin(user_id, body.is_admin) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +// --- Utility Endpoints --- + #[axum::debug_handler] async fn sync_airtable_users(State(state): State) -> Response { match airtable_users::sync_airtable_users_to_kinde_and_db(state).await { Ok(()) => { tracing::info!("Successfully synchronized Airtable users"); - (axum::http::StatusCode::OK, "OK").into_response() + (StatusCode::OK, "OK").into_response() } Err(e) => { tracing::error!("Failed to synchronize Airtable users: {e}"); @@ -99,7 +506,7 @@ async fn sync_airtable_users(State(state): State) -> Response { tracing::error!("Failed to log error to Airtable: {e}"); } ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, "Failed to synchronize Airtable users", ) .into_response() @@ -111,29 +518,29 @@ async fn sync_airtable_users(State(state): State) -> Response { async fn upload_image( State(state): State, mut multipart: Multipart, -) -> Result { +) -> Result { let Some(field) = multipart.next_field().await.map_err(|e| { ( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, format!("Failed to process form data: {e}"), ) })? else { return Err(( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, "No file found in request".to_string(), )); }; let content_type = field.content_type().ok_or(( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, "Missing content type".to_string(), ))?; // Validate content type if !content_type.starts_with("image/") { return Err(( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, "Invalid file type. Only images are allowed.".to_string(), )); } @@ -142,7 +549,7 @@ async fn upload_image( let extension = mime::Mime::from_str(content_type) .map_err(|_| { ( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, "Invalid content type".to_string(), ) })? @@ -156,14 +563,14 @@ async fn upload_image( // Read the file data and write it to disk let data = field.bytes().await.map_err(|e| { ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to read file data: {e}"), ) })?; tokio::fs::write(&filepath, &data).await.map_err(|e| { ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save file: {e}"), ) })?; @@ -179,20 +586,20 @@ async fn upload_image( async fn serve_image( State(state): State, AxumPath(filename): AxumPath, -) -> Result { +) -> Result { let filepath = state.uploads_dir.join(filename); // Validate the path to prevent directory traversal if !filepath.starts_with(&state.uploads_dir) { return Err(( - axum::http::StatusCode::BAD_REQUEST, + StatusCode::BAD_REQUEST, "Invalid filename".to_string(), )); } let data = tokio::fs::read(&filepath).await.map_err(|e| { ( - axum::http::StatusCode::NOT_FOUND, + StatusCode::NOT_FOUND, format!("Image not found: {e}"), ) })?; diff --git a/backend/src/test_utils.rs b/backend/src/test_utils.rs index 293c7629..ee0c92cc 100644 --- a/backend/src/test_utils.rs +++ b/backend/src/test_utils.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::sync::Arc; +use dashmap::DashMap; use futures::{SinkExt, StreamExt}; use governor::{Quota, RateLimiter}; use nonzero_ext::nonzero; @@ -17,13 +18,14 @@ use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage}; use crate::{ db::DB, + global_db::{CohortInfo, GlobalDB}, subscriptions::Subscriptions, websocket_api::{ client_message::Message as CM, server_message::Message as SM, ActAs, Authenticate, ClientMessage, CreateMarket, CreateOrder, GetFullTradeHistory, GetMarketPositions, RevokeOwnership, ServerMessage, SetSudo, Side, }, - AppState, + AppState, CohortState, }; /// Creates a test `AppState` with a temporary `SQLite` database. @@ -60,12 +62,29 @@ pub async fn create_test_app_state() -> anyhow::Result<(AppState, TempDir)> { let db = DB::new_for_tests(arbor_pixie_account_id, pool); + // Create global DB in temp dir + let global_db_path = temp_dir.path().join("global.db"); + let global_db = GlobalDB::init_with_path(&global_db_path.to_string_lossy()).await?; + + // Create a test cohort in the global DB + let cohort_info = global_db + .create_cohort("test", "Test", &db_path.to_string_lossy()) + .await?; + + let cohorts = Arc::new(DashMap::new()); + let cohort_state = Arc::new(CohortState { + db, + subscriptions: Subscriptions::new(), + info: cohort_info, + }); + cohorts.insert("test".to_string(), cohort_state); + // Use permissive rate limits for testing let quota = Quota::per_second(nonzero!(10000u32)); let state = AppState { - db, - subscriptions: Subscriptions::new(), + global_db, + cohorts, expensive_ratelimit: Arc::new(RateLimiter::keyed(quota)), admin_expensive_ratelimit: Arc::new(RateLimiter::keyed(quota)), mutate_ratelimit: Arc::new(RateLimiter::keyed(quota)), @@ -81,16 +100,27 @@ pub async fn create_test_app_state() -> anyhow::Result<(AppState, TempDir)> { /// # Errors /// Returns an error if the server fails to start. pub async fn spawn_test_server(app_state: AppState) -> anyhow::Result { - use axum::{extract::State, routing::get, Router}; + use axum::{ + extract::{Path as AxumPath, State}, + routing::get, + Router, + }; use crate::handle_socket::handle_socket; let app = Router::new() .route( - "/api", + "/api/ws/:cohort_name", get( - |ws: axum::extract::WebSocketUpgrade, State(state): State| async move { - ws.on_upgrade(move |socket| handle_socket(socket, state)) + |ws: axum::extract::WebSocketUpgrade, + AxumPath(cohort_name): AxumPath, + State(state): State| async move { + let cohort = state + .cohorts + .get(&cohort_name) + .map(|c| Arc::clone(&c)) + .unwrap(); + ws.on_upgrade(move |socket| handle_socket(socket, state, cohort)) }, ), ) @@ -106,7 +136,7 @@ pub async fn spawn_test_server(app_state: AppState) -> anyhow::Result { // Give the server a moment to start tokio::time::sleep(std::time::Duration::from_millis(10)).await; - Ok(format!("ws://127.0.0.1:{}/api", addr.port())) + Ok(format!("ws://127.0.0.1:{}/api/ws/test", addr.port())) } /// WebSocket test client for integration tests. diff --git a/backend/tests/websocket_sudo.rs b/backend/tests/websocket_sudo.rs index cd503ce6..977b3b21 100644 --- a/backend/tests/websocket_sudo.rs +++ b/backend/tests/websocket_sudo.rs @@ -239,12 +239,11 @@ async fn test_act_as_shows_not_owner_for_non_admin() { async fn test_act_as_shows_sudo_required_for_admin() { let (app_state, _temp) = create_test_app_state().await.unwrap(); - // Create a second user that admin will try to act as - let _ = app_state - .db - .ensure_user_created("user2", Some("Second User"), rust_decimal_macros::dec!(100)) - .await - .unwrap(); + // Create a second user through the global DB flow so they match what WS auth creates + let global_user2 = app_state.global_db.ensure_global_user("user2", "Second User").await.unwrap(); + let cohort = app_state.cohorts.get("test").unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "Second User", rust_decimal_macros::dec!(100)).await.unwrap(); + drop(cohort); let url = spawn_test_server(app_state).await.unwrap(); @@ -311,16 +310,13 @@ async fn test_hide_account_ids_respects_sudo() { let (app_state, _temp) = create_test_app_state().await.unwrap(); // Pre-create users with initial balance so they can place orders - let _ = app_state - .db - .ensure_user_created("user1", Some("User One"), rust_decimal_macros::dec!(1000)) - .await - .unwrap(); - let _ = app_state - .db - .ensure_user_created("user2", Some("User Two"), rust_decimal_macros::dec!(1000)) - .await - .unwrap(); + // Create users through the global DB flow so they match what the WS auth creates + let global_user1 = app_state.global_db.ensure_global_user("user1", "User One").await.unwrap(); + let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two").await.unwrap(); + let cohort = app_state.cohorts.get("test").unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user1.id, "User One", rust_decimal_macros::dec!(1000)).await.unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "User Two", rust_decimal_macros::dec!(1000)).await.unwrap(); + drop(cohort); let url = spawn_test_server(app_state).await.unwrap(); @@ -555,16 +551,13 @@ async fn test_hide_account_ids_in_market_positions() { let (app_state, _temp) = create_test_app_state().await.unwrap(); // Pre-create users with initial balance so they can place orders - let _ = app_state - .db - .ensure_user_created("user1", Some("User One"), rust_decimal_macros::dec!(1000)) - .await - .unwrap(); - let _ = app_state - .db - .ensure_user_created("user2", Some("User Two"), rust_decimal_macros::dec!(1000)) - .await - .unwrap(); + // Create users through the global DB flow so they match what the WS auth creates + let global_user1 = app_state.global_db.ensure_global_user("user1", "User One").await.unwrap(); + let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two").await.unwrap(); + let cohort = app_state.cohorts.get("test").unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user1.id, "User One", rust_decimal_macros::dec!(1000)).await.unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "User Two", rust_decimal_macros::dec!(1000)).await.unwrap(); + drop(cohort); let url = spawn_test_server(app_state).await.unwrap(); @@ -697,16 +690,13 @@ async fn test_hide_account_ids_in_full_trade_history() { let (app_state, _temp) = create_test_app_state().await.unwrap(); // Pre-create users with initial balance so they can place orders - let _ = app_state - .db - .ensure_user_created("user1", Some("User One"), rust_decimal_macros::dec!(1000)) - .await - .unwrap(); - let _ = app_state - .db - .ensure_user_created("user2", Some("User Two"), rust_decimal_macros::dec!(1000)) - .await - .unwrap(); + // Create users through the global DB flow so they match what the WS auth creates + let global_user1 = app_state.global_db.ensure_global_user("user1", "User One").await.unwrap(); + let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two").await.unwrap(); + let cohort = app_state.cohorts.get("test").unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user1.id, "User One", rust_decimal_macros::dec!(1000)).await.unwrap(); + let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "User Two", rust_decimal_macros::dec!(1000)).await.unwrap(); + drop(cohort); let url = spawn_test_server(app_state).await.unwrap(); diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts new file mode 100644 index 00000000..757e2412 --- /dev/null +++ b/frontend/src/lib/adminApi.ts @@ -0,0 +1,134 @@ +import { kinde } from './auth.svelte'; + +export interface CohortInfo { + id: number; + name: string; + display_name: string; + db_path: string; + is_read_only: boolean; +} + +export interface CohortMember { + id: number; + cohort_id: number; + global_user_id: number | null; + email: string | null; + display_name: string | null; +} + +export interface GlobalConfig { + active_auction_cohort_id: number | null; + public_auction_enabled: boolean; +} + +async function authHeaders(): Promise { + const token = await kinde.getToken(); + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }; +} + +async function handleResponse(res: Response): Promise { + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.json(); +} + +export async function fetchAllCohorts(): Promise { + const res = await fetch('/api/admin/cohorts', { headers: await authHeaders() }); + return handleResponse(res); +} + +export async function createCohort( + name: string, + displayName: string, + dbPath: string +): Promise { + const res = await fetch('/api/admin/cohorts', { + method: 'POST', + headers: await authHeaders(), + body: JSON.stringify({ name, display_name: displayName, db_path: dbPath }) + }); + return handleResponse(res); +} + +export async function updateCohort( + name: string, + displayName?: string, + isReadOnly?: boolean +): Promise { + const res = await fetch(`/api/admin/cohorts/${name}`, { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify({ display_name: displayName, is_read_only: isReadOnly }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} + +export async function fetchMembers(cohortName: string): Promise { + const res = await fetch(`/api/admin/cohorts/${cohortName}/members`, { + headers: await authHeaders() + }); + return handleResponse(res); +} + +export async function batchAddMembers( + cohortName: string, + emails: string[] +): Promise<{ added: number; already_existing: number }> { + const res = await fetch(`/api/admin/cohorts/${cohortName}/members`, { + method: 'POST', + headers: await authHeaders(), + body: JSON.stringify({ emails }) + }); + return handleResponse(res); +} + +export async function removeMember(cohortName: string, memberId: number): Promise { + const res = await fetch(`/api/admin/cohorts/${cohortName}/members/${memberId}`, { + method: 'DELETE', + headers: await authHeaders() + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} + +export async function fetchConfig(): Promise { + const res = await fetch('/api/admin/config', { headers: await authHeaders() }); + return handleResponse(res); +} + +export async function updateConfig(config: { + active_auction_cohort_id?: number; + public_auction_enabled?: boolean; +}): Promise { + const res = await fetch('/api/admin/config', { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify(config) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} + +export async function toggleAdmin(userId: number, isAdmin: boolean): Promise { + const res = await fetch(`/api/admin/users/${userId}/admin`, { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify({ is_admin: isAdmin }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} diff --git a/frontend/src/lib/api.svelte.ts b/frontend/src/lib/api.svelte.ts index 98fabace..d5b68ebc 100644 --- a/frontend/src/lib/api.svelte.ts +++ b/frontend/src/lib/api.svelte.ts @@ -32,10 +32,8 @@ import { notifyUser } from './notifications'; // // Using new Error().stack is generally more reliable for the original call site. // }; -const socket = new ReconnectingWebSocket(PUBLIC_SERVER_URL); -socket.binaryType = 'arraybuffer'; - -console.log('Connecting to', PUBLIC_SERVER_URL); +let socket: ReconnectingWebSocket | null = null; +let currentCohort: string | null = null; export class MarketData { definition: websocket_api.IMarket = $state({}); @@ -64,7 +62,8 @@ export const serverState = $state({ auctions: new SvelteMap(), universes: new SvelteMap(), lastKnownTransactionId: 0, - arborPixieAccountId: undefined as number | undefined + arborPixieAccountId: undefined as number | undefined, + auctionOnly: false }); export const hasArborPixieTransfer = () => { @@ -100,6 +99,10 @@ let messageQueue: websocket_api.IClientMessage[] = []; let hasAuthenticated = false; export const sendClientMessage = (msg: websocket_api.IClientMessage) => { + if (!socket) { + messageQueue.push(msg); + return; + } if (hasAuthenticated || 'authenticate' in msg) { const msgType = Object.keys(msg).find((key) => msg[key as keyof typeof msg]); console.log(`sending ${msgType} message`, msg[msgType as keyof typeof msg]); @@ -148,7 +151,8 @@ const authenticate = async () => { console.log('no id token'); return; } - const actAs = Number(localStorage.getItem('actAs')); + const actAsKey = currentCohort ? `${currentCohort}:actAs` : 'actAs'; + const actAs = Number(localStorage.getItem(actAsKey)); const authenticate = { jwt: accessToken, idJwt: idToken, @@ -158,13 +162,58 @@ const authenticate = async () => { sendClientMessage({ authenticate }); }; -socket.onopen = authenticate; - -socket.onclose = () => { +const resetServerState = () => { serverState.stale = true; + serverState.userId = undefined; + serverState.actingAs = undefined; + serverState.currentUniverseId = 0; + serverState.sudoEnabled = false; + serverState.portfolio = undefined; + serverState.portfolios.clear(); + serverState.transfers = []; + serverState.accounts.clear(); + serverState.markets.clear(); + serverState.marketTypes.clear(); + serverState.marketGroups.clear(); + serverState.auctions.clear(); + serverState.universes.clear(); + serverState.lastKnownTransactionId = 0; + serverState.arborPixieAccountId = undefined; + serverState.auctionOnly = false; + hasAuthenticated = false; + messageQueue = []; +}; + +export const connectToCohort = (cohortName: string) => { + if (currentCohort === cohortName && socket) return; + if (socket) { + socket.close(); + resetServerState(); + } + currentCohort = cohortName; + const wsUrl = `${PUBLIC_SERVER_URL}/ws/${cohortName}`; + console.log('Connecting to', wsUrl); + socket = new ReconnectingWebSocket(wsUrl); + socket.binaryType = 'arraybuffer'; + socket.onopen = authenticate; + socket.onclose = () => { + serverState.stale = true; + }; + socket.onmessage = handleMessage; +}; + +export const disconnectFromCohort = () => { + if (socket) { + socket.close(); + socket = null; + } + currentCohort = null; + resetServerState(); }; -socket.onmessage = (event: MessageEvent) => { +export const getCurrentCohort = () => currentCohort; + +const handleMessage = (event: MessageEvent) => { const data = event.data; const msg = websocket_api.ServerMessage.decode(new Uint8Array(data)); @@ -172,6 +221,7 @@ socket.onmessage = (event: MessageEvent) => { if (msg.authenticated) { serverState.userId = msg.authenticated.accountId; + serverState.auctionOnly = msg.authenticated.auctionOnly ?? false; } if (msg.actingAs) { @@ -181,7 +231,8 @@ socket.onmessage = (event: MessageEvent) => { resolveConnectionToast = undefined; } if (msg.actingAs.accountId) { - localStorage.setItem('actAs', msg.actingAs.accountId.toString()); + const actAsKey = currentCohort ? `${currentCohort}:actAs` : 'actAs'; + localStorage.setItem(actAsKey, msg.actingAs.accountId.toString()); } serverState.actingAs = msg.actingAs.accountId; const newUniverseId = msg.actingAs.universeId ?? 0; @@ -189,8 +240,8 @@ socket.onmessage = (event: MessageEvent) => { if (newUniverseId !== serverState.currentUniverseId) { serverState.markets.clear(); // Redirect to /market if on a specific market page (the market may not exist in the new universe) - if (browser && window.location.pathname.match(/^\/market\/\d+/)) { - goto('/market'); + if (browser && window.location.pathname.match(/\/market\/\d+/)) { + goto(currentCohort ? `/${currentCohort}/market` : '/market'); } } serverState.currentUniverseId = newUniverseId; @@ -471,7 +522,8 @@ socket.onmessage = (event: MessageEvent) => { } if (msg.requestFailed && msg.requestFailed.requestDetails?.kind === 'Authenticate') { - localStorage.removeItem('actAs'); + const actAsKey = currentCohort ? `${currentCohort}:actAs` : 'actAs'; + localStorage.removeItem(actAsKey); console.log('Authentication failed'); authenticate(); } @@ -519,5 +571,5 @@ export const setSudo = (enabled: boolean) => { /** Force WebSocket to reconnect and re-authenticate (useful after login state changes) */ export const reconnect = () => { - socket.reconnect(); + socket?.reconnect(); }; diff --git a/frontend/src/lib/cohortApi.ts b/frontend/src/lib/cohortApi.ts new file mode 100644 index 00000000..04ab369e --- /dev/null +++ b/frontend/src/lib/cohortApi.ts @@ -0,0 +1,23 @@ +import { kinde } from './auth.svelte'; + +export interface CohortInfo { + id: number; + name: string; + display_name: string; + is_read_only: boolean; +} + +export interface CohortsResponse { + cohorts: CohortInfo[]; +} + +export async function fetchCohorts(): Promise { + const token = await kinde.getToken(); + const res = await fetch('/api/cohorts', { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) { + throw new Error(`Failed to fetch cohorts: ${res.statusText}`); + } + return res.json(); +} diff --git a/frontend/src/lib/components/appSideBar.svelte b/frontend/src/lib/components/appSideBar.svelte index c97b56d2..b2948f4e 100644 --- a/frontend/src/lib/components/appSideBar.svelte +++ b/frontend/src/lib/components/appSideBar.svelte @@ -25,9 +25,13 @@ import PanelLeft from '@lucide/svelte/icons/panel-left'; import Moon from '@lucide/svelte/icons/moon'; import Sun from '@lucide/svelte/icons/sun'; + import Repeat from '@lucide/svelte/icons/repeat'; + import Settings from '@lucide/svelte/icons/settings'; import CreateMarket from './forms/createMarket.svelte'; import MakeTransfer from './forms/makeTransfer.svelte'; import { toggleMode, mode } from 'mode-watcher'; + + let { cohortName }: { cohortName: string } = $props(); let sidebarState = useSidebar(); const { allStarredMarkets, cleanupStarredMarkets } = useStarredMarkets(); @@ -142,7 +146,7 @@ {#snippet tooltipContent()}Home{/snippet} {#snippet child({ props })} - + - Pages - {#snippet tooltipContent()}Markets{/snippet} + {#snippet tooltipContent()}Switch Cohort{/snippet} {#snippet child({ props })} - - - Markets + + + Switch Cohort {/snippet} - - - {#snippet child({ props: tooltipProps })} - - {#snippet child({ props })} - - {/snippet} - + + + + + + Pages + + + {#if !serverState.auctionOnly} + + + {#snippet tooltipContent()}Markets{/snippet} + {#snippet child({ props })} + + + Markets + {/snippet} - - Create Market - - {#if allStarredMarkets().length > 0} - - {#each allStarredMarkets() as marketId} - - + + + + {#snippet child({ props: tooltipProps })} + {#snippet child({ props })} - - {formatMarketName( - serverState.markets.get(marketId)?.definition.name - )} + {/snippet} + + {/snippet} + + Create Market + + {#if allStarredMarkets().length > 0} + + {#each allStarredMarkets() as marketId} + + + {#snippet child({ props })} + - + {formatMarketName( + serverState.markets.get(marketId)?.definition.name + )} + + {/snippet} + + + {/each} + + {/if} + + + + {#snippet tooltipContent()}Transactions{/snippet} + {#snippet child({ props })} + + + Transactions + + {/snippet} + + + + {#snippet child({ props: tooltipProps })} + + {#snippet child({ props })} + {/snippet} - - - {/each} - - {/if} - - - - {#snippet tooltipContent()}Transactions{/snippet} - {#snippet child({ props })} - - - Transactions - - {/snippet} - - - - {#snippet child({ props: tooltipProps })} - - {#snippet child({ props })} - - {/snippet} - + + {/snippet} + + New Transaction + + + + + {#snippet tooltipContent()}Accounts{/snippet} + {#snippet child({ props })} + + + Accounts + {/snippet} - - New Transaction - - - - - {#snippet tooltipContent()}Accounts{/snippet} - {#snippet child({ props })} - - - Accounts - - {/snippet} - - - {#if serverState.isAdmin && serverState.sudoEnabled} + + + {/if} + {#if serverState.auctionOnly || (serverState.isAdmin && serverState.sudoEnabled)} {#snippet child({ props })} - + Auction @@ -293,11 +316,22 @@ Admin + + + {#snippet tooltipContent()}Admin{/snippet} + {#snippet child({ props })} + + + Admin + + {/snippet} + + {#snippet tooltipContent()}Internal Docs{/snippet} {#snippet child({ props })} - + Internal Docs diff --git a/frontend/src/lib/components/selectMarket.svelte b/frontend/src/lib/components/selectMarket.svelte index c1190d20..7c25270f 100644 --- a/frontend/src/lib/components/selectMarket.svelte +++ b/frontend/src/lib/components/selectMarket.svelte @@ -1,7 +1,7 @@ + +{#if loading} +
+
+
+{:else if error} +
+
+

{error}

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

+ Your account isn't authorized for any cohort yet. +

+

+ Contact an administrator to get access. +

+
+
+{:else} +
+
+

Select a Cohort

+
+ {#each cohorts as cohort} + + {/each} +
+
+
+{/if} diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts deleted file mode 100644 index 9abb5a73..00000000 --- a/frontend/src/routes/+page.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -export function load() { - redirect(307, '/market'); -} diff --git a/frontend/src/routes/[cohort_name]/+layout.svelte b/frontend/src/routes/[cohort_name]/+layout.svelte new file mode 100644 index 00000000..11f5f28a --- /dev/null +++ b/frontend/src/routes/[cohort_name]/+layout.svelte @@ -0,0 +1,230 @@ + + + + +
+
+ +
+
+
+
+
+
+ {@render children()} +
+
+
+
diff --git a/frontend/src/routes/[cohort_name]/+page.ts b/frontend/src/routes/[cohort_name]/+page.ts new file mode 100644 index 00000000..83eb0328 --- /dev/null +++ b/frontend/src/routes/[cohort_name]/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load({ params }) { + redirect(307, `/${params.cohort_name}/market`); +} diff --git a/frontend/src/routes/accounts/+page.svelte b/frontend/src/routes/[cohort_name]/accounts/+page.svelte similarity index 100% rename from frontend/src/routes/accounts/+page.svelte rename to frontend/src/routes/[cohort_name]/accounts/+page.svelte diff --git a/frontend/src/routes/auction/+page.svelte b/frontend/src/routes/[cohort_name]/auction/+page.svelte similarity index 81% rename from frontend/src/routes/auction/+page.svelte rename to frontend/src/routes/[cohort_name]/auction/+page.svelte index 68e8b32a..10cd1136 100644 --- a/frontend/src/routes/auction/+page.svelte +++ b/frontend/src/routes/[cohort_name]/auction/+page.svelte @@ -1,5 +1,4 @@ + +{#if loading} +
+
+
+{:else if !isAdmin} +
+

Admin access required.

+
+{:else} +
+
+

Admin

+ Back to cohorts +
+ + +
+

Cohorts

+
+

Create New Cohort

+
+ + + +
+ +
+
+ {#each cohorts as cohort} +
+
+ {cohort.display_name} + ({cohort.name}) + {#if cohort.is_read_only} + + Read-only + + {/if} +
+
+ + +
+
+ {/each} +
+
+ + + {#if selectedCohort} +
+

+ Members: {selectedCohort.display_name} +

+
+

Add Members

+ + +
+ {#if loadingMembers} +

Loading members...

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

No members yet.

+ {:else} +
+ {#each members as member} +
+
+ {#if member.display_name} + {member.display_name} + {/if} + {#if member.email} + {member.email} + {:else if !member.display_name} + User #{member.global_user_id} + {/if} +
+ +
+ {/each} +
+ {/if} +
+ {/if} + + +
+

Global Config

+
+
+ + +
+
+ +
+ +
+
+
+{/if} diff --git a/frontend/src/routes/auction/+page.ts b/frontend/src/routes/auction/+page.ts deleted file mode 100644 index 69833202..00000000 --- a/frontend/src/routes/auction/+page.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function load() { - // redirect(307, '/market'); -} diff --git a/schema-js/index.d.ts b/schema-js/index.d.ts index 2e8de787..1547c917 100644 --- a/schema-js/index.d.ts +++ b/schema-js/index.d.ts @@ -391,6 +391,9 @@ export namespace websocket_api { /** Authenticated accountId */ accountId?: (number|Long|null); + + /** Authenticated auctionOnly */ + auctionOnly?: (boolean|null); } /** Represents an Authenticated. */ @@ -405,6 +408,9 @@ export namespace websocket_api { /** Authenticated accountId. */ public accountId: (number|Long); + /** Authenticated auctionOnly. */ + public auctionOnly: boolean; + /** * Creates a new Authenticated instance using the specified properties. * @param [properties] Properties to set diff --git a/schema-js/index.js b/schema-js/index.js index 2f05d27a..829fdcf7 100644 --- a/schema-js/index.js +++ b/schema-js/index.js @@ -1543,6 +1543,7 @@ $root.websocket_api = (function() { * @memberof websocket_api * @interface IAuthenticated * @property {number|Long|null} [accountId] Authenticated accountId + * @property {boolean|null} [auctionOnly] Authenticated auctionOnly */ /** @@ -1568,6 +1569,14 @@ $root.websocket_api = (function() { */ Authenticated.prototype.accountId = $util.Long ? $util.Long.fromBits(0,0,false) : 0; + /** + * Authenticated auctionOnly. + * @member {boolean} auctionOnly + * @memberof websocket_api.Authenticated + * @instance + */ + Authenticated.prototype.auctionOnly = false; + /** * Creates a new Authenticated instance using the specified properties. * @function create @@ -1594,6 +1603,8 @@ $root.websocket_api = (function() { writer = $Writer.create(); if (message.accountId != null && Object.hasOwnProperty.call(message, "accountId")) writer.uint32(/* id 1, wireType 0 =*/8).int64(message.accountId); + if (message.auctionOnly != null && Object.hasOwnProperty.call(message, "auctionOnly")) + writer.uint32(/* id 2, wireType 0 =*/16).bool(message.auctionOnly); return writer; }; @@ -1632,6 +1643,10 @@ $root.websocket_api = (function() { message.accountId = reader.int64(); break; } + case 2: { + message.auctionOnly = reader.bool(); + break; + } default: reader.skipType(tag & 7); break; @@ -1670,6 +1685,9 @@ $root.websocket_api = (function() { if (message.accountId != null && message.hasOwnProperty("accountId")) if (!$util.isInteger(message.accountId) && !(message.accountId && $util.isInteger(message.accountId.low) && $util.isInteger(message.accountId.high))) return "accountId: integer|Long expected"; + if (message.auctionOnly != null && message.hasOwnProperty("auctionOnly")) + if (typeof message.auctionOnly !== "boolean") + return "auctionOnly: boolean expected"; return null; }; @@ -1694,6 +1712,8 @@ $root.websocket_api = (function() { message.accountId = object.accountId; else if (typeof object.accountId === "object") message.accountId = new $util.LongBits(object.accountId.low >>> 0, object.accountId.high >>> 0).toNumber(); + if (object.auctionOnly != null) + message.auctionOnly = Boolean(object.auctionOnly); return message; }; @@ -1710,17 +1730,21 @@ $root.websocket_api = (function() { if (!options) options = {}; var object = {}; - if (options.defaults) + if (options.defaults) { if ($util.Long) { var long = new $util.Long(0, 0, false); object.accountId = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; } else object.accountId = options.longs === String ? "0" : 0; + object.auctionOnly = false; + } if (message.accountId != null && message.hasOwnProperty("accountId")) if (typeof message.accountId === "number") object.accountId = options.longs === String ? String(message.accountId) : message.accountId; else object.accountId = options.longs === String ? $util.Long.prototype.toString.call(message.accountId) : options.longs === Number ? new $util.LongBits(message.accountId.low >>> 0, message.accountId.high >>> 0).toNumber() : message.accountId; + if (message.auctionOnly != null && message.hasOwnProperty("auctionOnly")) + object.auctionOnly = message.auctionOnly; return object; }; diff --git a/schema/server-message.proto b/schema/server-message.proto index 3afdb35d..42aa913b 100644 --- a/schema/server-message.proto +++ b/schema/server-message.proto @@ -61,6 +61,7 @@ message MarketTypeDeleted { } message Authenticated { int64 account_id = 1; + bool auction_only = 2; } message ActingAs { int64 account_id = 1; From 7ec644de4c0b5c77bbf90b29970fdc708a3bcb52 Mon Sep 17 00:00:00 2001 From: crthpl Date: Thu, 5 Mar 2026 10:35:40 -0800 Subject: [PATCH 02/21] Add per-member initial balance, cohort detail page, and python client multi-cohort support - Add initial_balance column to cohort_member table with migration - Look up configured initial balance on account creation, falling back to legacy defaults (admin=100M, non-admin=0) when NULL - Split member management into dedicated /admin/cohorts/[name] detail page - Simplify admin page cohort list with navigation links - Add default_cohort_id config option - Update python client for multi-cohort WebSocket endpoints --- ...8860ee9e6fb9c159895ae83c13d0308cd8cb0.json | 2 +- ...5faead219b685cdf38a466058067e3b795151.json | 2 +- backend/example.env | 1 + .../002_add_initial_balance.sql | 1 + backend/src/auth.rs | 31 +- backend/src/db.rs | 14 + backend/src/global_db.rs | 31 +- backend/src/handle_socket.rs | 13 +- backend/src/lib.rs | 4 +- backend/src/main.rs | 204 ++++++++-- frontend/src/lib/adminApi.ts | 45 ++- frontend/src/lib/components/appSideBar.svelte | 9 +- .../src/lib/components/selectMarket.svelte | 4 +- frontend/src/lib/testAuth.svelte.ts | 7 +- frontend/src/routes/(auth)/login/+page.svelte | 15 +- frontend/src/routes/+page.svelte | 25 +- .../[cohort_name]/docs/[slug]/+page.svelte | 4 +- frontend/src/routes/admin/+page.svelte | 353 ++++++++++-------- .../routes/admin/cohorts/[name]/+page.svelte | 346 +++++++++++++++++ python-client/README.md | 12 +- python-client/example.env | 3 +- python-client/examples/market_maker_bot.py | 5 +- python-client/examples/min_max_bot.py | 5 +- python-client/examples/naive_bot.py | 5 +- python-client/examples/twap_bot.py | 7 +- python-client/pyproject.toml | 2 +- python-client/src/metagame/__init__.py | 4 +- python-client/src/metagame/trading_client.py | 47 ++- python-client/src/metagame/websocket_api.py | 27 +- 29 files changed, 953 insertions(+), 275 deletions(-) create mode 100644 backend/global_migrations/002_add_initial_balance.sql create mode 100644 frontend/src/routes/admin/cohorts/[name]/+page.svelte diff --git a/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json b/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json index 62ccad36..0c20ab1c 100644 --- a/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json +++ b/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json @@ -53,7 +53,7 @@ "Right": 1 }, "nullable": [ - false, + true, false, false, false, diff --git a/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json b/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json index 9aa1ab1b..c0c21fca 100644 --- a/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json +++ b/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json @@ -53,7 +53,7 @@ "Right": 0 }, "nullable": [ - false, + true, false, false, false, diff --git a/backend/example.env b/backend/example.env index 5db904d6..a30afac4 100644 --- a/backend/example.env +++ b/backend/example.env @@ -1,4 +1,5 @@ KINDE_ISSUER=https://account.trading.camp KINDE_AUDIENCE=trading-server-api,a9869bb1225848b9ad5bad2a04b72b5f DATABASE_URL=sqlite://db.sqlite +GLOBAL_DATABASE_URL=sqlite://global.sqlite UPLOAD_DIR=./uploads diff --git a/backend/global_migrations/002_add_initial_balance.sql b/backend/global_migrations/002_add_initial_balance.sql new file mode 100644 index 00000000..a0e431ad --- /dev/null +++ b/backend/global_migrations/002_add_initial_balance.sql @@ -0,0 +1 @@ +ALTER TABLE cohort_member ADD COLUMN initial_balance TEXT; diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 31482b5c..bdd198c3 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -37,6 +37,9 @@ pub struct AccessClaims { pub sub: String, #[serde(default)] pub roles: Vec, + /// Email from the token (populated in dev-mode test tokens; not present in real JWTs) + #[serde(default)] + pub email: Option, } #[derive(Debug, Deserialize)] @@ -61,6 +64,27 @@ impl FromRequestParts for AccessClaims { (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response() })?; let token = bearer.token(); + + // Dev-mode: support test tokens for REST endpoints + #[cfg(feature = "dev-mode")] + if let Some(rest) = token.strip_prefix("test::") { + let parts: Vec<&str> = rest.split("::").collect(); + if parts.len() >= 3 { + let kinde_id = parts[0]; + let is_admin = parts[2].eq_ignore_ascii_case("true"); + let email = parts.get(3).map(|e| e.to_string()).filter(|e| !e.is_empty()); + let mut roles = vec![Role::Trader]; + if is_admin { + roles.push(Role::Admin); + } + return Ok(AccessClaims { + sub: kinde_id.to_string(), + roles, + email, + }); + } + } + let claims = validate_jwt(token).await.map_err(|e| { tracing::error!("JWT validation failed: {:?}", e); (StatusCode::UNAUTHORIZED, "Bad JWT").into_response() @@ -156,13 +180,14 @@ pub fn validate_test_token(token: &str) -> anyhow::Result { } let parts: Vec<&str> = token.split("::").collect(); - if parts.len() != 4 { - anyhow::bail!("Invalid test token format: expected test::::::"); + if parts.len() < 4 { + anyhow::bail!("Invalid test token format: expected test::::::[::]"); } let kinde_id = parts[1].to_string(); let name = parts[2].to_string(); let is_admin = parts[3].parse::().unwrap_or(false); + let email = parts.get(4).map(|e| e.to_string()).filter(|e| !e.is_empty()); let roles = if is_admin { vec![Role::Admin] @@ -174,7 +199,7 @@ pub fn validate_test_token(token: &str) -> anyhow::Result { id: kinde_id, roles, name: Some(name), - email: None, + email, }) } diff --git a/backend/src/db.rs b/backend/src/db.rs index 361a11ee..839ad5b2 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1035,6 +1035,20 @@ impl DB { .await } + /// Get the balance for an account by global_user_id. + pub async fn get_balance_by_global_user_id( + &self, + global_user_id: i64, + ) -> SqlxResult> { + let row = sqlx::query_scalar::<_, String>( + r#"SELECT balance FROM account WHERE global_user_id = ?"#, + ) + .bind(global_user_id) + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|b| b.parse::().ok())) + } + /// Set the global_user_id for an account (used during migration). pub async fn set_global_user_id( &self, diff --git a/backend/src/global_db.rs b/backend/src/global_db.rs index 51c9543d..5a9f3540 100644 --- a/backend/src/global_db.rs +++ b/backend/src/global_db.rs @@ -35,6 +35,7 @@ pub struct CohortMember { pub global_user_id: Option, pub email: Option, pub display_name: Option, + pub initial_balance: Option, } impl GlobalDB { @@ -300,6 +301,7 @@ impl GlobalDB { &self, cohort_id: i64, emails: &[String], + initial_balance: Option<&str>, ) -> Result { let mut added = 0; for email in emails { @@ -311,10 +313,11 @@ impl GlobalDB { // Check if this email matches an existing global user // (we don't have email in global_user, so just store the email for now) let result = sqlx::query( - r#"INSERT INTO cohort_member (cohort_id, email) VALUES (?, ?) ON CONFLICT DO NOTHING"#, + r#"INSERT INTO cohort_member (cohort_id, email, initial_balance) VALUES (?, ?, ?) ON CONFLICT DO NOTHING"#, ) .bind(cohort_id) .bind(&email) + .bind(initial_balance) .execute(&self.pool) .await?; @@ -333,12 +336,14 @@ impl GlobalDB { &self, cohort_id: i64, global_user_id: i64, + initial_balance: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query( - r#"INSERT INTO cohort_member (cohort_id, global_user_id) VALUES (?, ?) ON CONFLICT DO NOTHING"#, + r#"INSERT INTO cohort_member (cohort_id, global_user_id, initial_balance) VALUES (?, ?, ?) ON CONFLICT DO NOTHING"#, ) .bind(cohort_id) .bind(global_user_id) + .bind(initial_balance) .execute(&self.pool) .await?; Ok(()) @@ -371,7 +376,7 @@ impl GlobalDB { ) -> Result, sqlx::Error> { let rows = sqlx::query_as::<_, CohortMemberRow>( r#" - SELECT cm.id, cm.cohort_id, cm.global_user_id, cm.email, gu.display_name + SELECT cm.id, cm.cohort_id, cm.global_user_id, cm.email, gu.display_name, cm.initial_balance FROM cohort_member cm LEFT JOIN global_user gu ON gu.id = cm.global_user_id WHERE cm.cohort_id = ? @@ -390,6 +395,7 @@ impl GlobalDB { global_user_id: r.global_user_id, email: r.email, display_name: r.display_name, + initial_balance: r.initial_balance, }) .collect()) } @@ -431,6 +437,24 @@ impl GlobalDB { Ok(()) } + /// Get a member's initial balance for a specific cohort. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn get_member_initial_balance( + &self, + cohort_id: i64, + global_user_id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, String>( + r#"SELECT initial_balance FROM cohort_member WHERE cohort_id = ? AND global_user_id = ? AND initial_balance IS NOT NULL"#, + ) + .bind(cohort_id) + .bind(global_user_id) + .fetch_optional(&self.pool) + .await + } + /// Get all global users (for admin UI). /// /// # Errors @@ -451,4 +475,5 @@ struct CohortMemberRow { global_user_id: Option, email: Option, display_name: Option, + initial_balance: Option, } diff --git a/backend/src/handle_socket.rs b/backend/src/handle_socket.rs index 3dcad908..4d290fee 100644 --- a/backend/src/handle_socket.rs +++ b/backend/src/handle_socket.rs @@ -1240,7 +1240,7 @@ async fn authenticate( #[cfg(feature = "dev-mode")] if !is_member { if let Err(e) = global_db - .add_member_by_user_id(cohort.info.id, global_user.id) + .add_member_by_user_id(cohort.info.id, global_user.id, None) .await { tracing::warn!("Failed to auto-add user as cohort member: {e}"); @@ -1279,7 +1279,16 @@ async fn authenticate( } } - let initial_balance = if is_admin { dec!(100_000_000) } else { dec!(0) }; + let initial_balance = match global_db + .get_member_initial_balance(cohort.info.id, global_user.id) + .await + .ok() + .flatten() + .and_then(|s| rust_decimal::Decimal::from_str_exact(&s).ok()) + { + Some(bal) => bal, + None => if is_admin { dec!(100_000_000) } else { dec!(0) }, + }; // Create/find user in cohort DB using global_user_id let result = db diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 39347b32..5f0b1056 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -132,7 +132,7 @@ impl AppState { .await?; // Also add as cohort member global_db - .add_member_by_user_id(cohort_info.id, global_user.id) + .add_member_by_user_id(cohort_info.id, global_user.id, None) .await?; } Ok(()) @@ -158,7 +158,7 @@ impl AppState { let global_user = self.global_db.ensure_global_user(&kinde_id, &name).await?; db.set_global_user_id(account_id, global_user.id).await?; self.global_db - .add_member_by_user_id(cohort_info.id, global_user.id) + .add_member_by_user_id(cohort_info.id, global_user.id, None) .await?; } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 999a051c..dc7bbea6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -59,6 +59,7 @@ async fn main() -> anyhow::Result<()> { ) .route("/api/admin/config", get(get_config).put(update_config)) .route("/api/admin/users", get(list_users)) + .route("/api/admin/users/details", get(list_users_detailed)) .route("/api/admin/users/:id/admin", put(toggle_admin)) // Legacy / utility routes .route("/sync-airtable-users", get(sync_airtable_users)) @@ -122,6 +123,7 @@ async fn cohort_ws( struct CohortsResponse { cohorts: Vec, active_auction_cohort: Option, + default_cohort: Option, public_auction_enabled: bool, } @@ -130,35 +132,26 @@ async fn list_cohorts( claims: AccessClaims, State(state): State, ) -> Result, (StatusCode, String)> { + // Ensure global user exists (creates if needed, same as WS auth flow) + let display_name = claims.sub.clone(); // Fallback; WS auth will update with real name let global_user = state .global_db - .get_global_user_by_kinde_id(&claims.sub) + .ensure_global_user(&claims.sub, &display_name) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let Some(global_user) = global_user else { - // User hasn't connected via WebSocket yet, so no global user record. - // Return empty cohorts. - let public_auction_enabled = state - .global_db - .get_config("public_auction_enabled") - .await - .unwrap_or(None) - .is_some_and(|v| v == "true"); - - let active_auction_cohort = get_active_auction_cohort_name(&state).await; + // Link email-based pre-authorizations if we have an email + if let Some(email) = &claims.email { + if let Err(e) = state.global_db.link_email_to_user(email, global_user.id).await { + tracing::warn!("Failed to link email to user in list_cohorts: {e}"); + } + } - return Ok(Json(CohortsResponse { - cohorts: vec![], - active_auction_cohort, - public_auction_enabled, - })); - }; + let is_admin_by_role = claims + .roles + .contains(&backend::auth::Role::Admin); - let is_admin = global_user.is_admin - || claims - .roles - .contains(&backend::auth::Role::Admin); + let is_admin = global_user.is_admin || is_admin_by_role; let cohorts = if is_admin { state @@ -182,18 +175,20 @@ async fn list_cohorts( .is_some_and(|v| v == "true"); let active_auction_cohort = get_active_auction_cohort_name(&state).await; + let default_cohort = get_cohort_name_by_config_key(&state, "default_cohort_id").await; Ok(Json(CohortsResponse { cohorts, active_auction_cohort, + default_cohort, public_auction_enabled, })) } -async fn get_active_auction_cohort_name(state: &AppState) -> Option { +async fn get_cohort_name_by_config_key(state: &AppState, config_key: &str) -> Option { let cohort_id = state .global_db - .get_config("active_auction_cohort_id") + .get_config(config_key) .await .ok() .flatten() @@ -206,6 +201,10 @@ async fn get_active_auction_cohort_name(state: &AppState) -> Option { .map(|c| c.name) } +async fn get_active_auction_cohort_name(state: &AppState) -> Option { + get_cohort_name_by_config_key(state, "active_auction_cohort_id").await +} + // --- Admin Endpoints --- fn check_admin(claims: &AccessClaims) -> Result<(), (StatusCode, String)> { @@ -236,7 +235,6 @@ async fn admin_list_cohorts( struct CreateCohortRequest { name: String, display_name: String, - db_path: String, } #[axum::debug_handler] @@ -247,9 +245,20 @@ async fn create_cohort( ) -> Result, (StatusCode, String)> { check_admin(&claims)?; + // Determine data directory from DATABASE_URL or default + let data_dir = std::env::var("DATABASE_URL") + .ok() + .and_then(|url| { + let path = url.trim_start_matches("sqlite://").trim_start_matches('/'); + Path::new(path).parent().map(|p| p.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "/data".to_string()); + + let db_path = format!("{}/{}.sqlite", data_dir, body.name); + let cohort_info = state .global_db - .create_cohort(&body.name, &body.display_name, &body.db_path) + .create_cohort(&body.name, &body.display_name, &db_path) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -306,12 +315,19 @@ async fn update_cohort( Ok(StatusCode::OK) } +#[derive(Serialize)] +struct MemberWithBalance { + #[serde(flatten)] + member: backend::global_db::CohortMember, + balance: Option, +} + #[axum::debug_handler] async fn list_members( claims: AccessClaims, AxumPath(name): AxumPath, State(state): State, -) -> Result>, (StatusCode, String)> { +) -> Result>, (StatusCode, String)> { check_admin(&claims)?; let cohort = state @@ -321,17 +337,36 @@ async fn list_members( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; - state + let members = state .global_db .get_cohort_members(cohort.id) .await - .map(Json) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let cohort_state = state.cohorts.get(&name); + + let mut result = Vec::with_capacity(members.len()); + for member in members { + let balance = if let (Some(global_user_id), Some(cs)) = + (member.global_user_id, &cohort_state) + { + cs.db.get_balance_by_global_user_id(global_user_id).await.ok().flatten() + } else { + None + }; + result.push(MemberWithBalance { member, balance }); + } + + Ok(Json(result)) } #[derive(Deserialize)] struct BatchAddMembersRequest { + #[serde(default)] emails: Vec, + #[serde(default)] + user_ids: Vec, + initial_balance: Option, } #[derive(Serialize)] @@ -355,11 +390,24 @@ async fn batch_add_members( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; - let added = state - .global_db - .batch_add_members(cohort.id, &body.emails) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let mut added = 0; + + if !body.emails.is_empty() { + added += state + .global_db + .batch_add_members(cohort.id, &body.emails, body.initial_balance.as_deref()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + for user_id in &body.user_ids { + state + .global_db + .add_member_by_user_id(cohort.id, *user_id, body.initial_balance.as_deref()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + added += 1; + } Ok(Json(BatchAddMembersResponse { added })) } @@ -391,6 +439,7 @@ async fn remove_member( #[derive(Serialize)] struct GlobalConfig { active_auction_cohort_id: Option, + default_cohort_id: Option, public_auction_enabled: bool, } @@ -408,6 +457,13 @@ async fn get_config( .unwrap_or(None) .and_then(|v| v.parse().ok()); + let default_cohort_id = state + .global_db + .get_config("default_cohort_id") + .await + .unwrap_or(None) + .and_then(|v| v.parse().ok()); + let public_auction_enabled = state .global_db .get_config("public_auction_enabled") @@ -417,13 +473,15 @@ async fn get_config( Ok(Json(GlobalConfig { active_auction_cohort_id, + default_cohort_id, public_auction_enabled, })) } #[derive(Deserialize)] struct UpdateConfigRequest { - active_auction_cohort_id: Option, + active_auction_cohort_id: Option>, + default_cohort_id: Option>, public_auction_enabled: Option, } @@ -435,10 +493,25 @@ async fn update_config( ) -> Result { check_admin(&claims)?; - if let Some(id) = body.active_auction_cohort_id { + if let Some(maybe_id) = body.active_auction_cohort_id { + let value = match maybe_id { + Some(id) => id.to_string(), + None => String::new(), + }; state .global_db - .set_config("active_auction_cohort_id", &id.to_string()) + .set_config("active_auction_cohort_id", &value) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + if let Some(maybe_id) = body.default_cohort_id { + let value = match maybe_id { + Some(id) => id.to_string(), + None => String::new(), + }; + state + .global_db + .set_config("default_cohort_id", &value) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } @@ -468,6 +541,61 @@ async fn list_users( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } +#[derive(Serialize)] +struct UserCohortDetail { + cohort_name: String, + cohort_display_name: String, + balance: Option, +} + +#[derive(Serialize)] +struct UserWithCohorts { + #[serde(flatten)] + user: backend::global_db::GlobalUser, + cohorts: Vec, +} + +#[axum::debug_handler] +async fn list_users_detailed( + claims: AccessClaims, + State(state): State, +) -> Result>, (StatusCode, String)> { + check_admin(&claims)?; + + let users = state + .global_db + .get_all_users() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut result = Vec::with_capacity(users.len()); + for user in users { + let user_cohort_infos = state + .global_db + .get_user_cohorts(user.id) + .await + .unwrap_or_default(); + + let mut cohorts = Vec::new(); + for ci in &user_cohort_infos { + let balance = if let Some(cs) = state.cohorts.get(&ci.name) { + cs.db.get_balance_by_global_user_id(user.id).await.ok().flatten() + } else { + None + }; + cohorts.push(UserCohortDetail { + cohort_name: ci.name.clone(), + cohort_display_name: ci.display_name.clone(), + balance, + }); + } + + result.push(UserWithCohorts { user, cohorts }); + } + + Ok(Json(result)) +} + #[derive(Deserialize)] struct ToggleAdminRequest { is_admin: bool, diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 757e2412..597876fe 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -14,13 +14,33 @@ export interface CohortMember { global_user_id: number | null; email: string | null; display_name: string | null; + initial_balance: string | null; + balance: number | null; } export interface GlobalConfig { active_auction_cohort_id: number | null; + default_cohort_id: number | null; public_auction_enabled: boolean; } +export interface GlobalUser { + id: number; + kinde_id: string; + display_name: string; + is_admin: boolean; +} + +export interface UserCohortDetail { + cohort_name: string; + cohort_display_name: string; + balance: number | null; +} + +export interface UserWithCohorts extends GlobalUser { + cohorts: UserCohortDetail[]; +} + async function authHeaders(): Promise { const token = await kinde.getToken(); return { @@ -42,15 +62,11 @@ export async function fetchAllCohorts(): Promise { return handleResponse(res); } -export async function createCohort( - name: string, - displayName: string, - dbPath: string -): Promise { +export async function createCohort(name: string, displayName: string): Promise { const res = await fetch('/api/admin/cohorts', { method: 'POST', headers: await authHeaders(), - body: JSON.stringify({ name, display_name: displayName, db_path: dbPath }) + body: JSON.stringify({ name, display_name: displayName }) }); return handleResponse(res); } @@ -78,14 +94,24 @@ export async function fetchMembers(cohortName: string): Promise return handleResponse(res); } +export async function fetchGlobalUsers(): Promise { + const res = await fetch('/api/admin/users', { headers: await authHeaders() }); + return handleResponse(res); +} + +export async function fetchUsersDetailed(): Promise { + const res = await fetch('/api/admin/users/details', { headers: await authHeaders() }); + return handleResponse(res); +} + export async function batchAddMembers( cohortName: string, - emails: string[] + opts: { emails?: string[]; user_ids?: number[]; initial_balance?: string } ): Promise<{ added: number; already_existing: number }> { const res = await fetch(`/api/admin/cohorts/${cohortName}/members`, { method: 'POST', headers: await authHeaders(), - body: JSON.stringify({ emails }) + body: JSON.stringify(opts) }); return handleResponse(res); } @@ -107,7 +133,8 @@ export async function fetchConfig(): Promise { } export async function updateConfig(config: { - active_auction_cohort_id?: number; + active_auction_cohort_id?: number | null; + default_cohort_id?: number | null; public_auction_enabled?: boolean; }): Promise { const res = await fetch('/api/admin/config', { diff --git a/frontend/src/lib/components/appSideBar.svelte b/frontend/src/lib/components/appSideBar.svelte index b2948f4e..0d26eaba 100644 --- a/frontend/src/lib/components/appSideBar.svelte +++ b/frontend/src/lib/components/appSideBar.svelte @@ -171,7 +171,14 @@ {#snippet tooltipContent()}Switch Cohort{/snippet} {#snippet child({ props })} - + { + localStorage.removeItem('lastCohort'); + handleClick(); + }} + > Switch Cohort diff --git a/frontend/src/lib/components/selectMarket.svelte b/frontend/src/lib/components/selectMarket.svelte index 7c25270f..bbd07a14 100644 --- a/frontend/src/lib/components/selectMarket.svelte +++ b/frontend/src/lib/components/selectMarket.svelte @@ -91,7 +91,9 @@ No markets available onSelect()}> - All Markets + + All Markets + {#each availableMarkets as { id, name, market } (id)} +
+ + +
+
+ + {#if availableDbs.length > 0} +
+

Available Databases

+
+ {#each availableDbs as db} + + {/each} +
+
+ {/if}
{#each cohorts as cohort} Date: Thu, 5 Mar 2026 13:13:30 -0800 Subject: [PATCH 09/21] Make read-only toggle take effect immediately via AtomicBool - CohortState.is_read_only is now AtomicBool, loaded per-message - Admin toggle updates the atomic, no restart needed --- backend/src/handle_socket.rs | 16 ++++++++-------- backend/src/lib.rs | 6 +++++- backend/src/main.rs | 8 +++----- backend/src/test_utils.rs | 1 + 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/backend/src/handle_socket.rs b/backend/src/handle_socket.rs index 4d290fee..9c9ca65c 100644 --- a/backend/src/handle_socket.rs +++ b/backend/src/handle_socket.rs @@ -36,7 +36,7 @@ pub async fn handle_socket(socket: WebSocket, app_state: AppState, cohort: Arc) -> anyhow::Result<()> { - let is_read_only = cohort.info.is_read_only; + let is_read_only = &cohort.is_read_only; let AuthenticatedClient { id: mut user_id, is_admin, @@ -527,7 +527,7 @@ async fn handle_client_message( user_id: i64, acting_as: i64, owned_accounts: &[i64], - is_read_only: bool, + is_read_only: &std::sync::atomic::AtomicBool, auction_only: bool, msg: ws::Message, ) -> anyhow::Result> { @@ -593,7 +593,7 @@ async fn handle_client_message( // Check read-only and auction-only restrictions macro_rules! check_mutation_allowed { ($msg_type:expr) => { - if is_read_only { + if is_read_only.load(std::sync::atomic::Ordering::Relaxed) { fail!($msg_type, "Cohort is read-only"); } if auction_only { @@ -929,7 +929,7 @@ async fn handle_client_message( }; } CM::CreateAuction(create_auction) => { - if is_read_only { + if is_read_only.load(std::sync::atomic::Ordering::Relaxed) { fail!("CreateAuction", "Cohort is read-only"); } check_expensive_rate_limit!("CreateMarket"); @@ -947,7 +947,7 @@ async fn handle_client_message( }; } CM::SettleAuction(settle_auction) => { - if is_read_only { + if is_read_only.load(std::sync::atomic::Ordering::Relaxed) { fail!("SettleAuction", "Cohort is read-only"); } check_expensive_rate_limit!("SettleAuction"); @@ -982,7 +982,7 @@ async fn handle_client_message( } } CM::BuyAuction(buy_auction) => { - if is_read_only { + if is_read_only.load(std::sync::atomic::Ordering::Relaxed) { fail!("BuyAuction", "Cohort is read-only"); } check_expensive_rate_limit!("SettleAuction"); @@ -1018,7 +1018,7 @@ async fn handle_client_message( }; } CM::DeleteAuction(delete_auction) => { - if is_read_only { + if is_read_only.load(std::sync::atomic::Ordering::Relaxed) { fail!("DeleteAuction", "Cohort is read-only"); } check_expensive_rate_limit!("DeleteAuction"); @@ -1039,7 +1039,7 @@ async fn handle_client_message( } } CM::EditAuction(edit_auction) => { - if is_read_only { + if is_read_only.load(std::sync::atomic::Ordering::Relaxed) { fail!("EditAuction", "Cohort is read-only"); } check_expensive_rate_limit!("EditAuction"); diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 4f908a60..486aa6e2 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::{atomic::AtomicBool, Arc}}; use dashmap::DashMap; use db::DB; @@ -17,6 +17,7 @@ pub struct CohortState { pub db: DB, pub subscriptions: Subscriptions, pub info: CohortInfo, + pub is_read_only: AtomicBool, } #[derive(Clone)] @@ -52,6 +53,7 @@ impl AppState { let cohort_state = Arc::new(CohortState { db, subscriptions: Subscriptions::new(), + is_read_only: AtomicBool::new(cohort_info.is_read_only), info: cohort_info.clone(), }); cohorts.insert(cohort_info.name.clone(), cohort_state); @@ -90,6 +92,7 @@ impl AppState { let cohort_state = Arc::new(CohortState { db, subscriptions: Subscriptions::new(), + is_read_only: AtomicBool::new(cohort_info.is_read_only), info: cohort_info, }); cohorts.insert("main".to_string(), cohort_state); @@ -166,6 +169,7 @@ impl AppState { let cohort_state = Arc::new(CohortState { db, subscriptions: Subscriptions::new(), + is_read_only: AtomicBool::new(cohort_info.is_read_only), info: cohort_info.clone(), }); self.cohorts diff --git a/backend/src/main.rs b/backend/src/main.rs index f73a2a06..2aa74d9c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -314,12 +314,10 @@ async fn update_cohort( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Update in-memory cohort info if read-only changed + // Update in-memory read-only flag (takes effect immediately for all connections) if let Some(is_read_only) = body.is_read_only { - if let Some(_cohort_state) = state.cohorts.get_mut(&name) { - // Read-only enforcement is checked per-request from DB. - // In-memory CohortInfo will be updated on next restart. - let _ = is_read_only; + if let Some(cohort_state) = state.cohorts.get(&name) { + cohort_state.is_read_only.store(is_read_only, std::sync::atomic::Ordering::Relaxed); } } diff --git a/backend/src/test_utils.rs b/backend/src/test_utils.rs index 3063b93b..b09a42bc 100644 --- a/backend/src/test_utils.rs +++ b/backend/src/test_utils.rs @@ -75,6 +75,7 @@ pub async fn create_test_app_state() -> anyhow::Result<(AppState, TempDir)> { let cohort_state = Arc::new(CohortState { db, subscriptions: Subscriptions::new(), + is_read_only: std::sync::atomic::AtomicBool::new(false), info: cohort_info, }); cohorts.insert("test".to_string(), cohort_state); From e644b8ab96f6756079137987e966a9242460d69d Mon Sep 17 00:00:00 2001 From: crthpl Date: Thu, 5 Mar 2026 13:22:12 -0800 Subject: [PATCH 10/21] Fix CORS preflight: use explicit CorsLayer config instead of permissive() --- backend/src/main.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 2aa74d9c..1fc03180 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -65,7 +65,13 @@ async fn main() -> anyhow::Result<()> { .route("/api/images/:filename", get(serve_image)) .layer(TraceLayer::new_for_http()) .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) - .layer(CorsLayer::permissive()) + .layer( + CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods(tower_http::cors::Any) + .allow_headers(tower_http::cors::Any) + .allow_private_network(true), + ) .with_state(AppState { uploads_dir: uploads_dir.to_path_buf(), ..state From 850b14da65d704edb1c8f20105e414a94dd2535f Mon Sep 17 00:00:00 2001 From: crthpl Date: Thu, 5 Mar 2026 13:28:50 -0800 Subject: [PATCH 11/21] Add email to global users, show in admin UI - New migration adds email column to global_user - ensure_global_user stores email from Kinde auth - Cohort members query falls back to global_user email - Admin "All Users" list shows emails and searches by email --- .../global_migrations/003_add_user_email.sql | 1 + backend/src/global_db.rs | 29 +++++++++++-------- backend/src/handle_socket.rs | 2 +- backend/src/lib.rs | 4 +-- backend/src/main.rs | 2 +- frontend/src/lib/adminApi.ts | 1 + frontend/src/routes/admin/+page.svelte | 13 +++++++-- 7 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 backend/global_migrations/003_add_user_email.sql diff --git a/backend/global_migrations/003_add_user_email.sql b/backend/global_migrations/003_add_user_email.sql new file mode 100644 index 00000000..29c04de2 --- /dev/null +++ b/backend/global_migrations/003_add_user_email.sql @@ -0,0 +1 @@ +ALTER TABLE "global_user" ADD COLUMN "email" TEXT; diff --git a/backend/src/global_db.rs b/backend/src/global_db.rs index 54cfad17..52729bdc 100644 --- a/backend/src/global_db.rs +++ b/backend/src/global_db.rs @@ -17,6 +17,7 @@ pub struct GlobalUser { pub kinde_id: String, pub display_name: String, pub is_admin: bool, + pub email: Option, } #[derive(Debug, Clone, FromRow, Serialize)] @@ -78,7 +79,7 @@ impl GlobalDB { Ok(Self { pool }) } - /// Create or find a global user by kinde_id. Updates display_name if user already exists. + /// Create or find a global user by kinde_id. Updates display_name and email if changed. /// /// # Errors /// Returns an error on database failure. @@ -86,45 +87,49 @@ impl GlobalDB { &self, kinde_id: &str, name: &str, + email: Option<&str>, ) -> Result { // Try to find existing user let existing = sqlx::query_as::<_, GlobalUser>( - r#"SELECT id, kinde_id, display_name, is_admin FROM global_user WHERE kinde_id = ?"#, + r#"SELECT id, kinde_id, display_name, is_admin, email FROM global_user WHERE kinde_id = ?"#, ) .bind(kinde_id) .fetch_optional(&self.pool) .await?; if let Some(mut user) = existing { - // Update display_name if changed - if user.display_name != name { - sqlx::query("UPDATE global_user SET display_name = ? WHERE id = ?") + // Update display_name and email if changed + if user.display_name != name || user.email.as_deref() != email { + sqlx::query("UPDATE global_user SET display_name = ?, email = COALESCE(?, email) WHERE id = ?") .bind(name) + .bind(email) .bind(user.id) .execute(&self.pool) .await?; user.display_name = name.to_string(); + if email.is_some() { + user.email = email.map(String::from); + } } return Ok(user); } // Create new user let id = sqlx::query_scalar::<_, i64>( - r#"INSERT INTO global_user (kinde_id, display_name) VALUES (?, ?) RETURNING id"#, + r#"INSERT INTO global_user (kinde_id, display_name, email) VALUES (?, ?, ?) RETURNING id"#, ) .bind(kinde_id) .bind(name) + .bind(email) .fetch_one(&self.pool) .await?; - // Try to link any pre-authorized email-based cohort memberships - // (This would require knowing the user's email, which comes from the ID token) - Ok(GlobalUser { id, kinde_id: kinde_id.to_string(), display_name: name.to_string(), is_admin: false, + email: email.map(String::from), }) } @@ -137,7 +142,7 @@ impl GlobalDB { kinde_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, GlobalUser>( - r#"SELECT id, kinde_id, display_name, is_admin FROM global_user WHERE kinde_id = ?"#, + r#"SELECT id, kinde_id, display_name, is_admin, email FROM global_user WHERE kinde_id = ?"#, ) .bind(kinde_id) .fetch_optional(&self.pool) @@ -388,7 +393,7 @@ impl GlobalDB { ) -> Result, sqlx::Error> { let rows = sqlx::query_as::<_, CohortMemberRow>( r#" - SELECT cm.id, cm.cohort_id, cm.global_user_id, cm.email, gu.display_name, cm.initial_balance + SELECT cm.id, cm.cohort_id, cm.global_user_id, COALESCE(cm.email, gu.email) AS email, gu.display_name, cm.initial_balance FROM cohort_member cm LEFT JOIN global_user gu ON gu.id = cm.global_user_id WHERE cm.cohort_id = ? @@ -473,7 +478,7 @@ impl GlobalDB { /// Returns an error on database failure. pub async fn get_all_users(&self) -> Result, sqlx::Error> { sqlx::query_as::<_, GlobalUser>( - r#"SELECT id, kinde_id, display_name, is_admin FROM global_user ORDER BY created_at"#, + r#"SELECT id, kinde_id, display_name, is_admin, email FROM global_user ORDER BY created_at"#, ) .fetch_all(&self.pool) .await diff --git a/backend/src/handle_socket.rs b/backend/src/handle_socket.rs index 9c9ca65c..81c99e59 100644 --- a/backend/src/handle_socket.rs +++ b/backend/src/handle_socket.rs @@ -1200,7 +1200,7 @@ async fn authenticate( // Get or create global user let display_name = valid_client.name.as_deref().unwrap_or("Unknown"); let global_user = match global_db - .ensure_global_user(&valid_client.id, display_name) + .ensure_global_user(&valid_client.id, display_name, valid_client.email.as_deref()) .await { Ok(user) => user, diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 486aa6e2..3985191e 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -129,7 +129,7 @@ impl AppState { ) -> anyhow::Result<()> { let legacy_users = cohort_db.get_legacy_kinde_users().await?; for (account_id, kinde_id, name) in legacy_users { - let global_user = global_db.ensure_global_user(&kinde_id, &name).await?; + let global_user = global_db.ensure_global_user(&kinde_id, &name, None).await?; cohort_db .set_global_user_id(account_id, global_user.id) .await?; @@ -158,7 +158,7 @@ impl AppState { cohort_info.name ); for (account_id, kinde_id, name) in legacy_users { - let global_user = self.global_db.ensure_global_user(&kinde_id, &name).await?; + let global_user = self.global_db.ensure_global_user(&kinde_id, &name, None).await?; db.set_global_user_id(account_id, global_user.id).await?; self.global_db .add_member_by_user_id(cohort_info.id, global_user.id, None) diff --git a/backend/src/main.rs b/backend/src/main.rs index 1fc03180..b769458b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -137,7 +137,7 @@ async fn list_cohorts( let display_name = claims.sub.clone(); // Fallback; WS auth will update with real name let global_user = state .global_db - .ensure_global_user(&claims.sub, &display_name) + .ensure_global_user(&claims.sub, &display_name, claims.email.as_deref()) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 2a710f78..89c79a4d 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -30,6 +30,7 @@ export interface GlobalUser { kinde_id: string; display_name: string; is_admin: boolean; + email: string | null; } export interface UserCohortDetail { diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 6c32a6dd..c3d5c48d 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -58,7 +58,11 @@ let filteredUsers = $derived.by(() => { if (!userSearch.trim()) return allUsers; const q = userSearch.toLowerCase(); - return allUsers.filter((u) => u.display_name.toLowerCase().includes(q)); + return allUsers.filter( + (u) => + u.display_name.toLowerCase().includes(q) || + (u.email && u.email.toLowerCase().includes(q)) + ); }); let lastCohortName = $state(null); @@ -324,7 +328,12 @@ {#each filteredUsers as user (user.id)}
- {user.display_name} +
+ {user.display_name} + {#if user.email} + {user.email} + {/if} +
{#if user.is_admin} Date: Thu, 5 Mar 2026 13:56:25 -0800 Subject: [PATCH 12/21] Admin UI improvements and account color coding - Display name editing for all users on accounts page (cross-cohort) - Admin user management: edit names, toggle admin, delete users with confirm modals - Account color coding: color field in proto/DB, color picker for admins, colored dots in act-as dropdown, colored header when acting as colored account - Switch cohort button only shown with multiple cohorts, moved to sidebar footer - Editable initial balance for non-instantiated cohort members - Fix 404 on admin page reload (disable prerender/SSR for admin routes) - Hide read-only toggle from main admin page (keep on cohort detail page) --- ...c4b3233d962f2bfed3a419274be6dde7983d.json} | 6 +- ...ae70c4360d7c7f087942f34fc880da0960c7.json} | 10 +- ...fdd15baf76d68296c07a3bf00a3f33115f3e.json} | 10 +- .../20260305000000_add_account_color.sql | 1 + backend/src/airtable_users.rs | 1 + backend/src/convert.rs | 4 +- backend/src/db.rs | 18 +- backend/src/global_db.rs | 50 +++++ backend/src/handle_socket.rs | 1 + backend/src/main.rs | 101 ++++++++- backend/tests/websocket_sudo.rs | 10 +- backend/tests/websocket_universes.rs | 1 + frontend/src/lib/adminApi.ts | 51 +++++ frontend/src/lib/components/appSideBar.svelte | 56 +++-- .../src/lib/components/forms/actAs.svelte | 8 +- .../lib/components/forms/createAccount.svelte | 18 ++ .../src/routes/[cohort_name]/+layout.svelte | 16 +- .../[cohort_name]/accounts/+page.svelte | 58 ++++- frontend/src/routes/admin/+layout.ts | 2 + frontend/src/routes/admin/+page.svelte | 206 +++++++++++++++--- .../routes/admin/cohorts/[name]/+page.svelte | 68 +++++- schema-js/index.d.ts | 12 + schema-js/index.js | 46 ++++ schema/account.proto | 1 + schema/client-message.proto | 1 + 25 files changed, 672 insertions(+), 84 deletions(-) rename backend/.sqlx/{query-70fa98229613ea7661c9a7857816973565c162be79937b9f1022e56db697037c.json => query-0dfe127b9457115c801f51859405c4b3233d962f2bfed3a419274be6dde7983d.json} (59%) rename backend/.sqlx/{query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json => query-411e2737e334e6e70fc1b288e146ae70c4360d7c7f087942f34fc880da0960c7.json} (71%) rename backend/.sqlx/{query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json => query-c5b871f0027f934fbacb865decf5fdd15baf76d68296c07a3bf00a3f33115f3e.json} (70%) create mode 100644 backend/migrations/20260305000000_add_account_color.sql create mode 100644 frontend/src/routes/admin/+layout.ts diff --git a/backend/.sqlx/query-70fa98229613ea7661c9a7857816973565c162be79937b9f1022e56db697037c.json b/backend/.sqlx/query-0dfe127b9457115c801f51859405c4b3233d962f2bfed3a419274be6dde7983d.json similarity index 59% rename from backend/.sqlx/query-70fa98229613ea7661c9a7857816973565c162be79937b9f1022e56db697037c.json rename to backend/.sqlx/query-0dfe127b9457115c801f51859405c4b3233d962f2bfed3a419274be6dde7983d.json index 216565e4..3437181d 100644 --- a/backend/.sqlx/query-70fa98229613ea7661c9a7857816973565c162be79937b9f1022e56db697037c.json +++ b/backend/.sqlx/query-0dfe127b9457115c801f51859405c4b3233d962f2bfed3a419274be6dde7983d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n INSERT INTO account (name, balance, universe_id)\n VALUES (?, ?, ?)\n RETURNING id\n ", + "query": "\n INSERT INTO account (name, balance, universe_id, color)\n VALUES (?, ?, ?, ?)\n RETURNING id\n ", "describe": { "columns": [ { @@ -10,11 +10,11 @@ } ], "parameters": { - "Right": 3 + "Right": 4 }, "nullable": [ false ] }, - "hash": "70fa98229613ea7661c9a7857816973565c162be79937b9f1022e56db697037c" + "hash": "0dfe127b9457115c801f51859405c4b3233d962f2bfed3a419274be6dde7983d" } diff --git a/backend/.sqlx/query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json b/backend/.sqlx/query-411e2737e334e6e70fc1b288e146ae70c4360d7c7f087942f34fc880da0960c7.json similarity index 71% rename from backend/.sqlx/query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json rename to backend/.sqlx/query-411e2737e334e6e70fc1b288e146ae70c4360d7c7f087942f34fc880da0960c7.json index 992885eb..feb8ce89 100644 --- a/backend/.sqlx/query-20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b.json +++ b/backend/.sqlx/query-411e2737e334e6e70fc1b288e146ae70c4360d7c7f087942f34fc880da0960c7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as \"is_user: bool\", universe_id FROM account", + "query": "SELECT id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as \"is_user: bool\", universe_id, color FROM account", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "name": "universe_id", "ordinal": 3, "type_info": "Int64" + }, + { + "name": "color", + "ordinal": 4, + "type_info": "Text" } ], "parameters": { @@ -31,8 +36,9 @@ false, false, false, + false, false ] }, - "hash": "20a4a851a0d54ce298d4752074d9c7bff1d4b96b4d5b9aa586f268a89624155b" + "hash": "411e2737e334e6e70fc1b288e146ae70c4360d7c7f087942f34fc880da0960c7" } diff --git a/backend/.sqlx/query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json b/backend/.sqlx/query-c5b871f0027f934fbacb865decf5fdd15baf76d68296c07a3bf00a3f33115f3e.json similarity index 70% rename from backend/.sqlx/query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json rename to backend/.sqlx/query-c5b871f0027f934fbacb865decf5fdd15baf76d68296c07a3bf00a3f33115f3e.json index f629564c..a594a3bd 100644 --- a/backend/.sqlx/query-32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f.json +++ b/backend/.sqlx/query-c5b871f0027f934fbacb865decf5fdd15baf76d68296c07a3bf00a3f33115f3e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id,\n name,\n (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) AS \"is_user: bool\",\n universe_id\n FROM account\n WHERE id = ?\n ", + "query": "\n SELECT\n id,\n name,\n (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) AS \"is_user: bool\",\n universe_id,\n color\n FROM account\n WHERE id = ?\n ", "describe": { "columns": [ { @@ -22,6 +22,11 @@ "name": "universe_id", "ordinal": 3, "type_info": "Int64" + }, + { + "name": "color", + "ordinal": 4, + "type_info": "Text" } ], "parameters": { @@ -31,8 +36,9 @@ false, false, false, + false, false ] }, - "hash": "32e0c294516626cae89d4c17603781af976ef2bf5b773cab191bfcb3c9b6249f" + "hash": "c5b871f0027f934fbacb865decf5fdd15baf76d68296c07a3bf00a3f33115f3e" } diff --git a/backend/migrations/20260305000000_add_account_color.sql b/backend/migrations/20260305000000_add_account_color.sql new file mode 100644 index 00000000..0a9b195b --- /dev/null +++ b/backend/migrations/20260305000000_add_account_color.sql @@ -0,0 +1 @@ +ALTER TABLE account ADD COLUMN color TEXT NOT NULL DEFAULT ''; diff --git a/backend/src/airtable_users.rs b/backend/src/airtable_users.rs index 1f5cda30..3fa5bffc 100644 --- a/backend/src/airtable_users.rs +++ b/backend/src/airtable_users.rs @@ -236,6 +236,7 @@ async fn process_user( name: name.clone(), is_user: true, universe_id: 0, + color: String::new(), })), }; subscriptions.send_public(msg); diff --git a/backend/src/convert.rs b/backend/src/convert.rs index 9bacfc77..08a29568 100644 --- a/backend/src/convert.rs +++ b/backend/src/convert.rs @@ -293,8 +293,8 @@ impl From for websocket_api::order_created::OrderFill { } impl From for websocket_api::Account { - fn from(db::Account { id, name, is_user, universe_id }: db::Account) -> Self { - Self { id, name, is_user, universe_id } + fn from(db::Account { id, name, is_user, universe_id, color }: db::Account) -> Self { + Self { id, name, is_user, universe_id, color } } } diff --git a/backend/src/db.rs b/backend/src/db.rs index 8a101113..a08f623f 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -266,7 +266,8 @@ impl DB { id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) AS "is_user: bool", - universe_id + universe_id, + color FROM account WHERE id = ? "#, @@ -461,6 +462,7 @@ impl DB { let universe_id = create_account.universe_id; let initial_balance = create_account.initial_balance; + let color = create_account.color.clone(); let mut transaction = self.pool.begin().await?; @@ -493,13 +495,14 @@ impl DB { let balance = initial_balance.to_string(); let result = sqlx::query_scalar!( r#" - INSERT INTO account (name, balance, universe_id) - VALUES (?, ?, ?) + INSERT INTO account (name, balance, universe_id, color) + VALUES (?, ?, ?, ?) RETURNING id "#, account_name, balance, - universe_id + universe_id, + color ) .fetch_one(transaction.as_mut()) .await; @@ -558,6 +561,7 @@ impl DB { name: account_name, is_user: false, universe_id, + color, })) } Err(sqlx::Error::Database(db_err)) => { @@ -1020,7 +1024,7 @@ impl DB { pub fn get_all_accounts(&self) -> BoxStream<'_, SqlxResult> { sqlx::query_as!( Account, - r#"SELECT id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as "is_user: bool", universe_id FROM account"# + r#"SELECT id, name, (kinde_id IS NOT NULL OR global_user_id IS NOT NULL) as "is_user: bool", universe_id, color FROM account"# ) .fetch(&self.pool) } @@ -4074,6 +4078,7 @@ pub struct Account { pub name: String, pub is_user: bool, pub universe_id: i64, + pub color: String, } #[derive(Debug, FromRow)] @@ -5413,6 +5418,7 @@ mod tests { name: String::new(), universe_id: 0, initial_balance: 0.0, + color: String::new(), }, ) .await?; @@ -5426,6 +5432,7 @@ mod tests { name: " ".into(), universe_id: 0, initial_balance: 0.0, + color: String::new(), }, ) .await?; @@ -5439,6 +5446,7 @@ mod tests { name: "test_bot".into(), universe_id: 0, initial_balance: 0.0, + color: String::new(), }, ) .await?; diff --git a/backend/src/global_db.rs b/backend/src/global_db.rs index 52729bdc..d29b3839 100644 --- a/backend/src/global_db.rs +++ b/backend/src/global_db.rs @@ -483,6 +483,56 @@ impl GlobalDB { .fetch_all(&self.pool) .await } + + /// Update a user's display name. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn update_user_display_name( + &self, + global_user_id: i64, + display_name: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE global_user SET display_name = ? WHERE id = ?") + .bind(display_name) + .bind(global_user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Delete a global user and all their cohort memberships. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn delete_user(&self, global_user_id: i64) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM cohort_member WHERE global_user_id = ?") + .bind(global_user_id) + .execute(&self.pool) + .await?; + sqlx::query("DELETE FROM global_user WHERE id = ?") + .bind(global_user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Update a member's initial balance. + /// + /// # Errors + /// Returns an error on database failure. + pub async fn update_member_initial_balance( + &self, + member_id: i64, + initial_balance: Option<&str>, + ) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE cohort_member SET initial_balance = ? WHERE id = ?") + .bind(initial_balance) + .bind(member_id) + .execute(&self.pool) + .await?; + Ok(()) + } } #[derive(Debug, FromRow)] diff --git a/backend/src/handle_socket.rs b/backend/src/handle_socket.rs index 81c99e59..a6f2cc1b 100644 --- a/backend/src/handle_socket.rs +++ b/backend/src/handle_socket.rs @@ -1311,6 +1311,7 @@ async fn authenticate( name: name.clone(), is_user: true, universe_id: 0, + color: String::new(), }), ); cohort.subscriptions.send_public(msg); diff --git a/backend/src/main.rs b/backend/src/main.rs index b769458b..9b25e864 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -52,13 +52,17 @@ async fn main() -> anyhow::Result<()> { ) .route( "/api/admin/cohorts/:name/members/:id", - delete(remove_member), + put(update_member).delete(remove_member), ) .route("/api/admin/config", get(get_config).put(update_config)) .route("/api/admin/users", get(list_users)) .route("/api/admin/users/details", get(list_users_detailed)) .route("/api/admin/users/:id/admin", put(toggle_admin)) + .route("/api/admin/users/:id/display-name", put(admin_update_display_name)) + .route("/api/admin/users/:id", delete(delete_user_endpoint)) .route("/api/admin/available-dbs", get(list_available_dbs)) + // Authenticated user endpoints + .route("/api/users/me/display-name", put(update_my_display_name)) // Legacy / utility routes .route("/sync-airtable-users", get(sync_airtable_users)) .route("/api/upload-image", post(upload_image)) @@ -634,6 +638,101 @@ async fn toggle_admin( Ok(StatusCode::OK) } +#[derive(Deserialize)] +struct UpdateDisplayNameRequest { + display_name: String, +} + +#[axum::debug_handler] +async fn update_my_display_name( + claims: AccessClaims, + State(state): State, + Json(body): Json, +) -> Result { + let display_name = body.display_name.trim(); + if display_name.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Display name cannot be empty".to_string())); + } + let global_user = state + .global_db + .ensure_global_user(&claims.sub, &claims.sub, claims.email.as_deref()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + state + .global_db + .update_user_display_name(global_user.id, display_name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + +#[axum::debug_handler] +async fn admin_update_display_name( + claims: AccessClaims, + AxumPath(user_id): AxumPath, + State(state): State, + Json(body): Json, +) -> Result { + check_admin(&claims)?; + let display_name = body.display_name.trim(); + if display_name.is_empty() { + return Err((StatusCode::BAD_REQUEST, "Display name cannot be empty".to_string())); + } + state + .global_db + .update_user_display_name(user_id, display_name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(StatusCode::OK) +} + +#[axum::debug_handler] +async fn delete_user_endpoint( + claims: AccessClaims, + AxumPath(user_id): AxumPath, + State(state): State, +) -> Result { + check_admin(&claims)?; + state + .global_db + .delete_user(user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(StatusCode::OK) +} + +#[derive(Deserialize)] +struct UpdateMemberRequest { + initial_balance: Option, +} + +#[axum::debug_handler] +async fn update_member( + claims: AccessClaims, + AxumPath((name, member_id)): AxumPath<(String, i64)>, + State(state): State, + Json(body): Json, +) -> Result { + check_admin(&claims)?; + + let _cohort = state + .global_db + .get_cohort_by_name(&name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Cohort not found".to_string()))?; + + state + .global_db + .update_member_initial_balance(member_id, body.initial_balance.as_deref()) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(StatusCode::OK) +} + #[axum::debug_handler] async fn list_available_dbs( claims: AccessClaims, diff --git a/backend/tests/websocket_sudo.rs b/backend/tests/websocket_sudo.rs index 11822635..cf6705f1 100644 --- a/backend/tests/websocket_sudo.rs +++ b/backend/tests/websocket_sudo.rs @@ -240,7 +240,7 @@ async fn test_act_as_shows_sudo_required_for_admin() { let (app_state, _temp) = create_test_app_state().await.unwrap(); // Create a second user through the global DB flow so they match what WS auth creates - let global_user2 = app_state.global_db.ensure_global_user("user2", "Second User").await.unwrap(); + let global_user2 = app_state.global_db.ensure_global_user("user2", "Second User", None).await.unwrap(); let cohort = app_state.cohorts.get("test").unwrap(); let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "Second User", rust_decimal_macros::dec!(100)).await.unwrap(); drop(cohort); @@ -311,8 +311,8 @@ async fn test_hide_account_ids_respects_sudo() { // Pre-create users with initial balance so they can place orders // Create users through the global DB flow so they match what the WS auth creates - let global_user1 = app_state.global_db.ensure_global_user("user1", "User One").await.unwrap(); - let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two").await.unwrap(); + let global_user1 = app_state.global_db.ensure_global_user("user1", "User One", None).await.unwrap(); + let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two", None).await.unwrap(); let cohort = app_state.cohorts.get("test").unwrap(); let _ = cohort.db.ensure_user_created_by_global_id(global_user1.id, "User One", rust_decimal_macros::dec!(1000)).await.unwrap(); let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "User Two", rust_decimal_macros::dec!(1000)).await.unwrap(); @@ -552,8 +552,8 @@ async fn test_hide_account_ids_in_full_trade_history() { // Pre-create users with initial balance so they can place orders // Create users through the global DB flow so they match what the WS auth creates - let global_user1 = app_state.global_db.ensure_global_user("user1", "User One").await.unwrap(); - let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two").await.unwrap(); + let global_user1 = app_state.global_db.ensure_global_user("user1", "User One", None).await.unwrap(); + let global_user2 = app_state.global_db.ensure_global_user("user2", "User Two", None).await.unwrap(); let cohort = app_state.cohorts.get("test").unwrap(); let _ = cohort.db.ensure_user_created_by_global_id(global_user1.id, "User One", rust_decimal_macros::dec!(1000)).await.unwrap(); let _ = cohort.db.ensure_user_created_by_global_id(global_user2.id, "User Two", rust_decimal_macros::dec!(1000)).await.unwrap(); diff --git a/backend/tests/websocket_universes.rs b/backend/tests/websocket_universes.rs index 7efdee86..f9a298df 100644 --- a/backend/tests/websocket_universes.rs +++ b/backend/tests/websocket_universes.rs @@ -86,6 +86,7 @@ async fn create_account( name: name.to_string(), universe_id, initial_balance, + color: String::new(), })), }; client.send_raw(msg).await?; diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index 89c79a4d..3fe822a1 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -170,3 +170,54 @@ export async function toggleAdmin(userId: number, isAdmin: boolean): Promise { + const res = await fetch(`${API_BASE}/api/admin/users/${userId}/display-name`, { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify({ display_name: displayName }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} + +export async function deleteUser(userId: number): Promise { + const res = await fetch(`${API_BASE}/api/admin/users/${userId}`, { + method: 'DELETE', + headers: await authHeaders() + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} + +export async function updateMyDisplayName(displayName: string): Promise { + const res = await fetch(`${API_BASE}/api/users/me/display-name`, { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify({ display_name: displayName }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} + +export async function updateMemberInitialBalance( + cohortName: string, + memberId: number, + initialBalance: string | null +): Promise { + const res = await fetch(`${API_BASE}/api/admin/cohorts/${cohortName}/members/${memberId}`, { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify({ initial_balance: initialBalance }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } +} diff --git a/frontend/src/lib/components/appSideBar.svelte b/frontend/src/lib/components/appSideBar.svelte index 0d26eaba..f036300b 100644 --- a/frontend/src/lib/components/appSideBar.svelte +++ b/frontend/src/lib/components/appSideBar.svelte @@ -1,6 +1,7 @@ @@ -155,12 +158,15 @@ 'w-full transition-all duration-200', serverState.isAdmin && serverState.sudoEnabled ? 'bg-red-700/40' - : universeMode.enabled && serverState.currentUniverseId !== 0 - ? 'bg-amber-500/30' - : serverState.actingAs && serverState.actingAs !== serverState.userId - ? 'bg-green-700/30' - : 'bg-primary/30' + : actingAsColor + ? '' + : universeMode.enabled && serverState.currentUniverseId !== 0 + ? 'bg-amber-500/30' + : serverState.actingAs && serverState.actingAs !== serverState.userId + ? 'bg-green-700/30' + : 'bg-primary/30' )} + style={actingAsColor && !(serverState.isAdmin && serverState.sudoEnabled) ? `background-color: ${actingAsColor}40` : ''} >