From e03b291ca63012649855bc7b696f2f04fb81920e Mon Sep 17 00:00:00 2001 From: Brian Nelson Date: Wed, 28 Jan 2026 12:13:04 -0500 Subject: [PATCH] started on adding openid login --- .env.example | 12 ++ app/api/auth/[...nextauth]/route.ts | 42 +++++ components/forms/LoginForm.tsx | 147 +++++++++++++----- components/icons/KeyIcon.tsx | 9 ++ components/icons/OpenIDIcon.tsx | 7 + components/vendor/SidebarCard.tsx | 10 +- .../migration.sql | 6 + prisma/schema.prisma | 5 + 8 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 components/icons/KeyIcon.tsx create mode 100644 components/icons/OpenIDIcon.tsx create mode 100644 prisma/migrations/20260128164514_add_oidc_user/migration.sql diff --git a/.env.example b/.env.example index 27c1d49..2046c77 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,18 @@ NEXTAUTH_SECRET=your-secret NEXTAUTH_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000 +# OIDC Configuration (optional) +OIDC_ENABLED=true +OIDC_PROVIDER_NAME="Company SSO" # Display name for the button +OIDC_CLIENT_ID=your-oidc-client-id +OIDC_CLIENT_SECRET=your-oidc-client-secret +OIDC_ISSUER=https://your-oidc-provider.com/auth/realms/your-realm + +# Optional: Role mapping +OIDC_ROLE_CLAIM=roles +OIDC_ADMIN_ROLES=admin,superuser +OIDC_PLANNER_ROLES=planner,editor + # SMTP (optional) SMTP_HOST=smtpserver SMTP_PORT=587 diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 8a35123..e2f3d17 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,6 @@ import NextAuth, { type NextAuthOptions } from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' +import KeycloakProvider from 'next-auth/providers/keycloak' import { PrismaClient } from '@prisma/client' import bcrypt from 'bcrypt' @@ -44,6 +45,31 @@ export const authOptions: NextAuthOptions = { } }, }), + ...(process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET && process.env.OIDC_ISSUER + ? [ + { + id: 'oidc', + name: process.env.OIDC_PROVIDER_NAME || 'PocketID', + type: 'oauth' as const, // Use const assertion here + wellKnown: `${process.env.OIDC_ISSUER}/.well-known/openid-configuration`, + authorization: { params: { scope: 'openid email profile' } }, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + idToken: true, + checks: ['pkce', 'state'] as any, + profile(profile: any) { + return { + id: profile.sub, + name: profile.name || profile.preferred_username || profile.email, + email: profile.email, + image: profile.picture, + role: mapPocketIDRoleToAppRole(profile), + } + }, + } as any + ] + : [] + ), ], session: { strategy: 'jwt', @@ -69,5 +95,21 @@ export const authOptions: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, } +function mapPocketIDRoleToAppRole(profile: any): "COUPLE" | "PLANNER" | "GUEST" { + const roles = profile.roles || + profile.groups || + profile.realm_access?.roles || + profile.resource_access?.[process.env.OIDC_CLIENT_ID || '']?.roles || + [] + + if (roles.includes('admin') || roles.includes('planner')) { + return 'PLANNER' + } else if (roles.includes('couple') || roles.includes('user')) { + return 'COUPLE' + } else { + return 'GUEST' + } +} + const handler = NextAuth(authOptions) export { handler as GET, handler as POST } diff --git a/components/forms/LoginForm.tsx b/components/forms/LoginForm.tsx index 6cf624e..ef34c40 100644 --- a/components/forms/LoginForm.tsx +++ b/components/forms/LoginForm.tsx @@ -1,18 +1,42 @@ 'use client' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Label } from '../ui/label' import { Input } from '../ui/input' import Link from 'next/link' import { Button } from '../ui/button' -import { signIn } from 'next-auth/react' +import { getProviders, signIn } from 'next-auth/react' import { useRouter } from 'next/navigation' +import { Separator } from '../ui/separator' +import KeyIcon from '../icons/KeyIcon' +import OpenIDIcon from '../icons/OpenIDIcon' + +interface Provider { + id: string + name: string +} export default function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState('') const [error, setError] = useState(''); + const [providers, setProviders] = useState([]) const router = useRouter(); + useEffect(() => { + // Fetch available auth providers + const fetchProviders = async () => { + const authProviders = await getProviders() + if (authProviders) { + // Filter out credentials provider (email/password) + const filtered = Object.values(authProviders).filter( + p => p.id !== 'credentials' + ) + setProviders(filtered) + } + } + fetchProviders() + }, []) + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -30,48 +54,87 @@ export default function LoginForm() { router.push('/dashboard') } } + + const handleOidcLogin = (providerId: string) => { + signIn(providerId, { callbackUrl: '/dashboard' }) + } + return ( -
-
-
- - setEmail(e.target.value)} - required - /> -
-
-
- - - Forgot your password? - +
+ {/* OIDC Providers Section + {providers.length > 0 && ( + <> +
+

+ Sign in with +

+
+ {providers.map((provider) => ( + + ))} +
+
+ +
+ Or continue with email +
+ + )} */} + + {/* Email/Password Form */} + +
+
+ + setEmail(e.target.value)} + required + /> +
+
+
+ + + Forgot your password? + +
+ setPassword(e.target.value)} + required + /> +
+
+ + {error &&

{error}

}
- setPassword(e.target.value)} - required - />
-
- - {error &&

{error}

} -
-
- + +
) } diff --git a/components/icons/KeyIcon.tsx b/components/icons/KeyIcon.tsx new file mode 100644 index 0000000..24ed7c7 --- /dev/null +++ b/components/icons/KeyIcon.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +export default function KeyIcon() { + return ( + + + + ) +} diff --git a/components/icons/OpenIDIcon.tsx b/components/icons/OpenIDIcon.tsx new file mode 100644 index 0000000..d2b3646 --- /dev/null +++ b/components/icons/OpenIDIcon.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function OpenIDIcon() { + return ( + OpenID + ) +} diff --git a/components/vendor/SidebarCard.tsx b/components/vendor/SidebarCard.tsx index 8b743a6..0cda686 100644 --- a/components/vendor/SidebarCard.tsx +++ b/components/vendor/SidebarCard.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' interface SidebarCardsProps { description?: string | null - events: Array<{ + events?: Array<{ id: string name: string date: Date | null @@ -44,7 +44,7 @@ export function SidebarCards({ description, events, timeline, vendorId }: Sideba )} {/* Associated Events */} - {events.length > 0 && ( + {events && events.length > 0 && ( Associated Events @@ -108,7 +108,7 @@ export function SidebarCards({ description, events, timeline, vendorId }: Sideba
Final Payment Due - {formatDate(timeline.finalPaymentDue)} + {formatDate(timeline.finalPaymentDue)}
)} @@ -124,7 +124,7 @@ export function SidebarCards({ description, events, timeline, vendorId }: Sideba diff --git a/prisma/migrations/20260128164514_add_oidc_user/migration.sql b/prisma/migrations/20260128164514_add_oidc_user/migration.sql new file mode 100644 index 0000000..cc91e48 --- /dev/null +++ b/prisma/migrations/20260128164514_add_oidc_user/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "authProvider" TEXT, +ADD COLUMN "providerId" TEXT; + +-- CreateIndex +CREATE INDEX "User_authProvider_providerId_idx" ON "User"("authProvider", "providerId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 21a8728..b1670ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,7 +17,12 @@ model User { events Event[] @relation("EventCreator") createdAt DateTime @default(now()) + authProvider String? // 'credentials', 'oidc', 'google', etc. + providerId String? // OIDC subject ID + FileUpload FileUpload[] + + @@index([authProvider, providerId]) } enum Role {