From 99a010076b6cb3c7a90ced9c2283f7360fa36814 Mon Sep 17 00:00:00 2001 From: Matej Stastny Date: Fri, 21 Aug 2026 00:06:05 -0700 Subject: [PATCH 01/11] feat: collapse two-tier permission system to single access tier Removes role and canAccessVault from the user model, plus the whole per-feature grant system (userFeature table, features.ts, /features page, FEATURE_ROUTES middleware logic). Every approved account is now full-access; the approve/deactivate onboarding gate is unchanged. Vault entries move from an admin-always-reads model to per-entry grants for everyone, with the creator auto-granted on create. --- drizzle/migrations/0013_lowly_dagger.sql | 3 + drizzle/migrations/meta/0013_snapshot.json | 1618 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + package.json | 2 +- scripts/seed.ts | 4 +- src/app/(dashboard)/admin/layout.tsx | 17 - src/app/(dashboard)/admin/server/page.tsx | 1 - src/app/(dashboard)/admin/users/page.tsx | 58 +- src/app/(dashboard)/api-keys/page.tsx | 44 +- src/app/(dashboard)/dashboard/page.tsx | 94 +- src/app/(dashboard)/features/page.tsx | 59 - src/app/(dashboard)/layout.tsx | 34 +- src/app/api/admin/features/[id]/route.ts | 38 - src/app/api/admin/features/route.ts | 30 - .../api/admin/finance/buckets/[id]/route.ts | 1 - src/app/api/admin/finance/buckets/route.ts | 2 - src/app/api/admin/finance/gift/route.ts | 1 - .../api/admin/finance/quarter-reset/route.ts | 1 - src/app/api/admin/finance/route.ts | 1 - src/app/api/admin/finance/settings/route.ts | 1 - .../admin/github/invitations/[id]/route.ts | 1 - src/app/api/admin/github/invitations/route.ts | 1 - .../admin/github/members/[username]/route.ts | 1 - src/app/api/admin/github/members/route.ts | 2 - src/app/api/admin/github/org/route.ts | 1 - .../github/teams/[slug]/members/route.ts | 1 - src/app/api/admin/github/teams/route.ts | 1 - src/app/api/admin/network/nodes/[id]/route.ts | 1 - src/app/api/admin/network/nodes/route.ts | 1 - src/app/api/admin/onshape/company/route.ts | 1 - .../api/admin/onshape/members/[uid]/route.ts | 1 - src/app/api/admin/onshape/members/route.ts | 2 - .../admin/onshape/teams/[id]/members/route.ts | 1 - src/app/api/admin/onshape/teams/route.ts | 1 - src/app/api/admin/server/command/route.ts | 1 - src/app/api/admin/server/config/route.ts | 2 - src/app/api/admin/server/logs/route.ts | 1 - src/app/api/admin/server/start/route.ts | 1 - src/app/api/admin/server/status/route.ts | 1 - src/app/api/admin/server/stop/route.ts | 1 - src/app/api/admin/server/update/route.ts | 1 - .../api/admin/users/[id]/features/route.ts | 85 - src/app/api/admin/users/[id]/route.ts | 22 +- src/app/api/admin/vault/[id]/access/route.ts | 8 +- src/app/api/admin/vault/[id]/route.ts | 8 +- src/app/api/admin/vault/route.ts | 11 +- .../api/admin/whitelist/[id]/action/route.ts | 3 - src/app/api/admin/whitelist/[id]/route.ts | 3 - src/app/api/orders/[id]/action/route.ts | 3 - src/app/api/orders/[id]/route.ts | 7 +- src/app/api/orders/assign/route.ts | 3 - src/app/api/orders/bulk-action/route.ts | 3 - src/app/api/orders/mark-ordered/route.ts | 3 - src/app/api/users/features/route.ts | 72 - src/app/api/vault/[id]/reveal/route.ts | 4 +- src/components/admin/FeatureRequests.tsx | 121 -- src/components/admin/UserTable.tsx | 197 +- src/components/features/FeaturesPanel.tsx | 145 -- src/components/layout/MobileNav.tsx | 22 +- src/components/layout/Sidebar.tsx | 21 +- src/components/layout/SidebarNav.tsx | 59 +- src/components/layout/TopNav.tsx | 24 +- src/components/vault/VaultAccessDialog.tsx | 22 +- src/components/vault/VaultManager.tsx | 97 +- src/lib/auth/auth.ts | 12 - src/lib/auth/session.ts | 17 +- src/lib/db/auth-schema.ts | 2 - src/lib/db/schema.ts | 27 - src/lib/features.ts | 33 - src/lib/validation.test.ts | 7 +- src/lib/validation.ts | 27 +- src/middleware.ts | 31 - 72 files changed, 1827 insertions(+), 1312 deletions(-) create mode 100644 drizzle/migrations/0013_lowly_dagger.sql create mode 100644 drizzle/migrations/meta/0013_snapshot.json delete mode 100644 src/app/(dashboard)/admin/layout.tsx delete mode 100644 src/app/(dashboard)/features/page.tsx delete mode 100644 src/app/api/admin/features/[id]/route.ts delete mode 100644 src/app/api/admin/features/route.ts delete mode 100644 src/app/api/admin/users/[id]/features/route.ts delete mode 100644 src/app/api/users/features/route.ts delete mode 100644 src/components/admin/FeatureRequests.tsx delete mode 100644 src/components/features/FeaturesPanel.tsx delete mode 100644 src/lib/features.ts diff --git a/drizzle/migrations/0013_lowly_dagger.sql b/drizzle/migrations/0013_lowly_dagger.sql new file mode 100644 index 0000000..f8427dc --- /dev/null +++ b/drizzle/migrations/0013_lowly_dagger.sql @@ -0,0 +1,3 @@ +DROP TABLE `user_feature`;--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `role`;--> statement-breakpoint +ALTER TABLE `user` DROP COLUMN `can_access_vault`; \ No newline at end of file diff --git a/drizzle/migrations/meta/0013_snapshot.json b/drizzle/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..858d166 --- /dev/null +++ b/drizzle/migrations/meta/0013_snapshot.json @@ -0,0 +1,1618 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d73f20b6-bac3-4513-b5c9-2e02e429f275", + "prevId": "f785fb2f-0f17-4547-abc6-058c1af76eff", + "tables": { + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_revoked": { + "name": "is_revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "finance_settings": { + "name": "finance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tax_percent_bps": { + "name": "tax_percent_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1100 + }, + "shipping_percent_bps": { + "name": "shipping_percent_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2000 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gift_fund": { + "name": "gift_fund", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_value_cents": { + "name": "current_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gift_fund_log": { + "name": "gift_fund_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_value_cents": { + "name": "previous_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_value_cents": { + "name": "new_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "gift_fund_log_changed_by_user_id_fk": { + "name": "gift_fund_log_changed_by_user_id_fk", + "tableFrom": "gift_fund_log", + "tableTo": "user", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "gift_fund_log_order_id_orders_id_fk": { + "name": "gift_fund_log_order_id_orders_id_fk", + "tableFrom": "gift_fund_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "minecraft_whitelist": { + "name": "minecraft_whitelist", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_directly": { + "name": "added_directly", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "minecraft_whitelist_user_id_user_id_fk": { + "name": "minecraft_whitelist_user_id_user_id_fk", + "tableFrom": "minecraft_whitelist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "minecraft_whitelist_reviewed_by_user_id_fk": { + "name": "minecraft_whitelist_reviewed_by_user_id_fk", + "tableFrom": "minecraft_whitelist", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_join_request": { + "name": "network_join_request", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "machine_key": { + "name": "machine_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "network_join_request_user_id_user_id_fk": { + "name": "network_join_request_user_id_user_id_fk", + "tableFrom": "network_join_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "network_join_request_reviewed_by_user_id_fk": { + "name": "network_join_request_reviewed_by_user_id_fk", + "tableFrom": "network_join_request", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fund_type": { + "name": "fund_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stf_bucket_id": { + "name": "stf_bucket_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarter_id": { + "name": "quarter_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "denial_comment": { + "name": "denial_comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "orders_batch_id_idx": { + "name": "orders_batch_id_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orders_stf_bucket_id_stf_bucket_id_fk": { + "name": "orders_stf_bucket_id_stf_bucket_id_fk", + "tableFrom": "orders", + "tableTo": "stf_bucket", + "columnsFrom": [ + "stf_bucket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_assigned_by_user_id_fk": { + "name": "orders_assigned_by_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_quarter_id_stf_quarter_id_fk": { + "name": "orders_quarter_id_stf_quarter_id_fk", + "tableFrom": "orders", + "tableTo": "stf_quarter", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_reviewed_by_user_id_fk": { + "name": "orders_reviewed_by_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_history": { + "name": "order_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "order_history_order_id_orders_id_fk": { + "name": "order_history_order_id_orders_id_fk", + "tableFrom": "order_history", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_history_changed_by_user_id_fk": { + "name": "order_history_changed_by_user_id_fk", + "tableFrom": "order_history", + "tableTo": "user", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sim_export_cache": { + "name": "sim_export_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_path": { + "name": "archive_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_size_bytes": { + "name": "archive_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cached_at": { + "name": "cached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "sim_export_cache_key": { + "name": "sim_export_cache_key", + "columns": [ + "document_id", + "workspace_id", + "element_id" + ], + "isUnique": true + }, + "sim_export_cache_accessed": { + "name": "sim_export_cache_accessed", + "columns": [ + "last_accessed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stf_bucket": { + "name": "stf_bucket", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "quarter_id": { + "name": "quarter_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starting_balance_cents": { + "name": "starting_balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "stf_bucket_quarter_id_stf_quarter_id_fk": { + "name": "stf_bucket_quarter_id_stf_quarter_id_fk", + "tableFrom": "stf_bucket", + "tableTo": "stf_quarter", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stf_quarter": { + "name": "stf_quarter", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "stf_quarter_name_unique": { + "name": "stf_quarter_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_name_unique": { + "name": "team_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_entry": { + "name": "vault_entry", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_entry_created_by_user_id_fk": { + "name": "vault_entry_created_by_user_id_fk", + "tableFrom": "vault_entry", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_entry_access": { + "name": "vault_entry_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entry_id": { + "name": "entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "vault_entry_access_entry_user": { + "name": "vault_entry_access_entry_user", + "columns": [ + "entry_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_entry_access_entry_id_vault_entry_id_fk": { + "name": "vault_entry_access_entry_id_vault_entry_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "vault_entry", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_entry_access_user_id_user_id_fk": { + "name": "vault_entry_access_user_id_user_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_entry_access_granted_by_user_id_fk": { + "name": "vault_entry_access_granted_by_user_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "approved": { + "name": "approved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "name_changed_at": { + "name": "name_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 9897df6..f005e09 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1787267817158, "tag": "0012_smiling_loa", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1787295619743, + "tag": "0013_lowly_dagger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 15c7f05..b5c4e60 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "setup": "pnpm db:migrate && pnpm db:seed", "staging": "bash scripts/stage.sh", "deploy": "bash scripts/deploy.sh", - "auth:generate": "pnpm dlx @better-auth/cli@latest generate --config src/lib/auth.ts --output src/lib/db/auth-schema.ts -y", + "auth:generate": "pnpm dlx @better-auth/cli@latest generate --config src/lib/auth/auth.ts --output src/lib/db/auth-schema.ts -y", "docs:dev": "npx -y trickfire-docs@latest dev", "prepare": "husky" }, diff --git a/scripts/seed.ts b/scripts/seed.ts index ebc8299..1854f8f 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -107,11 +107,11 @@ async function main() { } db.update(user) - .set({ role: "admin", isActive: true, emailVerified: true, approved: true }) + .set({ isActive: true, emailVerified: true, approved: true }) .where(eq(user.email, email)) .run(); - console.log(`Ensured ${email} has role=admin, isActive=true, approved=true.`); + console.log(`Ensured ${email} has isActive=true, approved=true.`); const adminUser = db.select().from(user).where(eq(user.email, email)).get(); const mechanical = db.select().from(stfBucket).where(eq(stfBucket.name, "Mechanical")).get(); diff --git a/src/app/(dashboard)/admin/layout.tsx b/src/app/(dashboard)/admin/layout.tsx deleted file mode 100644 index d770fa6..0000000 --- a/src/app/(dashboard)/admin/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; - -import { auth } from "@/lib/auth/auth"; - -export default async function AdminLayout({ children }: { children: React.ReactNode }) { - const session = await auth.api.getSession({ headers: await headers() }); - - if (!session?.user) { - redirect("/login"); - } - if (session.user.role !== "admin") { - redirect("/dashboard"); - } - - return <>{children}; -} diff --git a/src/app/(dashboard)/admin/server/page.tsx b/src/app/(dashboard)/admin/server/page.tsx index eec63b7..da9f1b0 100644 --- a/src/app/(dashboard)/admin/server/page.tsx +++ b/src/app/(dashboard)/admin/server/page.tsx @@ -10,7 +10,6 @@ import { getSessionUser } from "@/lib/auth/session"; export default async function AdminServerPage() { const user = await getSessionUser(); if (!user) redirect("/login"); - if (user.role !== "admin") redirect("/dashboard"); const configured = isConfigured(); const running = isRunning(); diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx index c408cb2..ee9cd3c 100644 --- a/src/app/(dashboard)/admin/users/page.tsx +++ b/src/app/(dashboard)/admin/users/page.tsx @@ -1,12 +1,10 @@ -import { and, asc, eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { redirect } from "next/navigation"; -import { FeatureRequests, type FeatureRequestRow } from "@/components/admin/FeatureRequests"; import { PendingApprovals, type PendingUserRow } from "@/components/admin/PendingApprovals"; import { UserTable, type AdminUserRow } from "@/components/admin/UserTable"; import { db } from "@/lib/db"; -import { user, userFeature } from "@/lib/db/schema"; -import type { FeatureKey } from "@/lib/features"; +import { user } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; export default async function AdminUsersPage() { @@ -34,9 +32,7 @@ export default async function AdminUsersPage() { id: user.id, name: user.name, email: user.email, - role: user.role, isActive: user.isActive, - canAccessVault: user.canAccessVault, createdAt: user.createdAt, }) .from(user) @@ -44,71 +40,23 @@ export default async function AdminUsersPage() { .orderBy(asc(user.name)) .all(); - // Fetch granted features for each approved user - const allFeatures = db - .select({ userId: userFeature.userId, featureKey: userFeature.featureKey }) - .from(userFeature) - .where(eq(userFeature.status, "granted")) - .all(); - - const featuresByUser = new Map(); - for (const f of allFeatures) { - const list = featuresByUser.get(f.userId) ?? []; - list.push(f.featureKey as FeatureKey); - featuresByUser.set(f.userId, list); - } - const members: AdminUserRow[] = memberRows.map((u) => ({ id: u.id, name: u.name, email: u.email, - role: u.role === "admin" ? "admin" : "member", isActive: u.isActive ?? true, - canAccessVault: u.canAccessVault ?? false, - grantedFeatures: featuresByUser.get(u.id) ?? [], createdAt: u.createdAt, })); - // Pending feature requests - const featureReqRows = db - .select({ - id: userFeature.id, - userId: userFeature.userId, - featureKey: userFeature.featureKey, - requestNote: userFeature.requestNote, - requestedAt: userFeature.requestedAt, - userName: user.name, - userEmail: user.email, - }) - .from(userFeature) - .innerJoin(user, eq(userFeature.userId, user.id)) - .where(and(eq(userFeature.status, "pending"), eq(user.approved, true))) - .orderBy(asc(userFeature.requestedAt)) - .all(); - - const featureRequests: FeatureRequestRow[] = featureReqRows.map((r) => ({ - id: r.id, - userId: r.userId, - userName: r.userName, - userEmail: r.userEmail, - featureKey: r.featureKey, - requestNote: r.requestNote, - requestedAt: r.requestedAt, - })); - return (

Users

-

- Manage member approvals, roles, and feature access. -

+

Manage member approvals and account status.

- {featureRequests.length > 0 && } -

Members

diff --git a/src/app/(dashboard)/api-keys/page.tsx b/src/app/(dashboard)/api-keys/page.tsx index 917c846..903300e 100644 --- a/src/app/(dashboard)/api-keys/page.tsx +++ b/src/app/(dashboard)/api-keys/page.tsx @@ -5,14 +5,11 @@ import { VaultManager, type VaultMember } from "@/components/vault/VaultManager" import type { VaultEntryRow } from "@/components/vault/VaultEntryDialog"; import { db } from "@/lib/db"; import { user, vaultEntry, vaultEntryAccess } from "@/lib/db/schema"; -import { canUseVault, getSessionUser } from "@/lib/auth/session"; +import { getSessionUser } from "@/lib/auth/session"; export default async function ApiKeysPage() { const sessionUser = await getSessionUser(); if (!sessionUser) redirect("/login"); - if (!canUseVault(sessionUser)) redirect("/dashboard"); - - const isAdmin = sessionUser.role === "admin"; const rows = db .select({ @@ -29,27 +26,18 @@ export default async function ApiKeysPage() { const entries: VaultEntryRow[] = rows; - let members: VaultMember[] = []; - const grants: Record = {}; - if (isAdmin) { - members = db - .select({ id: user.id, name: user.name, email: user.email, role: user.role }) - .from(user) - .orderBy(asc(user.name)) - .all() - .map((m) => ({ - id: m.id, - name: m.name, - email: m.email, - isAdmin: m.role === "admin", - })); + const members: VaultMember[] = db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .orderBy(asc(user.name)) + .all(); - for (const g of db - .select({ entryId: vaultEntryAccess.entryId, userId: vaultEntryAccess.userId }) - .from(vaultEntryAccess) - .all()) { - (grants[g.entryId] ??= []).push(g.userId); - } + const grants: Record = {}; + for (const g of db + .select({ entryId: vaultEntryAccess.entryId, userId: vaultEntryAccess.userId }) + .from(vaultEntryAccess) + .all()) { + (grants[g.entryId] ??= []).push(g.userId); } return ( @@ -57,14 +45,12 @@ export default async function ApiKeysPage() {

API Keys

- A secret vault for the club's shared credentials. - {isAdmin - ? " Create entries and manage per-person access." - : " Reveal logins, or fetch API keys from their endpoint."} + A secret vault for the club's shared credentials. Create entries and manage + per-person access.

- +
); } diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index f2f93b5..2407061 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -10,7 +10,7 @@ import { SystemVitals } from "@/components/dashboard/SystemVitals"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { db } from "@/lib/db"; -import { networkJoinRequest, minecraftWhitelist, order, user, userFeature } from "@/lib/db/schema"; +import { networkJoinRequest, minecraftWhitelist, order, user } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; export default async function DashboardHome() { @@ -18,56 +18,42 @@ export default async function DashboardHome() { if (!sessionUser) redirect("/login"); const firstName = sessionUser.name?.split(" ")[0] ?? "there"; - const isAdmin = sessionUser.role === "admin"; - let pendingOrders = 0; - let adminStats: { label: string; value: number; href: string }[] = []; - - if (isAdmin) { - pendingOrders = - db.select({ value: count() }).from(order).where(eq(order.status, "pending")).get() - ?.value ?? 0; - const pendingApprovals = - db.select({ value: count() }).from(user).where(eq(user.approved, false)).get()?.value ?? - 0; - const activeMembers = - db - .select({ value: count() }) - .from(user) - .where(and(eq(user.isActive, true), eq(user.approved, true))) - .get()?.value ?? 0; - const pendingFeatureRequests = - db - .select({ value: count() }) - .from(userFeature) - .where(eq(userFeature.status, "pending")) - .get()?.value ?? 0; - const openWhitelist = - db - .select({ value: count() }) - .from(minecraftWhitelist) - .where(eq(minecraftWhitelist.status, "pending")) - .get()?.value ?? 0; - const pendingNetworkRequests = - db - .select({ value: count() }) - .from(networkJoinRequest) - .where(eq(networkJoinRequest.status, "pending")) - .get()?.value ?? 0; - - adminStats = [ - { label: "Pending approvals", value: pendingApprovals, href: "/admin/users" }, - { label: "Pending orders", value: pendingOrders, href: "/admin/orders" }, - { label: "Feature requests", value: pendingFeatureRequests, href: "/admin/users" }, - { label: "Active members", value: activeMembers, href: "/admin/users" }, - { label: "Open whitelist requests", value: openWhitelist, href: "/admin/minecraft" }, - { - label: "Network join requests", - value: pendingNetworkRequests, - href: "/admin/network", - }, - ]; - } + const pendingOrders = + db.select({ value: count() }).from(order).where(eq(order.status, "pending")).get()?.value ?? + 0; + const pendingApprovals = + db.select({ value: count() }).from(user).where(eq(user.approved, false)).get()?.value ?? 0; + const activeMembers = + db + .select({ value: count() }) + .from(user) + .where(and(eq(user.isActive, true), eq(user.approved, true))) + .get()?.value ?? 0; + const openWhitelist = + db + .select({ value: count() }) + .from(minecraftWhitelist) + .where(eq(minecraftWhitelist.status, "pending")) + .get()?.value ?? 0; + const pendingNetworkRequests = + db + .select({ value: count() }) + .from(networkJoinRequest) + .where(eq(networkJoinRequest.status, "pending")) + .get()?.value ?? 0; + + const adminStats = [ + { label: "Pending approvals", value: pendingApprovals, href: "/admin/users" }, + { label: "Pending orders", value: pendingOrders, href: "/admin/orders" }, + { label: "Active members", value: activeMembers, href: "/admin/users" }, + { label: "Open whitelist requests", value: openWhitelist, href: "/admin/minecraft" }, + { + label: "Network join requests", + value: pendingNetworkRequests, + href: "/admin/network", + }, + ]; const urgentItems = adminStats.filter((s) => s.value > 0 && s.label !== "Active members"); @@ -83,7 +69,7 @@ export default async function DashboardHome() {
{/* Admin alert strip */} - {isAdmin && urgentItems.length > 0 && ( + {urgentItems.length > 0 && (
{urgentItems.map((item) => ( @@ -132,7 +118,7 @@ export default async function DashboardHome() { Order a Part - {isAdmin && pendingOrders > 0 && ( + {pendingOrders > 0 && ( {pendingOrders} pending @@ -197,7 +183,7 @@ export default async function DashboardHome() { {/* Admin Overview */} - {isAdmin && ( + {

Admin Overview

@@ -243,7 +229,7 @@ export default async function DashboardHome() { })}
- )} + }
); } diff --git a/src/app/(dashboard)/features/page.tsx b/src/app/(dashboard)/features/page.tsx deleted file mode 100644 index e6a484f..0000000 --- a/src/app/(dashboard)/features/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -import { FeaturesPanel } from "@/components/features/FeaturesPanel"; -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { FEATURE_KEYS, FEATURES, type FeatureKey } from "@/lib/features"; -import { getSessionUser } from "@/lib/auth/session"; - -export default async function FeaturesPage({ - searchParams, -}: { - searchParams: Promise<{ denied?: string }>; -}) { - const user = await getSessionUser(); - if (!user) redirect("/login"); - - const isAdmin = user.role === "admin"; - - const features = isAdmin - ? FEATURE_KEYS.map((key) => ({ - key, - label: FEATURES[key].label, - description: FEATURES[key].description, - status: "granted" as const, - })) - : (() => { - const rows = db - .select({ featureKey: userFeature.featureKey, status: userFeature.status }) - .from(userFeature) - .where(eq(userFeature.userId, user.id)) - .all(); - const statusMap = Object.fromEntries( - rows.map((r) => [r.featureKey, r.status]) - ) as Record; - return FEATURE_KEYS.map((key) => ({ - key, - label: FEATURES[key].label, - description: FEATURES[key].description, - status: statusMap[key] ?? null, - })); - })(); - - const { denied } = await searchParams; - - return ( -
-
-

My Access

-

- {isAdmin - ? "Admins have full access to all features." - : "Request access to features. An admin will review and approve your requests."} -

-
- -
- ); -} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index f8217ec..f9c5889 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,14 +1,10 @@ import * as Sentry from "@sentry/nextjs"; -import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { Sidebar } from "@/components/layout/Sidebar"; import { TopNav } from "@/components/layout/TopNav"; import { auth } from "@/lib/auth/auth"; -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import type { FeatureKey } from "@/lib/features"; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); @@ -22,37 +18,11 @@ export default async function DashboardLayout({ children }: { children: React.Re Sentry.setUser({ id: session.user.id, email: session.user.email }); - const isAdmin = session.user.role === "admin"; - const canAccessVault = isAdmin || session.user.canAccessVault === true; - - const grantedFeatures: FeatureKey[] = isAdmin - ? ["orders", "api-keys", "minecraft", "network"] - : db - .select({ featureKey: userFeature.featureKey }) - .from(userFeature) - .where( - and(eq(userFeature.userId, session.user.id), eq(userFeature.status, "granted")) - ) - .all() - .map((r) => r.featureKey as FeatureKey); - return (
- +
- +
{children}
diff --git a/src/app/api/admin/features/[id]/route.ts b/src/app/api/admin/features/[id]/route.ts deleted file mode 100644 index 0d97282..0000000 --- a/src/app/api/admin/features/[id]/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { eq } from "drizzle-orm"; -import { NextResponse, type NextRequest } from "next/server"; - -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; -import { featureActionSchema } from "@/lib/validation"; - -export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const id = parseInt((await params).id); - if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 }); - - const body = await req.json().catch(() => null); - const parsed = featureActionSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - - const existing = db.select().from(userFeature).where(eq(userFeature.id, id)).get(); - if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); - - const newStatus = parsed.data.action === "grant" ? "granted" : "rejected"; - const updated = db - .update(userFeature) - .set({ - status: newStatus, - adminNote: parsed.data.adminNote ?? null, - reviewedBy: admin.id, - reviewedAt: new Date(), - }) - .where(eq(userFeature.id, id)) - .returning() - .get(); - - return NextResponse.json({ feature: updated }); -} diff --git a/src/app/api/admin/features/route.ts b/src/app/api/admin/features/route.ts deleted file mode 100644 index 9c2fa85..0000000 --- a/src/app/api/admin/features/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; - -import { db } from "@/lib/db"; -import { user, userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; - -export async function GET() { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const pending = db - .select({ - id: userFeature.id, - featureKey: userFeature.featureKey, - status: userFeature.status, - requestNote: userFeature.requestNote, - requestedAt: userFeature.requestedAt, - userId: userFeature.userId, - userName: user.name, - userEmail: user.email, - }) - .from(userFeature) - .innerJoin(user, eq(userFeature.userId, user.id)) - .where(eq(userFeature.status, "pending")) - .all(); - - return NextResponse.json({ requests: pending }); -} diff --git a/src/app/api/admin/finance/buckets/[id]/route.ts b/src/app/api/admin/finance/buckets/[id]/route.ts index c772bd1..c9eca2e 100644 --- a/src/app/api/admin/finance/buckets/[id]/route.ts +++ b/src/app/api/admin/finance/buckets/[id]/route.ts @@ -9,7 +9,6 @@ import { stfBucketUpdateSchema } from "@/lib/validation"; export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const id = Number((await params).id); if (!Number.isInteger(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 }); diff --git a/src/app/api/admin/finance/buckets/route.ts b/src/app/api/admin/finance/buckets/route.ts index a88d945..9688652 100644 --- a/src/app/api/admin/finance/buckets/route.ts +++ b/src/app/api/admin/finance/buckets/route.ts @@ -9,7 +9,6 @@ import { stfBucketInputSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = stfBucketInputSchema.safeParse(body); @@ -44,7 +43,6 @@ export async function POST(req: NextRequest) { export async function PUT(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const quarterName = body?.quarterName?.trim(); diff --git a/src/app/api/admin/finance/gift/route.ts b/src/app/api/admin/finance/gift/route.ts index 5508684..c62026a 100644 --- a/src/app/api/admin/finance/gift/route.ts +++ b/src/app/api/admin/finance/gift/route.ts @@ -7,7 +7,6 @@ import { giftFundAdjustSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = giftFundAdjustSchema.safeParse(body); diff --git a/src/app/api/admin/finance/quarter-reset/route.ts b/src/app/api/admin/finance/quarter-reset/route.ts index be8c3e8..5370a7b 100644 --- a/src/app/api/admin/finance/quarter-reset/route.ts +++ b/src/app/api/admin/finance/quarter-reset/route.ts @@ -10,7 +10,6 @@ import { quarterResetSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = quarterResetSchema.safeParse(body); diff --git a/src/app/api/admin/finance/route.ts b/src/app/api/admin/finance/route.ts index b75a492..76a2471 100644 --- a/src/app/api/admin/finance/route.ts +++ b/src/app/api/admin/finance/route.ts @@ -16,7 +16,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); ensureGiftFundRow(); ensureFinanceSettingsRow(); diff --git a/src/app/api/admin/finance/settings/route.ts b/src/app/api/admin/finance/settings/route.ts index e235b89..2185fac 100644 --- a/src/app/api/admin/finance/settings/route.ts +++ b/src/app/api/admin/finance/settings/route.ts @@ -8,7 +8,6 @@ import { financeSettingsUpdateSchema } from "@/lib/validation"; export async function PATCH(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = financeSettingsUpdateSchema.safeParse(body); diff --git a/src/app/api/admin/github/invitations/[id]/route.ts b/src/app/api/admin/github/invitations/[id]/route.ts index 653cdbc..3d39587 100644 --- a/src/app/api/admin/github/invitations/[id]/route.ts +++ b/src/app/api/admin/github/invitations/[id]/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const id = Number((await params).id); if (!Number.isInteger(id)) { diff --git a/src/app/api/admin/github/invitations/route.ts b/src/app/api/admin/github/invitations/route.ts index 28828f9..e99f689 100644 --- a/src/app/api/admin/github/invitations/route.ts +++ b/src/app/api/admin/github/invitations/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const invitations = await getPendingInvitations(); diff --git a/src/app/api/admin/github/members/[username]/route.ts b/src/app/api/admin/github/members/[username]/route.ts index eaabc88..af77f9a 100644 --- a/src/app/api/admin/github/members/[username]/route.ts +++ b/src/app/api/admin/github/members/[username]/route.ts @@ -9,7 +9,6 @@ export async function DELETE( ) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { username } = await params; try { diff --git a/src/app/api/admin/github/members/route.ts b/src/app/api/admin/github/members/route.ts index a879bd3..33ab487 100644 --- a/src/app/api/admin/github/members/route.ts +++ b/src/app/api/admin/github/members/route.ts @@ -7,7 +7,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const members = await getOrgMembers(); @@ -43,7 +42,6 @@ const inviteSchema = z export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = inviteSchema.safeParse(body); diff --git a/src/app/api/admin/github/org/route.ts b/src/app/api/admin/github/org/route.ts index a062a1b..ad8b2de 100644 --- a/src/app/api/admin/github/org/route.ts +++ b/src/app/api/admin/github/org/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const org = await getOrg(); return NextResponse.json({ configured: isGithubConfigured(), org }); diff --git a/src/app/api/admin/github/teams/[slug]/members/route.ts b/src/app/api/admin/github/teams/[slug]/members/route.ts index ec0dfca..6fa6e0d 100644 --- a/src/app/api/admin/github/teams/[slug]/members/route.ts +++ b/src/app/api/admin/github/teams/[slug]/members/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { slug } = await params; try { diff --git a/src/app/api/admin/github/teams/route.ts b/src/app/api/admin/github/teams/route.ts index b694401..a487b89 100644 --- a/src/app/api/admin/github/teams/route.ts +++ b/src/app/api/admin/github/teams/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const teams = await getOrgTeams(); diff --git a/src/app/api/admin/network/nodes/[id]/route.ts b/src/app/api/admin/network/nodes/[id]/route.ts index 87f06f9..82fd15a 100644 --- a/src/app/api/admin/network/nodes/[id]/route.ts +++ b/src/app/api/admin/network/nodes/[id]/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { id } = await params; const ok = await deleteNetworkNode(id); diff --git a/src/app/api/admin/network/nodes/route.ts b/src/app/api/admin/network/nodes/route.ts index b4c2d44..2ab48dc 100644 --- a/src/app/api/admin/network/nodes/route.ts +++ b/src/app/api/admin/network/nodes/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const data = await getNetworkNodes(); if (!data) return NextResponse.json({ nodes: [] }); diff --git a/src/app/api/admin/onshape/company/route.ts b/src/app/api/admin/onshape/company/route.ts index 0e3f4a1..a23da6b 100644 --- a/src/app/api/admin/onshape/company/route.ts +++ b/src/app/api/admin/onshape/company/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const company = await getOnshapeCompany(); return NextResponse.json({ configured: isOnshapeConfigured(), company }); diff --git a/src/app/api/admin/onshape/members/[uid]/route.ts b/src/app/api/admin/onshape/members/[uid]/route.ts index ec9f719..aacdd27 100644 --- a/src/app/api/admin/onshape/members/[uid]/route.ts +++ b/src/app/api/admin/onshape/members/[uid]/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function DELETE(req: NextRequest, { params }: { params: Promise<{ uid: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { uid } = await params; try { diff --git a/src/app/api/admin/onshape/members/route.ts b/src/app/api/admin/onshape/members/route.ts index f753306..648f561 100644 --- a/src/app/api/admin/onshape/members/route.ts +++ b/src/app/api/admin/onshape/members/route.ts @@ -7,7 +7,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const members = await getOnshapeMembers(); @@ -30,7 +29,6 @@ const addMemberSchema = z.object({ export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const parsed = addMemberSchema.safeParse(body); diff --git a/src/app/api/admin/onshape/teams/[id]/members/route.ts b/src/app/api/admin/onshape/teams/[id]/members/route.ts index 23f4690..bc788d3 100644 --- a/src/app/api/admin/onshape/teams/[id]/members/route.ts +++ b/src/app/api/admin/onshape/teams/[id]/members/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { id } = await params; try { diff --git a/src/app/api/admin/onshape/teams/route.ts b/src/app/api/admin/onshape/teams/route.ts index 1922f92..ff85f16 100644 --- a/src/app/api/admin/onshape/teams/route.ts +++ b/src/app/api/admin/onshape/teams/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { const teams = await getOnshapeTeams(); diff --git a/src/app/api/admin/server/command/route.ts b/src/app/api/admin/server/command/route.ts index 06c8ec5..411df86 100644 --- a/src/app/api/admin/server/command/route.ts +++ b/src/app/api/admin/server/command/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST(req: NextRequest) { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); const cmd = typeof body?.command === "string" ? body.command.trim() : null; diff --git a/src/app/api/admin/server/config/route.ts b/src/app/api/admin/server/config/route.ts index 7fab254..ba78ade 100644 --- a/src/app/api/admin/server/config/route.ts +++ b/src/app/api/admin/server/config/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); try { return NextResponse.json(readConfig()); @@ -18,7 +17,6 @@ export async function GET() { export async function PUT(req: NextRequest) { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json().catch(() => null); if (!body || typeof body !== "object") { diff --git a/src/app/api/admin/server/logs/route.ts b/src/app/api/admin/server/logs/route.ts index 8cb2e79..b43cd8c 100644 --- a/src/app/api/admin/server/logs/route.ts +++ b/src/app/api/admin/server/logs/route.ts @@ -14,7 +14,6 @@ export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const admin = await getSessionUser(); if (!admin) return new Response("Unauthorized", { status: 401 }); - if (admin.role !== "admin") return new Response("Forbidden", { status: 403 }); const encoder = new TextEncoder(); diff --git a/src/app/api/admin/server/start/route.ts b/src/app/api/admin/server/start/route.ts index 7fbdb33..da8f576 100644 --- a/src/app/api/admin/server/start/route.ts +++ b/src/app/api/admin/server/start/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const result = startServer(); if (!result.ok) { diff --git a/src/app/api/admin/server/status/route.ts b/src/app/api/admin/server/status/route.ts index 5966453..6b3fc6f 100644 --- a/src/app/api/admin/server/status/route.ts +++ b/src/app/api/admin/server/status/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function GET() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const configured = isConfigured(); const running = isRunning(); diff --git a/src/app/api/admin/server/stop/route.ts b/src/app/api/admin/server/stop/route.ts index cef7ce3..0ca57d1 100644 --- a/src/app/api/admin/server/stop/route.ts +++ b/src/app/api/admin/server/stop/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const result = stopServer(); if (!result.ok) { diff --git a/src/app/api/admin/server/update/route.ts b/src/app/api/admin/server/update/route.ts index b4214ec..d52e65e 100644 --- a/src/app/api/admin/server/update/route.ts +++ b/src/app/api/admin/server/update/route.ts @@ -6,7 +6,6 @@ import { getSessionUser } from "@/lib/auth/session"; export async function POST() { const admin = await getSessionUser(); if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const result = updateServer(); if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }); diff --git a/src/app/api/admin/users/[id]/features/route.ts b/src/app/api/admin/users/[id]/features/route.ts deleted file mode 100644 index 9eb0e6b..0000000 --- a/src/app/api/admin/users/[id]/features/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { NextResponse, type NextRequest } from "next/server"; - -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; -import { featureRequestSchema } from "@/lib/validation"; - -// GET all features for a user -export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const userId = (await params).id; - const features = db.select().from(userFeature).where(eq(userFeature.userId, userId)).all(); - return NextResponse.json({ features }); -} - -// POST to directly grant a feature to a user (no request flow) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const userId = (await params).id; - const body = await req.json().catch(() => null); - const parsed = featureRequestSchema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - - const { featureKey, requestNote } = parsed.data; - - const existing = db - .select() - .from(userFeature) - .where(and(eq(userFeature.userId, userId), eq(userFeature.featureKey, featureKey))) - .get(); - - if (existing) { - const updated = db - .update(userFeature) - .set({ - status: "granted", - adminNote: requestNote ?? null, - reviewedBy: admin.id, - reviewedAt: new Date(), - }) - .where(eq(userFeature.id, existing.id)) - .returning() - .get(); - return NextResponse.json({ feature: updated }); - } - - const created = db - .insert(userFeature) - .values({ - userId, - featureKey, - status: "granted", - reviewedBy: admin.id, - reviewedAt: new Date(), - }) - .returning() - .get(); - - return NextResponse.json({ feature: created }, { status: 201 }); -} - -// DELETE to revoke a feature from a user -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const admin = await getSessionUser(); - if (!admin) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - if (admin.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - - const userId = (await params).id; - const body = await req.json().catch(() => null); - const featureKey = body?.featureKey as string | undefined; - if (!featureKey) return NextResponse.json({ error: "featureKey required" }, { status: 400 }); - - db.delete(userFeature) - .where(and(eq(userFeature.userId, userId), eq(userFeature.featureKey, featureKey))) - .run(); - - return new NextResponse(null, { status: 204 }); -} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 667fc13..766ccaf 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -11,9 +11,6 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (admin.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const userId = (await params).id; @@ -26,16 +23,11 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id ); } - // An admin cannot demote or deactivate themselves - avoids locking the club - // out of its own admin panel. - if ( - userId === admin.id && - (parsed.data.role === "member" || - parsed.data.isActive === false || - parsed.data.approved === false) - ) { + // A member cannot deactivate or unapprove themselves - avoids locking the + // club out of its own member list. + if (userId === admin.id && (parsed.data.isActive === false || parsed.data.approved === false)) { return NextResponse.json( - { error: "You cannot change your own role, active status, or approval" }, + { error: "You cannot change your own active status or approval" }, { status: 400 } ); } @@ -48,19 +40,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const updated = db .update(user) .set({ - ...(parsed.data.role !== undefined ? { role: parsed.data.role } : {}), ...(parsed.data.isActive !== undefined ? { isActive: parsed.data.isActive } : {}), - ...(parsed.data.canAccessVault !== undefined - ? { canAccessVault: parsed.data.canAccessVault } - : {}), ...(parsed.data.approved !== undefined ? { approved: parsed.data.approved } : {}), }) .where(eq(user.id, userId)) .returning({ id: user.id, - role: user.role, isActive: user.isActive, - canAccessVault: user.canAccessVault, approved: user.approved, }) .get(); diff --git a/src/app/api/admin/vault/[id]/access/route.ts b/src/app/api/admin/vault/[id]/access/route.ts index b2ca4e1..c29bbab 100644 --- a/src/app/api/admin/vault/[id]/access/route.ts +++ b/src/app/api/admin/vault/[id]/access/route.ts @@ -6,12 +6,10 @@ import { user, vaultEntry, vaultEntryAccess } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; import { vaultAccessSchema } from "@/lib/validation"; -async function requireAdmin() { +async function requireSession() { const sessionUser = await getSessionUser(); if (!sessionUser) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; - if (sessionUser.role !== "admin") - return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; return { user: sessionUser }; } @@ -48,7 +46,7 @@ async function loadEntryAndBody(req: NextRequest, idRaw: string) { } export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const loaded = await loadEntryAndBody(req, (await params).id); @@ -67,7 +65,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const loaded = await loadEntryAndBody(req, (await params).id); diff --git a/src/app/api/admin/vault/[id]/route.ts b/src/app/api/admin/vault/[id]/route.ts index 63389c5..2c61df1 100644 --- a/src/app/api/admin/vault/[id]/route.ts +++ b/src/app/api/admin/vault/[id]/route.ts @@ -7,11 +7,9 @@ import { getSessionUser } from "@/lib/auth/session"; import { encryptSecret } from "@/lib/security/vault-crypto"; import { vaultEntryUpdateSchema } from "@/lib/validation"; -async function requireAdmin() { +async function requireSession() { const user = await getSessionUser(); if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; - if (user.role !== "admin") - return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) }; return { user }; } @@ -21,7 +19,7 @@ function parseId(raw: string) { } export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const id = parseId((await params).id); @@ -59,7 +57,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id } export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const auth = await requireAdmin(); + const auth = await requireSession(); if (auth.error) return auth.error; const id = parseId((await params).id); diff --git a/src/app/api/admin/vault/route.ts b/src/app/api/admin/vault/route.ts index 081d4ab..891244e 100644 --- a/src/app/api/admin/vault/route.ts +++ b/src/app/api/admin/vault/route.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { db } from "@/lib/db"; -import { vaultEntry } from "@/lib/db/schema"; +import { vaultEntry, vaultEntryAccess } from "@/lib/db/schema"; import { getSessionUser } from "@/lib/auth/session"; import { encryptSecret } from "@/lib/security/vault-crypto"; import { vaultEntrySchema } from "@/lib/validation"; @@ -11,9 +11,6 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => null); const parsed = vaultEntrySchema.safeParse(body); @@ -38,5 +35,11 @@ export async function POST(req: NextRequest) { .returning({ id: vaultEntry.id }) .get(); + // Grant the creator read access to their own entry - there's no more + // always-on admin bypass in canReadVaultEntry. + db.insert(vaultEntryAccess) + .values({ entryId: created.id, userId: user.id, grantedBy: user.id }) + .run(); + return NextResponse.json({ id: created.id }, { status: 201 }); } diff --git a/src/app/api/admin/whitelist/[id]/action/route.ts b/src/app/api/admin/whitelist/[id]/action/route.ts index 9dd7dcd..3fc8423 100644 --- a/src/app/api/admin/whitelist/[id]/action/route.ts +++ b/src/app/api/admin/whitelist/[id]/action/route.ts @@ -14,9 +14,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (admin.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const id = Number((await params).id); if (!Number.isInteger(id)) { diff --git a/src/app/api/admin/whitelist/[id]/route.ts b/src/app/api/admin/whitelist/[id]/route.ts index 2cfbcf2..0c66645 100644 --- a/src/app/api/admin/whitelist/[id]/route.ts +++ b/src/app/api/admin/whitelist/[id]/route.ts @@ -11,9 +11,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i if (!admin) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (admin.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const id = Number((await params).id); if (!Number.isInteger(id)) { diff --git a/src/app/api/orders/[id]/action/route.ts b/src/app/api/orders/[id]/action/route.ts index 801572c..49d8a3c 100644 --- a/src/app/api/orders/[id]/action/route.ts +++ b/src/app/api/orders/[id]/action/route.ts @@ -19,9 +19,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (!sessionUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (sessionUser.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const orderId = Number((await params).id); if (!Number.isInteger(orderId)) { diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts index 85052ec..44e19b2 100644 --- a/src/app/api/orders/[id]/route.ts +++ b/src/app/api/orders/[id]/route.ts @@ -107,12 +107,7 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: "Order not found" }, { status: 404 }); } - const isAdmin = user.role === "admin"; - if (isLockedOrderStatus(existing.status)) { - if (!isAdmin) { - return NextResponse.json({ error: "This order cannot be deleted" }, { status: 403 }); - } - } else if (!memberCanModifyOrder(existing, user.id)) { + if (!isLockedOrderStatus(existing.status) && !memberCanModifyOrder(existing, user.id)) { return NextResponse.json({ error: "This order cannot be deleted" }, { status: 403 }); } diff --git a/src/app/api/orders/assign/route.ts b/src/app/api/orders/assign/route.ts index 462dbb1..c39defb 100644 --- a/src/app/api/orders/assign/route.ts +++ b/src/app/api/orders/assign/route.ts @@ -17,9 +17,6 @@ export async function POST(req: NextRequest) { if (!sessionUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (sessionUser.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => null); const parsed = orderAssignSchema.safeParse(body); diff --git a/src/app/api/orders/bulk-action/route.ts b/src/app/api/orders/bulk-action/route.ts index 08139d9..c261815 100644 --- a/src/app/api/orders/bulk-action/route.ts +++ b/src/app/api/orders/bulk-action/route.ts @@ -19,9 +19,6 @@ export async function POST(req: NextRequest) { if (!sessionUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (sessionUser.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => null); const parsed = orderBulkActionSchema.safeParse(body); diff --git a/src/app/api/orders/mark-ordered/route.ts b/src/app/api/orders/mark-ordered/route.ts index d25b16a..8c4c8e1 100644 --- a/src/app/api/orders/mark-ordered/route.ts +++ b/src/app/api/orders/mark-ordered/route.ts @@ -9,9 +9,6 @@ export async function POST(req: NextRequest) { if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } const body = await req.json().catch(() => ({})); const parsed = markOrderedSchema.safeParse(body); diff --git a/src/app/api/users/features/route.ts b/src/app/api/users/features/route.ts deleted file mode 100644 index f884dd4..0000000 --- a/src/app/api/users/features/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { and, eq } from "drizzle-orm"; -import { NextResponse, type NextRequest } from "next/server"; - -import { db } from "@/lib/db"; -import { userFeature } from "@/lib/db/schema"; -import { getSessionUser } from "@/lib/auth/session"; -import { featureRequestSchema } from "@/lib/validation"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const features = db.select().from(userFeature).where(eq(userFeature.userId, user.id)).all(); - - return NextResponse.json({ features }); -} - -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const body = await req.json().catch(() => null); - const parsed = featureRequestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - const { featureKey, requestNote } = parsed.data; - - const existing = db - .select() - .from(userFeature) - .where(and(eq(userFeature.userId, user.id), eq(userFeature.featureKey, featureKey))) - .get(); - - if (existing?.status === "granted") { - return NextResponse.json({ error: "Feature already granted" }, { status: 409 }); - } - if (existing?.status === "pending") { - return NextResponse.json({ error: "Request already pending" }, { status: 409 }); - } - - if (existing) { - // Re-request after rejection - const updated = db - .update(userFeature) - .set({ - status: "pending", - requestNote: requestNote ?? null, - adminNote: null, - reviewedBy: null, - reviewedAt: null, - }) - .where(eq(userFeature.id, existing.id)) - .returning() - .get(); - return NextResponse.json({ feature: updated }); - } - - const created = db - .insert(userFeature) - .values({ - userId: user.id, - featureKey, - status: "pending", - requestNote: requestNote ?? null, - }) - .returning() - .get(); - - return NextResponse.json({ feature: created }, { status: 201 }); -} diff --git a/src/app/api/vault/[id]/reveal/route.ts b/src/app/api/vault/[id]/reveal/route.ts index aae2c00..7505d71 100644 --- a/src/app/api/vault/[id]/reveal/route.ts +++ b/src/app/api/vault/[id]/reveal/route.ts @@ -7,7 +7,7 @@ import { canReadVaultEntry, getSessionUser } from "@/lib/auth/session"; import { decryptSecret } from "@/lib/security/vault-crypto"; // Login secrets never ship in the page payload - they are decrypted on demand -// here, only for users who hold a per-entry grant (or are an admin). +// here, only for users who hold a per-entry grant. export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const user = await getSessionUser(); if (!user) { @@ -44,7 +44,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: { status: 403 } ); } - // Per-entry access: admin or an explicit grant for this login. + // Per-entry access: an explicit grant for this login. if (!canReadVaultEntry(user, id)) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } diff --git a/src/components/admin/FeatureRequests.tsx b/src/components/admin/FeatureRequests.tsx deleted file mode 100644 index b5013af..0000000 --- a/src/components/admin/FeatureRequests.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { FEATURES } from "@/lib/features"; -import { formatDate } from "@/lib/utils"; - -export type FeatureRequestRow = { - id: number; - userId: string; - userName: string; - userEmail: string; - featureKey: string; - requestNote: string | null; - requestedAt: Date; -}; - -export function FeatureRequests({ requests }: { requests: FeatureRequestRow[] }) { - const router = useRouter(); - const [busy, setBusy] = useState(null); - - if (requests.length === 0) return null; - - async function act(id: number, action: "grant" | "reject") { - setBusy(id); - try { - const res = await fetch(`/api/admin/features/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Action failed"); - } - toast.success(action === "grant" ? "Access granted" : "Request rejected"); - router.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setBusy(null); - } - } - - return ( -
-
-

Feature Requests

- {requests.length} -
-
- - - - User - Feature - Note - Requested - Actions - - - - {requests.map((r) => { - const featureLabel = - FEATURES[r.featureKey as keyof typeof FEATURES]?.label ?? - r.featureKey; - return ( - - -

{r.userName}

-

- {r.userEmail} -

-
- {featureLabel} - - {r.requestNote ?? "-"} - - - {formatDate(r.requestedAt)} - - -
- - -
-
-
- ); - })} -
-
-
-
- ); -} diff --git a/src/components/admin/UserTable.tsx b/src/components/admin/UserTable.tsx index f0c2c55..63cd88f 100644 --- a/src/components/admin/UserTable.tsx +++ b/src/components/admin/UserTable.tsx @@ -1,19 +1,11 @@ "use client"; -import { Fragment } from "react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Table, TableBody, @@ -22,23 +14,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { FEATURES, type FeatureKey } from "@/lib/features"; import { formatDate } from "@/lib/utils"; export type AdminUserRow = { id: string; name: string; email: string; - role: "member" | "admin"; isActive: boolean; - canAccessVault: boolean; - grantedFeatures: FeatureKey[]; createdAt: Date; }; -const ROLE_ITEMS = { member: "Member", admin: "Admin" }; -const ALL_FEATURES = Object.keys(FEATURES) as FeatureKey[]; - export function UserTable({ users, currentUserId, @@ -48,7 +33,6 @@ export function UserTable({ }) { const router = useRouter(); const [busy, setBusy] = useState(null); - const [expanded, setExpanded] = useState(null); async function patchUser(id: string, body: Record) { setBusy(id); @@ -71,34 +55,6 @@ export function UserTable({ } } - async function toggleFeature(userId: string, featureKey: FeatureKey, granted: boolean) { - setBusy(`${userId}-${featureKey}`); - try { - if (granted) { - const res = await fetch(`/api/admin/users/${userId}/features`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ featureKey }), - }); - if (!res.ok) throw new Error("Failed to revoke"); - toast.success("Feature revoked"); - } else { - const res = await fetch(`/api/admin/users/${userId}/features`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ featureKey }), - }); - if (!res.ok) throw new Error("Failed to grant"); - toast.success("Feature granted"); - } - router.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setBusy(null); - } - } - return (
@@ -106,9 +62,7 @@ export function UserTable({ Name Email - Role Status - Vault access Joined Actions @@ -116,125 +70,40 @@ export function UserTable({ {users.map((u) => { const isSelf = u.id === currentUserId; - const isExpanded = expanded === u.id; return ( - - - - {u.name} - {isSelf ? ( - - (you) - - ) : null} - - - {u.email} - - - - - - {u.isActive ? ( - Active - ) : ( - Deactivated - )} - - - {u.role === "admin" ? ( - Always (admin) - ) : ( - - )} - - - {formatDate(u.createdAt)} - - -
- {u.role !== "admin" && ( - - )} - -
-
-
- {isExpanded && u.role !== "admin" && ( - - -

- Feature access for {u.name} -

-
- {ALL_FEATURES.map((key) => { - const granted = u.grantedFeatures.includes(key); - const busyKey = `${u.id}-${key}`; - return ( - - ); - })} -
-
-
- )} -
+ + + {u.name} + {isSelf ? ( + + (you) + + ) : null} + + + {u.email} + + + {u.isActive ? ( + Active + ) : ( + Deactivated + )} + + + {formatDate(u.createdAt)} + + + + + ); })}
diff --git a/src/components/features/FeaturesPanel.tsx b/src/components/features/FeaturesPanel.tsx deleted file mode 100644 index 17f04cf..0000000 --- a/src/components/features/FeaturesPanel.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import type { FeatureKey } from "@/lib/features"; -import { FEATURES } from "@/lib/features"; - -type FeatureRow = { - key: FeatureKey; - label: string; - description: string; - status: "pending" | "granted" | "rejected" | null; -}; - -function statusBadge(status: FeatureRow["status"]) { - if (status === "granted") return Active; - if (status === "pending") return Pending; - if (status === "rejected") return Rejected; - return null; -} - -export function FeaturesPanel({ - features, - deniedKey, -}: { - features: FeatureRow[]; - deniedKey: string | null; -}) { - const router = useRouter(); - const [busy, setBusy] = useState(null); - const [notes, setNotes] = useState>( - {} as Record - ); - const deniedRef = useRef(null); - - useEffect(() => { - if (deniedKey && deniedRef.current) { - deniedRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); - } - }, [deniedKey]); - - async function requestFeature(key: FeatureKey) { - setBusy(key); - try { - const res = await fetch("/api/users/features", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ featureKey: key, requestNote: notes[key] || undefined }), - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Request failed"); - } - toast.success("Access requested"); - router.refresh(); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Something went wrong"); - } finally { - setBusy(null); - } - } - - return ( -
- {features.map((f) => { - const isDenied = deniedKey === f.key; - const canRequest = f.status === null || f.status === "rejected"; - const isGranted = f.status === "granted"; - const card = ( - - -
- {f.label} - {statusBadge(f.status)} -
- {f.description} - {isDenied && ( -

- You don't have access to this feature yet. -

- )} -
- {canRequest && ( - <> - -