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>
159 lines
4.2 KiB
TypeScript
159 lines
4.2 KiB
TypeScript
import {
|
|
mutation,
|
|
query,
|
|
internalMutation,
|
|
internalQuery,
|
|
} from "./_generated/server";
|
|
import { paginationOptsValidator } from "convex/server";
|
|
import { v } from "convex/values";
|
|
import * as Users from "./model/users";
|
|
|
|
export const store = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) throw new Error("Unauthenticated");
|
|
|
|
const existing = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_external_id", (q) =>
|
|
q.eq("externalId", identity.subject),
|
|
)
|
|
.unique();
|
|
|
|
if (existing) {
|
|
if (existing.name !== identity.name) {
|
|
await ctx.db.patch(existing._id, {
|
|
name: identity.name ?? existing.name,
|
|
});
|
|
}
|
|
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,
|
|
externalId: identity.subject,
|
|
avatarUrl: identity.pictureUrl ?? undefined,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const current = query({
|
|
args: {},
|
|
handler: async (ctx) => Users.getCurrentUser(ctx),
|
|
});
|
|
|
|
export const updateProfile = mutation({
|
|
args: {
|
|
name: v.optional(v.string()),
|
|
phone: v.optional(v.string()),
|
|
avatarUrl: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const user = await Users.getCurrentUserOrThrow(ctx);
|
|
const patch: { name?: string; phone?: string; avatarUrl?: string } = {};
|
|
if (args.name !== undefined) patch.name = args.name;
|
|
if (args.phone !== undefined) patch.phone = args.phone;
|
|
if (args.avatarUrl !== undefined) patch.avatarUrl = args.avatarUrl;
|
|
if (Object.keys(patch).length === 0) return user._id;
|
|
await ctx.db.patch(user._id, patch);
|
|
return user._id;
|
|
},
|
|
});
|
|
|
|
export const listCustomers = query({
|
|
args: {
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await Users.requireAdmin(ctx);
|
|
return await ctx.db
|
|
.query("users")
|
|
.withIndex("by_role", (q) => q.eq("role", "customer"))
|
|
.order("desc")
|
|
.paginate(args.paginationOpts);
|
|
},
|
|
});
|
|
|
|
export const upsertFromClerk = internalMutation({
|
|
args: {
|
|
externalId: v.string(),
|
|
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
|
|
.query("users")
|
|
.withIndex("by_external_id", (q) =>
|
|
q.eq("externalId", args.externalId),
|
|
)
|
|
.unique();
|
|
|
|
if (existing) {
|
|
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", {
|
|
externalId: args.externalId,
|
|
name: args.name,
|
|
email: args.email,
|
|
avatarUrl: args.avatarUrl,
|
|
role: args.role ?? "customer",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
export const deleteFromClerk = internalMutation({
|
|
args: { externalId: v.string() },
|
|
handler: async (ctx, { externalId }) => {
|
|
const user = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_external_id", (q) => q.eq("externalId", externalId))
|
|
.unique();
|
|
if (user) await ctx.db.delete(user._id);
|
|
},
|
|
});
|
|
|
|
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) => {
|
|
const user = await ctx.db.get(args.userId);
|
|
if (!user) throw new Error("User not found");
|
|
return user;
|
|
},
|
|
});
|
|
|
|
export const setStripeCustomerId = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
stripeCustomerId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.userId, {
|
|
stripeCustomerId: args.stripeCustomerId,
|
|
});
|
|
},
|
|
}); |