Skip to content

Commit dd5115f

Browse files
authored
Merge pull request #406 from daveades/feat/rbac-admin-dashboard
feat: role-based access control for admin dashboard
2 parents 6c7b6ce + 827d216 commit dd5115f

19 files changed

Lines changed: 1595 additions & 58 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import Link from "next/link";
2+
import { auth } from "@/auth";
3+
import { AdminUsersTable } from "@/components/dashboard/AdminUsersTable";
4+
import type { AdminUser } from "@/components/dashboard/AdminUsersTable";
5+
import { ADMIN_ROLES, ROLE_LABELS, ROLE_DESCRIPTIONS } from "@/lib/permissions";
6+
7+
async function fetchUsers(adminJwt: string): Promise<AdminUser[]> {
8+
const serverUrl = process.env.FLUID_SERVER_URL;
9+
const adminToken = process.env.FLUID_ADMIN_TOKEN;
10+
11+
if (!serverUrl || !adminToken) return [];
12+
13+
try {
14+
const res = await fetch(`${serverUrl}/admin/users`, {
15+
headers: {
16+
"x-admin-token": adminToken,
17+
"x-admin-jwt": adminJwt,
18+
},
19+
cache: "no-store",
20+
});
21+
if (!res.ok) return [];
22+
return await res.json();
23+
} catch {
24+
return [];
25+
}
26+
}
27+
28+
const ROLE_KEY_PERMISSIONS: Record<string, string[]> = {
29+
SUPER_ADMIN: ["Manage users", "Full operational control", "Billing & payments", "Config changes"],
30+
ADMIN: ["Full operational control", "View billing", "Config changes", "Manage API keys & tenants"],
31+
READ_ONLY: ["View transactions, analytics, signers", "View API keys & tenants", "View SAR & audit logs", "View billing"],
32+
BILLING: ["View transactions & analytics", "View tenants", "Manage billing & payments"],
33+
};
34+
35+
export default async function AdminUsersPage() {
36+
const session = await auth();
37+
const users = await fetchUsers(session?.user?.adminJwt ?? "");
38+
39+
return (
40+
<main className="min-h-screen bg-slate-100">
41+
<div className="border-b border-slate-200 bg-white/90 backdrop-blur">
42+
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
43+
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
44+
<div>
45+
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-sky-600">
46+
Fluid Admin — Access Control
47+
</p>
48+
<h1 className="mt-2 text-3xl font-bold text-slate-900">Admin Users</h1>
49+
<p className="mt-2 max-w-2xl text-sm text-slate-600">
50+
Manage admin accounts and role assignments.
51+
</p>
52+
</div>
53+
<div className="flex items-center gap-4">
54+
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
55+
<div className="font-medium text-slate-900">{session?.user?.email}</div>
56+
<div>{session?.user?.role ?? "Unknown role"}</div>
57+
</div>
58+
<Link
59+
href="/admin/dashboard"
60+
className="inline-flex min-h-10 items-center justify-center rounded-full border border-slate-300 bg-white px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
61+
>
62+
Back to dashboard
63+
</Link>
64+
</div>
65+
</div>
66+
</div>
67+
</div>
68+
69+
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 space-y-10">
70+
<AdminUsersTable users={users} currentUserRole={session?.user?.role ?? ""} />
71+
72+
{/* Role permissions reference */}
73+
<div>
74+
<h2 className="mb-3 text-base font-semibold text-slate-800">Role Permissions Reference</h2>
75+
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
76+
<div className="overflow-x-auto">
77+
<table className="min-w-full divide-y divide-slate-200">
78+
<thead>
79+
<tr className="bg-slate-50">
80+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Role</th>
81+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Description</th>
82+
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Key Permissions</th>
83+
</tr>
84+
</thead>
85+
<tbody className="divide-y divide-slate-100">
86+
{ADMIN_ROLES.map(role => (
87+
<tr key={role} className="align-top">
88+
<td className="whitespace-nowrap px-4 py-3">
89+
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${
90+
role === "SUPER_ADMIN"
91+
? "bg-purple-50 text-purple-700 ring-purple-200"
92+
: role === "ADMIN"
93+
? "bg-blue-50 text-blue-700 ring-blue-200"
94+
: role === "BILLING"
95+
? "bg-emerald-50 text-emerald-700 ring-emerald-200"
96+
: "bg-slate-100 text-slate-600 ring-slate-200"
97+
}`}>
98+
{ROLE_LABELS[role]}
99+
</span>
100+
</td>
101+
<td className="px-4 py-3 text-sm text-slate-600">{ROLE_DESCRIPTIONS[role]}</td>
102+
<td className="px-4 py-3">
103+
<ul className="space-y-0.5">
104+
{ROLE_KEY_PERMISSIONS[role].map(perm => (
105+
<li key={perm} className="text-xs text-slate-500">
106+
{perm}
107+
</li>
108+
))}
109+
</ul>
110+
</td>
111+
</tr>
112+
))}
113+
</tbody>
114+
</table>
115+
</div>
116+
</div>
117+
</div>
118+
</div>
119+
</main>
120+
);
121+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/auth";
3+
4+
async function adminHeaders(): Promise<Record<string, string>> {
5+
const session = await auth();
6+
const headers: Record<string, string> = {
7+
"Content-Type": "application/json",
8+
"x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "",
9+
};
10+
if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt;
11+
return headers;
12+
}
13+
14+
export async function PATCH(
15+
req: NextRequest,
16+
{ params }: { params: { id: string } }
17+
) {
18+
try {
19+
const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, "");
20+
if (!serverUrl) throw new Error("FLUID_SERVER_URL not configured");
21+
const body = await req.json();
22+
const response = await fetch(`${serverUrl}/admin/users/${params.id}/role`, {
23+
method: "PATCH",
24+
headers: await adminHeaders(),
25+
body: JSON.stringify(body),
26+
});
27+
return NextResponse.json(await response.json(), { status: response.status });
28+
} catch (error) {
29+
return NextResponse.json(
30+
{ error: error instanceof Error ? error.message : "Failed to update role" },
31+
{ status: 500 }
32+
);
33+
}
34+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/auth";
3+
4+
async function adminHeaders(): Promise<Record<string, string>> {
5+
const session = await auth();
6+
const headers: Record<string, string> = {
7+
"x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "",
8+
};
9+
if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt;
10+
return headers;
11+
}
12+
13+
export async function DELETE(
14+
_req: NextRequest,
15+
{ params }: { params: { id: string } }
16+
) {
17+
try {
18+
const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, "");
19+
if (!serverUrl) throw new Error("FLUID_SERVER_URL not configured");
20+
const response = await fetch(`${serverUrl}/admin/users/${params.id}`, {
21+
method: "DELETE",
22+
headers: await adminHeaders(),
23+
});
24+
return NextResponse.json(await response.json(), { status: response.status });
25+
} catch (error) {
26+
return NextResponse.json(
27+
{ error: error instanceof Error ? error.message : "Failed to deactivate user" },
28+
{ status: 500 }
29+
);
30+
}
31+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { auth } from "@/auth";
3+
4+
function getServerConfig() {
5+
const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, "");
6+
const adminToken = process.env.FLUID_ADMIN_TOKEN?.trim();
7+
if (!serverUrl || !adminToken) {
8+
throw new Error("FLUID_SERVER_URL and FLUID_ADMIN_TOKEN must be configured");
9+
}
10+
return { serverUrl, adminToken };
11+
}
12+
13+
async function adminHeaders(): Promise<Record<string, string>> {
14+
const session = await auth();
15+
const headers: Record<string, string> = {
16+
"Content-Type": "application/json",
17+
"x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "",
18+
};
19+
if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt;
20+
return headers;
21+
}
22+
23+
export async function GET() {
24+
try {
25+
const { serverUrl } = getServerConfig();
26+
const response = await fetch(`${serverUrl}/admin/users`, {
27+
cache: "no-store",
28+
headers: await adminHeaders(),
29+
});
30+
return NextResponse.json(await response.json(), { status: response.status });
31+
} catch (error) {
32+
return NextResponse.json(
33+
{ error: error instanceof Error ? error.message : "Failed to fetch users" },
34+
{ status: 500 }
35+
);
36+
}
37+
}
38+
39+
export async function POST(req: NextRequest) {
40+
try {
41+
const { serverUrl } = getServerConfig();
42+
const body = await req.json();
43+
const response = await fetch(`${serverUrl}/admin/users`, {
44+
method: "POST",
45+
headers: await adminHeaders(),
46+
body: JSON.stringify(body),
47+
});
48+
return NextResponse.json(await response.json(), { status: response.status });
49+
} catch (error) {
50+
return NextResponse.json(
51+
{ error: error instanceof Error ? error.message : "Failed to create user" },
52+
{ status: 500 }
53+
);
54+
}
55+
}

admin-dashboard/auth.ts

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,102 @@
11
import NextAuth from "next-auth";
22
import Credentials from "next-auth/providers/credentials";
33
import bcrypt from "bcryptjs";
4+
import type { AdminRole } from "./lib/permissions";
5+
6+
declare module "next-auth" {
7+
interface User {
8+
role?: string;
9+
adminJwt?: string;
10+
}
11+
interface Session {
12+
user: {
13+
email?: string | null;
14+
role?: string;
15+
adminJwt?: string;
16+
};
17+
}
18+
}
19+
20+
declare module "next-auth/jwt" {
21+
interface JWT {
22+
role?: string;
23+
adminJwt?: string;
24+
}
25+
}
426

527
export const { handlers, auth, signIn, signOut } = NextAuth({
628
providers: [
729
Credentials({
830
credentials: {
931
email: { label: "Email", type: "email" },
10-
password: { label: "Password", type: "password" }
32+
password: { label: "Password", type: "password" },
1133
},
1234
async authorize(credentials) {
13-
if (!credentials?.email || !credentials?.password) {
14-
return null;
35+
if (!credentials?.email || !credentials?.password) return null;
36+
37+
const email = credentials.email as string;
38+
const password = credentials.password as string;
39+
40+
// 1. Try DB-based admin users via the backend login endpoint
41+
const serverUrl = process.env.FLUID_SERVER_URL;
42+
const adminToken = process.env.FLUID_ADMIN_TOKEN;
43+
44+
if (serverUrl && adminToken) {
45+
try {
46+
const resp = await fetch(`${serverUrl}/admin/auth/login`, {
47+
method: "POST",
48+
headers: { "Content-Type": "application/json" },
49+
body: JSON.stringify({ email, password }),
50+
});
51+
if (resp.ok) {
52+
const data = await resp.json();
53+
return {
54+
id: email,
55+
email,
56+
role: data.role as AdminRole,
57+
adminJwt: data.token,
58+
};
59+
}
60+
} catch {
61+
// Backend unreachable — fall through to env-var auth
62+
}
1563
}
1664

65+
// 2. Env-var fallback (single-admin / bootstrap deployments)
1766
const adminEmail = process.env.ADMIN_EMAIL;
1867
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH;
1968

20-
if (!adminEmail || !adminPasswordHash) {
21-
console.error("Admin credentials not configured");
22-
return null;
23-
}
69+
if (!adminEmail || !adminPasswordHash) return null;
2470

25-
// Timing-safe comparison to prevent timing attacks
2671
const emailMatch = await new Promise<boolean>((resolve) => {
27-
const isEqual = credentials.email === adminEmail;
28-
// Add artificial delay to prevent timing attacks
72+
const isEqual = email === adminEmail;
2973
setTimeout(() => resolve(isEqual), Math.random() * 10);
3074
});
75+
if (!emailMatch) return null;
3176

32-
if (!emailMatch) {
33-
return null;
34-
}
35-
36-
const passwordMatch = await bcrypt.compare(
37-
credentials.password as string,
38-
adminPasswordHash
39-
);
77+
const passwordMatch = await bcrypt.compare(password, adminPasswordHash);
78+
if (!passwordMatch) return null;
4079

41-
if (!passwordMatch) {
42-
return null;
43-
}
44-
45-
return {
46-
id: "1",
47-
email: adminEmail,
48-
role: "admin"
49-
};
50-
}
51-
})
80+
return { id: "env-admin", email: adminEmail, role: "SUPER_ADMIN" };
81+
},
82+
}),
5283
],
53-
session: {
54-
strategy: "jwt",
55-
maxAge: 8 * 60 * 60, // 8 hours
56-
},
84+
session: { strategy: "jwt", maxAge: 8 * 60 * 60 },
5785
callbacks: {
5886
jwt: async ({ token, user }) => {
5987
if (user) {
6088
token.role = user.role;
89+
token.adminJwt = user.adminJwt;
6190
}
6291
return token;
6392
},
6493
session: async ({ session, token }) => {
6594
if (session.user) {
6695
session.user.role = token.role as string;
96+
session.user.adminJwt = token.adminJwt as string | undefined;
6797
}
6898
return session;
69-
}
99+
},
70100
},
71-
pages: {
72-
signIn: "/login"
73-
}
101+
pages: { signIn: "/login" },
74102
});

0 commit comments

Comments
 (0)