# Admin Authentication & Authorization — Implementation Plan

**Date:** 2026-03-04
**Updated:** 2026-03-04 (v3 — review fixes applied)
**Audience:** Senior software engineers
**Scope:** `apps/admin` + shared `convex/` backend + separate Clerk instance

---

## 1. Current State

### What exists

| Component | Status | Detail |
|-----------|--------|--------|
| `ClerkProvider` + `ConvexClientProvider` | Wired | Root layout wraps `{children}` inside both providers |
| `clerkMiddleware` with `auth.protect()` | Active | Every route requires Clerk authentication |
| `useStoreUserEffect` hook | **Dead code** | Exists at `apps/admin/src/hooks/useStoreUserEffect.ts` but never imported |
| Convex `requireAdmin` helper | Working | All admin-only mutations/queries enforce `role === "admin" \|\| "super_admin"` server-side |
| Custom sign-in page | Missing | Users are redirected to Clerk's hosted sign-in |
| Role-based access gating | Missing | Any authenticated Clerk user (including `customer`) can reach the admin UI |
| Admin user sync | Missing | First sign-in to the admin app may fail Convex calls if the webhook hasn't fired yet |
| Clerk instance | **Shared** | Both apps use the same Clerk application — customers can sign in on the admin domain |

### Architecture diagram (current)

```
One Clerk App (shared keys)
├── Storefront — public signup, customers
└── Admin — same keys, no access control
       ↓
Browser → Clerk Middleware (auth.protect) → Admin App (no role check)
                                                ↓
                                           Convex Backend (single JWT issuer)
                                           ├─ requireAdmin → rejects customers
                                           └─ users.store → creates "customer" role
```

**Problems:**
1. Any customer can sign in on the admin domain — Clerk treats them as a valid user.
2. Once past middleware, the UI breaks with "Unauthorized" errors from every Convex query.
3. No graceful denial or redirect.
4. No invite-only account creation for staff.

---

## 2. Target Architecture

```
Clerk App A: "PetLoft Storefront"        Clerk App B: "PetLoft Admin"
├── Public signup: ON                    ├── Public signup: OFF
├── Social logins: ON                    ├── Invite-only
├── Customers                            ├── Staff/admins only
└── apps/storefront uses keys A          └── apps/admin uses keys B

                    ↓                                    ↓
          Storefront JWT (issuer A)             Admin JWT (issuer B)
                    ↓                                    ↓
                    └──────────── Convex Backend ─────────┘
                                 auth.config.ts trusts BOTH issuers
                                 Same users table, same requireAdmin()
```

```
Browser
  ↓
Clerk Middleware (auth.protect — only admin Clerk accounts can sign in)
  ↓
Admin App Shell
  ├─ AdminUserSync (calls users.store → ensures Convex record exists)
  │     └─ users.store reads publicMetadata.role from JWT → creates with correct role
  ├─ AdminAuthGate (queries users.current → checks role)
  │     ├─ role is admin/super_admin → render app
  │     └─ role is customer (edge case) → render "Access Denied"
  │     └─ user not yet in Convex → render loading state
  └─ Pages (all nested inside the auth gate)
        ↓
Convex Backend (requireAdmin on every admin function — unchanged)
```

**Three-layer defense:**

| Layer | Where | What it checks | Security role |
|-------|-------|----------------|---------------|
| 0. Isolation | Clerk Dashboard | Signup disabled on Admin app; invite-only | **Prevents account creation** — customers cannot create admin accounts |
| 1. Authentication | Edge middleware | Valid JWT from Admin Clerk instance | Prevents unauthenticated access |
| 2. Authorization (server) | Convex function handlers | `requireAdmin(ctx)` | **True security boundary** — rejects non-admin users at the data layer |
| 3. Authorization (client) | React component gate | `users.current` query result | **UX boundary** — prevents broken UI for edge cases |

Layer 0 is new (Clerk isolation). Layer 2 already exists. This plan adds Layers 0, 1 (improved), and 3.

---

## 3. Design Decisions

### D1: Separate Clerk instances (not shared)

The previous plan used a single shared Clerk app. The revised strategy creates a **second, isolated Clerk application** for the admin.

| Aspect | Shared Clerk (old) | Separate Clerk (new) |
|--------|-------------------|---------------------|
| Customer signs in on admin? | Yes — then blocked by client gate | **No — no account exists in App B** |
| Signup on admin domain | Possible (creates customer) | **Impossible — signup disabled** |
| Account creation for staff | Manual role promotion in Convex dashboard | **Invite-only via Clerk Backend SDK** |
| JWT cross-contamination | Same issuer, same JWT | **Different issuers, cryptographically isolated** |
| Convex config | Single provider | Two providers in `auth.config.ts` |

**Decision: Separate instances.** This eliminates the entire class of "customer reaches admin" problems at the infrastructure level. The client-side `AdminAuthGate` becomes a safety net for edge cases, not the primary defense.

### D2: Shared `@repo/convex` package — no changes needed

The `ConvexClientProvider` in `packages/convex/src/provider.tsx` is context-dependent:
- `NEXT_PUBLIC_CONVEX_URL` — same Convex deployment for both apps (read from each app's `.env.local`).
- `useAuth` from `@clerk/nextjs` — reads from the nearest `ClerkProvider` in the React tree, which is initialized with that app's Clerk keys.

Each app provides its own Clerk context via its own environment variables. The shared package works without modification.

### D3: Single `users` table for both Clerk apps

Users from both Clerk applications land in the same Convex `users` table, keyed by `externalId` (the Clerk `subject`). Since the two Clerk apps are separate databases, Clerk IDs are globally unique — zero collision risk.

### D4: Role-aware `users.store` via `publicMetadata`

When an admin is invited via Clerk App B, the invitation carries `publicMetadata: { role: "admin" }`. The JWT identity includes this metadata. The `users.store` mutation reads it on first creation:
- If `publicMetadata.role` is `"admin"` or `"super_admin"` → use that role.
- Otherwise → default to `"customer"` (storefront behavior unchanged).

This replaces the manual "promote in Convex dashboard" flow.

### D5: Admin webhook — separate endpoint

The Admin Clerk app needs its own webhook endpoint in Convex so user creation/update/deletion events from App B are processed. This webhook uses a separate signing secret (`CLERK_ADMIN_WEBHOOK_SECRET`). The handler calls the same `upsertFromClerk` internal mutation but passes the role from `publicMetadata`.

### D6: Role authority — webhook is authoritative, `users.store` is best-effort

Two paths can create a user record: the Clerk webhook (`upsertFromClerk`) and the client-side sync (`users.store`). Their role-setting behavior is intentionally asymmetric:

| Path | On insert | On update |
|------|-----------|-----------|
| `upsertFromClerk` (webhook) | Sets `role` from `publicMetadata` (or defaults to `"customer"`) | **Patches `role` if provided** — webhook is authoritative |
| `users.store` (client sync) | Sets `role` from JWT `public_metadata` (or defaults to `"customer"`) | **Never changes role** — only patches `name` |

**The webhook is the authoritative source for role.** If both paths race:
- Webhook fires first (sets `role: "admin"`) → `users.store` finds existing record → skips role. Correct.
- `users.store` fires first (sets `role: "admin"` from JWT metadata) → webhook fires → patches role from `publicMetadata`. Also correct (idempotent).
- `users.store` fires first but JWT template is misconfigured (no `public_metadata` claim) → sets `role: "customer"` → webhook fires → patches to `role: "admin"`. Corrected by webhook.

The webhook acts as a self-healing mechanism for JWT template misconfiguration.

---

## 4. Implementation Phases

### Phase 0 — Clerk & Convex Infrastructure Setup

**Goal:** Create the second Clerk application, configure Convex to trust both JWT issuers, and set up the admin webhook.

#### 0.1 Create Clerk App B ("PetLoft Admin")

In the Clerk Dashboard:
1. Create new application → name it `"PetLoft Admin"`.
2. Under **User & Authentication** → disable all signup methods (email, social, etc.).
3. Note the new keys: `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`.
4. Note the JWT issuer domain (e.g. `https://admin-clerk.petloft.com`).
5. **Configure the `convex` JWT template** — Go to **JWT Templates** → edit (or create) the `convex` template → add `"public_metadata": "{{user.public_metadata}}"` to the custom claims. **This is required.** Without it, `users.store` cannot read the invited role from the JWT and will silently default every admin to `role: "customer"` — a confusing failure mode with no error.

#### 0.2 Update `convex/auth.config.ts` — dual provider

```typescript
import { AuthConfig } from "convex/server";

export default {
  providers: [
    {
      domain: process.env.CLERK_STOREFRONT_JWT_ISSUER_DOMAIN!,
      applicationID: "convex",
    },
    {
      domain: process.env.CLERK_ADMIN_JWT_ISSUER_DOMAIN!,
      applicationID: "convex",
    },
  ],
} satisfies AuthConfig;
```

Convex will accept JWTs from **either** issuer. All existing `requireAdmin` / `getCurrentUser` checks work unchanged — they resolve the user by `externalId` regardless of which Clerk app issued the JWT.

#### 0.3 Set Convex dashboard environment variables

> **Atomic deploy warning:** Step 0.2 renames `CLERK_JWT_ISSUER_DOMAIN` → `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` in code. If `convex/auth.config.ts` is deployed before the env var is renamed in the Convex dashboard, **all storefront auth will break** — Convex will reject every storefront JWT. These must happen together: set the new env var in the dashboard first, then deploy the code, then remove the old env var.

Rename the existing variable and add the new one:

| Variable | Value | Purpose |
|----------|-------|---------|
| `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` | `https://<storefront-clerk-domain>` | Existing storefront issuer (was `CLERK_JWT_ISSUER_DOMAIN`) |
| `CLERK_ADMIN_JWT_ISSUER_DOMAIN` | `https://<admin-clerk-domain>` | New admin issuer |
| `CLERK_WEBHOOK_SECRET` | `whsec_...` (storefront) | Unchanged |
| `CLERK_ADMIN_WEBHOOK_SECRET` | `whsec_...` (admin) | New — for admin Clerk webhook |
| `CLERK_ADMIN_SECRET_KEY` | `sk_...` (admin) | New — for Clerk Backend SDK (invite flow) |
| `ADMIN_URL` | `https://admin.petloft.com` | New — redirect URL for invite emails |

#### 0.4 Add admin webhook endpoint in `convex/http.ts`

A second webhook route for admin Clerk events. Uses a separate signing secret and passes `publicMetadata.role` to the upsert mutation:

```typescript
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?.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;
  }
}
```

#### 0.5 Update `users.upsertFromClerk` to accept optional role

The existing mutation hard-codes `role: "customer"` on creation. Add an optional `role` argument so the admin webhook can pass the role from `publicMetadata`:

```typescript
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",
      });
    }
  },
});
```

The storefront webhook call-site doesn't pass `role`, so it defaults to `"customer"` — backward compatible.

#### 0.6 Update `users.store` to read `publicMetadata.role` from JWT

The client-side sync hook calls `users.store`. When an invited admin signs in for the first time (before the webhook fires), `users.store` should also create the record with the correct role:

```typescript
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 any).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,
    });
  },
});
```

**Prerequisite:** Step 0.1 (item 5) must be completed — the Admin Clerk `convex` JWT template must include `public_metadata` as a custom claim. Without it, `identity.public_metadata` will be `undefined` and all invited admins will silently land with `role: "customer"`.

#### 0.7 Configure admin Clerk webhook in Clerk Dashboard

In Clerk App B dashboard:
1. Go to **Webhooks** → Add Endpoint.
2. URL: `<CONVEX_SITE_URL>/clerk-admin-webhook`.
3. Events: `user.created`, `user.updated`, `user.deleted`.
4. Copy the signing secret → set as `CLERK_ADMIN_WEBHOOK_SECRET` in Convex env vars.

#### Files changed

| File | Action |
|------|--------|
| `convex/auth.config.ts` | Edit — add second provider |
| `convex/http.ts` | Edit — add `/clerk-admin-webhook` route |
| `convex/users.ts` | Edit — add optional `role` arg to `upsertFromClerk`, read `public_metadata` in `store` |
| Convex dashboard env vars | Add `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN`, `CLERK_ADMIN_JWT_ISSUER_DOMAIN`, `CLERK_ADMIN_WEBHOOK_SECRET`, `CLERK_ADMIN_SECRET_KEY`, `ADMIN_URL`; rename `CLERK_JWT_ISSUER_DOMAIN` → `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` |

---

### Phase 1 — Admin User Sync

**Goal:** Ensure every authenticated admin user has a Convex user record on sign-in.

#### 1.1 Create `AdminUserSync` component

Create `apps/admin/src/components/auth/AdminUserSync.tsx`:

```typescript
"use client";

import { useStoreUserEffect } from "@/hooks/useStoreUserEffect";

export function AdminUserSync() {
  useStoreUserEffect();
  return null;
}
```

This mirrors the storefront's `StoreUserSync` pattern. The hook calls `users.store`, which now reads `publicMetadata.role` from the JWT — invited admins get the correct role on first sync.

#### 1.2 Wire into dashboard layout (see Phase 3 for route group structure)

`AdminUserSync` will be placed inside the `(dashboard)` group layout, not the root layout.

#### Files changed

| File | Action |
|------|--------|
| `apps/admin/src/components/auth/AdminUserSync.tsx` | Create |

---

### Phase 2 — Admin Auth Gate

**Goal:** Block unauthorized users from seeing the admin UI. With separate Clerk instances this is primarily a safety net — only users with an Admin Clerk account can reach this point — but it handles edge cases gracefully.

#### 2.1 Create `useAdminAuth` hook

Create `apps/admin/src/hooks/useAdminAuth.ts`:

```typescript
"use client";

import { useConvexAuth, useQuery } from "convex/react";
import { api } from "../../../../convex/_generated/api";

type AdminAuthState =
  | { status: "loading" }
  | { status: "authorized"; role: "admin" | "super_admin" }
  | { status: "denied"; reason: "not_authenticated" | "not_admin" | "no_user_record" };

export function useAdminAuth(): AdminAuthState {
  const { isLoading, isAuthenticated } = useConvexAuth();
  const user = useQuery(
    api.users.current,
    isAuthenticated ? {} : "skip",
  );

  if (isLoading) return { status: "loading" };
  if (!isAuthenticated) return { status: "denied", reason: "not_authenticated" };
  if (user === undefined) return { status: "loading" };
  if (user === null) return { status: "denied", reason: "no_user_record" };
  if (user.role !== "admin" && user.role !== "super_admin") {
    return { status: "denied", reason: "not_admin" };
  }
  return { status: "authorized", role: user.role };
}
```

**State machine:**

```
isLoading=true        → { status: "loading" }
!isAuthenticated      → { status: "denied", reason: "not_authenticated" }
user === undefined    → { status: "loading" }         // query still resolving
user === null         → { status: "denied", reason: "no_user_record" }
user.role ∉ admin set → { status: "denied", reason: "not_admin" }
user.role ∈ admin set → { status: "authorized", role }
```

#### 2.2 Create `AdminAuthGate` component

Create `apps/admin/src/components/auth/AdminAuthGate.tsx`:

```typescript
"use client";

import { useAdminAuth } from "@/hooks/useAdminAuth";
import { useClerk } from "@clerk/nextjs";

export function AdminAuthGate({ children }: { children: React.ReactNode }) {
  const auth = useAdminAuth();
  const { signOut } = useClerk();

  if (auth.status === "loading") {
    return <LoadingSkeleton />;
  }

  if (auth.status === "denied") {
    return <AccessDenied reason={auth.reason} onSignOut={() => signOut()} />;
  }

  return <>{children}</>;
}
```

`LoadingSkeleton` — full-page centered spinner with "Verifying access..." text.

`AccessDenied` — full-page component with:
- "You don't have permission to access the admin dashboard."
- A "Sign out" button.
- A link back to the storefront.

With separate Clerk instances, the `AccessDenied` state should be extremely rare — it only occurs if:
- A Convex record has `role: "customer"` but an Admin Clerk account somehow exists (misconfiguration).
- The user record hasn't been created yet (transient — resolves once `AdminUserSync` completes).

#### Files changed

| File | Action |
|------|--------|
| `apps/admin/src/hooks/useAdminAuth.ts` | Create |
| `apps/admin/src/components/auth/AdminAuthGate.tsx` | Create |
| `apps/admin/src/components/auth/AccessDenied.tsx` | Create |
| `apps/admin/src/components/auth/LoadingSkeleton.tsx` | Create |

---

### Phase 3 — Custom Sign-In Page + Route Groups

**Goal:** Replace the Clerk hosted sign-in redirect with an in-app sign-in page. Structure routes so the sign-in page bypasses the auth gate.

#### 3.1 Route group structure

```
apps/admin/src/app/
├── layout.tsx                         (root: ClerkProvider + ConvexClientProvider only)
├── (auth)/
│   └── sign-in/[[...sign-in]]/
│       └── page.tsx                   (public — Clerk <SignIn />, no gate)
└── (dashboard)/
    ├── layout.tsx                     (AdminUserSync + AdminAuthGate + admin shell)
    └── page.tsx                       (dashboard home)
```

The root layout provides only providers. The `(dashboard)` group layout wraps everything in the auth gate. The `(auth)` group has no additional layout — the sign-in page renders without admin chrome.

#### 3.2 Root layout (final shape)

```tsx
// apps/admin/src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ConvexClientProvider } from "@repo/convex";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: { template: "%s | Admin", default: "Admin Dashboard" },
  description: "Store administration",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <ClerkProvider>
          <ConvexClientProvider>
            {children}
          </ConvexClientProvider>
        </ClerkProvider>
      </body>
    </html>
  );
}
```

`ClerkProvider` reads `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` from the admin app's `.env.local` — these are the **Admin Clerk (App B)** keys. No code change; the provider is key-driven.

#### 3.3 Dashboard group layout (final shape)

```tsx
// apps/admin/src/app/(dashboard)/layout.tsx
import { AdminUserSync } from "@/components/auth/AdminUserSync";
import { AdminAuthGate } from "@/components/auth/AdminAuthGate";
import { AdminHeader } from "@/components/layout/AdminHeader";

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <AdminUserSync />
      <AdminAuthGate>
        <AdminHeader />
        <main>{children}</main>
      </AdminAuthGate>
    </>
  );
}
```

`AdminUserSync` is a sibling of `AdminAuthGate`, not nested inside it. The sync mutation fires in parallel with the role query. If the record doesn't exist yet, `users.store` creates it (with role from `publicMetadata`), and `users.current` re-fires reactively via Convex subscription.

#### 3.4 Sign-in page

```tsx
// apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <SignIn />
    </main>
  );
}
```

Since signup is disabled on Admin Clerk, the `<SignIn />` component will only show sign-in fields — no "Create account" link.

#### 3.5 Update middleware to exclude sign-in route

```typescript
// apps/admin/src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher(["/sign-in(.*)"]);

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};
```

#### 3.6 Set admin environment variables

Update `apps/admin/.env.local`:

```bash
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_admin_xxxx    # App B keys
CLERK_SECRET_KEY=sk_live_admin_xxxx                     # App B keys
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000
```

#### Files changed

| File | Action |
|------|--------|
| `apps/admin/src/app/layout.tsx` | Edit — remove dead imports, providers only |
| `apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx` | Create |
| `apps/admin/src/app/(dashboard)/layout.tsx` | Create — auth gate + shell |
| `apps/admin/src/app/(dashboard)/page.tsx` | Move from `apps/admin/src/app/page.tsx` |
| `apps/admin/src/app/page.tsx` | Delete (moved) |
| `apps/admin/src/middleware.ts` | Edit — add `createRouteMatcher` exclusion |
| `apps/admin/.env.local` | Edit — App B Clerk keys + sign-in URL vars |
| `apps/admin/.env.example` | Edit — document all new vars |

---

### Phase 4 — Admin Layout Shell (Header with UserButton)

**Goal:** Add a persistent header with the Clerk `<UserButton />` so admins can manage their session.

#### 4.1 Create admin header

Create `apps/admin/src/components/layout/AdminHeader.tsx`:

A client component that renders:
- App title / logo ("The Pet Loft — Admin")
- Clerk `<UserButton />` for sign-out and profile management

This is a minimal header. It will be expanded as admin pages are built (sidebar, breadcrumbs, etc.).

#### Files changed

| File | Action |
|------|--------|
| `apps/admin/src/components/layout/AdminHeader.tsx` | Create |

---

### Phase 5 — `super_admin` vs `admin` Permission Differentiation

**Goal:** Prepare for granular permissions where `super_admin` can do things `admin` cannot (e.g., invite other admins, change app settings).

#### 5.1 Create `RequireRole` component

```typescript
"use client";

import { useAdminAuth } from "@/hooks/useAdminAuth";

interface RequireRoleProps {
  role: "super_admin";
  fallback?: React.ReactNode;
  children: React.ReactNode;
}

export function RequireRole({ role, fallback = null, children }: RequireRoleProps) {
  const auth = useAdminAuth();
  if (auth.status !== "authorized") return null;
  if (auth.role !== role) return <>{fallback}</>;
  return <>{children}</>;
}
```

#### 5.2 Add `requireSuperAdmin` helper to Convex

Add to `convex/model/users.ts`:

```typescript
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;
}
```

#### Files changed

| File | Action |
|------|--------|
| `apps/admin/src/components/auth/RequireRole.tsx` | Create |
| `convex/model/users.ts` | Edit — add `requireSuperAdmin` |

---

### Phase 6 — Admin Invitation System

**Goal:** Enable super-admins to invite staff members via the Clerk Backend SDK. This is the only way to create admin accounts since signup is disabled on Clerk App B.

#### 6.1 Add `assertSuperAdmin` internal query

Add to `convex/users.ts`:

```typescript
export const assertSuperAdmin = internalQuery({
  args: {},
  handler: async (ctx) => {
    return await Users.requireSuperAdmin(ctx);
  },
});
```

This wraps the existing `requireSuperAdmin` helper as an internal query that actions can call via `ctx.runQuery`. It keeps authorization logic centralized in `model/users.ts` rather than duplicating role checks inline.

#### 6.2 Create admin invitation action

Create `convex/adminInvitations.ts`:

```typescript
"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 },
    });
  },
});
```

The action delegates authorization to `assertSuperAdmin` via `ctx.runQuery` — this runs in a transaction with full `ctx.db` access, reusing the centralized `requireSuperAdmin` helper from `model/users.ts`. No inline role-check duplication.

#### 6.3 Admin invitation flow

```
Super-admin → Admin UI → "Invite Staff Member" form
                ↓
     inviteAdmin action (assertSuperAdmin gate)
                ↓
     Clerk Backend SDK (App B keys) creates invitation
     with redirectUrl: ADMIN_URL/sign-in
                ↓
     Staff receives email with invitation link
                ↓
     Staff clicks link → Clerk invite-only signup (App B)
     → sets password → redirected to /sign-in
                ↓
     Clerk webhook → /clerk-admin-webhook → upsertFromClerk(role: "admin")
     + users.store reads publicMetadata.role → creates with role: "admin"
                ↓
     AdminAuthGate: authorized ✅
```

#### 6.4 Seed the first super-admin

Before the invite system exists, you need at least one `super_admin` user. Two options:

**Option A (recommended for development):** Manually create a user in Admin Clerk Dashboard → set their `publicMetadata` to `{ "role": "super_admin" }`. When they sign in, `users.store` reads this and creates the Convex record with `role: "super_admin"`.

**Option B:** Create the user record directly in the Convex dashboard with `role: "super_admin"` and the correct `externalId` matching the Admin Clerk user.

#### Files changed

| File | Action |
|------|--------|
| `convex/adminInvitations.ts` | Create |

---

## 5. File Inventory

### New files

| File | Phase | Purpose |
|------|-------|---------|
| `apps/admin/src/components/auth/AdminUserSync.tsx` | 1 | Calls `useStoreUserEffect` to sync Clerk → Convex |
| `apps/admin/src/hooks/useAdminAuth.ts` | 2 | Hook returning `loading \| authorized \| denied` state |
| `apps/admin/src/components/auth/AdminAuthGate.tsx` | 2 | Wraps app; blocks non-admin users |
| `apps/admin/src/components/auth/AccessDenied.tsx` | 2 | Full-page "no permission" UI with sign-out button |
| `apps/admin/src/components/auth/LoadingSkeleton.tsx` | 2 | Full-page loading state while auth resolves |
| `apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx` | 3 | Clerk `<SignIn />` component page |
| `apps/admin/src/app/(dashboard)/layout.tsx` | 3 | Dashboard group layout (auth gate + shell) |
| `apps/admin/src/app/(dashboard)/page.tsx` | 3 | Dashboard home (moved from root) |
| `apps/admin/src/components/layout/AdminHeader.tsx` | 4 | Header with app title + `<UserButton />` |
| `apps/admin/src/components/auth/RequireRole.tsx` | 5 | Conditional render by role |
| `convex/adminInvitations.ts` | 6 | Invite staff via Clerk Backend SDK |

### Modified files (Phase 6)

| File | Phase | Change |
|------|-------|--------|
| `convex/users.ts` | 6 | Add `assertSuperAdmin` internal query |

### Modified files

| File | Phase | Change |
|------|-------|--------|
| `convex/auth.config.ts` | 0 | Add second provider (admin JWT issuer) |
| `convex/http.ts` | 0 | Add `/clerk-admin-webhook` route |
| `convex/users.ts` | 0 | `upsertFromClerk`: add optional `role` arg. `store`: read `public_metadata.role` |
| `apps/admin/src/app/layout.tsx` | 3 | Simplify to providers only |
| `apps/admin/src/middleware.ts` | 3 | Add `createRouteMatcher` exclusion for `/sign-in` |
| `apps/admin/.env.local` | 3 | App B Clerk keys + sign-in URL vars |
| `apps/admin/.env.example` | 3 | Document all new vars |
| `convex/model/users.ts` | 5 | Add `requireSuperAdmin` helper |

### Deleted files

| File | Phase | Reason |
|------|-------|--------|
| `apps/admin/src/app/page.tsx` | 3 | Moved to `(dashboard)/page.tsx` |

---

## 6. Auth Flow Walkthroughs

### Scenario A: Invited admin signs in for the first time

```
1. Super-admin invites staff@petloft.com via admin UI
2. Clerk Backend SDK (App B) sends invitation email
3. Staff clicks link → lands on admin.petloft.com/sign-in
4. Staff creates password (Clerk invite-only signup)
5. Clerk redirects to / (NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL)
6. Middleware: signed in (valid App B JWT) → pass through
7. (dashboard)/layout.tsx renders:
   a. AdminUserSync calls users.store
      - No existing record → reads publicMetadata.role = "admin"
      - Creates Convex user with role: "admin"
   b. AdminAuthGate calls users.current:
      - user === undefined → show LoadingSkeleton (briefly)
      - user.role === "admin" → render children
8. Dashboard page renders ✅
```

### Scenario B: Random person finds admin URL

```
1. Person visits admin.petloft.com
2. Middleware: not signed in → redirect to /sign-in
3. Person sees Clerk <SignIn /> — no "Create account" option (signup disabled)
4. Person cannot proceed — no account exists in Clerk App B
5. Dead end ✅
```

### Scenario C: Customer tries to use storefront credentials on admin

```
1. Customer visits admin.petloft.com/sign-in
2. Enters their storefront email/password
3. Clerk App B rejects — those credentials don't exist in App B's database
4. "Invalid email or password" error
5. Dead end ✅
```

### Scenario D: Admin already signed in (session exists)

```
1. Admin visits admin.petloft.com
2. Middleware: signed in (valid App B JWT) → pass through
3. (dashboard)/layout.tsx renders:
   a. AdminUserSync: user already exists, no-op
   b. AdminAuthGate: users.current returns admin → render children
4. Dashboard page renders immediately ✅
```

### Scenario E: Admin is demoted (role changed in Convex)

```
1. Admin is using the dashboard
2. Super-admin changes their role in Convex to "customer"
3. Convex real-time subscription on users.current fires
4. useAdminAuth returns { status: "denied", reason: "not_admin" }
5. AdminAuthGate immediately renders AccessDenied
6. All open tabs update reactively ✅
```

---

## 7. Security Analysis

| Attack vector | Mitigation |
|---------------|------------|
| Random person finds admin URL | **Signup disabled** on Admin Clerk (App B) — no account can be created |
| Customer uses storefront credentials | Clerk App B is a **separate user database** — storefront credentials are invalid |
| Storefront JWT presented to Convex admin function | `auth.config.ts` trusts both issuers, but `requireAdmin` checks the role in the Convex `users` table — customer records have `role: "customer"` → rejected |
| Direct Convex API call from browser console | Every admin mutation/query calls `requireAdmin(ctx)` — cannot bypass |
| JWT spoofing | Clerk JWT validation via `auth.config.ts` — Convex verifies issuer domain cryptographically |
| Role escalation via `users.store` | Reads `publicMetadata.role` only; `publicMetadata` can only be set server-side via Clerk Backend SDK (requires `CLERK_ADMIN_SECRET_KEY`) — not settable by the user |
| Brute force sign-in | Attacker must know a valid staff email. Clerk rate-limits sign-in attempts. |
| XSS accessing admin data | Even if `AdminAuthGate` is bypassed via devtools, Convex `requireAdmin` rejects all queries/mutations server-side |

**Defense-in-depth summary:**

```
Layer 0: Clerk App B — signup disabled, invite-only
Layer 1: Edge middleware — valid App B JWT required
Layer 2: Convex requireAdmin — role check on every function
Layer 3: AdminAuthGate — UX safety net (client-side)
```

Every layer independently blocks unauthorized access. Storefront credentials are cryptographically useless on the admin app.

---

## 8. Testing Checklist

| # | Test case | Expected result |
|---|-----------|-----------------|
| 1 | Unauthenticated user visits `/` | Redirected to `/sign-in` |
| 2 | Unauthenticated user visits `/sign-in` | Sign-in page renders (no redirect loop, no "Create account" link) |
| 3 | Random person tries to sign up on admin | Not possible — no signup UI, no signup API |
| 4 | Customer uses storefront email/password on admin | "Invalid email or password" — credentials don't exist in App B |
| 5 | Invited admin signs in for the first time | Convex record created with `role: "admin"`, dashboard renders |
| 6 | Invited super-admin signs in | Convex record created with `role: "super_admin"`, dashboard renders |
| 7 | Admin already signed in, refreshes page | Dashboard renders immediately |
| 8 | Admin clicks `UserButton` → Sign out | Redirected to `/sign-in` |
| 9 | Super-admin invites new staff via admin UI | Invitation email sent; new staff can sign in |
| 10 | Admin demoted to customer in Convex dashboard | All open tabs reactively show AccessDenied |
| 11 | Sign-in page has no admin shell chrome | Clean sign-in UI without header/sidebar |
| 12 | Storefront auth still works after `auth.config.ts` change | Storefront sign-in/sign-up, cart merge, checkout — all pass |
| 13 | Storefront `users.store` still defaults to `"customer"` | No `publicMetadata.role` in storefront JWT → role: "customer" |

---

## 9. Environment Variables

### Admin app — `apps/admin/.env.local`

| Variable | Value | Purpose |
|----------|-------|---------|
| `NEXT_PUBLIC_CONVEX_URL` | `https://your-project.convex.cloud` | Same Convex deployment |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | `pk_..._admin_xxxx` | **App B** publishable key |
| `CLERK_SECRET_KEY` | `sk_..._admin_xxxx` | **App B** secret key |
| `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | `/sign-in` | In-app sign-in route |
| `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` | `/` | Post-sign-in redirect |
| `NEXT_PUBLIC_APP_URL` | `http://localhost:3001` | Admin URL |
| `NEXT_PUBLIC_STOREFRONT_URL` | `http://localhost:3000` | For "Go to storefront" links |

### Storefront app — `apps/storefront/.env.local`

No changes. Keeps using the original Clerk App A keys.

### Convex dashboard environment variables

| Variable | Value | Purpose |
|----------|-------|---------|
| `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` | `https://<storefront-clerk-domain>` | Renamed from `CLERK_JWT_ISSUER_DOMAIN` |
| `CLERK_ADMIN_JWT_ISSUER_DOMAIN` | `https://<admin-clerk-domain>` | New — admin Clerk JWT issuer |
| `CLERK_WEBHOOK_SECRET` | `whsec_...` (storefront) | Unchanged |
| `CLERK_ADMIN_WEBHOOK_SECRET` | `whsec_...` (admin) | New — admin Clerk webhook signing secret |
| `CLERK_ADMIN_SECRET_KEY` | `sk_..._admin_xxxx` | New — for Clerk Backend SDK (invite flow) |
| `ADMIN_URL` | `https://admin.petloft.com` | New — redirect URL for invite emails |
| `STRIPE_SECRET_KEY` | Unchanged | — |
| `STRIPE_WEBHOOK_SECRET` | Unchanged | — |
| `SHIPPO_API_KEY` | Unchanged | — |

---

## 10. Dependency Audit

| Dependency | Already installed | Used for | Notes |
|------------|-------------------|----------|-------|
| `@clerk/nextjs` | Yes (`apps/admin`) | `ClerkProvider`, `SignIn`, `UserButton`, `clerkMiddleware`, `createRouteMatcher` | No version change |
| `convex/react` | Yes (via `@repo/convex`) | `useConvexAuth`, `useQuery`, `useMutation` | No version change |
| `@repo/convex` | Yes | `ConvexClientProvider` — shared, context-dependent | **No changes to package** |
| `@clerk/backend` | Yes (`convex/`) | Already imported in `convex/http.ts` for `WebhookEvent` type. Phase 6 uses `createClerkClient` for invitations. | No new install needed |

---

## 11. Implementation Order & Effort Estimates

| Phase | Description | Effort | Dependencies |
|-------|-------------|--------|--------------|
| **0** | Clerk App B setup + Convex dual-provider + admin webhook + role-aware `users.store` | ~1.5 hr | None |
| **1** | Admin user sync component | ~15 min | Phase 0 |
| **2** | Admin auth gate + hook + denial page | ~1 hr | Phase 1 |
| **3** | Custom sign-in page + middleware + route groups | ~45 min | Phase 2 |
| **4** | Admin layout shell (header) | ~30 min | Phase 3 |
| **5** | `RequireRole` + `requireSuperAdmin` | ~20 min | Phase 2 |
| **6** | Admin invitation system | ~1 hr | Phase 5 |

Phases 0–4 are sequential. Phases 5 and 6 can be deferred but are needed before launch.

Total estimated effort: **~5 hours** for a senior engineer.

---

## 12. Seeding the First Super-Admin

This is a one-time bootstrap step required before the invite system (Phase 6) works.

1. Create a user in Admin Clerk Dashboard (App B) manually.
2. In Clerk Dashboard, edit that user → set **Public Metadata** to `{ "role": "super_admin" }`.
3. Sign in to the admin app with that account.
4. `users.store` reads `publicMetadata.role = "super_admin"` → creates Convex record with `role: "super_admin"`.
5. `AdminAuthGate` confirms role → dashboard renders.
6. This user can now use the invite system (Phase 6) to onboard other admins.

---

## 13. Future Considerations

| Item | Notes |
|------|-------|
| **Same-email policy** | Decide whether admin staff can use the same email as their storefront customer account. Clerk treats them as separate users (separate databases). Either works; enforce domain restriction in `inviteAdmin` if desired. |
| **Audit logging** | Log admin actions (who changed what, when) in a Convex `auditLogs` table. |
| **Session timeout** | Consider reducing session lifetime for admin users in Admin Clerk Dashboard → Sessions. |
| **Multi-tab sync** | Convex real-time subscriptions ensure role changes propagate across tabs automatically. If an admin is demoted, all open tabs reactively show AccessDenied. |
| **Admin user management page** | A super-admin page to view staff, revoke access (delete from Clerk App B via Backend SDK), and re-invite. |
