Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
121 changes: 121 additions & 0 deletions admin-dashboard/app/admin/users/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Link from "next/link";
import { auth } from "@/auth";
import { AdminUsersTable } from "@/components/dashboard/AdminUsersTable";
import type { AdminUser } from "@/components/dashboard/AdminUsersTable";
import { ADMIN_ROLES, ROLE_LABELS, ROLE_DESCRIPTIONS } from "@/lib/permissions";

async function fetchUsers(adminJwt: string): Promise<AdminUser[]> {
const serverUrl = process.env.FLUID_SERVER_URL;
const adminToken = process.env.FLUID_ADMIN_TOKEN;

if (!serverUrl || !adminToken) return [];

try {
const res = await fetch(`${serverUrl}/admin/users`, {
headers: {
"x-admin-token": adminToken,
"x-admin-jwt": adminJwt,
},
cache: "no-store",
});
if (!res.ok) return [];
return await res.json();
} catch {
return [];
}
}

const ROLE_KEY_PERMISSIONS: Record<string, string[]> = {
SUPER_ADMIN: ["Manage users", "Full operational control", "Billing & payments", "Config changes"],
ADMIN: ["Full operational control", "View billing", "Config changes", "Manage API keys & tenants"],
READ_ONLY: ["View transactions, analytics, signers", "View API keys & tenants", "View SAR & audit logs", "View billing"],
BILLING: ["View transactions & analytics", "View tenants", "Manage billing & payments"],
};

export default async function AdminUsersPage() {
const session = await auth();
const users = await fetchUsers(session?.user?.adminJwt ?? "");

return (
<main className="min-h-screen bg-slate-100">
<div className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-sky-600">
Fluid Admin — Access Control
</p>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Admin Users</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-600">
Manage admin accounts and role assignments.
</p>
</div>
<div className="flex items-center gap-4">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<div className="font-medium text-slate-900">{session?.user?.email}</div>
<div>{session?.user?.role ?? "Unknown role"}</div>
</div>
<Link
href="/admin/dashboard"
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"
>
Back to dashboard
</Link>
</div>
</div>
</div>
</div>

<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 space-y-10">
<AdminUsersTable users={users} currentUserRole={session?.user?.role ?? ""} />

{/* Role permissions reference */}
<div>
<h2 className="mb-3 text-base font-semibold text-slate-800">Role Permissions Reference</h2>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Role</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Description</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Key Permissions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ADMIN_ROLES.map(role => (
<tr key={role} className="align-top">
<td className="whitespace-nowrap px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${
role === "SUPER_ADMIN"
? "bg-purple-50 text-purple-700 ring-purple-200"
: role === "ADMIN"
? "bg-blue-50 text-blue-700 ring-blue-200"
: role === "BILLING"
? "bg-emerald-50 text-emerald-700 ring-emerald-200"
: "bg-slate-100 text-slate-600 ring-slate-200"
}`}>
{ROLE_LABELS[role]}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-600">{ROLE_DESCRIPTIONS[role]}</td>
<td className="px-4 py-3">
<ul className="space-y-0.5">
{ROLE_KEY_PERMISSIONS[role].map(perm => (
<li key={perm} className="text-xs text-slate-500">
{perm}
</li>
))}
</ul>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
);
}
34 changes: 34 additions & 0 deletions admin-dashboard/app/api/admin/users/[id]/role/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";

async function adminHeaders(): Promise<Record<string, string>> {
const session = await auth();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "",
};
if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt;
return headers;
}

export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, "");
if (!serverUrl) throw new Error("FLUID_SERVER_URL not configured");
const body = await req.json();
const response = await fetch(`${serverUrl}/admin/users/${params.id}/role`, {
method: "PATCH",
headers: await adminHeaders(),
body: JSON.stringify(body),
});
return NextResponse.json(await response.json(), { status: response.status });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to update role" },
{ status: 500 }
);
}
}
31 changes: 31 additions & 0 deletions admin-dashboard/app/api/admin/users/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";

async function adminHeaders(): Promise<Record<string, string>> {
const session = await auth();
const headers: Record<string, string> = {
"x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "",
};
if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt;
return headers;
}

export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, "");
if (!serverUrl) throw new Error("FLUID_SERVER_URL not configured");
const response = await fetch(`${serverUrl}/admin/users/${params.id}`, {
method: "DELETE",
headers: await adminHeaders(),
});
return NextResponse.json(await response.json(), { status: response.status });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to deactivate user" },
{ status: 500 }
);
}
}
55 changes: 55 additions & 0 deletions admin-dashboard/app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";

function getServerConfig() {
const serverUrl = process.env.FLUID_SERVER_URL?.trim().replace(/\/$/, "");
const adminToken = process.env.FLUID_ADMIN_TOKEN?.trim();
if (!serverUrl || !adminToken) {
throw new Error("FLUID_SERVER_URL and FLUID_ADMIN_TOKEN must be configured");
}
return { serverUrl, adminToken };
}

async function adminHeaders(): Promise<Record<string, string>> {
const session = await auth();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-admin-token": process.env.FLUID_ADMIN_TOKEN?.trim() ?? "",
};
if (session?.user?.adminJwt) headers["x-admin-jwt"] = session.user.adminJwt;
return headers;
}

export async function GET() {
try {
const { serverUrl } = getServerConfig();
const response = await fetch(`${serverUrl}/admin/users`, {
cache: "no-store",
headers: await adminHeaders(),
});
return NextResponse.json(await response.json(), { status: response.status });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to fetch users" },
{ status: 500 }
);
}
}

export async function POST(req: NextRequest) {
try {
const { serverUrl } = getServerConfig();
const body = await req.json();
const response = await fetch(`${serverUrl}/admin/users`, {
method: "POST",
headers: await adminHeaders(),
body: JSON.stringify(body),
});
return NextResponse.json(await response.json(), { status: response.status });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create user" },
{ status: 500 }
);
}
}
102 changes: 65 additions & 37 deletions admin-dashboard/auth.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,102 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import type { AdminRole } from "./lib/permissions";

declare module "next-auth" {
interface User {
role?: string;
adminJwt?: string;
}
interface Session {
user: {
email?: string | null;
role?: string;
adminJwt?: string;
};
}
}

declare module "next-auth/jwt" {
interface JWT {
role?: string;
adminJwt?: string;
}
}

export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
if (!credentials?.email || !credentials?.password) return null;

const email = credentials.email as string;
const password = credentials.password as string;

// 1. Try DB-based admin users via the backend login endpoint
const serverUrl = process.env.FLUID_SERVER_URL;
const adminToken = process.env.FLUID_ADMIN_TOKEN;

if (serverUrl && adminToken) {
try {
const resp = await fetch(`${serverUrl}/admin/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (resp.ok) {
const data = await resp.json();
return {
id: email,
email,
role: data.role as AdminRole,
adminJwt: data.token,
};
}
} catch {
// Backend unreachable — fall through to env-var auth
}
}

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

if (!adminEmail || !adminPasswordHash) {
console.error("Admin credentials not configured");
return null;
}
if (!adminEmail || !adminPasswordHash) return null;

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

if (!emailMatch) {
return null;
}

const passwordMatch = await bcrypt.compare(
credentials.password as string,
adminPasswordHash
);
const passwordMatch = await bcrypt.compare(password, adminPasswordHash);
if (!passwordMatch) return null;

if (!passwordMatch) {
return null;
}

return {
id: "1",
email: adminEmail,
role: "admin"
};
}
})
return { id: "env-admin", email: adminEmail, role: "SUPER_ADMIN" };
},
}),
],
session: {
strategy: "jwt",
maxAge: 8 * 60 * 60, // 8 hours
},
session: { strategy: "jwt", maxAge: 8 * 60 * 60 },
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
token.role = user.role;
token.adminJwt = user.adminJwt;
}
return token;
},
session: async ({ session, token }) => {
if (session.user) {
session.user.role = token.role as string;
session.user.adminJwt = token.adminJwt as string | undefined;
}
return session;
}
},
},
pages: {
signIn: "/login"
}
pages: { signIn: "/login" },
});
Loading
Loading