diff --git a/docs/brainstorms/2026-04-06-sso-bridge-requirements.md b/docs/brainstorms/2026-04-06-sso-bridge-requirements.md new file mode 100644 index 0000000..9ce4f87 --- /dev/null +++ b/docs/brainstorms/2026-04-06-sso-bridge-requirements.md @@ -0,0 +1,68 @@ +--- +date: 2026-04-06 +topic: sso-bridge +--- + +# SSO Bridge: Microsoft Entra ID for Production + +## Problem Frame + +Real producers and artists need to log into the HP CG Production Tracker using their Oliver Agency Microsoft accounts. The auth infrastructure exists (next-auth v5 with Microsoft Entra ID provider in `src/lib/auth.ts`) but is unconfigured — env vars are empty placeholders. The database has 33 seeded users with real `@oliver.agency` emails. When a real person signs in via SSO, their Account must link to the existing seeded User record (preserving role, skills, assignments) rather than creating a duplicate. Users who authenticate but aren't in the seed (e.g., someone from a different team) need a graceful experience instead of a raw 403. + +## Requirements + +**SSO Configuration** +- R1. Configure Microsoft Entra ID as the sole SSO provider for the initial rollout. Remove or disable the Google provider to avoid confusion. +- R2. Tenant-lock the Entra ID provider to the Oliver Agency tenant ID. Users from other Microsoft tenants must not be able to create accounts. + +**Seed-to-SSO User Linking** +- R3. When a user signs in via SSO, the PrismaAdapter must match on email and link the new Account/Session to the existing seeded User record. Verify this works correctly — the seeded users have no Account rows, only User rows with matching emails. +- R4. If a user signs in whose email does not match any existing User record, create a new User with `organizationId: null` (do not auto-assign to the org). This covers edge cases where someone outside the known roster authenticates. + +**Organization Auto-Assignment** +- R5. Add a `signIn` or post-authentication callback that checks if a newly created user's email domain matches an Organization's `domain` field. If it matches, auto-set `organizationId` on the User record. For seeded users who already have `organizationId` set, this is a no-op. + +**Limbo Page** +- R6. Users who authenticate via SSO but have no `organizationId` must see a branded "pending setup" page instead of a JSON 403 error. The page should display their name/email and a message like "Your account is pending setup — contact your admin." +- R7. The limbo page must be accessible to authenticated users only (not public). Unauthenticated users still redirect to login. + +**Production Safety** +- R8. Guard `DEV_BYPASS_AUTH` against production use: refuse to honor the flag when `NODE_ENV=production`. Log a warning if the flag is detected in production. + +## Success Criteria + +- A seeded user (e.g., `bohdana.phillips@oliver.agency`) can sign in via Microsoft SSO and immediately see the dashboard with their existing role, skills, and org membership intact. +- A non-seeded `@oliver.agency` user who signs in is auto-assigned to the org and sees the dashboard. +- A user from a different Microsoft tenant cannot sign in. +- A user who authenticates but has no org sees the limbo page, not a 403. +- `DEV_BYPASS_AUTH=true` has no effect when `NODE_ENV=production`. + +## Scope Boundaries + +- **Not in scope:** Google SSO — disabled for now, can be re-enabled later. +- **Not in scope:** Invitation flow — the Invitation model exists but wiring it up is separate work. +- **Not in scope:** Role assignment during SSO — users get whatever role is already in their seed record, or ARTIST by default for new users. +- **Not in scope:** Azure AD app registration — the user will configure this in Azure portal and provide the tenant ID, client ID, and client secret. + +## Key Decisions + +- **Microsoft-only for initial rollout:** Simplifies configuration and avoids needing Google domain restriction. Google can be re-enabled later. +- **Auto-assign by domain, not by invitation:** For a small team where everyone is `@oliver.agency`, domain matching is simpler than requiring invitations. The `Organization.domain` field already exists for this purpose. +- **Limbo page over auto-rejection:** Gracefully handling unknown users builds trust during rollout. An admin can later assign them to the org manually. + +## Dependencies / Assumptions + +- The user will register an Azure AD application in the Oliver Agency tenant and provide: tenant ID, client ID, client secret. +- The `Organization.domain` field is set to `oliver.agency` (done by the clean-slate toolkit). +- PrismaAdapter v5 links SSO accounts to existing users by email match. This is the documented behavior but must be verified with the actual seeded data. + +## Outstanding Questions + +### Deferred to Planning +- [Affects R3][Needs research] Verify PrismaAdapter's exact behavior when a user with a matching email but no Account row signs in via SSO. Does it link automatically, or does it create a duplicate User? +- [Affects R5][Technical] Determine the best next-auth callback for org auto-assignment: `signIn` event, `createUser` event, or `session` callback. +- [Affects R6][Technical] Determine where the limbo page routing should live: middleware redirect, layout-level check, or a dedicated route. + +## Next Steps + +-> `/ce:plan` for structured implementation planning diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 4e3c906..2d66be7 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,11 +1,27 @@ import { Suspense } from "react"; +import { redirect } from "next/navigation"; import { Toaster } from "sonner"; import { Sidebar, MobileSidebar } from "@/components/layout/sidebar"; import { Topbar } from "@/components/layout/topbar"; import { QueryProvider } from "@/components/query-provider"; import { LazyCommandPalette } from "@/components/lazy-command-palette"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; -export default function AppLayout({ children }: { children: React.ReactNode }) { +export default async function AppLayout({ children }: { children: React.ReactNode }) { + // Skip org check in dev bypass mode + if (!(process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production")) { + const session = await auth(); + if (session?.user?.id) { + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { organizationId: true }, + }); + if (!user?.organizationId) { + redirect("/pending"); + } + } + } return (
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index fc83799..40fa25b 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -56,22 +56,6 @@ export default async function LoginPage() {
-
{ - "use server"; - await signIn("google", { redirectTo: "/dashboard" }); - }} - > - -
-
{ "use server"; diff --git a/src/app/(auth)/pending/page.tsx b/src/app/(auth)/pending/page.tsx new file mode 100644 index 0000000..98d06b1 --- /dev/null +++ b/src/app/(auth)/pending/page.tsx @@ -0,0 +1,109 @@ +import { auth, signOut } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { Button } from "@/components/ui/button"; + +export default async function PendingPage() { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + // If user already has an org, redirect to dashboard + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { organizationId: true }, + }); + + if (user?.organizationId) { + redirect("/dashboard"); + } + + return ( +
+ {/* Left panel — green brand block */} +
+
+

+ Oliver Agency +

+
+
+

+ HP CG
Production
Tracker +

+

+ Pipeline management for CG production +

+
+
+

+ Brandtech Group +

+
+
+ + {/* Right panel — pending message */} +
+
+ {/* Mobile wordmark */} +
+

+ HP CG Production Tracker +

+

+ Oliver Agency +

+
+ +
+ 👋 +
+ +

+ Account Pending Setup +

+ +

+ Welcome, {session.user.name || session.user.email}. + Your sign-in was successful, but your account hasn't been added to an organization yet. +

+ +

+ Contact your production lead to get set up. +

+ +
+

+ Signed in as +

+

{session.user.email}

+
+ + { + "use server"; + await signOut({ redirectTo: "/login" }); + }} + className="mt-6" + > + + + +
+

+ © {new Date().getFullYear()} Oliver Agency · Brandtech Group +

+
+
+
+
+ ); +} diff --git a/src/lib/api-utils.ts b/src/lib/api-utils.ts index f5c883b..7116cdc 100644 --- a/src/lib/api-utils.ts +++ b/src/lib/api-utils.ts @@ -2,8 +2,8 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; export async function getAuthSession() { - // Dev bypass: return a mock session pointing to the seeded dev user - if (process.env.DEV_BYPASS_AUTH === "true") { + // Dev bypass: return a mock session pointing to the seeded dev user (never in production) + if (process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production") { const devUserId = process.env.DEV_USER_ID ?? "dev-user-001"; return { session: { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index aeef523..20cc9ab 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,4 @@ import NextAuth from "next-auth"; -import Google from "next-auth/providers/google"; import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { prisma } from "@/lib/prisma"; @@ -8,19 +7,45 @@ import type { Role } from "@/generated/prisma/client"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), providers: [ - Google({ - clientId: process.env.AUTH_GOOGLE_ID, - clientSecret: process.env.AUTH_GOOGLE_SECRET, - }), MicrosoftEntraID({ clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, issuer: `https://login.microsoftonline.com/${process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID}/v2.0`, + // Safe for Entra ID — Microsoft verifies organizational emails. + // Required to link SSO accounts to pre-seeded User records by email match. + allowDangerousEmailAccountLinking: true, }), ], session: { strategy: "database", }, + events: { + async signIn({ user }) { + if (!user.id || !user.email) return; + + // Auto-assign organization by email domain match + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { organizationId: true, email: true }, + }); + + if (dbUser?.organizationId || !dbUser?.email) return; + + const domain = dbUser.email.split("@")[1]; + if (!domain) return; + + const org = await prisma.organization.findFirst({ + where: { domain }, + }); + + if (org) { + await prisma.user.update({ + where: { id: user.id }, + data: { organizationId: org.id }, + }); + } + }, + }, callbacks: { async session({ session, user }) { // Fetch user with role and org from database diff --git a/src/middleware.ts b/src/middleware.ts index c42183a..25dbe35 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,13 +2,14 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export function middleware(request: NextRequest) { - // Dev bypass: skip all auth checks for local testing - if (process.env.DEV_BYPASS_AUTH === "true") { + // Dev bypass: skip all auth checks for local testing (never in production) + if (process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production") { return NextResponse.next(); } const { pathname } = request.nextUrl; const isAuthPage = pathname.startsWith("/login"); + const isPendingPage = pathname.startsWith("/pending"); const isApiAuth = pathname.startsWith("/api/auth"); // Always allow auth API routes @@ -28,6 +29,11 @@ export function middleware(request: NextRequest) { return NextResponse.redirect(new URL("/dashboard", request.url)); } + // Allow authenticated users to access the pending page + if (isPendingPage && isLoggedIn) { + return NextResponse.next(); + } + // Redirect unauthenticated users to login if (!isAuthPage && !isLoggedIn) { return NextResponse.redirect(new URL("/login", request.url));