Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
75ba158
chore: add admin dashboard and testing dependencies
Feyisara2108 Jul 21, 2026
a98590a
chore: configure Vitest test runner
Feyisara2108 Jul 21, 2026
1c5a5a1
feat: implement shared currency, date and address formatters
Feyisara2108 Jul 21, 2026
ce7f4a3
feat: add authentication domain types
Feyisara2108 Jul 21, 2026
21b75d4
feat: add shared auth constants
Feyisara2108 Jul 21, 2026
84ba064
feat: add role-based authorization predicates
Feyisara2108 Jul 21, 2026
2b70a35
test: cover role predicates with unit tests
Feyisara2108 Jul 21, 2026
d070c30
feat: add mock user directory
Feyisara2108 Jul 21, 2026
f2ffda4
feat: add mock session reader
Feyisara2108 Jul 21, 2026
18fad31
feat: add auth data access layer with requireAdmin guard
Feyisara2108 Jul 21, 2026
9ed9b15
feat: add demo sign-in and sign-out server actions
Feyisara2108 Jul 21, 2026
23f45d4
feat: protect routes with Next.js 16 proxy
Feyisara2108 Jul 21, 2026
17a7b6b
feat: add demo login page
Feyisara2108 Jul 21, 2026
36e47d9
feat: add admin dashboard domain types
Feyisara2108 Jul 21, 2026
d0ac648
feat: add deterministic admin mock data
Feyisara2108 Jul 21, 2026
38551c3
feat: add pure table sort, filter and pagination utilities
Feyisara2108 Jul 21, 2026
62ae35d
test: cover table sort, filter and pagination logic
Feyisara2108 Jul 21, 2026
e8bd536
feat: add admin UI primitives (badge, metric card, section card)
Feyisara2108 Jul 21, 2026
1a6d97f
feat: add reusable sortable, filterable, paginated data table
Feyisara2108 Jul 21, 2026
f6eb572
feat: add dashboard charts (savings, volume, growth, group status)
Feyisara2108 Jul 21, 2026
bd45929
feat: add dashboard sections for metrics, users, groups and more
Feyisara2108 Jul 21, 2026
a61435c
feat: add admin shell layout with sidebar and tabbed navigation
Feyisara2108 Jul 21, 2026
e2600ac
feat: add guarded admin dashboard route
Feyisara2108 Jul 21, 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,386 changes: 2,237 additions & 149 deletions package-lock.json

Large diffs are not rendered by default.

18 changes: 15 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,34 @@
"start": "next start",
"lint": "eslint",
"format": "prettier --check .",
"format:fix": "prettier --write ."
"format:fix": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-table": "^8.21.3",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"recharts": "^3.9.2",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.3",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"jsdom": "^29.1.1",
"prettier": "^3.8.4",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.10"
}
}
26 changes: 26 additions & 0 deletions src/app/(admin)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Metadata } from "next";

import { AdminShell } from "@/components/admin/AdminShell";
import { getAdminDashboardData } from "@/lib/admin/mock-data";
import { requireAdmin } from "@/lib/auth/dal";

export const metadata: Metadata = {
title: "Admin Dashboard — Kolo",
description:
"Platform metrics, user management and analytics for Kolo admins.",
};

/**
* Admin dashboard entry point.
*
* `requireAdmin()` is the secure server-side guard: it runs before any markup
* is produced and redirects non-admins away, so the dashboard only ever renders
* for authenticated admin users. Data is fetched on the server and streamed to
* the client shell as serializable props.
*/
export default async function AdminPage() {
const user = await requireAdmin();
const data = await getAdminDashboardData();

return <AdminShell user={user} data={data} />;
}
78 changes: 76 additions & 2 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,77 @@
export default function Login() {
return <div>Login Page</div>;
import Link from "next/link";

import { signInAsAdmin, signInAsMember } from "@/lib/auth/actions";

/**
* Demo login page.
*
* The real credential form is out of scope for the admin dashboard issue, so
* this provides two one-click demo sessions that exercise the authorization
* flow: sign in as an admin (can reach `/admin`) or as a member (cannot).
*/
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const params = await searchParams;
const forbidden = params.error === "forbidden";
const from = typeof params.from === "string" ? params.from : undefined;

return (
<main className="flex min-h-screen items-center justify-center bg-[#f6f3f5] px-4">
<div className="w-full max-w-md rounded-3xl bg-white p-8 shadow-[8px_8px_24px_0px_#d1d5db,-8px_-8px_24px_0px_#ffffff]">
<Link
href="/"
className="mb-6 inline-flex items-center gap-2 text-sm font-medium text-[#006c49]"
>
← Back to home
</Link>

<h1 className="font-[family-name:var(--font-space-grotesk)] text-2xl font-bold text-slate-900">
Sign in to Kolo
</h1>
<p className="mt-2 text-sm text-slate-500">
This is a demo sign-in used while real authentication is being built.
Choose a role to explore the app.
</p>

{forbidden && (
<div
role="alert"
className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
>
You need an <span className="font-semibold">admin</span> account to
view <span className="font-mono">{from ?? "that page"}</span>. Sign
in as an admin below.
</div>
)}

<div className="mt-6 space-y-3">
<form action={signInAsAdmin}>
<button
type="submit"
className="w-full rounded-xl bg-[#006c49] px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#005238]"
>
Continue as Admin
</button>
</form>

<form action={signInAsMember}>
<button
type="submit"
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50"
>
Continue as Member
</button>
</form>
</div>

<p className="mt-6 text-center text-xs text-slate-400">
Admin sessions can access the administrative dashboard at{" "}
<span className="font-mono">/admin</span>.
</p>
</div>
</main>
);
}
149 changes: 149 additions & 0 deletions src/components/admin/AdminShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"use client";

import { useState } from "react";

import { signOut } from "@/lib/auth/actions";
import type { AuthUser } from "@/types/auth";
import type { AdminDashboardData } from "@/types/admin";

import { ActivitySection } from "./sections/ActivitySection";
import { ContractHealthSection } from "./sections/ContractHealthSection";
import { GroupsSection } from "./sections/GroupsSection";
import { OverviewSection } from "./sections/OverviewSection";
import { TransactionsSection } from "./sections/TransactionsSection";
import { UsersSection } from "./sections/UsersSection";

type TabId =
| "overview"
| "users"
| "groups"
| "transactions"
| "contracts"
| "activity";

const TABS: { id: TabId; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "users", label: "Users" },
{ id: "groups", label: "Groups" },
{ id: "transactions", label: "Transactions" },
{ id: "contracts", label: "Contracts" },
{ id: "activity", label: "Activity" },
];

interface AdminShellProps {
user: AuthUser;
data: AdminDashboardData;
}

/** Client shell owning tab state and the admin layout (sidebar + topbar). */
export function AdminShell({ user, data }: AdminShellProps) {
const [activeTab, setActiveTab] = useState<TabId>("overview");
const activeLabel =
TABS.find((tab) => tab.id === activeTab)?.label ?? "Overview";
const initials = user.name
.split(" ")
.map((part) => part[0])
.slice(0, 2)
.join("")
.toUpperCase();

return (
<div className="flex min-h-screen bg-[#f6f3f5] text-slate-900">
{/* Sidebar (md and up) */}
<aside className="hidden w-60 shrink-0 flex-col border-r border-slate-200 bg-white p-5 md:flex">
<div className="mb-8 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#006c49] font-[family-name:var(--font-space-grotesk)] text-sm font-bold text-white">
K
</span>
<span className="font-[family-name:var(--font-space-grotesk)] text-lg font-bold">
Kolo
</span>
</div>
<nav className="flex flex-col gap-1">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
aria-current={activeTab === tab.id ? "page" : undefined}
className={`rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
activeTab === tab.id
? "bg-[#006c49]/10 text-[#006c49]"
: "text-slate-600 hover:bg-slate-50"
}`}
>
{tab.label}
</button>
))}
</nav>
</aside>

{/* Main column */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Topbar */}
<header className="flex flex-col gap-4 border-b border-slate-200 bg-white px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-[family-name:var(--font-space-grotesk)] text-xl font-bold">
{activeLabel}
</h1>
<p className="text-sm text-slate-500">Administrative dashboard</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-[#006c49]/10 text-sm font-semibold text-[#006c49]">
{initials}
</span>
<div className="hidden text-right sm:block">
<p className="text-sm font-medium leading-tight">{user.name}</p>
<p className="text-xs capitalize text-slate-400">{user.role}</p>
</div>
</div>
<form action={signOut}>
<button
type="submit"
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
Sign out
</button>
</form>
</div>
</header>

{/* Mobile tab bar */}
<nav className="flex gap-1 overflow-x-auto border-b border-slate-200 bg-white px-3 py-2 md:hidden">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
aria-current={activeTab === tab.id ? "page" : undefined}
className={`whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === tab.id
? "bg-[#006c49]/10 text-[#006c49]"
: "text-slate-600 hover:bg-slate-50"
}`}
>
{tab.label}
</button>
))}
</nav>

{/* Content */}
<main className="flex-1 overflow-x-hidden p-5">
{activeTab === "overview" && <OverviewSection data={data} />}
{activeTab === "users" && <UsersSection users={data.users} />}
{activeTab === "groups" && <GroupsSection groups={data.groups} />}
{activeTab === "transactions" && (
<TransactionsSection transactions={data.transactions} />
)}
{activeTab === "contracts" && (
<ContractHealthSection contracts={data.contracts} />
)}
{activeTab === "activity" && (
<ActivitySection activity={data.activity} />
)}
</main>
</div>
</div>
);
}
Loading