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

@@ -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,
});
},
});
});