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:
26
convex/adminInvitations.ts
Normal file
26
convex/adminInvitations.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use node";
|
||||
|
||||
import { v } from "convex/values";
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { createClerkClient } from "@clerk/backend";
|
||||
|
||||
export const inviteAdmin = action({
|
||||
args: {
|
||||
email: v.string(),
|
||||
role: v.union(v.literal("admin"), v.literal("super_admin")),
|
||||
},
|
||||
handler: async (ctx, { email, role }) => {
|
||||
await ctx.runQuery(internal.users.assertSuperAdmin);
|
||||
|
||||
const clerk = createClerkClient({
|
||||
secretKey: process.env.CLERK_ADMIN_SECRET_KEY!,
|
||||
});
|
||||
|
||||
await clerk.invitations.createInvitation({
|
||||
emailAddress: email,
|
||||
redirectUrl: `${process.env.ADMIN_URL}/sign-in`,
|
||||
publicMetadata: { role },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -6,5 +6,9 @@ export default {
|
||||
domain: process.env.CLERK_STOREFRONT_JWT_ISSUER_DOMAIN!,
|
||||
applicationID: "convex",
|
||||
},
|
||||
{
|
||||
domain: process.env.CLERK_ADMIN_JWT_ISSUER_DOMAIN!,
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
} satisfies AuthConfig;
|
||||
} satisfies AuthConfig;
|
||||
@@ -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;
|
||||
@@ -26,6 +26,14 @@ export async function requireAdmin(ctx: QueryCtx) {
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireSuperAdmin(ctx: QueryCtx) {
|
||||
const user = await getCurrentUserOrThrow(ctx);
|
||||
if (user.role !== "super_admin") {
|
||||
throw new Error("Unauthorized: super_admin access required");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireOwnership(
|
||||
ctx: AuthCtx,
|
||||
resourceUserId: Id<"users">,
|
||||
|
||||
@@ -30,10 +30,16 @@ export const store = mutation({
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
const metadataRole = (identity as Record<string, unknown> & { public_metadata?: { role?: unknown } }).public_metadata?.role;
|
||||
const role =
|
||||
metadataRole === "admin" || metadataRole === "super_admin"
|
||||
? metadataRole
|
||||
: "customer";
|
||||
|
||||
return await ctx.db.insert("users", {
|
||||
name: identity.name ?? "Anonymous",
|
||||
email: identity.email ?? "",
|
||||
role: "customer",
|
||||
role,
|
||||
externalId: identity.subject,
|
||||
avatarUrl: identity.pictureUrl ?? undefined,
|
||||
});
|
||||
@@ -83,6 +89,7 @@ export const upsertFromClerk = internalMutation({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
role: v.optional(v.union(v.literal("admin"), v.literal("super_admin"))),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
@@ -93,15 +100,20 @@ export const upsertFromClerk = internalMutation({
|
||||
.unique();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
const patch: Record<string, string | undefined> = {
|
||||
name: args.name,
|
||||
email: args.email,
|
||||
avatarUrl: args.avatarUrl,
|
||||
});
|
||||
};
|
||||
if (args.role) patch.role = args.role;
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
} else {
|
||||
await ctx.db.insert("users", {
|
||||
...args,
|
||||
role: "customer",
|
||||
externalId: args.externalId,
|
||||
name: args.name,
|
||||
email: args.email,
|
||||
avatarUrl: args.avatarUrl,
|
||||
role: args.role ?? "customer",
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -118,6 +130,13 @@ export const deleteFromClerk = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const assertSuperAdmin = internalQuery({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await Users.requireSuperAdmin(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
export const getById = internalQuery({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -137,4 +156,4 @@ export const setStripeCustomerId = internalMutation({
|
||||
stripeCustomerId: args.stripeCustomerId,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user