feat(admin): implement admin auth & authorization system (Phases 0–6)

Complete implementation of the admin authentication and authorization
plan using a separate Clerk instance (App B) for cryptographic isolation
from the storefront.

Convex backend changes:
- auth.config.ts: dual JWT provider (storefront + admin Clerk issuers)
- http.ts: add /clerk-admin-webhook route with separate signing secret
- users.ts: role-aware upsertFromClerk (optional role arg), store reads
  publicMetadata.role from JWT, assertSuperAdmin internal query
- model/users.ts: add requireSuperAdmin helper
- adminInvitations.ts: inviteAdmin action (super_admin gated, Clerk Backend SDK)

Admin app (apps/admin):
- Route groups: (auth) for sign-in, (dashboard) for gated pages
- AdminUserSync, AdminAuthGate, AccessDenied, LoadingSkeleton components
- useAdminAuth hook with loading/authorized/denied state machine
- RequireRole component for super_admin-only UI sections
- useStoreUserEffect hook for Clerk→Convex user sync
- Sidebar shell with nav-main, nav-user, app-sidebar
- clerkMiddleware with /sign-in excluded from auth.protect
- ShadCN UI components (sidebar, dropdown, avatar, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 16:13:07 +03:00
parent cc15338ad9
commit a897089fdc
50 changed files with 6224 additions and 15 deletions

View File

@@ -49,7 +49,64 @@ async function validateRequest(
"svix-signature": req.headers.get("svix-signature")!,
};
try {
return new Webhook(process.env.CLERK_WEBHOOK_SECRET!).verify(
return new Webhook(process.env.CLERK_STOREFRONT_WEBHOOK_SECRET!).verify(
payload,
headers,
) as WebhookEvent;
} catch {
return null;
}
}
http.route({
path: "/clerk-admin-webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const event = await validateAdminRequest(request);
if (!event) return new Response("Error", { status: 400 });
switch (event.type) {
case "user.created":
case "user.updated": {
const role = (event.data.public_metadata as Record<string, unknown>)
?.role;
await ctx.runMutation(internal.users.upsertFromClerk, {
externalId: event.data.id,
name:
`${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(),
email: event.data.email_addresses[0]?.email_address ?? "",
avatarUrl: event.data.image_url ?? undefined,
role:
role === "admin" || role === "super_admin" ? role : undefined,
});
break;
}
case "user.deleted":
if (event.data.id) {
await ctx.runMutation(internal.users.deleteFromClerk, {
externalId: event.data.id,
});
}
break;
default:
console.log("Ignored admin webhook event:", event.type);
}
return new Response(null, { status: 200 });
}),
});
async function validateAdminRequest(
req: Request,
): Promise<WebhookEvent | null> {
const payload = await req.text();
const headers = {
"svix-id": req.headers.get("svix-id")!,
"svix-timestamp": req.headers.get("svix-timestamp")!,
"svix-signature": req.headers.get("svix-signature")!,
};
try {
return new Webhook(process.env.CLERK_ADMIN_WEBHOOK_SECRET!).verify(
payload,
headers,
) as WebhookEvent;
@@ -77,4 +134,4 @@ http.route({
}),
});
export default http;
export default http;