diff --git a/apps/core/src/common/contributors.test.ts b/apps/core/src/common/contributors.test.ts index af5996c..eae4286 100644 --- a/apps/core/src/common/contributors.test.ts +++ b/apps/core/src/common/contributors.test.ts @@ -135,7 +135,7 @@ describe('createContributorsList', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(REPO_COUNT); expect(result.external).toHaveLength(1); - expect(result.external[0].contributions).toBe(15); // 10 + 5 + expect(result.external[0]?.contributions).toBe(15); // 10 + 5 }); it('should use cached value and avoid network calls before TTL expires', async () => { diff --git a/apps/core/src/common/contributors.ts b/apps/core/src/common/contributors.ts index af17611..e6d48f8 100644 --- a/apps/core/src/common/contributors.ts +++ b/apps/core/src/common/contributors.ts @@ -114,7 +114,7 @@ export function createContributorsList(opts: { }); return async function getContributors(): Promise { - // if (!opts.isProd) return noUpdate(); + if (!opts.isProd) return noUpdate(); if (cache && cache.expiresAt > now()) return cache.value; try { diff --git a/apps/core/src/routes/api/settings/index.ts b/apps/core/src/routes/api/settings/index.ts index 3d30851..222aec4 100644 --- a/apps/core/src/routes/api/settings/index.ts +++ b/apps/core/src/routes/api/settings/index.ts @@ -3,6 +3,7 @@ import { sessionAuth } from '../../../middleware/session'; import AdminManagementModule from './admins'; import GroupManagementModule from './groups'; import AuditLogModule from './audit'; +import ProfileModule from './profile'; import type { ApiResponse, SettingsKey, @@ -177,6 +178,12 @@ const SettingsEndpoints: RouteModule['handler'] = async ( pm, gm, }); + + fastify.register(ProfileModule.handler, { + prefix: ProfileModule.prefix, + pm, + gm, + }); }; export default { diff --git a/apps/core/src/routes/api/settings/profile.ts b/apps/core/src/routes/api/settings/profile.ts new file mode 100644 index 0000000..f15d4d9 --- /dev/null +++ b/apps/core/src/routes/api/settings/profile.ts @@ -0,0 +1,115 @@ +import { repo } from '@fxmanager/database'; +import type { AdminProfile } from '@fxmanager/database/types'; +import { UserPermissions } from '@fxmanager/shared/constants'; +import type { ApiResponse } from '@fxmanager/shared/types'; +import { PermissionManager } from '@fxmanager/shared/utils'; +import type { AuthedRequest, RouteModule } from '../../../types'; + +const ProfileEndpoints: RouteModule['handler'] = async (fastify, { pm }) => { + fastify.get('/', async (request) => { + const { admin } = request as AuthedRequest; + + const profile = await repo.admins.getProfile( + admin.id, + PermissionManager.has(admin.permissions, UserPermissions.AUDIT_LOG), + ); + + if (!profile) + return { + success: false, + error: `Admin id ${admin.id} does not exist.`, + }; + + return { success: true, data: profile }; + }); + + fastify.post('/password', async (request) => { + const { admin } = request as AuthedRequest; + const { currentPassword, newPassword } = request.body as { + currentPassword: string; + newPassword: string; + }; + + const data = await repo.auth.verifyPassword( + admin.username, + currentPassword, + ); + + if (!data) return { success: false, error: 'Invalid current password' }; + + await repo.auth.updatePassword(admin.id, newPassword); + + return { success: true }; + }); + + fastify.post( + '/identifiers', + async ( + request, + ): Promise< + ApiResponse<{ + newCfxId: AdminProfile['cfxId']; + newDiscordId: AdminProfile['discordId']; + }> + > => { + const { admin } = request as AuthedRequest; + const { cfxId, discordId } = request.body as { + cfxId: AdminProfile['cfxId']; + discordId: AdminProfile['discordId']; + }; + + try { + const { newCfxId, newDiscordId, previousCfxId, previousDiscordId } = + await repo.admins.updateIdentifiers(admin.id, cfxId, discordId); + + repo.audit.log({ + adminId: admin.id, + action: 'admin.update', + metadata: { + new_cfxId: newCfxId ?? (previousCfxId ? 'removed' : undefined), + new_discordId: + newDiscordId ?? (previousDiscordId ? 'removed' : undefined), + previous_cfxId: previousCfxId ?? undefined, + previous_discordId: previousDiscordId ?? undefined, + }, + }); + + 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 'UNIQUE constraint failed: admin_users.discord_id': + case 'UNIQUE constraint failed: admin_users.cfx_id': + return { + success: false, + 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 profile identifier:', { + by: admin, + data: { cfxId, discordId }, + error: msg, + }); + throw err; + } + } + }, + ); +}; + +export default { + prefix: '/profile', + handler: ProfileEndpoints, +} satisfies RouteModule; diff --git a/apps/webpanel/src/components/sidebar/nav-user.tsx b/apps/webpanel/src/components/sidebar/nav-user.tsx index a201935..1aab93c 100644 --- a/apps/webpanel/src/components/sidebar/nav-user.tsx +++ b/apps/webpanel/src/components/sidebar/nav-user.tsx @@ -10,15 +10,17 @@ import { SidebarMenu, SidebarMenuButton, } from '@fxmanager/ui/components/sidebar'; -import { User2, ChevronUp, LogOut, X, Moon, Sun } from 'lucide-react'; +import { User2, ChevronUp, LogOut, X, Moon, Sun, User } from 'lucide-react'; import { useTheme } from '../theme-provider'; import { Switch } from '@fxmanager/ui/components/switch'; import { useState } from 'react'; import { useAuth } from '@/hooks/use-auth'; +import { useNavigate } from 'react-router-dom'; export function NavUser() { const { user, logout } = useAuth(); const { setTheme, theme } = useTheme(); + const navigate = useNavigate(); const [toggleState, setToggleState] = useState(theme === 'light'); const toggleTheme = (checked: boolean) => { @@ -65,6 +67,16 @@ export function NavUser() { /> + navigate('/profile')} + className="flex items-center justify-between px-1.5 py-1 cursor-pointer" + > +
+ + Profile +
+
+ { 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)'); @@ -451,44 +453,108 @@ export default function AdminView() { - Action Recap + Action Recap{' '} + {hasPermission(UserPermissions.AUDIT_LOG) && + `(${adminData.auditLogs.length})`} - - - {!hasPermission(UserPermissions.AUDIT_LOG) ? ( - - - - Access Restricted - - - You do not have permissions to view an admins audit log. - - - ) : ( -
- {adminData.auditLogs.length > 0 ? ( - adminData.auditLogs.map((log) => ( - - )) - ) : ( -
- -

- No recent activity logs -

-

- Actions performed by this admin will appear here. -

+ + +
+ {!hasPermission(UserPermissions.AUDIT_LOG) ? ( + + + + Access Restricted + + + You do not have permissions to view an admins audit + log. + + + ) : ( +
+ {adminData.auditLogs.length > 0 ? ( + adminData.auditLogs.map((log) => ( + + )) + ) : ( +
+ +

+ No recent activity logs +

+

+ Actions performed by this admin will appear here. +

+
+ )} +
+ )} +
+
+
+ + + + + + +
+ {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. +

+
+ + +
+
@@ -563,7 +629,7 @@ export default function AdminView() { - + {isMaster ? ( + {/* header */} +
+ +
+ + +
+
+ + {/* stat cards */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: indexes are immutable + + ))} +
+ + {/* tabs */} + + +
+ ); +} + +function PlayerCardContent({ + id, + name, +}: { + id: number | null; + name: string | null; +}) { + const navigate = useNavigate(); + + function handleClick() { + toast.info(`Navigating to "${name}" player view`, { + icon: , + duration: 1_500, + }); + + setTimeout(() => navigate(`/players/${id}`), 1_000); + } + + 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 && ( +
+ +
+ )} +
+ ); +} + +function PasswordChangeForm() { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (newPassword !== confirmPassword) { + toast.error('New passwords do not match.'); + return; + } + + setIsSubmitting(true); + const passwordPromise = QueryService>({ + endpoint: `/settings/profile/password`, + method: 'POST', + body: { currentPassword, newPassword }, + }); + + toast.promise(passwordPromise, { + loading: 'Updating password...', + success: (r) => { + if (!r.success) throw new Error(r.error); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + return 'Password has been successfully updated.'; + }, + error: (err) => { + console.error('Failed to update password', err.message); + return `Password update failed: ${err.message}`; + }, + }); + + try { + await passwordPromise; + } finally { + setIsSubmitting(false); + } + }; + + const isDirty = + currentPassword.length > 0 || + newPassword.length > 0 || + confirmPassword.length > 0; + + return ( +
+
+
+

Change Password

+

+ Update your panel login password securely. +

+
+ +
+
+ + setCurrentPassword(e.target.value)} + placeholder="••••••••••••" + disabled={isSubmitting} + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="••••••••••••" + disabled={isSubmitting} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••••••" + disabled={isSubmitting} + /> +
+
+
+ +
+ +
+
+ ); +} + +export default function ProfilePage() { + const { hasPermission } = useAuth(); + const navigate = useNavigate(); + const [profileData, setProfileData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + QueryService>({ + endpoint: `/settings/profile`, + method: 'GET', + }) + .then((res) => { + setError(null); + if (res.success) { + setProfileData(res.data); + } else { + setError(res.error); + } + }) + .catch((err) => { + console.error('Loading profile failed', err.status, err.message); + setError((err as ApiError).message ?? 'Failed to load profile data.'); + }) + .finally(() => setLoading(false)); + }, []); + + async function handleIdentiferChange(data: { + cfxId: AdminProfile['cfxId']; + discordId: AdminProfile['discordId']; + }) { + const changePromise = QueryService< + ApiResponse<{ + newCfxId: AdminProfile['cfxId']; + newDiscordId: AdminProfile['discordId']; + }> + >({ + endpoint: `/settings/profile/identifiers`, + method: 'POST', + body: data, + }); + + toast.promise(changePromise, { + loading: 'Updating player identifiers...', + success: (r) => { + if (!r.success) throw new Error(r.error); + + setProfileData((prev) => { + if (!prev) + throw new Error('Invalid Action Sequence (no profile 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 || !profileData) { + return ( + + + + +

Failed to load profile

+

+ {error ?? 'Profile not found.'} +

+ + +
+
+ ); + } + + return ( +
+
+
+ + + + {initials(profileData.username)} + + +
+
+

+ {profileData.username} +

+
+

+ Profile #{profileData.id} +

+
+
+
+ +
+
+ + } + /> + + + ( + + ) + : UsersRound + } + className="hidden lg:block" + label={`Staff Group`} + value={profileData.group?.name ?? 'Custom'} + /> +
+ + + + Recent Activity + Identifiers & Player + Security + Permissions + + + + + + + Action Recap{' '} + {hasPermission(UserPermissions.AUDIT_LOG) && + `(${profileData.auditLogs.length})`} + + + + +
+ {hasPermission(UserPermissions.AUDIT_LOG) ? ( + profileData.auditLogs.length > 0 ? ( + profileData.auditLogs.map((log) => ( + + )) + ) : ( +
+ +

+ No recent activity logs +

+

+ Actions performed by you will appear here. +

+
+ ) + ) : ( +
+ +

+ Access Restricted +

+

+ You do not have the required permissions to view audit + logs. +

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

+ In-Game Player Account +

+

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

+
+ +
+
+ +
+

+ Linked Player +

+ +
+
+
+
+ +
+
+

+ External Platform Identifiers +

+

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

+
+ + +
+
+
+
+
+ + + + +
+ +
+
+
+
+ + + + + + Assigned Permissions + + + + + {}} + updateGroup={() => {}} + /> + + + +
+
+
+ ); +} diff --git a/packages/database/src/repositories/auth.ts b/packages/database/src/repositories/auth.ts index 74adc28..c6472fc 100644 --- a/packages/database/src/repositories/auth.ts +++ b/packages/database/src/repositories/auth.ts @@ -128,6 +128,18 @@ class AuthRepository { return user; } + async updatePassword(adminId: number, newPassword: string) { + const passwordHash = await Bun.password.hash(newPassword, { + algorithm: 'bcrypt', + }); + + return this.db + .update(adminUsers) + .set({ passwordHash }) + .where(eq(adminUsers.id, adminId)) + .run(); + } + createSession(adminId: number) { const id = crypto.randomUUID(); const now = new Date();