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>
137 lines
3.9 KiB
TypeScript
137 lines
3.9 KiB
TypeScript
import { httpRouter } from "convex/server";
|
|
import { httpAction } from "./_generated/server";
|
|
import { internal } from "./_generated/api";
|
|
import type { WebhookEvent } from "@clerk/backend";
|
|
import { Webhook } from "svix";
|
|
|
|
const http = httpRouter();
|
|
|
|
http.route({
|
|
path: "/clerk-users-webhook",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const event = await validateRequest(request);
|
|
if (!event) return new Response("Error", { status: 400 });
|
|
|
|
switch (event.type) {
|
|
case "user.created":
|
|
case "user.updated":
|
|
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,
|
|
});
|
|
break;
|
|
case "user.deleted":
|
|
if (event.data.id) {
|
|
await ctx.runMutation(internal.users.deleteFromClerk, {
|
|
externalId: event.data.id,
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
console.log("Ignored webhook event:", event.type);
|
|
}
|
|
|
|
return new Response(null, { status: 200 });
|
|
}),
|
|
});
|
|
|
|
async function validateRequest(
|
|
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_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;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
http.route({
|
|
path: "/stripe/webhook",
|
|
method: "POST",
|
|
handler: httpAction(async (ctx, request) => {
|
|
const body = await request.text();
|
|
const signature = request.headers.get("stripe-signature");
|
|
if (!signature) {
|
|
return new Response("Missing stripe-signature header", { status: 400 });
|
|
}
|
|
|
|
const result = await ctx.runAction(internal.stripeActions.handleWebhook, {
|
|
payload: body,
|
|
signature,
|
|
});
|
|
|
|
return new Response(null, { status: result.success ? 200 : 400 });
|
|
}),
|
|
});
|
|
|
|
export default http; |