Skip to content

Commit 2901e87

Browse files
committed
feat: expand admin operations center
1 parent cba78c9 commit 2901e87

16 files changed

Lines changed: 1340 additions & 37 deletions

File tree

src/app/admin/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { redirect } from 'next/navigation';
22

33
export default function AdminPage() {
4-
redirect('/dashboard/audit');
4+
redirect('/dashboard/admin' as any);
55
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { auth } from '@/auth';
3+
import { prisma } from '@/lib/prisma';
4+
import { buildAuditWhere } from '@/lib/admin-audit';
5+
6+
function csvCell(value: unknown) {
7+
if (value === null || value === undefined) return '';
8+
const text = value instanceof Date ? value.toISOString() : String(value);
9+
return `"${text.replace(/"/g, '""')}"`;
10+
}
11+
12+
export async function GET(request: NextRequest) {
13+
const session = await auth();
14+
if (session?.user?.role !== 'ADMIN') {
15+
return NextResponse.json({ error: 'Only admins can export audit logs' }, { status: 403 });
16+
}
17+
18+
const params = Object.fromEntries(request.nextUrl.searchParams.entries());
19+
const logs = await prisma.auditLog.findMany({
20+
take: 1000,
21+
where: buildAuditWhere(params),
22+
orderBy: { createdAt: 'desc' },
23+
include: { user: { select: { name: true, email: true, role: true } } },
24+
});
25+
26+
const headers = [
27+
'createdAt',
28+
'actorEmail',
29+
'actorName',
30+
'actorRole',
31+
'action',
32+
'resource',
33+
'resourceId',
34+
'patientId',
35+
'ipAddress',
36+
'userAgent',
37+
'metadata',
38+
];
39+
40+
const rows = logs.map((log) => [
41+
log.createdAt,
42+
log.user?.email,
43+
log.user?.name,
44+
log.user?.role,
45+
log.action,
46+
log.resource,
47+
log.resourceId,
48+
log.patientId,
49+
log.ipAddress,
50+
log.userAgent,
51+
log.metadata ? JSON.stringify(log.metadata) : '',
52+
]);
53+
54+
const csv = [
55+
headers.map(csvCell).join(','),
56+
...rows.map((row) => row.map(csvCell).join(',')),
57+
].join('\n');
58+
59+
const stamp = new Date().toISOString().slice(0, 10);
60+
61+
return new NextResponse(csv, {
62+
status: 200,
63+
headers: {
64+
'Content-Type': 'text/csv; charset=utf-8',
65+
'Content-Disposition': `attachment; filename="mamamtu-audit-${stamp}.csv"`,
66+
'Cache-Control': 'no-store',
67+
},
68+
});
69+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { execFile } from 'child_process';
2+
import { promisify } from 'util';
3+
import { NextRequest, NextResponse } from 'next/server';
4+
import { AuditAction } from '@prisma/client';
5+
import { auth } from '@/auth';
6+
import { writeAuditLog } from '@/lib/audit';
7+
8+
const execFileAsync = promisify(execFile);
9+
10+
export async function POST(request: NextRequest) {
11+
const session = await auth();
12+
if (session?.user?.role !== 'ADMIN') {
13+
return NextResponse.json({ error: 'Only admins can reset demo data' }, { status: 403 });
14+
}
15+
16+
try {
17+
const { stdout, stderr } = await execFileAsync(
18+
process.execPath,
19+
['scripts/seed-deck-demo-metrics.js'],
20+
{
21+
cwd: process.cwd(),
22+
timeout: 120000,
23+
maxBuffer: 1024 * 1024,
24+
env: process.env,
25+
},
26+
);
27+
28+
await writeAuditLog({
29+
request,
30+
userId: session.user.id,
31+
action: AuditAction.AUTH_EVENT,
32+
resource: 'DemoData',
33+
resourceId: 'deck-demo',
34+
metadata: {
35+
adminAction: 'reset-deck-demo-data',
36+
stdout: stdout.slice(-2000),
37+
stderr: stderr.slice(-2000),
38+
},
39+
});
40+
41+
return NextResponse.json({
42+
message: 'Demo data reset completed',
43+
output: stdout.slice(-2000),
44+
});
45+
} catch (error) {
46+
console.error('Demo data reset failed:', error);
47+
return NextResponse.json(
48+
{
49+
error: 'Demo data reset failed',
50+
detail: error instanceof Error ? error.message : 'Unknown error',
51+
},
52+
{ status: 500 },
53+
);
54+
}
55+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { AuditAction } from '@prisma/client';
3+
import { z } from 'zod';
4+
import { auth } from '@/auth';
5+
import { prisma } from '@/lib/prisma';
6+
import { generateSecureToken, hashToken } from '@/lib/security';
7+
import { writeAuditLog } from '@/lib/audit';
8+
9+
const accessActionSchema = z.object({
10+
action: z.enum(['lock', 'unlock', 'force-reset']),
11+
});
12+
13+
async function assertAdmin() {
14+
const session = await auth();
15+
if (session?.user?.role !== 'ADMIN') {
16+
return {
17+
session,
18+
response: NextResponse.json({ error: 'Only admins can manage staff access' }, { status: 403 }),
19+
};
20+
}
21+
22+
return { session, response: null };
23+
}
24+
25+
async function isLastActiveAdmin(userId: string) {
26+
const activeAdmins = await prisma.user.count({
27+
where: {
28+
role: 'ADMIN',
29+
isActive: true,
30+
OR: [
31+
{ accountLockedUntil: null },
32+
{ accountLockedUntil: { lte: new Date() } },
33+
],
34+
id: { not: userId },
35+
},
36+
});
37+
38+
return activeAdmins === 0;
39+
}
40+
41+
export async function POST(
42+
request: NextRequest,
43+
{ params }: { params: Promise<{ id: string }> },
44+
) {
45+
const { session, response } = await assertAdmin();
46+
if (response) return response;
47+
48+
const { id } = await params;
49+
const { action } = accessActionSchema.parse(await request.json());
50+
const target = await prisma.user.findUnique({
51+
where: { id },
52+
select: {
53+
id: true,
54+
name: true,
55+
email: true,
56+
role: true,
57+
isActive: true,
58+
},
59+
});
60+
61+
if (!target || !['ADMIN', 'HEALTHCARE_PROVIDER', 'RECEPTIONIST'].includes(target.role)) {
62+
return NextResponse.json({ error: 'Staff account not found' }, { status: 404 });
63+
}
64+
65+
if (session?.user?.id === id) {
66+
return NextResponse.json(
67+
{ error: 'You cannot change access controls for your own account from this panel' },
68+
{ status: 400 },
69+
);
70+
}
71+
72+
if (target.role === 'ADMIN' && (action === 'lock' || action === 'force-reset') && await isLastActiveAdmin(id)) {
73+
return NextResponse.json(
74+
{ error: 'At least one active unlocked admin account is required' },
75+
{ status: 400 },
76+
);
77+
}
78+
79+
if (action === 'unlock') {
80+
const user = await prisma.user.update({
81+
where: { id },
82+
data: {
83+
accountLockedUntil: null,
84+
failedLoginAttempts: 0,
85+
},
86+
select: {
87+
id: true,
88+
name: true,
89+
email: true,
90+
role: true,
91+
isActive: true,
92+
accountLockedUntil: true,
93+
passwordResetExpires: true,
94+
failedLoginAttempts: true,
95+
},
96+
});
97+
98+
await writeAuditLog({
99+
request,
100+
userId: session?.user?.id,
101+
action: AuditAction.AUTH_EVENT,
102+
resource: 'StaffUser',
103+
resourceId: id,
104+
metadata: { adminAction: 'unlock-staff-account', targetEmail: target.email },
105+
});
106+
107+
return NextResponse.json({ user });
108+
}
109+
110+
if (action === 'lock') {
111+
const lockedUntil = new Date('2099-12-31T23:59:59.000Z');
112+
const user = await prisma.user.update({
113+
where: { id },
114+
data: {
115+
accountLockedUntil: lockedUntil,
116+
failedLoginAttempts: 0,
117+
},
118+
select: {
119+
id: true,
120+
name: true,
121+
email: true,
122+
role: true,
123+
isActive: true,
124+
accountLockedUntil: true,
125+
passwordResetExpires: true,
126+
failedLoginAttempts: true,
127+
},
128+
});
129+
130+
await writeAuditLog({
131+
request,
132+
userId: session?.user?.id,
133+
action: AuditAction.AUTH_EVENT,
134+
resource: 'StaffUser',
135+
resourceId: id,
136+
metadata: { adminAction: 'lock-staff-account', targetEmail: target.email },
137+
});
138+
139+
return NextResponse.json({ user });
140+
}
141+
142+
const resetToken = generateSecureToken();
143+
const resetExpires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
144+
const resetUrl = new URL('/auth/reset-password', request.nextUrl.origin);
145+
resetUrl.searchParams.set('token', resetToken);
146+
147+
const user = await prisma.user.update({
148+
where: { id },
149+
data: {
150+
passwordResetToken: hashToken(resetToken),
151+
passwordResetExpires: resetExpires,
152+
accountLockedUntil: resetExpires,
153+
failedLoginAttempts: 0,
154+
},
155+
select: {
156+
id: true,
157+
name: true,
158+
email: true,
159+
role: true,
160+
isActive: true,
161+
accountLockedUntil: true,
162+
passwordResetExpires: true,
163+
failedLoginAttempts: true,
164+
},
165+
});
166+
167+
await writeAuditLog({
168+
request,
169+
userId: session?.user?.id,
170+
action: AuditAction.AUTH_EVENT,
171+
resource: 'StaffUser',
172+
resourceId: id,
173+
metadata: {
174+
adminAction: 'force-password-reset',
175+
targetEmail: target.email,
176+
resetExpires: resetExpires.toISOString(),
177+
},
178+
});
179+
180+
return NextResponse.json({
181+
user,
182+
resetUrl: resetUrl.toString(),
183+
resetExpires,
184+
});
185+
}

src/app/api/admin/users/[id]/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { NextRequest, NextResponse } from 'next/server';
2+
import { AuditAction } from '@prisma/client';
23
import { z } from 'zod';
34
import bcrypt from 'bcryptjs';
45
import { auth } from '@/auth';
56
import { prisma } from '@/lib/prisma';
7+
import { writeAuditLog } from '@/lib/audit';
68

79
const updateUserSchema = z.object({
810
name: z.string().trim().min(1),
@@ -95,6 +97,21 @@ export async function PATCH(
9597
select: { id: true, name: true, email: true, role: true, isActive: true, emailVerified: true, lastLogin: true },
9698
});
9799

100+
await writeAuditLog({
101+
request,
102+
userId: session?.user?.id,
103+
action: AuditAction.AUTH_EVENT,
104+
resource: 'StaffUser',
105+
resourceId: id,
106+
metadata: {
107+
adminAction: 'update-staff-account',
108+
targetEmail: user.email,
109+
role: user.role,
110+
isActive: user.isActive,
111+
passwordChanged: Boolean(data.password),
112+
},
113+
});
114+
98115
return NextResponse.json(user);
99116
} catch (error) {
100117
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
@@ -143,5 +160,14 @@ export async function DELETE(
143160
select: { id: true, name: true, email: true, role: true, isActive: true },
144161
});
145162

163+
await writeAuditLog({
164+
request,
165+
userId: session?.user?.id,
166+
action: AuditAction.AUTH_EVENT,
167+
resource: 'StaffUser',
168+
resourceId: id,
169+
metadata: { adminAction: 'deactivate-staff-account', targetEmail: user.email, role: user.role },
170+
});
171+
146172
return NextResponse.json(user);
147173
}

0 commit comments

Comments
 (0)