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>
47 lines
1.3 KiB
TypeScript
47 lines
1.3 KiB
TypeScript
import { QueryCtx, MutationCtx } from "../_generated/server";
|
|
import type { Id } from "../_generated/dataModel";
|
|
|
|
type AuthCtx = QueryCtx | MutationCtx;
|
|
|
|
export async function getCurrentUser(ctx: QueryCtx) {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) return null;
|
|
return await ctx.db
|
|
.query("users")
|
|
.withIndex("by_external_id", (q) => q.eq("externalId", identity.subject))
|
|
.unique();
|
|
}
|
|
|
|
export async function getCurrentUserOrThrow(ctx: AuthCtx) {
|
|
const user = await getCurrentUser(ctx as QueryCtx);
|
|
if (!user) throw new Error("Unauthenticated");
|
|
return user;
|
|
}
|
|
|
|
export async function requireAdmin(ctx: QueryCtx) {
|
|
const user = await getCurrentUserOrThrow(ctx);
|
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
|
throw new Error("Unauthorized: admin access required");
|
|
}
|
|
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">,
|
|
) {
|
|
const user = await getCurrentUserOrThrow(ctx);
|
|
if (resourceUserId !== user._id) {
|
|
throw new Error("Unauthorized: resource does not belong to you");
|
|
}
|
|
return user;
|
|
}
|