Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0fd31ef
feat: add dashboard with auth, tickets, billing, and settings
mundume Jul 12, 2026
4f55eb2
feat: fix auth
mundume Jul 12, 2026
b50b8f6
feat: add yarnrrc
mundume Jul 13, 2026
6af898d
feat: add workspace switcher in dropdown
mundume Jul 13, 2026
292a355
feat:fix animations
mundume Jul 13, 2026
537de4d
feat: add paystack integration
mundume Sep 17, 2026
f5c0f36
refactor: improve code readability in checkout route
mundume Sep 17, 2026
180e8a6
feat: add payments cancellation
mundume Oct 4, 2026
4a18990
feat: move auth to clerk
mundume Oct 26, 2026
42a2680
feat: move auth to clerk
mundume Oct 26, 2026
a4fca46
feat: remove clerk ui and add custom ui
mundume Oct 26, 2026
3d0d759
feat: finish auth setttings
mundume Oct 27, 2026
7950325
Merge branch 'main' into dashboard
mundume Oct 27, 2026
4e4389a
feat: initial clerk billing config
mundume Oct 28, 2026
548279d
feat: integrate clerk billing and organisations as the source of truth
mundume Nov 1, 2026
22d85b6
feat: fix apps creation by passing metadata to client
mundume Nov 1, 2026
3384b6d
fix: fix page reloads on workspace switcher
mundume Nov 2, 2026
888c36b
chore: remove paystack
mundume Nov 2, 2026
d3d3eea
feat: add billing cancellation
mundume Nov 2, 2026
5b2fc7f
fix: correct cancelled subscription spend and link buttons
mundume Nov 2, 2026
2c5d908
feat: add workspace member invites and management
mundume Nov 2, 2026
4bae18d
fix: gate workspace invites on purchased clerk seats
mundume Nov 2, 2026
8df0488
feat: add billing history and workspace management flows
mundume Nov 3, 2026
84a8b55
fix: fix workspace members cars design
mundume Nov 3, 2026
1c2499e
Merge branch 'main' of https://github.com/mundume/website into dashbo…
mundume Nov 3, 2026
e275960
Merge remote-tracking branch 'origin/dashboard' into dashboard/billing
mundume Nov 3, 2026
f92e872
fix: resolve merge layout duplication
mundume Nov 3, 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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Clerk (development keys)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replace_me
CLERK_SECRET_KEY=sk_test_replace_me
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,17 @@ examples/target
.env
.env.local
.env.production

# Yarn
.pnp.*
.yarn/*

# SQLite databases
*.db
*.db-journal
sqlite.db

# CJS files
.tmp*
.tmp-2fa-test.cjs
.DS_Store
7 changes: 7 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
enableGlobalCache: true

enableScripts: true

enableTelemetry: false

pnpEnableInlining: false
32 changes: 32 additions & 0 deletions app/api/account/2fa/disable/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
auth,
clerkClient,
reverificationErrorResponse,
} from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

export const runtime = "nodejs";

export async function POST() {
const { userId, has } = await auth();

if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

if (!has({ reverification: "strict" })) {
return reverificationErrorResponse("strict");
}

try {
const client = await clerkClient();
await client.users.disableUserMFA(userId);

return NextResponse.json({ ok: true });
} catch {
return NextResponse.json(
{ error: "We couldn't disable two-factor authentication." },
{ status: 500 }
);
}
}
100 changes: 100 additions & 0 deletions app/api/billing/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { auth, clerkClient } from "@clerk/nextjs/server";
import { isClerkAPIResponseError } from "@clerk/nextjs/errors";
import { NextResponse } from "next/server";

import { isCancelablePaidSubscriptionItem } from "@/lib/workspace/plan";

export const runtime = "nodejs";

/**
* Cancels the active workspace subscription at the end of the current billing
* period. Clerk remains the source of truth; the UI re-reads the subscription
* after this call instead of keeping local billing state.
*/
export async function POST() {
const { userId, orgId, orgRole } = await auth();

if (!userId) {
return NextResponse.json(
{ error: "Please sign in to continue." },
{ status: 401 }
);
}

if (!orgId) {
return NextResponse.json(
{ error: "Choose a workspace before managing billing." },
{ status: 400 }
);
}

if (orgRole !== "org:admin" && orgRole !== "org:owner") {
return NextResponse.json(
{ error: "Only workspace admins can cancel the subscription." },
{ status: 403 }
);
}

const client = await clerkClient();

let subscription;

try {
subscription = await client.billing.getOrganizationBillingSubscription(
orgId
);
} catch (error) {
if (isClerkAPIResponseError(error) && error.status === 404) {
return NextResponse.json(
{ error: "This workspace does not have an active subscription." },
{ status: 400 }
);
}

return NextResponse.json(
{ error: "We couldn't load this workspace's subscription." },
{ status: 502 }
);
}

const item = subscription.subscriptionItems.find(
(subscriptionItem) =>
isCancelablePaidSubscriptionItem(subscriptionItem) &&
!subscriptionItem.canceledAt
);

if (!item) {
return NextResponse.json(
{
error:
"This workspace has no paid subscription renewal to cancel. It may already be set to end.",
},
{ status: 409 }
);
}

try {
const cancelled = await client.billing.cancelSubscriptionItem(item.id, {
endNow: false,
});

return NextResponse.json({
status: "non-renewing",
endsAt: cancelled.periodEnd
? new Date(cancelled.periodEnd).toISOString()
: null,
});
} catch (error) {
if (isClerkAPIResponseError(error)) {
return NextResponse.json(
{ error: error.message },
{ status: error.status ?? 502 }
);
}

return NextResponse.json(
{ error: "We couldn't cancel this subscription. Try again." },
{ status: 502 }
);
}
}
2 changes: 1 addition & 1 deletion app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default async function Page({
return (
<>
<Navigation />
<div className="blog-container relative w-full max-w-screen-2xl mx-auto px-4 sm:px-8 lg:px-16 flex items-start pt-32 sm:pt-40 min-h-screen">
<div className="blog-container relative w-full max-w-(--breakpoint-2xl) mx-auto px-4 sm:px-8 lg:px-16 flex items-start pt-32 sm:pt-40 min-h-screen">
<aside className="hidden md:flex shrink-0 sticky top-32 sm:top-40 mb-16 flex-col w-64">
<div className="text-black dark:text-white uppercase text-sm font-semibold h-8 flex items-end mb-9">
{post.authors.length > 1 ? "Authors" : "Author"}
Expand Down
4 changes: 2 additions & 2 deletions app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function Blog() {
return (
<>
<Navigation />
<main className="docs-container relative w-full max-w-screen-2xl mx-auto px-4 sm:px-8 lg:px-16 pb-24 pt-32 sm:pt-40 min-h-screen">
<main className="docs-container relative w-full max-w-(--breakpoint-2xl) mx-auto px-4 sm:px-8 lg:px-16 pb-24 pt-32 sm:pt-40 min-h-screen">
<h2 className="font-display text-2xl sm:text-3xl lg:text-4xl text-black dark:text-white">
{content.heading}
</h2>
Expand All @@ -33,7 +33,7 @@ export default function Blog() {
<Link
href={urlPath}
key={index}
className="bg-gradient-to-br from-zinc-50 to-zinc-100 dark:from-zinc-800 dark:to-zinc-900 rounded-sm p-6 generic-hover"
className="bg-linear-to-br from-zinc-50 to-zinc-100 dark:from-zinc-800 dark:to-zinc-900 rounded-sm p-6 generic-hover"
>
<div className="flex items-start justify-between">
<h3 className="font-display text-2xl text-black dark:text-white">
Expand Down
22 changes: 22 additions & 0 deletions app/dashboard/apps/[appId]/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getAppById, getApiKeyPreview } from "@/lib/dashboard/mock-data";
import { getDashboardWorkspace } from "@/lib/dashboard/workspace-server";
import { ApiKeyPage } from "@/components/dashboard/app-api-key-content";

export default async function Page({
params,
}: {
params: Promise<{ appId: string }>;
}) {
const { appId } = await params;
const workspace = await getDashboardWorkspace();
const app = getAppById(workspace, appId);

if (!app) return null;

return (
<ApiKeyPage
apiKeyId={app.apiKeyId}
apiKeyPreview={getApiKeyPreview(app)}
/>
);
}
26 changes: 26 additions & 0 deletions app/dashboard/apps/[appId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { notFound } from "next/navigation";

import { AppHeader } from "@/components/dashboard/app-header";
import { getAppById } from "@/lib/dashboard/mock-data";
import { getDashboardWorkspace } from "@/lib/dashboard/workspace-server";

export default async function AppDetailLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ appId: string }>;
}) {
const { appId } = await params;
const workspace = await getDashboardWorkspace();
const app = getAppById(workspace, appId);

if (!app) notFound();

return (
<div className="space-y-6">
<AppHeader app={app} workspacePlan={workspace.plan} />
{children}
</div>
);
}
156 changes: 156 additions & 0 deletions app/dashboard/apps/[appId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Link from "next/link";
import { format } from "date-fns";
import {
ActivityIcon,
Building2Icon,
CalendarIcon,
CreditCardIcon,
KeyIcon,
LayersIcon,
UsersIcon,
} from "lucide-react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
getApiKeyPreview,
getAppById,
} from "@/lib/dashboard/mock-data";
import { getDashboardWorkspace } from "@/lib/dashboard/workspace-server";
import {
getAppCount,
getMemberCount,
getWorkspaceLimits,
} from "@/lib/workspace/policy";
import { cn } from "@/lib/utils";

export default async function Page({
params,
}: {
params: Promise<{ appId: string }>;
}) {
const { appId } = await params;
const workspace = await getDashboardWorkspace();
const app = getAppById(workspace, appId);

if (!app) return null;

const limits = getWorkspaceLimits(workspace.plan);
const apiKeyPreview = getApiKeyPreview(app);

const statusColor = {
active: "bg-brand",
inactive: "bg-muted-foreground",
pending: "bg-amber-500",
}[app.status];

return (
<div className="space-y-6">
<Card size="sm">
<CardHeader>
<CardTitle>General</CardTitle>
</CardHeader>

<CardContent className="divide-y">
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<ActivityIcon className="size-4" />
<span>Status</span>
</div>

<div className="flex items-center gap-2">
<span className={cn("size-2 rounded-full", statusColor)} />
<span className="capitalize font-medium">{app.status}</span>
</div>
</div>

<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<CreditCardIcon className="size-4" />
<span>Workspace plan</span>
</div>

<Badge>{workspace.plan}</Badge>
</div>

<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarIcon className="size-4" />
<span>Created</span>
</div>

<span className="font-medium">
{format(new Date(app.createdAt), "MMM d, yyyy")}
</span>
</div>

<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<KeyIcon className="size-4" />
<span>API Key</span>
</div>

{apiKeyPreview ? (
<code className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
{apiKeyPreview}
</code>
) : (
<span className="text-sm text-muted-foreground">Not created</span>
)}
</div>
</CardContent>
</Card>

<Card size="sm">
<CardHeader>
<CardTitle>Workspace</CardTitle>
</CardHeader>

<CardContent className="space-y-4">
<div className="divide-y">
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Building2Icon className="size-4" />
<span>Workspace</span>
</div>

<span className="font-medium">{workspace.name}</span>
</div>

<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<LayersIcon className="size-4" />
<span>Apps</span>
</div>

<span className="font-medium">
{getAppCount(workspace)} of {limits.maxApps}
</span>
</div>

<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<UsersIcon className="size-4" />
<span>Members</span>
</div>

<span className="font-medium">
{getMemberCount(workspace)} of {limits.maxMembers}
</span>
</div>
</div>

<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link href="/dashboard/billing" />}
>
Manage workspace billing
</Button>
</CardContent>
</Card>
</div>
);
}
Loading
Loading