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 00000000..c7af93b7 --- /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 new file mode 100644 index 00000000..40ccc4e3 --- /dev/null +++ b/apps/core/src/modules/auth/manager.ts @@ -0,0 +1,37 @@ +import type { IOAuthProvider } from '../../types'; +import { DiscordOAuthProvider } from './providers/discord'; + +export 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 { + for (const provider of this.providers.values()) { + 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.test.ts b/apps/core/src/modules/auth/providers/discord.test.ts new file mode 100644 index 00000000..f1eeeb24 --- /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(); + }); + }); +}); 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 00000000..671b29a6 --- /dev/null +++ b/apps/core/src/modules/auth/providers/discord.ts @@ -0,0 +1,110 @@ +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() { + 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 { + 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 f21d3b94..1cc990db 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/admins.ts b/apps/core/src/routes/api/settings/admins.ts index bd9e644b..22857deb 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) { @@ -190,47 +192,59 @@ const AdminManagementEndpoints: RouteModule['handler'] = async ( ); fastify.post( - '/:adminId/player', + '/:adminId/identifiers', async ( request, - ): Promise> => { + ): Promise< + ApiResponse<{ + newCfxId: AdminProfile['cfxId']; + newDiscordId: AdminProfile['discordId']; + playerId: AdminProfile['playerId']; + }> + > => { const { admin } = request as AuthedRequest; const { adminId: adminIdRaw } = request.params as { adminId: string }; - const { playerId } = request.body as { - playerId: AdminProfile['playerId']; + const { cfxId, discordId } = request.body as { + cfxId: AdminProfile['cfxId']; + discordId: AdminProfile['discordId']; }; const adminId = parseInt(adminIdRaw, 10); - const allowed = PermissionManager.has( - admin.permissions, - UserPermissions.SETTINGS_ADMIN_MANAGEMENT, - ); + const allowed = + admin.id === adminId || + 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), - ); + const { + username, + playerId, + newCfxId, + newDiscordId, + previousCfxId, + previousDiscordId, + } = await repo.admins.updateIdentifiers(adminId, cfxId, discordId); repo.audit.log({ adminId: admin.id, action: 'admin.update', metadata: { - target: username, - previous_playerId: newPlayerId, - new_playerId: previousPlayerId, + target: `${username} (#${adminId})`, + new_cfxId: newCfxId ?? (previousCfxId ? 'removed' : undefined), + new_discordId: + newDiscordId ?? (previousDiscordId ? 'removed' : undefined), + previous_cfxId: previousCfxId ?? undefined, + previous_discordId: previousDiscordId ?? undefined, }, }); - aceSync.resync(pm); - return { success: true, - data: { newPlayerId }, + data: { newDiscordId, newCfxId, playerId }, }; } catch (err) { const msg = (err as Error).message; @@ -238,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; } } diff --git a/apps/core/src/routes/api/settings/index.ts b/apps/core/src/routes/api/settings/index.ts index 731658f3..3d30851c 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 00000000..4af0d923 --- /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 161ba393..e8bbbda7 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 6d7a34c7..37256e36 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 a0ee69cc..ef20e947 100644 --- a/apps/webpanel/src/pages/login/index.tsx +++ b/apps/webpanel/src/pages/login/index.tsx @@ -8,23 +8,26 @@ 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 { 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 +92,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/admincreate.tsx b/apps/webpanel/src/pages/settings/admincreate.tsx index 1419908c..28bc78ed 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 +107,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(); @@ -173,33 +266,44 @@ export default function AdminView() { }); } - async function handleLinkedPlayerChange(playerId: AdminProfile['playerId']) { + async function handleIdentiferChange(data: { + cfxId: AdminProfile['cfxId']; + discordId: AdminProfile['discordId']; + }) { const changePromise = QueryService< - ApiResponse<{ newPlayerId: AdminProfile['playerId'] }> + ApiResponse<{ + newCfxId: AdminProfile['cfxId']; + newDiscordId: AdminProfile['discordId']; + playerId: AdminProfile['playerId']; + }> >({ - endpoint: `/settings/admins/${params.adminId}/player`, + endpoint: `/settings/admins/${params.adminId}/identifiers`, method: 'POST', - body: { playerId }, + body: data, }); toast.promise(changePromise, { - loading: 'Updating linked player...', + loading: 'Updating player identifiers...', 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.newPlayerId, + playerId: r.data.playerId, + cfxId: r.data.newCfxId, + discordId: r.data.newDiscordId, }; }); - return `Linked player has been updated.`; + return `Identifiers have been updated.`; }, error: (err) => { - console.error('Failed to update linked player', err.message); + console.error('Failed to update identifiers', err.message); return `Update failed: ${err.message}`; }, }); @@ -230,6 +334,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 +363,6 @@ export default function AdminView() {
- {(!isMaster || adminData.id === user?.id) && ( - handleLinkedPlayerChange(id)} - align="end" - trigger={ - - } - /> - )} {!isMaster && ( @@ -346,8 +440,9 @@ export default function AdminView() {
- + Recent Activity + Identifiers & Player Permissions @@ -377,7 +472,7 @@ export default function AdminView() { ) : ( -
+
{adminData.auditLogs.length > 0 ? ( adminData.auditLogs.map((log) => ( @@ -400,6 +495,65 @@ export default function AdminView() { + + + +
+ {canEdit && ( +
+
+

+ In-Game Player Account +

+

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

+
+ +
+
+ +
+

+ Linked Player +

+ +
+
+
+
+ )} + +
+
+

+ 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" + /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/apps/webpanel/src/pages/settings/index.tsx b/apps/webpanel/src/pages/settings/index.tsx index d41ee87b..1d1c7e60 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 00000000..11cf04e4 --- /dev/null +++ b/apps/webpanel/src/pages/settings/tabs/oauth.tsx @@ -0,0 +1,89 @@ +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'; +import { ExternalLink } from 'lucide-react'; + +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 ( +
+
+
+ + Need help setting this up? + +

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

+
+ + View Guide + + +
+ + + + 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 4ea795d7..3c682ad6 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; diff --git a/bun.lock b/bun.lock index d7ae051b..2aef326a 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 d2cf6a90..2553e39c 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" diff --git a/packages/database/migrations/0008_vengeful_microbe.sql b/packages/database/migrations/0008_vengeful_microbe.sql new file mode 100644 index 00000000..45c59237 --- /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 00000000..3a98594c --- /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 0b5a3e67..1b5a6198 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 00000000..55421f75 --- /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 8352e1fb..ce6b6c6d 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/repositories/admins.ts b/packages/database/src/repositories/admins.ts index 2a2d034b..83bc6674 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 { @@ -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,92 @@ 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, + 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'); + + cfxId = cfxId?.trim() || null; + discordId = discordId?.trim() || null; + + const result = await this.db + .update(adminUsers) + .set({ + cfxId, + discordId, + }) + .where(eq(adminUsers.id, adminId)) + .returning(); + + 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, + 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 0144b6c6..74adc28b 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/database/src/repositories/players.test.ts b/packages/database/src/repositories/players.test.ts index f72d633a..3e2b52e1 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 diff --git a/packages/database/src/repositories/players.ts b/packages/database/src/repositories/players.ts index 4f81cc30..f7113e77 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; @@ -154,7 +157,36 @@ 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.replace(/\D/g, '')) + : undefined, + fivemVal + ? eq(adminUsers.cfxId, fivemVal.replace(/\D/g, '')) + : 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 +302,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 { diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 09833899..fcb5a8f9 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( diff --git a/packages/shared/src/constants/settings.ts b/packages/shared/src/constants/settings.ts index 14b0b308..65ca1987 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 df6ca286..b1fdb3ef 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;