Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fbac2d2
feat(database/schema): cfx & discord id columns in adminUsers for OAuth2
Maximus7474 Jul 21, 2026
dc10e09
chore(package): added better-sqlite3 to use drizzle studio
Maximus7474 Jul 21, 2026
e9c43af
feat(panel): discord OAuth2 login handling
Maximus7474 Jul 22, 2026
281fed1
feat(panel): allow admin management perm to update identifiers of a
Maximus7474 Jul 22, 2026
d1b8798
fix(packages): missing methods in db & missing settings types /
Maximus7474 Jul 22, 2026
31d9028
feat(core/auth): tests
Maximus7474 Jul 22, 2026
4237ed9
chore(webpanel/settings): added guide link to oauth page
Maximus7474 Jul 22, 2026
e2bc687
feat(webpanel): profile page for connected user
Maximus7474 Jul 22, 2026
f73633b
refactor(webpanel/admins): automatically link player when updating admin
Maximus7474 Jul 22, 2026
11089e5
fix(database/players): link admin account to new player if oauth IDs
Maximus7474 Jul 22, 2026
0eff252
fix(database/players): checking for admin identifiers using type:value
Maximus7474 Jul 23, 2026
5a088bd
chore(database/players.test): added test to check admin account linking
Maximus7474 Jul 23, 2026
503c456
Merge branch 'feat/oauth2' into feat/personal-profile-page
Maximus7474 Jul 23, 2026
4821dbd
fix(panel/settings): profile page not allowing perm editor to overflow
Maximus7474 Jul 23, 2026
d3bf9bf
fix(panel/settings): admin view page not allowing perm editor to
Maximus7474 Jul 23, 2026
84fc5a6
fix(panel/settings): audit log not scrollable in admin & profile view
Maximus7474 Jul 23, 2026
f129a2d
chore(webpanel/pages): removed unused imports
Maximus7474 Jul 23, 2026
1c263a6
fix(core/auth): catch error on settings.getMultiple on first start of
Maximus7474 Jul 23, 2026
1c3a219
fix(webpanel/aduit): saved content when identifiers are changed for an
Maximus7474 Jul 23, 2026
8b920d1
chore(panel/auth): improve error responses when failing to update an
Maximus7474 Jul 23, 2026
9ff8846
Merge branch 'feat/oauth2' into feat/personal-profile-page
Maximus7474 Jul 23, 2026
10553b7
chore(panel/profile): improve error responses when failing to update an
Maximus7474 Jul 23, 2026
5ccb07f
Merge branch 'main' into feat/personal-profile-page
Maximus7474 Aug 2, 2026
4594654
fix(core/common): contributors test with an index undefined TS error
Maximus7474 Aug 2, 2026
9915568
fix(core/common): contributors test had not isProd check removed for
Maximus7474 Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/core/src/common/contributors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/core/src/common/contributors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export function createContributorsList(opts: {
});

return async function getContributors(): Promise<ContributorSummary> {
// if (!opts.isProd) return noUpdate();
if (!opts.isProd) return noUpdate();
if (cache && cache.expiresAt > now()) return cache.value;

try {
Expand Down
7 changes: 7 additions & 0 deletions apps/core/src/routes/api/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -177,6 +178,12 @@ const SettingsEndpoints: RouteModule['handler'] = async (
pm,
gm,
});

fastify.register(ProfileModule.handler, {
prefix: ProfileModule.prefix,
pm,
gm,
});
};

export default {
Expand Down
115 changes: 115 additions & 0 deletions apps/core/src/routes/api/settings/profile.ts
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 13 additions & 1 deletion apps/webpanel/src/components/sidebar/nav-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(theme === 'light');

const toggleTheme = (checked: boolean) => {
Expand Down Expand Up @@ -65,6 +67,16 @@ export function NavUser() {
/>
</div>

<DropdownMenuItem
onClick={() => navigate('/profile')}
className="flex items-center justify-between px-1.5 py-1 cursor-pointer"
>
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
<span className="text-sm">Profile</span>
</div>
</DropdownMenuItem>

<DropdownMenuSeparator />

<DropdownMenuItem
Expand Down
4 changes: 4 additions & 0 deletions apps/webpanel/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import AuditLogPage from './settings/auditlogs';
import ConfigEditor from './settings/configeditor';
import PerformancePage from './performance';
import CreditsPage from './settings/credits';
import ProfilePage from './settings/selfprofile';

type RouteConfig = {
path: string;
Expand Down Expand Up @@ -97,4 +98,7 @@ export const routes: RouteConfig[] = [
path: '/settings/credits',
element: CreditsPage,
},

// Profile
{ path: '/profile', element: ProfilePage },
];
134 changes: 100 additions & 34 deletions apps/webpanel/src/pages/settings/adminview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ export default function AdminView() {
success: (r) => {
if (!r.success) throw new Error(r.error);

console.log('Identifiers updated', r.data);

setAdminData((prev) => {
if (!prev) throw new Error('Invalid Action Sequence (no admin data)');

Expand Down Expand Up @@ -451,44 +453,108 @@ export default function AdminView() {
<Card className="flex-1 flex flex-col min-h-0">
<CardHeader>
<CardTitle className="text-lg font-bold">
Action Recap
Action Recap{' '}
{hasPermission(UserPermissions.AUDIT_LOG) &&
`(${adminData.auditLogs.length})`}
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-full">
{!hasPermission(UserPermissions.AUDIT_LOG) ? (
<Alert
variant="destructive"
className="bg-destructive/5 border-destructive/20"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle className="font-bold">
Access Restricted
</AlertTitle>
<AlertDescription>
You do not have permissions to view an admins audit log.
</AlertDescription>
</Alert>
) : (
<div>
{adminData.auditLogs.length > 0 ? (
adminData.auditLogs.map((log) => (
<AuditLogRow key={log.id} log={log} />
))
) : (
<div className="flex flex-col items-center justify-center py-8 px-4 border-2 border-dashed rounded-lg bg-muted/30">
<Info className="h-8 w-8 text-muted-foreground/60 mb-2" />
<p className="text-sm font-medium text-muted-foreground">
No recent activity logs
</p>
<p className="text-xs text-muted-foreground/70">
Actions performed by this admin will appear here.
</p>
<CardContent className="flex-1 flex flex-col min-h-0 overflow-hidden">
<ScrollArea className="flex-1 min-h-0 h-full pr-4">
<div className="flex flex-col gap-2">
{!hasPermission(UserPermissions.AUDIT_LOG) ? (
<Alert
variant="destructive"
className="bg-destructive/5 border-destructive/20"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle className="font-bold">
Access Restricted
</AlertTitle>
<AlertDescription>
You do not have permissions to view an admins audit
log.
</AlertDescription>
</Alert>
) : (
<div>
{adminData.auditLogs.length > 0 ? (
adminData.auditLogs.map((log) => (
<AuditLogRow key={log.id} log={log} />
))
) : (
<div className="flex flex-col items-center justify-center py-8 px-4 border-2 border-dashed rounded-lg bg-muted/30">
<Info className="h-8 w-8 text-muted-foreground/60 mb-2" />
<p className="text-sm font-medium text-muted-foreground">
No recent activity logs
</p>
<p className="text-xs text-muted-foreground/70">
Actions performed by this admin will appear here.
</p>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>

<TabsContent
value="identifiers"
className="flex-1 flex flex-col min-h-0 mt-0 overflow-auto"
>
<Card className="flex-1 flex flex-col min-h-0">
<CardContent className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-2xl space-y-8">
{canEdit && (
<div className="space-y-3 pb-8 border-b">
<div>
<h3 className="text-sm font-semibold">
In-Game Player Account
</h3>
<p className="text-xs text-muted-foreground">
Connect this admin account to an existing in-game
player profile.
</p>
</div>

<div className="flex items-center justify-between p-4 rounded-lg border bg-muted/20 gap-4">
<div className="flex items-center gap-3 min-w-0">
<FileUser className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">
Linked Player
</p>
<PlayerCardContent
id={adminData.playerId}
name={adminData.playerName}
/>
</div>
</div>
)}
</div>
</div>
)}
</ScrollArea>

<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold">
External Platform Identifiers
</h3>
<p className="text-xs text-muted-foreground">
Configure third-party IDs linked to this account for
authentication and bot lookups.
</p>
</div>

<IdentifiersForm
cfxId={adminData.cfxId}
discordId={adminData.discordId}
canEdit={canEdit}
onSave={handleIdentiferChange}
/>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
Expand Down Expand Up @@ -563,7 +629,7 @@ export default function AdminView() {
</CardTitle>
</CardHeader>

<CardContent className="flex-1 overflow-hidden">
<CardContent className="flex-1 flex flex-col min-h-0 overflow-hidden">
{isMaster ? (
<Alert
variant="destructive"
Expand Down
Loading
Loading