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.
This commit is contained in:
62
portal/src/app/api/auth/error/page.tsx
Normal file
62
portal/src/app/api/auth/error/page.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center max-w-md mx-auto p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-4">Authentication Error</h1>
|
||||||
|
<p className="text-gray-400 mb-2">{errorMessage}</p>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-gray-500 mb-6">Error code: {error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors inline-block"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/api/auth/signin"
|
||||||
|
className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors inline-block"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthErrorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||||
|
<p className="text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<AuthErrorContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,41 @@
|
|||||||
import { getServerSession } from 'next-auth';
|
'use client';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
import { authOptions } from '@/lib/auth';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
import Dashboard from '@/components/Dashboard';
|
import Dashboard from '@/components/Dashboard';
|
||||||
|
|
||||||
export default async function Home() {
|
export default function Home() {
|
||||||
const session = await getServerSession(authOptions);
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
if (!session) {
|
if (status === 'loading') {
|
||||||
redirect('/api/auth/signin');
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||||
|
<p className="text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center max-w-md mx-auto p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Portal</h1>
|
||||||
|
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||||
|
<button
|
||||||
|
onClick={() => signIn()}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-gray-500 mt-4">
|
||||||
|
Development mode: Use any email/password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Dashboard />;
|
return <Dashboard />;
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { getServerSession } from 'next-auth';
|
'use client';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
import { authOptions } from '@/lib/auth';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
import VMList from '@/components/vms/VMList';
|
import VMList from '@/components/vms/VMList';
|
||||||
|
|
||||||
export default async function VMsPage() {
|
export default function VMsPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
if (!session) {
|
if (status === 'loading') {
|
||||||
redirect('/api/auth/signin');
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||||
|
<p className="text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||||
|
<div className="text-center max-w-md mx-auto p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-4">Authentication Required</h1>
|
||||||
|
<p className="text-gray-400 mb-6">Please sign in to view virtual machines</p>
|
||||||
|
<button
|
||||||
|
onClick={() => signIn()}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,22 +1,72 @@
|
|||||||
import { NextAuthOptions } from 'next-auth';
|
import { NextAuthOptions } from 'next-auth';
|
||||||
import KeycloakProvider from 'next-auth/providers/keycloak';
|
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 = {
|
export const authOptions: NextAuthOptions = {
|
||||||
providers: [
|
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'}`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
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) {
|
if (account) {
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
token.refreshToken = account.refresh_token;
|
token.refreshToken = account.refresh_token;
|
||||||
token.idToken = account.id_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
|
// Extract roles from Keycloak token
|
||||||
if (profile && 'realm_access' in profile) {
|
if (profile && 'realm_access' in profile) {
|
||||||
const realmAccess = profile.realm_access as { roles?: string[] };
|
const realmAccess = profile.realm_access as { roles?: string[] };
|
||||||
@@ -29,16 +79,24 @@ export const authOptions: NextAuthOptions = {
|
|||||||
if (token) {
|
if (token) {
|
||||||
session.accessToken = token.accessToken as string;
|
session.accessToken = token.accessToken as string;
|
||||||
session.roles = token.roles 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;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/api/auth/signin',
|
signIn: '/api/auth/signin',
|
||||||
|
error: '/api/auth/error',
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
strategy: 'jwt',
|
strategy: 'jwt',
|
||||||
maxAge: 24 * 60 * 60, // 24 hours
|
maxAge: 24 * 60 * 60, // 24 hours
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user