From e01131efaf98459ece7b67c2b0306736fa7b828b Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sat, 29 Nov 2025 01:54:03 -0800 Subject: [PATCH] Refactor authentication handling in Portal application - Transitioned from server-side session management to client-side using `useSession` from `next-auth/react`. - Added loading and unauthenticated states with user-friendly sign-in prompts in the Home and VMs pages. - Enhanced `auth.ts` to conditionally configure authentication providers based on Keycloak setup, with a fallback to a credentials provider for development mode. - Improved session management to include user details when using credentials provider. --- portal/src/app/api/auth/error/page.tsx | 62 +++++++++++++++++++++ portal/src/app/page.tsx | 42 +++++++++++--- portal/src/app/vms/page.tsx | 39 ++++++++++--- portal/src/lib/auth.ts | 76 +++++++++++++++++++++++--- 4 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 portal/src/app/api/auth/error/page.tsx diff --git a/portal/src/app/api/auth/error/page.tsx b/portal/src/app/api/auth/error/page.tsx new file mode 100644 index 0000000..8a6f55e --- /dev/null +++ b/portal/src/app/api/auth/error/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Suspense } from 'react'; + +function AuthErrorContent() { + const searchParams = useSearchParams(); + const error = searchParams.get('error'); + + const errorMessages: Record = { + Configuration: 'There is a problem with the server configuration.', + AccessDenied: 'You do not have permission to sign in.', + Verification: 'The verification token has expired or has already been used.', + Default: 'An error occurred during authentication.', + }; + + const errorMessage = error && errorMessages[error] + ? errorMessages[error] + : errorMessages.Default; + + return ( +
+
+

Authentication Error

+

{errorMessage}

+ {error && ( +

Error code: {error}

+ )} +
+ + Go Home + + + Try Again + +
+
+
+ ); +} + +export default function AuthErrorPage() { + return ( + +
+
+

Loading...

+
+ + }> + +
+ ); +} diff --git a/portal/src/app/page.tsx b/portal/src/app/page.tsx index d05ad54..5ca9fb7 100644 --- a/portal/src/app/page.tsx +++ b/portal/src/app/page.tsx @@ -1,13 +1,41 @@ -import { getServerSession } from 'next-auth'; -import { redirect } from 'next/navigation'; -import { authOptions } from '@/lib/auth'; +'use client'; + +import { useSession } from 'next-auth/react'; +import { signIn } from 'next-auth/react'; import Dashboard from '@/components/Dashboard'; -export default async function Home() { - const session = await getServerSession(authOptions); +export default function Home() { + const { data: session, status } = useSession(); - if (!session) { - redirect('/api/auth/signin'); + if (status === 'loading') { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (status === 'unauthenticated') { + return ( +
+
+

Welcome to Portal

+

Please sign in to continue

+ +

+ Development mode: Use any email/password +

+
+
+ ); } return ; diff --git a/portal/src/app/vms/page.tsx b/portal/src/app/vms/page.tsx index 728004c..efdf2e7 100644 --- a/portal/src/app/vms/page.tsx +++ b/portal/src/app/vms/page.tsx @@ -1,13 +1,38 @@ -import { getServerSession } from 'next-auth'; -import { redirect } from 'next/navigation'; -import { authOptions } from '@/lib/auth'; +'use client'; + +import { useSession } from 'next-auth/react'; +import { signIn } from 'next-auth/react'; import VMList from '@/components/vms/VMList'; -export default async function VMsPage() { - const session = await getServerSession(authOptions); +export default function VMsPage() { + const { data: session, status } = useSession(); - if (!session) { - redirect('/api/auth/signin'); + if (status === 'loading') { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (status === 'unauthenticated') { + return ( +
+
+

Authentication Required

+

Please sign in to view virtual machines

+ +
+
+ ); } return ( diff --git a/portal/src/lib/auth.ts b/portal/src/lib/auth.ts index f0d2942..024cb1d 100644 --- a/portal/src/lib/auth.ts +++ b/portal/src/lib/auth.ts @@ -1,22 +1,72 @@ import { NextAuthOptions } from 'next-auth'; import KeycloakProvider from 'next-auth/providers/keycloak'; +import CredentialsProvider from 'next-auth/providers/credentials'; + +// Check if Keycloak is configured +const isKeycloakConfigured = + process.env.KEYCLOAK_URL && + process.env.KEYCLOAK_CLIENT_ID && + process.env.KEYCLOAK_CLIENT_SECRET; + +const providers = []; + +// Add Keycloak provider if configured +if (isKeycloakConfigured) { + providers.push( + KeycloakProvider({ + clientId: process.env.KEYCLOAK_CLIENT_ID!, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, + issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}`, + }) + ); +} else { + // Development mode: Use credentials provider + providers.push( + CredentialsProvider({ + name: 'Credentials', + credentials: { + email: { label: 'Email', type: 'email', placeholder: 'dev@example.com' }, + password: { label: 'Password', type: 'password' }, + }, + async authorize(credentials) { + // In development, accept any credentials + if (process.env.NODE_ENV === 'development') { + return { + id: 'dev-user', + email: credentials?.email || 'dev@example.com', + name: 'Development User', + role: 'ADMIN', + }; + } + return null; + }, + }) + ); +} export const authOptions: NextAuthOptions = { - providers: [ - KeycloakProvider({ - clientId: process.env.KEYCLOAK_CLIENT_ID || 'portal-client', - clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '', - issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM || 'master'}`, - }), - ], + providers, callbacks: { - async jwt({ token, account, profile }) { + async redirect({ url, baseUrl }) { + // Prevent redirect loops - only allow redirects within the same origin + if (url.startsWith('/')) return `${baseUrl}${url}`; + if (new URL(url).origin === baseUrl) return url; + return baseUrl; + }, + async jwt({ token, account, profile, user }) { if (account) { token.accessToken = account.access_token; token.refreshToken = account.refresh_token; token.idToken = account.id_token; } + // For credentials provider, add user info + if (user) { + token.id = user.id; + token.email = user.email; + token.name = user.name; + } + // Extract roles from Keycloak token if (profile && 'realm_access' in profile) { const realmAccess = profile.realm_access as { roles?: string[] }; @@ -29,16 +79,24 @@ export const authOptions: NextAuthOptions = { if (token) { session.accessToken = token.accessToken as string; session.roles = token.roles as string[]; + if (token.id) { + session.user = { + ...session.user, + id: token.id as string, + email: token.email as string, + name: token.name as string, + }; + } } return session; }, }, pages: { signIn: '/api/auth/signin', + error: '/api/auth/error', }, session: { strategy: 'jwt', maxAge: 24 * 60 * 60, // 24 hours }, }; -