From fbac2d2c8dd9fe132263b4e344ee83697bd91706 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 21 Jul 2026 23:17:07 +0200 Subject: [PATCH 01/15] feat(database/schema): cfx & discord id columns in adminUsers for OAuth2 --- .../migrations/0008_vengeful_microbe.sql | 6 + .../migrations/meta/0008_snapshot.json | 1329 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../migrations/0008_vengeful_microbe.ts | 14 + .../src/migrations/migrations/index.ts | 2 + packages/database/src/schema.ts | 8 +- 6 files changed, 1365 insertions(+), 1 deletion(-) create mode 100644 packages/database/migrations/0008_vengeful_microbe.sql create mode 100644 packages/database/migrations/meta/0008_snapshot.json create mode 100644 packages/database/src/migrations/migrations/0008_vengeful_microbe.ts diff --git a/packages/database/migrations/0008_vengeful_microbe.sql b/packages/database/migrations/0008_vengeful_microbe.sql new file mode 100644 index 0000000..45c5923 --- /dev/null +++ b/packages/database/migrations/0008_vengeful_microbe.sql @@ -0,0 +1,6 @@ +ALTER TABLE `admin_users` ADD `cfx_id` text;--> statement-breakpoint +ALTER TABLE `admin_users` ADD `discord_id` text;--> statement-breakpoint +CREATE UNIQUE INDEX `admin_users_cfx_id_unique` ON `admin_users` (`cfx_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `admin_users_discord_id_unique` ON `admin_users` (`discord_id`);--> statement-breakpoint +CREATE INDEX `admin_cfx_id_idx` ON `admin_users` (`cfx_id`);--> statement-breakpoint +CREATE INDEX `admin_discord_id_idx` ON `admin_users` (`discord_id`); \ No newline at end of file diff --git a/packages/database/migrations/meta/0008_snapshot.json b/packages/database/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..3a98594 --- /dev/null +++ b/packages/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,1329 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "80273b99-40d3-4d20-ad53-c0cfbafa2a9e", + "prevId": "540f9400-9822-42ad-9948-96aa5d41473e", + "tables": { + "admin_groups": { + "name": "admin_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "colour": { + "name": "colour", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#ffffff'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "admin_groups_name_unique": { + "name": "admin_groups_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_users": { + "name": "admin_users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cfx_id": { + "name": "cfx_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_users_username_unique": { + "name": "admin_users_username_unique", + "columns": ["username"], + "isUnique": true + }, + "admin_users_cfx_id_unique": { + "name": "admin_users_cfx_id_unique", + "columns": ["cfx_id"], + "isUnique": true + }, + "admin_users_discord_id_unique": { + "name": "admin_users_discord_id_unique", + "columns": ["discord_id"], + "isUnique": true + }, + "admin_username_idx": { + "name": "admin_username_idx", + "columns": ["username"], + "isUnique": false + }, + "admin_cfx_id_idx": { + "name": "admin_cfx_id_idx", + "columns": ["cfx_id"], + "isUnique": false + }, + "admin_discord_id_idx": { + "name": "admin_discord_id_idx", + "columns": ["discord_id"], + "isUnique": false + } + }, + "foreignKeys": { + "admin_users_player_id_players_id_fk": { + "name": "admin_users_player_id_players_id_fk", + "tableFrom": "admin_users", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_users_group_id_admin_groups_id_fk": { + "name": "admin_users_group_id_admin_groups_id_fk", + "tableFrom": "admin_users", + "tableTo": "admin_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "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 + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "columns": ["token"], + "isUnique": true + }, + "tokens_token_idx": { + "name": "tokens_token_idx", + "columns": ["token"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_admin_idx": { + "name": "audit_admin_idx", + "columns": ["admin_id"], + "isUnique": false + }, + "audit_created_idx": { + "name": "audit_created_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "audit_log_admin_id_admin_users_id_fk": { + "name": "audit_log_admin_id_admin_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "admin_users", + "columnsFrom": ["admin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_player_id_players_id_fk": { + "name": "audit_log_player_id_players_id_fk", + "tableFrom": "audit_log", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bans": { + "name": "bans", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bans_player_idx": { + "name": "bans_player_idx", + "columns": ["player_id"], + "isUnique": false + } + }, + "foreignKeys": { + "bans_player_id_players_id_fk": { + "name": "bans_player_id_players_id_fk", + "tableFrom": "bans", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bans_issuer_admin_users_id_fk": { + "name": "bans_issuer_admin_users_id_fk", + "tableFrom": "bans", + "tableTo": "admin_users", + "columnsFrom": ["issuer"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "disconnect_events": { + "name": "disconnect_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "disconnect_event_session_ts_idx": { + "name": "disconnect_event_session_ts_idx", + "columns": ["session_id", "ts"], + "isUnique": false + } + }, + "foreignKeys": { + "disconnect_events_session_id_server_sessions_id_fk": { + "name": "disconnect_events_session_id_server_sessions_id_fk", + "tableFrom": "disconnect_events", + "tableTo": "server_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kicks": { + "name": "kicks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "issuer": { + "name": "issuer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "kicks_player_id_players_id_fk": { + "name": "kicks_player_id_players_id_fk", + "tableFrom": "kicks", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kicks_issuer_admin_users_id_fk": { + "name": "kicks_issuer_admin_users_id_fk", + "tableFrom": "kicks", + "tableTo": "admin_users", + "columnsFrom": ["issuer"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "perf_snapshots": { + "name": "perf_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ts": { + "name": "ts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "players": { + "name": "players", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fxs_memory": { + "name": "fxs_memory", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "node_memory": { + "name": "node_memory", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "perf": { + "name": "perf", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "perf_snapshot_session_ts_idx": { + "name": "perf_snapshot_session_ts_idx", + "columns": ["session_id", "ts"], + "isUnique": false + } + }, + "foreignKeys": { + "perf_snapshots_session_id_server_sessions_id_fk": { + "name": "perf_snapshots_session_id_server_sessions_id_fk", + "tableFrom": "perf_snapshots", + "tableTo": "server_sessions", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_identifiers": { + "name": "player_identifiers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_identifier_value": { + "name": "idx_identifier_value", + "columns": ["value"], + "isUnique": false + }, + "player_identifiers_type_value_unique": { + "name": "player_identifiers_type_value_unique", + "columns": ["type", "value"], + "isUnique": true + } + }, + "foreignKeys": { + "player_identifiers_player_id_players_id_fk": { + "name": "player_identifiers_player_id_players_id_fk", + "tableFrom": "player_identifiers", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_notes": { + "name": "player_notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "player_notes_player_id_players_id_fk": { + "name": "player_notes_player_id_players_id_fk", + "tableFrom": "player_notes", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_notes_issuer_admin_users_id_fk": { + "name": "player_notes_issuer_admin_users_id_fk", + "tableFrom": "player_notes", + "tableTo": "admin_users", + "columnsFrom": ["issuer"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_sessions": { + "name": "player_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_session_id": { + "name": "server_session_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "player_session_player_connected_idx": { + "name": "player_session_player_connected_idx", + "columns": ["player_id", "connected_at"], + "isUnique": false + } + }, + "foreignKeys": { + "player_sessions_player_id_players_id_fk": { + "name": "player_sessions_player_id_players_id_fk", + "tableFrom": "player_sessions", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_sessions_server_session_id_server_sessions_id_fk": { + "name": "player_sessions_server_session_id_server_sessions_id_fk", + "tableFrom": "player_sessions", + "tableTo": "server_sessions", + "columnsFrom": ["server_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "players": { + "name": "players", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playtime": { + "name": "playtime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_seen": { + "name": "first_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_messages": { + "name": "report_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "report_id": { + "name": "report_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "report_messages_report_id_reports_id_fk": { + "name": "report_messages_report_id_reports_id_fk", + "tableFrom": "report_messages", + "tableTo": "reports", + "columnsFrom": ["report_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "report_messages_player_id_players_id_fk": { + "name": "report_messages_player_id_players_id_fk", + "tableFrom": "report_messages", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "report_messages_admin_id_admin_users_id_fk": { + "name": "report_messages_admin_id_admin_users_id_fk", + "tableFrom": "report_messages", + "tableTo": "admin_users", + "columnsFrom": ["admin_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "reporter_id": { + "name": "reporter_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_action": { + "name": "last_action", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "reports_reporter_id_players_id_fk": { + "name": "reports_reporter_id_players_id_fk", + "tableFrom": "reports", + "tableTo": "players", + "columnsFrom": ["reporter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "server_sessions": { + "name": "server_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "server_session_started_idx": { + "name": "server_session_started_idx", + "columns": ["started_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_admin_idx": { + "name": "sessions_admin_idx", + "columns": ["admin_id"], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_admin_id_admin_users_id_fk": { + "name": "sessions_admin_id_admin_users_id_fk", + "tableFrom": "sessions", + "tableTo": "admin_users", + "columnsFrom": ["admin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "warns": { + "name": "warns", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "player_id": { + "name": "player_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "issuer": { + "name": "issuer", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "warns_player_id_players_id_fk": { + "name": "warns_player_id_players_id_fk", + "tableFrom": "warns", + "tableTo": "players", + "columnsFrom": ["player_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "warns_issuer_admin_users_id_fk": { + "name": "warns_issuer_admin_users_id_fk", + "tableFrom": "warns", + "tableTo": "admin_users", + "columnsFrom": ["issuer"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "whitelisted_identifiers": { + "name": "whitelisted_identifiers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_whitelist_identifier_value": { + "name": "idx_whitelist_identifier_value", + "columns": ["value"], + "isUnique": false + }, + "whitelisted_identifiers_type_value_unique": { + "name": "whitelisted_identifiers_type_value_unique", + "columns": ["type", "value"], + "isUnique": true + } + }, + "foreignKeys": { + "whitelisted_identifiers_admin_id_admin_users_id_fk": { + "name": "whitelisted_identifiers_admin_id_admin_users_id_fk", + "tableFrom": "whitelisted_identifiers", + "tableTo": "admin_users", + "columnsFrom": ["admin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 0b5a3e6..1b5a619 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1784656776216, "tag": "0007_petite_skrulls", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1784668600877, + "tag": "0008_vengeful_microbe", + "breakpoints": true } ] } diff --git a/packages/database/src/migrations/migrations/0008_vengeful_microbe.ts b/packages/database/src/migrations/migrations/0008_vengeful_microbe.ts new file mode 100644 index 0000000..55421f7 --- /dev/null +++ b/packages/database/src/migrations/migrations/0008_vengeful_microbe.ts @@ -0,0 +1,14 @@ +import type { Migration } from '../types'; + +export const m0008_vengeful_microbe: Migration = { + version: 8, + description: 'Add cfx & discord id to adminusers for Oauth', + up: [ + `ALTER TABLE \`admin_users\` ADD \`cfx_id\` text`, + `ALTER TABLE \`admin_users\` ADD \`discord_id\` text`, + `CREATE UNIQUE INDEX \`admin_users_cfx_id_unique\` ON \`admin_users\` (\`cfx_id\`)`, + `CREATE UNIQUE INDEX \`admin_users_discord_id_unique\` ON \`admin_users\` (\`discord_id\`)`, + `CREATE INDEX \`admin_cfx_id_idx\` ON \`admin_users\` (\`cfx_id\`)`, + `CREATE INDEX \`admin_discord_id_idx\` ON \`admin_users\` (\`discord_id\`)`, + ], +}; diff --git a/packages/database/src/migrations/migrations/index.ts b/packages/database/src/migrations/migrations/index.ts index 8352e1f..ce6b6c6 100644 --- a/packages/database/src/migrations/migrations/index.ts +++ b/packages/database/src/migrations/migrations/index.ts @@ -1,3 +1,4 @@ +import { m0008_vengeful_microbe } from './0008_vengeful_microbe'; import { m0007_petite_skrulls } from './0007_petite_skrulls'; import { m0006_productive_thor } from './0006_productive_thor'; import { m0005_chubby_daredevil } from './0005_chubby_daredevil'; @@ -33,4 +34,5 @@ export const migrations: Migration[] = [ m0005_chubby_daredevil, m0006_productive_thor, m0007_petite_skrulls, + m0008_vengeful_microbe, ]; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 0983389..fcb5a8f 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -79,6 +79,8 @@ export const adminUsers = sqliteTable( playerId: integer('player_id').references(() => players.id, { onDelete: 'set null', }), + cfxId: text('cfx_id').unique(), + discordId: text('discord_id').unique(), permissions: integer('permissions').default(0).notNull(), groupId: integer('group_id').references(() => adminGroups.id, { onDelete: 'set null', @@ -86,7 +88,11 @@ export const adminUsers = sqliteTable( createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), lastLoginAt: integer('last_login_at', { mode: 'timestamp' }), }, - (t) => [index('admin_username_idx').on(t.username)], + (t) => [ + index('admin_username_idx').on(t.username), + index('admin_cfx_id_idx').on(t.cfxId), + index('admin_discord_id_idx').on(t.discordId), + ], ); export const sessions = sqliteTable( From dc10e0903e533faef571c8f66e72a2bb1eeae57e Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 21 Jul 2026 23:17:39 +0200 Subject: [PATCH 02/15] chore(package): added better-sqlite3 to use drizzle studio --- bun.lock | 13 +++++++++---- package.json | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index d7ae051..2aef326 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.8", "@types/bun": "^1.3.11", + "better-sqlite3": "^13.0.1", "drizzle-kit": "^0.31.0", "turbo": "^2.8.20", "typescript": "5.9.3", @@ -35,7 +36,7 @@ }, "apps/resource": { "name": "@fxmanager/resource", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "@communityox/ox_lib": "latest", "@nativewrappers/fivem": "latest", @@ -63,7 +64,7 @@ }, "apps/webpanel": { "name": "@fxmanager/webpanel", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "@codemirror/commands": "^6.10.4", "@codemirror/language": "^6.12.4", @@ -97,7 +98,7 @@ }, "packages/database": { "name": "@fxmanager/database", - "version": "0.1.0", + "version": "0.3.2", "dependencies": { "drizzle-orm": "^0.43.0", }, @@ -117,7 +118,7 @@ }, "packages/ui": { "name": "@fxmanager/ui", - "version": "0.0.0", + "version": "0.3.2", "dependencies": { "@fontsource-variable/inter": "^5.2.8", "class-variance-authority": "^0.7.1", @@ -745,6 +746,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="], + "better-sqlite3": ["better-sqlite3@13.0.1", "", { "dependencies": { "node-addon-api": "^8.0.0" } }, "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -1191,6 +1194,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], diff --git a/package.json b/package.json index d2cf6a9..2553e39 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.8", "@types/bun": "^1.3.11", + "better-sqlite3": "^13.0.1", "drizzle-kit": "^0.31.0", "turbo": "^2.8.20", "typescript": "5.9.3" From e9c43affff4d18d1c5d99bde5728995754e7443c Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Wed, 22 Jul 2026 19:33:30 +0200 Subject: [PATCH 03/15] feat(panel): discord OAuth2 login handling --- apps/core/src/modules/auth/manager.ts | 37 +++++ .../src/modules/auth/providers/discord.ts | 108 +++++++++++++++ apps/core/src/routes/api/auth.ts | 112 ++++++++++++++++ apps/core/src/routes/api/settings/index.ts | 4 + apps/core/src/types/auth.ts | 14 ++ apps/core/src/types/index.ts | 1 + apps/webpanel/src/context/AuthContext.tsx | 21 ++- apps/webpanel/src/pages/login/index.tsx | 126 +++++++++--------- apps/webpanel/src/pages/settings/index.tsx | 7 + .../src/pages/settings/tabs/oauth.tsx | 67 ++++++++++ apps/webpanel/src/types/auth.ts | 1 + 11 files changed, 436 insertions(+), 62 deletions(-) create mode 100644 apps/core/src/modules/auth/manager.ts create mode 100644 apps/core/src/modules/auth/providers/discord.ts create mode 100644 apps/core/src/types/auth.ts create mode 100644 apps/webpanel/src/pages/settings/tabs/oauth.tsx diff --git a/apps/core/src/modules/auth/manager.ts b/apps/core/src/modules/auth/manager.ts new file mode 100644 index 0000000..4ce49a4 --- /dev/null +++ b/apps/core/src/modules/auth/manager.ts @@ -0,0 +1,37 @@ +import type { IOAuthProvider } from '../../types'; +import { DiscordOAuthProvider } from './providers/discord'; + +class OAuthManager { + private providers = new Map(); + + registerProvider(provider: IOAuthProvider): this { + this.providers.set(provider.name, provider); + return this; + } + + getProvider(name: string): IOAuthProvider { + const provider = this.providers.get(name); + if (!provider) { + throw new Error(`OAuth provider '${name}' is not registered.`); + } + return provider; + } + + hasProvider(name: string): boolean { + return this.providers.has(name); + } + + reload(): this { + this.providers.forEach((provider) => { + if (provider.initialize) { + provider.initialize(); + } + }); + return this; + } +} + +const oauthManager = new OAuthManager(); +oauthManager.registerProvider(new DiscordOAuthProvider()); + +export { oauthManager }; diff --git a/apps/core/src/modules/auth/providers/discord.ts b/apps/core/src/modules/auth/providers/discord.ts new file mode 100644 index 0000000..8128855 --- /dev/null +++ b/apps/core/src/modules/auth/providers/discord.ts @@ -0,0 +1,108 @@ +import { repo } from '@fxmanager/database'; +import type { IOAuthProvider, OAuthUser } from '../../../types'; + +export class DiscordOAuthProvider implements IOAuthProvider { + readonly name = 'discord'; + private enabled: boolean = false; + private clientId?: string; + private clientSecret?: string; + private scopes: string[] = ['identify', 'email']; + + constructor() { + this.initialize(); + } + + initialize() { + const settings = repo.settings.getMultiple([ + 'oauth.discordClientId', + 'oauth.discordSecret', + 'oauth.discordEnabled', + ]); + + this.clientId = settings['oauth.discordClientId']; + this.clientSecret = settings['oauth.discordSecret']; + this.enabled = settings['oauth.discordEnabled'] == 'true' ? true : false; + } + + isConfigured(): boolean { + return ( + typeof this.clientId === 'string' && typeof this.clientSecret === 'string' + ); + } + + getAuthUrl(state: string, redirectUri: string): string { + if (!this.enabled) { + throw new Error('Discord OAuth is not enabled.'); + } + if (!this.isConfigured()) { + throw new Error('Discord OAuth is not configured on the server.'); + } + + const params = new URLSearchParams({ + // ! used because covered in this.isConfigured check + client_id: this.clientId!, + redirect_uri: redirectUri, + response_type: 'code', + scope: this.scopes.join(' '), + state, + }); + + return `https://discord.com/oauth2/authorize?${params.toString()}`; + } + + async handleCallback(code: string, redirectUri: string): Promise { + if (!this.enabled) { + throw new Error('Discord OAuth is not enabled.'); + } + if (!this.isConfigured()) { + throw new Error('Discord OAuth is not configured on the server.'); + } + + const tokenResponse = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + // ! used because covered in this.isConfigured check + client_id: this.clientId!, + client_secret: this.clientSecret!, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }), + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + throw new Error(`Failed to exchange authorization code: ${errorText}`); + } + + const tokenData = (await tokenResponse.json()) as { access_token: string }; + + const userResponse = await fetch('https://discord.com/api/users/@me', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (!userResponse.ok) { + throw new Error('Failed to retrieve user profile from Discord.'); + } + + const userData = (await userResponse.json()) as { + id: string; + username: string; + global_name?: string; + email?: string; + avatar?: string; + }; + + return { + id: userData.id, + username: userData.global_name || userData.username, + email: userData.email, + avatarUrl: userData.avatar + ? `https://cdn.discordapp.com/avatars/${userData.id}/${userData.avatar}.png` + : undefined, + }; + } +} diff --git a/apps/core/src/routes/api/auth.ts b/apps/core/src/routes/api/auth.ts index f21d3b9..1cc990d 100644 --- a/apps/core/src/routes/api/auth.ts +++ b/apps/core/src/routes/api/auth.ts @@ -1,10 +1,12 @@ import { Type, type Static } from '@sinclair/typebox'; import type { FastifyPluginAsync } from 'fastify'; +import crypto from 'node:crypto'; import { repo } from '@fxmanager/database'; import { COOKIE_NAME } from '../../common/utils'; import { loginRateLimiter } from '../../modules/auth/rate-limiter'; import type { RouteModule } from '../../types'; +import { oauthManager } from '../../modules/auth/manager'; const LoginBody = Type.Object({ username: Type.String(), @@ -13,6 +15,12 @@ const LoginBody = Type.Object({ type LoginBodyType = Static; +function getCallbackUri(request: any, provider: string): string { + const protocol = request.headers['x-forwarded-proto'] || request.protocol; + const host = request.headers['x-forwarded-host'] || request.headers.host; + return `${protocol}://${host}/api/auth/oauth/${provider}/callback`; +} + const AuthEndpoints: FastifyPluginAsync = async (fastify) => { fastify.post( '/login', @@ -93,6 +101,110 @@ const AuthEndpoints: FastifyPluginAsync = async (fastify) => { : null, }; }); + + fastify.post('/oauth/:provider/init', async (request, reply) => { + const { provider: providerName } = request.params as { provider: string }; + + if (!oauthManager.hasProvider(providerName)) { + return reply + .code(404) + .send({ error: `Provider '${providerName}' not found.` }); + } + + const provider = oauthManager.getProvider(providerName); + + if (!provider.isConfigured()) { + return reply.code(400).send({ + error: `${providerName} login is currently unavailable or unconfigured.`, + }); + } + + const state = crypto.randomBytes(16).toString('hex'); + try { + const redirectUri = getCallbackUri(request, providerName); + const url = provider.getAuthUrl(state, redirectUri); + + return reply + .setCookie('oauth_state', state, { + httpOnly: true, + secure: request.protocol === 'https', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, // 10 minutes + }) + .send({ url }); + } catch (error) { + return reply.code(500).send({ + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + fastify.get('/oauth/:provider/callback', async (request, reply) => { + const { provider: providerName } = request.params as { provider: string }; + const { + code, + state, + error: providerError, + } = request.query as { + code?: string; + state?: string; + error?: string; + }; + + const savedState = request.cookies.oauth_state; + reply.clearCookie('oauth_state', { path: '/' }); + + if (providerError) { + return reply.redirect(`/?error=${encodeURIComponent(providerError)}`); + } + + if (!state || !savedState || state !== savedState) { + return reply + .code(400) + .send({ error: 'Invalid or expired CSRF state token.' }); + } + + if (!code) { + return reply.code(400).send({ error: 'Missing authorization code.' }); + } + + try { + const provider = oauthManager.getProvider(providerName); + const redirectUri = getCallbackUri(request, providerName); + + const oauthUser = await provider.handleCallback(code, redirectUri); + + const user = repo.auth.findOAuthUser({ + provider: providerName, + providerId: oauthUser.id, + username: oauthUser.username, + email: oauthUser.email, + }); + + if (!user) { + return reply.redirect( + `/?error=${encodeURIComponent('OAuth authentication failed.')}`, + ); + } + + const session = repo.auth.createSession(user.id); + + return reply + .setCookie(COOKIE_NAME, session.id, { + httpOnly: true, + secure: request.protocol === 'https', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7, + }) + .redirect('/'); + } catch (err: any) { + return reply.redirect( + `/?error=${encodeURIComponent(err.message || 'OAuth authentication failed.')}`, + ); + } + }); }; export default { diff --git a/apps/core/src/routes/api/settings/index.ts b/apps/core/src/routes/api/settings/index.ts index 731658f..3d30851 100644 --- a/apps/core/src/routes/api/settings/index.ts +++ b/apps/core/src/routes/api/settings/index.ts @@ -22,6 +22,7 @@ import { import { repo } from '@fxmanager/database'; import { restartScheduler } from '../../../modules/schedule/manager'; import { ConfigManager } from '../../../modules/config/manager'; +import { oauthManager } from '../../../modules/auth/manager'; interface HookResult { valid: boolean; @@ -147,6 +148,9 @@ const SettingsEndpoints: RouteModule['handler'] = async ( }); if (scope === 'restarts') restartScheduler.reload(); + if (scope === 'oauth') { + oauthManager.reload(); + } return { success: true, diff --git a/apps/core/src/types/auth.ts b/apps/core/src/types/auth.ts new file mode 100644 index 0000000..4af0d92 --- /dev/null +++ b/apps/core/src/types/auth.ts @@ -0,0 +1,14 @@ +export interface OAuthUser { + id: string; + username: string; + email?: string; + avatarUrl?: string; +} + +export interface IOAuthProvider { + readonly name: string; + isConfigured(): boolean; + initialize?(): Promise | void; + getAuthUrl(state: string, redirectUri: string): string; + handleCallback(code: string, redirectUri: string): Promise; +} diff --git a/apps/core/src/types/index.ts b/apps/core/src/types/index.ts index 161ba39..e8bbbda 100644 --- a/apps/core/src/types/index.ts +++ b/apps/core/src/types/index.ts @@ -1 +1,2 @@ +export * from './auth'; export * from './routing'; diff --git a/apps/webpanel/src/context/AuthContext.tsx b/apps/webpanel/src/context/AuthContext.tsx index 6d7a34c..37256e3 100644 --- a/apps/webpanel/src/context/AuthContext.tsx +++ b/apps/webpanel/src/context/AuthContext.tsx @@ -79,6 +79,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [navigate], ); + const oauth = useCallback(async (provider: string) => { + try { + const { url } = await QueryService<{ url: string }>({ + endpoint: `/auth/oauth/${provider}/init`, + method: 'POST', + }); + + if (url) { + // Redirect client browser to the OAuth provider consent page + window.location.href = url; + } + } catch (err) { + const apiErr = err as ApiError<{ error: string }>; + toast.error('OAuth Error', { + description: apiErr.data?.error || `Unable to user ${provider} oauth.`, + }); + } + }, []); + const logout = useCallback(async () => { await QueryService({ endpoint: '/auth/logout', @@ -120,7 +139,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return ( {loading ? : children} diff --git a/apps/webpanel/src/pages/login/index.tsx b/apps/webpanel/src/pages/login/index.tsx index a0ee69c..c5a3887 100644 --- a/apps/webpanel/src/pages/login/index.tsx +++ b/apps/webpanel/src/pages/login/index.tsx @@ -15,16 +15,24 @@ import { } from '@fxmanager/ui/components/tooltip'; import { cn } from '@fxmanager/ui/lib/utils'; import { Server } from 'lucide-react'; -import { useState } from 'react'; -import { Navigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { Navigate, useSearchParams } from 'react-router-dom'; function LoginForm({ className, ...props }: React.ComponentProps<'div'>) { - const { login } = useAuth(); + const { login, oauth } = useAuth(); const [formData, setData] = useState<{ username: string; password: string }>({ username: '', password: '', }); const [error, setError] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const errorParam = searchParams.get('error'); + + useEffect(() => { + if (errorParam) { + setError(decodeURIComponent(errorParam)); + } + }, [errorParam, searchParams, setSearchParams]); function handleSubmit(e: React.SubmitEvent) { e.preventDefault(); @@ -89,65 +97,61 @@ function LoginForm({ className, ...props }: React.ComponentProps<'div'>) { Or Use
- - -
- -
-
- Coming Soon... -
+ {/* + Depends on Cfx fixing discourse user api key gen, it would currently only work with + servers using a custom domain and not ip:port + + ----- - - -
- -
-
- Coming Soon... -
+
+ +
*/} + +
+ +
diff --git a/apps/webpanel/src/pages/settings/index.tsx b/apps/webpanel/src/pages/settings/index.tsx index d41ee87..1d1c7e6 100644 --- a/apps/webpanel/src/pages/settings/index.tsx +++ b/apps/webpanel/src/pages/settings/index.tsx @@ -32,6 +32,7 @@ import { cn } from '@fxmanager/ui/lib/utils'; import { Skeleton } from '@fxmanager/ui/components/skeleton'; import type { SettingsTabProps } from '@/types/settings'; import { toast } from 'sonner'; +import OAuthTab from './tabs/oauth'; interface Tab { value: SettingsScope; @@ -65,6 +66,12 @@ const TABS = [ description: 'Schedule automatic server restarts and warn players.', component: RestartsTab, }, + { + value: 'oauth', + label: 'Authentication', + description: 'Configure authentication providers for your server.', + component: OAuthTab, + }, ] satisfies Tab[]; type SettingsCache = { diff --git a/apps/webpanel/src/pages/settings/tabs/oauth.tsx b/apps/webpanel/src/pages/settings/tabs/oauth.tsx new file mode 100644 index 0000000..709ffb4 --- /dev/null +++ b/apps/webpanel/src/pages/settings/tabs/oauth.tsx @@ -0,0 +1,67 @@ +import SettingRow from '../components/settingrow'; +import { SETTINGS_DEFAULTS } from '@fxmanager/shared/constants'; +import type { SettingsTabProps } from '@/types/settings'; +import { Input } from '@fxmanager/ui/components/input'; +import { Switch } from '@fxmanager/ui/components/switch'; + +function blur(event: React.KeyboardEvent) { + if (event.key !== 'Enter') return; + event.currentTarget.blur(); +} + +export default function OAuthTab({ + data, + onChange, + disabled, +}: SettingsTabProps<'oauth'>) { + const discordAuthEnabled = + (data['oauth.discordEnabled'] ?? + SETTINGS_DEFAULTS['oauth.discordEnabled']) === 'true'; + const discordClientId = + data['oauth.discordClientId'] ?? SETTINGS_DEFAULTS['oauth.discordClientId']; + const discordSecret = + data['oauth.discordSecret'] ?? SETTINGS_DEFAULTS['oauth.discordSecret']; + + return ( +
+ + + onChange('oauth.discordEnabled', checked ? 'true' : 'false') + } + /> + + + + { + const value = event.currentTarget.value; + if (value === discordClientId) return; + + onChange('oauth.discordClientId', value); + }} + /> + + + + { + const value = event.currentTarget.value; + if (value === discordSecret) return; + + onChange('oauth.discordSecret', value); + }} + onKeyDown={blur} + /> + +
+ ); +} diff --git a/apps/webpanel/src/types/auth.ts b/apps/webpanel/src/types/auth.ts index 4ea795d..3c682ad 100644 --- a/apps/webpanel/src/types/auth.ts +++ b/apps/webpanel/src/types/auth.ts @@ -11,6 +11,7 @@ export interface AuthContextValue { user: AuthUser | null; loading: boolean; login: (username: string, password: string) => Promise; + oauth: (provider: string) => Promise; logout: () => Promise; setup: (username: string, password: string) => Promise; hasPermission: (permissions: UserPermissionsType) => boolean; From 281fed13f59e45492b803ff56c5869349158568d Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Wed, 22 Jul 2026 19:34:37 +0200 Subject: [PATCH 04/15] feat(panel): allow admin management perm to update identifiers of a profile & include them in creation form --- apps/core/src/routes/api/settings/admins.ts | 74 +++++- .../src/pages/settings/admincreate.tsx | 36 ++- .../webpanel/src/pages/settings/adminview.tsx | 232 ++++++++++++++++-- .../components/update-identifiers.tsx | 128 ++++++++++ 4 files changed, 450 insertions(+), 20 deletions(-) create mode 100644 apps/webpanel/src/pages/settings/components/update-identifiers.tsx diff --git a/apps/core/src/routes/api/settings/admins.ts b/apps/core/src/routes/api/settings/admins.ts index bd9e644..9fe6e24 100644 --- a/apps/core/src/routes/api/settings/admins.ts +++ b/apps/core/src/routes/api/settings/admins.ts @@ -56,7 +56,7 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( if (!allowed) throw new Error('Unauthorized'); - const { username, permissions, groupId, playerId } = + const { username, permissions, groupId, playerId, cfxId, discordId } = request.body as CreateAdminForm; const password = generatePassword(20); @@ -72,6 +72,8 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( storedPermissions, false, playerId ?? null, + cfxId ?? null, + discordId ?? null, ); if (groupId != null) { @@ -250,6 +252,76 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( }, ); + fastify.post( + '/:adminId/identifiers', + async ( + request, + ): Promise< + ApiResponse<{ + newCfxId: AdminProfile['cfxId']; + newDiscordId: AdminProfile['discordId']; + }> + > => { + const { admin } = request as AuthedRequest; + const { adminId: adminIdRaw } = request.params as { adminId: string }; + const { cfxId, discordId } = request.body as { + cfxId: AdminProfile['cfxId']; + discordId: AdminProfile['discordId']; + }; + const adminId = parseInt(adminIdRaw, 10); + + const allowed = + admin.id === adminId || + PermissionManager.has( + admin.permissions, + UserPermissions.SETTINGS_ADMIN_MANAGEMENT, + ); + + if (!allowed) throw new Error('Unauthorized'); + + try { + const { + username, + newCfxId, + newDiscordId, + previousCfxId, + previousDiscordId, + } = await repo.admins.updateIdentifiers(adminId, cfxId, discordId); + + repo.audit.log({ + adminId: admin.id, + action: 'admin.update', + metadata: { + target: username, + new_cfxId: newCfxId, + new_discordId: newDiscordId, + previous_cfxId: previousCfxId, + previous_discordId: previousDiscordId, + }, + }); + + return { + success: true, + data: { newDiscordId, newCfxId }, + }; + } catch (err) { + const msg = (err as Error).message; + + switch (msg) { + case 'not_found': + return { success: false, error: 'Admin not found' }; + case 'admin_is_master': + return { + success: false, + error: 'Can not change permissions of master account', + }; + default: + throw err; + } + } + }, + ); + fastify.post( '/:adminId/group', async (request): Promise> => { diff --git a/apps/webpanel/src/pages/settings/admincreate.tsx b/apps/webpanel/src/pages/settings/admincreate.tsx index 1419908..28bc78e 100644 --- a/apps/webpanel/src/pages/settings/admincreate.tsx +++ b/apps/webpanel/src/pages/settings/admincreate.tsx @@ -5,7 +5,7 @@ import type { ApiResponse, CreateAdminForm, } from '@fxmanager/shared/types'; -import { UserPlus } from 'lucide-react'; +import { Info, UserPlus } from 'lucide-react'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; @@ -25,6 +25,8 @@ export default function AdminCreate() { permissions: 0, groupId: null, playerId: null, + cfxId: null, + discordId: null, }); async function handleSubmit() { @@ -93,6 +95,38 @@ export default function AdminCreate() { /> +
+
+
+ + + + +
+ + setFormData({ ...formData, discordId: e.target.value }) + } + /> +
+ +
+ + + setFormData({ ...formData, cfxId: e.target.value }) + } + /> +
+
- {/* tabs*/} + {/* tabs */}
@@ -107,17 +109,110 @@ function PlayerCardContent({ setTimeout(() => navigate(`/players/${id}`), 1_000); } - if (!id || !name) return

Unlinked

; + if (!id || !name) + return

Unlinked

; return ( ); } +function IdentifiersForm({ + cfxId: initialCfxId, + discordId: initialDiscordId, + canEdit = true, + onSave, +}: { + cfxId: string | null; + discordId: string | null; + canEdit?: boolean; + onSave: (data: { cfxId: string; discordId: string }) => Promise; +}) { + const [cfxId, setCfxId] = useState(initialCfxId ?? ''); + const [discordId, setDiscordId] = useState(initialDiscordId ?? ''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + setCfxId(initialCfxId ?? ''); + setDiscordId(initialDiscordId ?? ''); + }, [initialCfxId, initialDiscordId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canEdit) return; + setIsSubmitting(true); + try { + await onSave({ cfxId, discordId }); + } finally { + setIsSubmitting(false); + } + }; + + const isDirty = + cfxId !== (initialCfxId ?? '') || discordId !== (initialDiscordId ?? ''); + + return ( +
+
+
+
+ + + + +
+ setDiscordId(e.target.value)} + placeholder="e.g. 123456789012345678" + disabled={!canEdit || isSubmitting} + /> +

+ Used for Discord permissions and OAuth. +

+
+ +
+ + setCfxId(e.target.value)} + placeholder="e.g. 123456" + disabled={!canEdit || isSubmitting} + /> +

+ FiveM / RedM Cfx.re account ID. +

+
+
+ + {canEdit && ( +
+ +
+ )} +
+ ); +} + export default function AdminView() { const navigate = useNavigate(); const { user, hasPermission } = useAuth(); @@ -205,6 +300,45 @@ export default function AdminView() { }); } + async function handleIdentiferChange(data: { + cfxId: AdminProfile['cfxId']; + discordId: AdminProfile['discordId']; + }) { + const changePromise = QueryService< + ApiResponse<{ + newCfxId: AdminProfile['cfxId']; + newDiscordId: AdminProfile['discordId']; + }> + >({ + endpoint: `/settings/admins/${params.adminId}/identifiers`, + method: 'POST', + body: data, + }); + + toast.promise(changePromise, { + loading: 'Updating player identifiers...', + success: (r) => { + if (!r.success) throw new Error(r.error); + + setAdminData((prev) => { + if (!prev) throw new Error('Invalid Action Sequence (no admin data)'); + + return { + ...prev, + cfxId: r.data.newCfxId, + discordId: r.data.newDiscordId, + }; + }); + + return `Identifiers have been updated.`; + }, + error: (err) => { + console.error('Failed to update identifiers', err.message); + return `Update failed: ${err.message}`; + }, + }); + } + if (loading) return ; if (error || !adminData || !params.adminId) { @@ -230,6 +364,9 @@ export default function AdminView() { } const isMaster = PermissionManager.isMaster(adminData.permissions); + const isSelf = adminData.id === user?.id; + const canEdit = + isSelf || hasPermission(UserPermissions.SETTINGS_ADMIN_MANAGEMENT); return (
@@ -256,19 +393,6 @@ export default function AdminView() {
- {(!isMaster || adminData.id === user?.id) && ( - handleLinkedPlayerChange(id)} - align="end" - trigger={ - - } - /> - )} {!isMaster && ( @@ -346,8 +470,9 @@ export default function AdminView() {
- + Recent Activity + Identifiers & Player Permissions @@ -377,7 +502,7 @@ export default function AdminView() { ) : ( -
+
{adminData.auditLogs.length > 0 ? ( adminData.auditLogs.map((log) => ( @@ -400,6 +525,77 @@ export default function AdminView() { + + + +
+ {canEdit && ( +
+
+

+ In-Game Player Account +

+

+ Connect this admin account to an existing in-game + player profile. +

+
+ +
+
+ +
+

+ Linked Player +

+ +
+
+ + handleLinkedPlayerChange(id)} + align="end" + trigger={ + + } + /> +
+
+ )} + +
+
+

+ External Platform Identifiers +

+

+ Configure third-party IDs linked to this account for + authentication and bot lookups. +

+
+ + +
+
+
+
+
+ Promise; + trigger?: React.ReactNode; +} + +export function IdentifiersDialog({ + initialCfxId, + initialDiscordId, + onSubmit, + trigger, +}: IdentifiersDialogProps) { + const [open, setOpen] = useState(false); + const [cfxId, setCfxId] = useState(initialCfxId ?? ''); + const [discordId, setDiscordId] = useState(initialDiscordId ?? ''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Synchronize local form state with admin data when opened or updated + useEffect(() => { + if (open) { + setCfxId(initialCfxId ?? ''); + setDiscordId(initialDiscordId ?? ''); + } + }, [open, initialCfxId, initialDiscordId]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + try { + await onSubmit({ cfxId, discordId }); + setOpen(false); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + {trigger ?? ( + + )} + + + +
+ + Update Player Identifiers + + Modify the linked Discord and Cfx identifiers for this account. + + + +
+ {/* Discord ID Field */} +
+
+ + + + +
+ setDiscordId(e.target.value)} + placeholder="e.g. 123456789012345678" + /> +
+ + {/* Cfx ID Field */} +
+ + setCfxId(e.target.value)} + placeholder="e.g. 123456" + /> +
+
+ + + + + +
+
+
+ ); +} From d1b87982ea182899f1c869e63470b476762717eb Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Wed, 22 Jul 2026 19:35:36 +0200 Subject: [PATCH 05/15] fix(packages): missing methods in db & missing settings types / constants --- packages/database/src/repositories/admins.ts | 36 ++++++++++++++++++++ packages/database/src/repositories/auth.ts | 26 ++++++++++++++ packages/shared/src/constants/settings.ts | 5 +++ packages/shared/src/types/settings.ts | 4 +++ 4 files changed, 71 insertions(+) diff --git a/packages/database/src/repositories/admins.ts b/packages/database/src/repositories/admins.ts index 2a2d034..6db7c14 100644 --- a/packages/database/src/repositories/admins.ts +++ b/packages/database/src/repositories/admins.ts @@ -84,6 +84,8 @@ class AdminsRepository { username: adminUsers.username, permissions: adminUsers.permissions, playerId: adminUsers.playerId, + cfxId: adminUsers.cfxId, + discordId: adminUsers.discordId, createdAt: adminUsers.createdAt, lastLoginAt: adminUsers.lastLoginAt, group: { @@ -131,6 +133,8 @@ class AdminsRepository { passwordHash: false, permissions: true, playerId: true, + cfxId: true, + discordId: true, createdAt: true, lastLoginAt: true, }, @@ -324,6 +328,38 @@ class AdminsRepository { newPlayerId: playerId, }; } + + async updateIdentifiers( + adminId: number, + cfxId: AdminProfile['cfxId'], + discordId: AdminProfile['discordId'], + ) { + const admin = await this.db.query.adminUsers.findFirst({ + where: eq(adminUsers.id, adminId), + columns: { permissions: true, cfxId: true, discordId: true }, + }); + + if (!admin) throw new Error('not_found'); + + const result = await this.db + .update(adminUsers) + .set({ + cfxId, + discordId, + }) + .where(eq(adminUsers.id, adminId)) + .returning(); + + if (!result[0]) throw new Error('not_found'); + + return { + ...result[0], + newCfxId: cfxId, + newDiscordId: discordId, + previousCfxId: admin.cfxId, + previousDiscordId: admin.discordId, + }; + } } export function createAdminsRepository(db: DB) { diff --git a/packages/database/src/repositories/auth.ts b/packages/database/src/repositories/auth.ts index 0144b6c..74adc28 100644 --- a/packages/database/src/repositories/auth.ts +++ b/packages/database/src/repositories/auth.ts @@ -38,6 +38,8 @@ class AuthRepository { permissions: number = 0, updateLoggedIn: boolean = false, playerId: number | null = null, + cfxId: string | null = null, + discordId: string | null = null, ) { const passwordHash = await Bun.password.hash(password, { algorithm: 'bcrypt', @@ -58,6 +60,8 @@ class AuthRepository { lastLoginAt: updateLoggedIn ? new Date() : null, permissions: sanitizedPerms, playerId, + cfxId, + discordId, }) .returning() .get(); @@ -159,6 +163,28 @@ class AuthRepository { deleteSession(sessionId: string) { return this.db.delete(sessions).where(eq(sessions.id, sessionId)).run(); } + + findOAuthUser(data: { + provider: string; + providerId: string; + username?: string; + email?: string; + }) { + const column = + data.provider === 'discord' + ? adminUsers.discordId + : data.provider === 'cfx' + ? adminUsers.cfxId + : undefined; + + if (!column) return null; + + return this.db + .select() + .from(adminUsers) + .where(eq(column, data.providerId)) + .get(); + } } export function createAuthRepository(db: DB) { diff --git a/packages/shared/src/constants/settings.ts b/packages/shared/src/constants/settings.ts index 14b0b30..65ca198 100644 --- a/packages/shared/src/constants/settings.ts +++ b/packages/shared/src/constants/settings.ts @@ -10,6 +10,7 @@ export const SETTINGS_SCOPES = { 'serverConfigPath', 'autostart', ], + oauth: ['discordEnabled', 'discordSecret', 'discordClientId'], whitelist: ['mode', 'discordBotToken', 'discordGuildId', 'discordRoleIds'], restarts: ['enabled', 'times'], } as const; @@ -30,10 +31,14 @@ export const SETTINGS_DEFAULTS = { 'whitelist.mode': 'none', 'restarts.enabled': 'false', 'restarts.times': '', + 'oauth.discordEnabled': 'false', + 'oauth.discordClientId': '', + 'oauth.discordSecret': '', } satisfies Partial>; export const SETTINGS_SENSITIVE_KEYS: SettingsKey[] = [ 'whitelist.discordBotToken', + 'oauth.discordSecret', ]; export const SETTINGS_MASTER_ONLY_KEYS: SettingsKey[] = [ diff --git a/packages/shared/src/types/settings.ts b/packages/shared/src/types/settings.ts index df6ca28..b1fdb3e 100644 --- a/packages/shared/src/types/settings.ts +++ b/packages/shared/src/types/settings.ts @@ -23,6 +23,8 @@ export interface BaseAdminUser { effectivePermissions: number; group: AdminGroup | null; playerId: number | null; + cfxId: string | null; + discordId: string | null; createdAt: Date; lastLoginAt: Date | null; } @@ -32,6 +34,8 @@ export interface CreateAdminForm { permissions: UserPermissionsType; groupId: number | null; playerId: number | null; + cfxId: string | null; + discordId: string | null; } export type SettingsScope = keyof typeof SETTINGS_SCOPES; From 31d9028105f9c8cb4fb35e82ef220b6790b9b871 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Wed, 22 Jul 2026 20:05:56 +0200 Subject: [PATCH 06/15] feat(core/auth): tests --- apps/core/src/modules/auth/manager.test.ts | 84 +++++++ apps/core/src/modules/auth/manager.ts | 6 +- .../modules/auth/providers/discord.test.ts | 218 ++++++++++++++++++ 3 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 apps/core/src/modules/auth/manager.test.ts create mode 100644 apps/core/src/modules/auth/providers/discord.test.ts diff --git a/apps/core/src/modules/auth/manager.test.ts b/apps/core/src/modules/auth/manager.test.ts new file mode 100644 index 0000000..c7af93b --- /dev/null +++ b/apps/core/src/modules/auth/manager.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, mock } from 'bun:test'; +import type { IOAuthProvider } from '../../types'; + +// Mock database to prevent SQLite errors when DiscordOAuthProvider initializes at top-level +mock.module('@fxmanager/database', () => ({ + repo: { + settings: { + getMultiple: mock(() => ({ + 'oauth.discordClientId': 'mock_client_id', + 'oauth.discordSecret': 'mock_client_secret', + 'oauth.discordEnabled': 'true', + })), + }, + }, +})); + +// Dynamically import manager after mocking the database module +const { OAuthManager, oauthManager } = await import('./manager'); + +describe('OAuthManager', () => { + it('should register a provider and return `this` for chaining', () => { + const manager = new OAuthManager(); + const mockProvider = { name: 'google' } as IOAuthProvider; + + const result = manager.registerProvider(mockProvider); + + expect(result).toBe(manager); + expect(manager.hasProvider('google')).toBe(true); + }); + + it('should retrieve a registered provider', () => { + const manager = new OAuthManager(); + const mockProvider = { name: 'github' } as IOAuthProvider; + + manager.registerProvider(mockProvider); + + const retrieved = manager.getProvider('github'); + expect(retrieved).toBe(mockProvider); + }); + + it('should throw an error when attempting to retrieve an unregistered provider', () => { + const manager = new OAuthManager(); + + expect(() => manager.getProvider('unregistered')).toThrow( + "OAuth provider 'unregistered' is not registered.", + ); + }); + + it('should return false for hasProvider when provider is missing', () => { + const manager = new OAuthManager(); + + expect(manager.hasProvider('nonexistent')).toBe(false); + }); + + it('should reload providers with initialize() AND handle providers without initialize()', () => { + const manager = new OAuthManager(); + const mockInit = mock(); + + const providerWithInit = { + name: 'with-init', + initialize: mockInit, + } as unknown as IOAuthProvider; + + const providerWithoutInit = { + name: 'without-init', + } as unknown as IOAuthProvider; + + manager.registerProvider(providerWithInit); + manager.registerProvider(providerWithoutInit); + + const result = manager.reload(); + + expect(result).toBe(manager); + expect(mockInit).toHaveBeenCalledTimes(1); + }); + + describe('Exported oauthManager Singleton', () => { + it('should export a default oauthManager instance pre-registered with Discord', () => { + expect(oauthManager).toBeInstanceOf(OAuthManager); + expect(oauthManager.hasProvider('discord')).toBe(true); + expect(oauthManager.getProvider('discord').name).toBe('discord'); + }); + }); +}); diff --git a/apps/core/src/modules/auth/manager.ts b/apps/core/src/modules/auth/manager.ts index 4ce49a4..40ccc4e 100644 --- a/apps/core/src/modules/auth/manager.ts +++ b/apps/core/src/modules/auth/manager.ts @@ -1,7 +1,7 @@ import type { IOAuthProvider } from '../../types'; import { DiscordOAuthProvider } from './providers/discord'; -class OAuthManager { +export class OAuthManager { private providers = new Map(); registerProvider(provider: IOAuthProvider): this { @@ -22,11 +22,11 @@ class OAuthManager { } reload(): this { - this.providers.forEach((provider) => { + for (const provider of this.providers.values()) { if (provider.initialize) { provider.initialize(); } - }); + } return this; } } diff --git a/apps/core/src/modules/auth/providers/discord.test.ts b/apps/core/src/modules/auth/providers/discord.test.ts new file mode 100644 index 0000000..f1eeeb2 --- /dev/null +++ b/apps/core/src/modules/auth/providers/discord.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test'; + +const mockGetMultipleSettings = mock(); + +mock.module('@fxmanager/database', () => ({ + repo: { + settings: { + getMultiple: mockGetMultipleSettings, + }, + }, +})); + +const { DiscordOAuthProvider } = await import('./discord'); + +describe('DiscordOAuthProvider', () => { + let provider: InstanceType; + + beforeEach(() => { + mockGetMultipleSettings.mockClear(); + mockGetMultipleSettings.mockImplementation(() => ({ + 'oauth.discordClientId': 'mock_client_id_123', + 'oauth.discordSecret': 'mock_client_secret_xyz', + 'oauth.discordEnabled': 'true', + })); + + provider = new DiscordOAuthProvider(); + }); + + describe('initialize & isConfigured', () => { + it('should load configuration settings from the database repository', () => { + expect(mockGetMultipleSettings).toHaveBeenCalledWith([ + 'oauth.discordClientId', + 'oauth.discordSecret', + 'oauth.discordEnabled', + ]); + expect(provider.isConfigured()).toBe(true); + }); + + it('should return false for isConfigured when clientId or clientSecret are missing', () => { + mockGetMultipleSettings.mockImplementation(() => ({ + 'oauth.discordClientId': undefined, + 'oauth.discordSecret': 'mock_client_secret_xyz', + 'oauth.discordEnabled': 'true', + })); + + const unconfigured = new DiscordOAuthProvider(); + expect(unconfigured.isConfigured()).toBe(false); + }); + }); + + describe('getAuthUrl', () => { + it('should construct a valid Discord authorization URL with query parameters', () => { + const state = 'csrf_state_token'; + const redirectUri = + 'http://localhost:3000/api/auth/oauth/discord/callback'; + + const urlStr = provider.getAuthUrl(state, redirectUri); + const url = new URL(urlStr); + + expect(url.origin + url.pathname).toBe( + 'https://discord.com/oauth2/authorize', + ); + expect(url.searchParams.get('client_id')).toBe('mock_client_id_123'); + expect(url.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('scope')).toBe('identify email'); + expect(url.searchParams.get('state')).toBe(state); + }); + + it('should throw an error if OAuth is disabled', () => { + mockGetMultipleSettings.mockImplementation(() => ({ + 'oauth.discordClientId': 'mock_client_id_123', + 'oauth.discordSecret': 'mock_client_secret_xyz', + 'oauth.discordEnabled': 'false', + })); + + const disabledProvider = new DiscordOAuthProvider(); + expect(() => + disabledProvider.getAuthUrl('state', 'http://localhost/callback'), + ).toThrow('Discord OAuth is not enabled.'); + }); + + it('should throw an error if OAuth credentials are missing', () => { + mockGetMultipleSettings.mockImplementation(() => ({ + 'oauth.discordClientId': undefined, + 'oauth.discordSecret': undefined, + 'oauth.discordEnabled': 'true', + })); + + const unconfiguredProvider = new DiscordOAuthProvider(); + expect(() => + unconfiguredProvider.getAuthUrl('state', 'http://localhost/callback'), + ).toThrow('Discord OAuth is not configured on the server.'); + }); + }); + + describe('handleCallback', () => { + const redirectUri = 'http://localhost:3000/callback'; + const code = 'valid_oauth_code'; + + it('should throw an error if OAuth is disabled', async () => { + mockGetMultipleSettings.mockImplementation(() => ({ + 'oauth.discordEnabled': 'false', + })); + + const disabledProvider = new DiscordOAuthProvider(); + expect( + disabledProvider.handleCallback(code, redirectUri), + ).rejects.toThrow('Discord OAuth is not enabled.'); + }); + + it('should throw an error when token exchange endpoint returns an error response', async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response('Invalid Authorization Code', { + status: 400, + statusText: 'Bad Request', + }), + ), + ) as unknown as typeof fetch; + + expect(provider.handleCallback(code, redirectUri)).rejects.toThrow( + 'Failed to exchange authorization code: Invalid Authorization Code', + ); + }); + + it('should throw an error when user profile retrieval fails', async () => { + const mockFetch = mock() + // Token exchange succeeds + .mockImplementationOnce(() => + Promise.resolve( + new Response(JSON.stringify({ access_token: 'mock_token_123' }), { + status: 200, + }), + ), + ) + // User profile lookup fails + .mockImplementationOnce(() => + Promise.resolve(new Response('Unauthorized', { status: 401 })), + ); + + globalThis.fetch = mockFetch as unknown as typeof fetch; + + expect(provider.handleCallback(code, redirectUri)).rejects.toThrow( + 'Failed to retrieve user profile from Discord.', + ); + }); + + it('should complete OAuth exchange and map the profile to OAuthUser format', async () => { + const mockFetch = mock() + .mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ access_token: 'mock_access_token' }), + { status: 200 }, + ), + ), + ) + .mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ + id: '123456789012345678', + username: 'discord_user', + global_name: 'Display Name', + email: 'user@example.com', + avatar: 'a_1234567890', + }), + { status: 200 }, + ), + ), + ); + + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const user = await provider.handleCallback(code, redirectUri); + + expect(user).toEqual({ + id: '123456789012345678', + username: 'Display Name', + email: 'user@example.com', + avatarUrl: + 'https://cdn.discordapp.com/avatars/123456789012345678/a_1234567890.png', + }); + }); + + it('should fallback to username when global_name is missing and handle null avatar', async () => { + const mockFetch = mock() + .mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ access_token: 'mock_access_token' }), + { status: 200 }, + ), + ), + ) + .mockImplementationOnce(() => + Promise.resolve( + new Response( + JSON.stringify({ + id: '999888777', + username: 'legacy_username', + avatar: null, + }), + { status: 200 }, + ), + ), + ); + + globalThis.fetch = mockFetch as unknown as typeof fetch; + + const user = await provider.handleCallback(code, redirectUri); + + expect(user.username).toBe('legacy_username'); + expect(user.avatarUrl).toBeUndefined(); + }); + }); +}); From 4237ed93f73066a098ad1371bb7edafc71bf3920 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Wed, 22 Jul 2026 23:56:18 +0200 Subject: [PATCH 07/15] chore(webpanel/settings): added guide link to oauth page --- .../src/pages/settings/tabs/oauth.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/webpanel/src/pages/settings/tabs/oauth.tsx b/apps/webpanel/src/pages/settings/tabs/oauth.tsx index 709ffb4..11cf04e 100644 --- a/apps/webpanel/src/pages/settings/tabs/oauth.tsx +++ b/apps/webpanel/src/pages/settings/tabs/oauth.tsx @@ -3,6 +3,7 @@ import { SETTINGS_DEFAULTS } from '@fxmanager/shared/constants'; import type { SettingsTabProps } from '@/types/settings'; import { Input } from '@fxmanager/ui/components/input'; import { Switch } from '@fxmanager/ui/components/switch'; +import { ExternalLink } from 'lucide-react'; function blur(event: React.KeyboardEvent) { if (event.key !== 'Enter') return; @@ -24,6 +25,27 @@ export default function OAuthTab({ return (
+
+
+ + Need help setting this up? + +

+ Follow our step-by-step instructions to create your application and + get your credentials. +

+
+ + View Guide + + +
+ Date: Thu, 23 Jul 2026 01:06:14 +0200 Subject: [PATCH 08/15] refactor(webpanel/admins): automatically link player when updating admin identifier --- apps/core/src/routes/api/settings/admins.ts | 65 +------------------ .../webpanel/src/pages/settings/adminview.tsx | 48 ++------------ packages/database/src/repositories/admins.ts | 55 +++++++++++++++- 3 files changed, 60 insertions(+), 108 deletions(-) diff --git a/apps/core/src/routes/api/settings/admins.ts b/apps/core/src/routes/api/settings/admins.ts index 9fe6e24..e40c2e6 100644 --- a/apps/core/src/routes/api/settings/admins.ts +++ b/apps/core/src/routes/api/settings/admins.ts @@ -191,67 +191,6 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( }, ); - fastify.post( - '/:adminId/player', - async ( - request, - ): Promise> => { - const { admin } = request as AuthedRequest; - const { adminId: adminIdRaw } = request.params as { adminId: string }; - const { playerId } = request.body as { - playerId: AdminProfile['playerId']; - }; - const adminId = parseInt(adminIdRaw, 10); - - const allowed = PermissionManager.has( - admin.permissions, - UserPermissions.SETTINGS_ADMIN_MANAGEMENT, - ); - - if (!allowed) throw new Error('Unauthorized'); - - try { - const { username, newPlayerId, previousPlayerId } = - await repo.admins.updateLinkedPlayer( - adminId, - playerId, - PermissionManager.has(admin.permissions, UserPermissions.MASTER), - ); - - repo.audit.log({ - adminId: admin.id, - action: 'admin.update', - metadata: { - target: username, - previous_playerId: newPlayerId, - new_playerId: previousPlayerId, - }, - }); - - aceSync.resync(pm); - - return { - success: true, - data: { newPlayerId }, - }; - } catch (err) { - const msg = (err as Error).message; - - switch (msg) { - case 'not_found': - return { success: false, error: 'Admin not found' }; - case 'admin_is_master': - return { - success: false, - error: 'Can not change permissions of master account', - }; - default: - throw err; - } - } - }, - ); - fastify.post( '/:adminId/identifiers', async ( @@ -260,6 +199,7 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( ApiResponse<{ newCfxId: AdminProfile['cfxId']; newDiscordId: AdminProfile['discordId']; + playerId: AdminProfile['playerId']; }> > => { const { admin } = request as AuthedRequest; @@ -282,6 +222,7 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( try { const { username, + playerId, newCfxId, newDiscordId, previousCfxId, @@ -302,7 +243,7 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( return { success: true, - data: { newDiscordId, newCfxId }, + data: { newDiscordId, newCfxId, playerId }, }; } catch (err) { const msg = (err as Error).message; diff --git a/apps/webpanel/src/pages/settings/adminview.tsx b/apps/webpanel/src/pages/settings/adminview.tsx index c2e58c7..00778a0 100644 --- a/apps/webpanel/src/pages/settings/adminview.tsx +++ b/apps/webpanel/src/pages/settings/adminview.tsx @@ -268,38 +268,6 @@ export default function AdminView() { }); } - async function handleLinkedPlayerChange(playerId: AdminProfile['playerId']) { - const changePromise = QueryService< - ApiResponse<{ newPlayerId: AdminProfile['playerId'] }> - >({ - endpoint: `/settings/admins/${params.adminId}/player`, - method: 'POST', - body: { playerId }, - }); - - toast.promise(changePromise, { - loading: 'Updating linked player...', - success: (r) => { - if (!r.success) throw new Error(r.error); - - setAdminData((prev) => { - if (!prev) throw new Error('Invalid Action Sequence (no admin data)'); - - return { - ...prev, - playerId: r.data.newPlayerId, - }; - }); - - return `Linked player has been updated.`; - }, - error: (err) => { - console.error('Failed to update linked player', err.message); - return `Update failed: ${err.message}`; - }, - }); - } - async function handleIdentiferChange(data: { cfxId: AdminProfile['cfxId']; discordId: AdminProfile['discordId']; @@ -308,6 +276,7 @@ export default function AdminView() { ApiResponse<{ newCfxId: AdminProfile['cfxId']; newDiscordId: AdminProfile['discordId']; + playerId: AdminProfile['playerId']; }> >({ endpoint: `/settings/admins/${params.adminId}/identifiers`, @@ -320,11 +289,14 @@ export default function AdminView() { success: (r) => { if (!r.success) throw new Error(r.error); + console.log('Identifiers updated', r.data); + setAdminData((prev) => { if (!prev) throw new Error('Invalid Action Sequence (no admin data)'); return { ...prev, + playerId: r.data.playerId, cfxId: r.data.newCfxId, discordId: r.data.newDiscordId, }; @@ -557,18 +529,6 @@ export default function AdminView() { />
- - handleLinkedPlayerChange(id)} - align="end" - trigger={ - - } - />
)} diff --git a/packages/database/src/repositories/admins.ts b/packages/database/src/repositories/admins.ts index 6db7c14..edbb06a 100644 --- a/packages/database/src/repositories/admins.ts +++ b/packages/database/src/repositories/admins.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, like, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, like, or, sql } from 'drizzle-orm'; import type { BaseAdminUser, PaginatedResponse } from '@fxmanager/shared/types'; import type * as schema from '../schema'; import { @@ -336,11 +336,20 @@ class AdminsRepository { ) { const admin = await this.db.query.adminUsers.findFirst({ where: eq(adminUsers.id, adminId), - columns: { permissions: true, cfxId: true, discordId: true }, + columns: { + permissions: true, + cfxId: true, + discordId: true, + playerId: true, + }, }); if (!admin) throw new Error('not_found'); + if (cfxId && !/^[0-9]+$/.test(cfxId)) throw new Error('invalid_cfx_id'); + if (discordId && !/^[0-9]+$/.test(discordId)) + throw new Error('invalid_discord_id'); + const result = await this.db .update(adminUsers) .set({ @@ -352,8 +361,50 @@ class AdminsRepository { if (!result[0]) throw new Error('not_found'); + let foundPlayerId: number | null = null; + + if (discordId || cfxId) { + const conditions = []; + if (discordId) { + conditions.push( + and( + eq(playerIdentifiers.type, 'discord'), + eq(playerIdentifiers.value, `discord:${discordId}`), + ), + ); + } + if (cfxId) { + conditions.push( + and( + eq(playerIdentifiers.type, 'fivem'), + eq(playerIdentifiers.value, `fivem:${cfxId}`), + ), + ); + } + + const player = this.db + .select({ playerId: players.id }) + .from(players) + .innerJoin( + playerIdentifiers, + eq(playerIdentifiers.playerId, players.id), + ) + .where(or(...conditions)) + .get(); + + if (player) { + foundPlayerId = player.playerId; + } + } + + await this.db + .update(adminUsers) + .set({ playerId: foundPlayerId }) + .where(eq(adminUsers.id, adminId)); + return { ...result[0], + playerId: foundPlayerId, newCfxId: cfxId, newDiscordId: discordId, previousCfxId: admin.cfxId, From 11089e593bff17c225255500c1b189d323dcdaac Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 01:12:16 +0200 Subject: [PATCH 09/15] fix(database/players): link admin account to new player if oauth IDs match --- packages/database/src/repositories/players.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/database/src/repositories/players.ts b/packages/database/src/repositories/players.ts index 4f81cc3..210a6ea 100644 --- a/packages/database/src/repositories/players.ts +++ b/packages/database/src/repositories/players.ts @@ -154,7 +154,31 @@ class PlayersRepository { ) .onConflictDoNothing(); - return { ...newPlayer, isStaff: false, identifiers }; + const discordVal = identifierRows.find( + (row) => row.type === 'discord', + )?.value; + const fivemVal = identifierRows.find( + (row) => row.type === 'fivem', + )?.value; + const conditions = [ + discordVal ? eq(adminUsers.discordId, discordVal) : undefined, + fivemVal ? eq(adminUsers.cfxId, fivemVal) : undefined, + ].filter(Boolean) as any; + + const staffCheck = await tx.query.adminUsers.findFirst({ + where: or(...conditions), + columns: { id: true }, + }); + + const isStaff = !!staffCheck; + + if (isStaff) { + await tx.update(adminUsers) + .set({ playerId: newPlayer.id }) + .where(eq(adminUsers.id, staffCheck.id)); + } + + return { ...newPlayer, isStaff, identifiers }; } }); } @@ -270,7 +294,8 @@ class PlayersRepository { const withIssuerName = (row: T) => ({ ...row, - issuerName: row.issuer != null ? (adminNames.get(row.issuer) ?? null) : null, + issuerName: + row.issuer != null ? (adminNames.get(row.issuer) ?? null) : null, }); return { From 0eff252534ea0e0f42d739c86df83ce02e3be64c Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 10:38:43 +0200 Subject: [PATCH 10/15] fix(database/players): checking for admin identifiers using type:value instead of value for identifiers format --- packages/database/src/repositories/players.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/database/src/repositories/players.ts b/packages/database/src/repositories/players.ts index 210a6ea..f7113e7 100644 --- a/packages/database/src/repositories/players.ts +++ b/packages/database/src/repositories/players.ts @@ -116,7 +116,10 @@ class PlayersRepository { .map(([type, value]) => ({ type, value, - })); + })) as { + type: 'fivem' | 'discord' | 'license' | 'steam'; + value: string; + }[]; if (existingIdentifier) { const playerId = existingIdentifier.playerId; @@ -161,8 +164,12 @@ class PlayersRepository { (row) => row.type === 'fivem', )?.value; const conditions = [ - discordVal ? eq(adminUsers.discordId, discordVal) : undefined, - fivemVal ? eq(adminUsers.cfxId, fivemVal) : undefined, + discordVal + ? eq(adminUsers.discordId, discordVal.replace(/\D/g, '')) + : undefined, + fivemVal + ? eq(adminUsers.cfxId, fivemVal.replace(/\D/g, '')) + : undefined, ].filter(Boolean) as any; const staffCheck = await tx.query.adminUsers.findFirst({ @@ -173,7 +180,8 @@ class PlayersRepository { const isStaff = !!staffCheck; if (isStaff) { - await tx.update(adminUsers) + await tx + .update(adminUsers) .set({ playerId: newPlayer.id }) .where(eq(adminUsers.id, staffCheck.id)); } From 5a088bde8be19092859eaaf1aa97b80fec8c6f59 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 10:47:34 +0200 Subject: [PATCH 11/15] chore(database/players.test): added test to check admin account linking on new player join --- .../database/src/repositories/players.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/database/src/repositories/players.test.ts b/packages/database/src/repositories/players.test.ts index f72d633..3e2b52e 100644 --- a/packages/database/src/repositories/players.test.ts +++ b/packages/database/src/repositories/players.test.ts @@ -197,6 +197,38 @@ describe('PlayersRepository', () => { .all(); expect(dbIdentifiers.length).toBe(2); // Retains original license and appends discord row safely }); + + it('should detect if a new player matches an admin user identifier and link them as staff', async () => { + const [admin] = testDb + .insert(adminUsers) + .values({ + username: 'admin_test', + passwordHash: 'hash', + createdAt: new Date(), + discordId: '123456789', + cfxId: '987654321', + }) + .returning() + .all(); + + const created = await playersRepo.upsert('admin_player', { + license: 'license:admin_key', + discord: 'discord:123456789', + fivem: 'fivem:987654321', + }); + + expect(created.id).toBeTypeOf('number'); + expect(created.name).toBe('admin_player'); + expect(created.isStaff).toBe(true); + + const updatedAdmin = testDb + .select() + .from(adminUsers) + .where(eq(adminUsers.id, admin.id)) + .get(); + + expect(updatedAdmin?.playerId).toBe(created.id); + }); }); // region ban validation From f129a2d0a7c46189a5cb515c4cb5a0ccd5a91030 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 11:18:20 +0200 Subject: [PATCH 12/15] chore(webpanel/pages): removed unused imports --- apps/webpanel/src/pages/login/index.tsx | 5 ----- apps/webpanel/src/pages/settings/adminview.tsx | 2 -- 2 files changed, 7 deletions(-) diff --git a/apps/webpanel/src/pages/login/index.tsx b/apps/webpanel/src/pages/login/index.tsx index c5a3887..ef20e94 100644 --- a/apps/webpanel/src/pages/login/index.tsx +++ b/apps/webpanel/src/pages/login/index.tsx @@ -8,11 +8,6 @@ import { FieldSeparator, } from '@fxmanager/ui/components/field'; import { Input } from '@fxmanager/ui/components/input'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@fxmanager/ui/components/tooltip'; import { cn } from '@fxmanager/ui/lib/utils'; import { Server } from 'lucide-react'; import { useEffect, useState } from 'react'; diff --git a/apps/webpanel/src/pages/settings/adminview.tsx b/apps/webpanel/src/pages/settings/adminview.tsx index 00778a0..c41260e 100644 --- a/apps/webpanel/src/pages/settings/adminview.tsx +++ b/apps/webpanel/src/pages/settings/adminview.tsx @@ -35,7 +35,6 @@ import { Loader2, Trash, UserPlus, - UserSearch, UsersRound, } from 'lucide-react'; import { formatDate, initials } from '@/lib/utils'; @@ -61,7 +60,6 @@ import { DynamicIcon, type LucidIconName, } from '@fxmanager/ui/components/dynamic-icon'; -import { PlayerSearch } from './components/player-search'; import { AuditLogRow } from './components/auditlog-row'; function LoadingSkeleton() { From 1c263a656294d1fa515692908d476cff311263d5 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 11:26:37 +0200 Subject: [PATCH 13/15] fix(core/auth): catch error on settings.getMultiple on first start of fxmanager -> database migration is still happening --- .../src/modules/auth/providers/discord.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/core/src/modules/auth/providers/discord.ts b/apps/core/src/modules/auth/providers/discord.ts index 8128855..671b29a 100644 --- a/apps/core/src/modules/auth/providers/discord.ts +++ b/apps/core/src/modules/auth/providers/discord.ts @@ -13,15 +13,17 @@ export class DiscordOAuthProvider implements IOAuthProvider { } initialize() { - const settings = repo.settings.getMultiple([ - 'oauth.discordClientId', - 'oauth.discordSecret', - 'oauth.discordEnabled', - ]); - - this.clientId = settings['oauth.discordClientId']; - this.clientSecret = settings['oauth.discordSecret']; - this.enabled = settings['oauth.discordEnabled'] == 'true' ? true : false; + try { + const settings = repo.settings.getMultiple([ + 'oauth.discordClientId', + 'oauth.discordSecret', + 'oauth.discordEnabled', + ]); + + this.clientId = settings['oauth.discordClientId']; + this.clientSecret = settings['oauth.discordSecret']; + this.enabled = settings['oauth.discordEnabled'] == 'true' ? true : false; + } catch {} } isConfigured(): boolean { From 1c3a2197abe86e4be8a4061a3ab6b10dc53193b2 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 11:39:07 +0200 Subject: [PATCH 14/15] fix(webpanel/aduit): saved content when identifiers are changed for an admin user --- apps/core/src/routes/api/settings/admins.ts | 11 ++++++----- packages/database/src/repositories/admins.ts | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/core/src/routes/api/settings/admins.ts b/apps/core/src/routes/api/settings/admins.ts index e40c2e6..44ea738 100644 --- a/apps/core/src/routes/api/settings/admins.ts +++ b/apps/core/src/routes/api/settings/admins.ts @@ -233,11 +233,12 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( adminId: admin.id, action: 'admin.update', metadata: { - target: username, - new_cfxId: newCfxId, - new_discordId: newDiscordId, - previous_cfxId: previousCfxId, - previous_discordId: previousDiscordId, + target: `${username} (#${adminId})`, + new_cfxId: newCfxId ?? (previousCfxId ? 'removed' : undefined), + new_discordId: + newDiscordId ?? (previousDiscordId ? 'removed' : undefined), + previous_cfxId: previousCfxId ?? undefined, + previous_discordId: previousDiscordId ?? undefined, }, }); diff --git a/packages/database/src/repositories/admins.ts b/packages/database/src/repositories/admins.ts index edbb06a..83bc667 100644 --- a/packages/database/src/repositories/admins.ts +++ b/packages/database/src/repositories/admins.ts @@ -350,6 +350,9 @@ class AdminsRepository { if (discordId && !/^[0-9]+$/.test(discordId)) throw new Error('invalid_discord_id'); + cfxId = cfxId?.trim() || null; + discordId = discordId?.trim() || null; + const result = await this.db .update(adminUsers) .set({ From 8b920d1d09f805bd0ab343d72babff33399cdfd2 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Thu, 23 Jul 2026 11:45:51 +0200 Subject: [PATCH 15/15] chore(panel/auth): improve error responses when failing to update an identifier --- apps/core/src/routes/api/settings/admins.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/core/src/routes/api/settings/admins.ts b/apps/core/src/routes/api/settings/admins.ts index 44ea738..22857de 100644 --- a/apps/core/src/routes/api/settings/admins.ts +++ b/apps/core/src/routes/api/settings/admins.ts @@ -252,12 +252,25 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( switch (msg) { case 'not_found': return { success: false, error: 'Admin not found' }; - case 'admin_is_master': + case 'UNIQUE constraint failed: admin_users.discord_id': + case 'UNIQUE constraint failed: admin_users.cfx_id': return { success: false, - error: 'Can not change permissions of master account', + error: 'Identifier already registered', + }; + case 'invalid_cfx_id': + case 'invalid_discord_id': + return { + success: false, + error: 'Identifier format is invalid', }; default: + console.error('Failed to update identifier for admin:', { + by: admin, + target: adminId, + data: { cfxId, discordId }, + error: msg, + }); throw err; } }