- code-quality/repository.ts: fix tsErrorMatch[3] → [4] for type field (group 3 is column, 4 is error|warning) - code-quality/repository.ts: fix ESLint regex to make rule brackets optional (not all formatters include them) - code-quality/repository.ts: fix Vitest test count — parse 'Tests' line (individual tests) instead of 'Test Files' (file count); improve Jest regex to capture pass/fail independently - env/repository.ts: replace raw process.env.ENCRYPTION_KEY with config.ENCRYPTION_KEY so the validated default flows through a single source of truth - config.ts: add startup console.warn when CSRF_SECRET or ENCRYPTION_KEY are using insecure defaults - deployments/orchestrator.ts: refactor runDeploymentScript to use try/catch/finally — deployment record is now always written in the finally block, preventing zombie 'running' states if updateDeployment itself throws - auth.tsx: remove dead 'user &&' guard (user is always truthy after the !user check above); remove debug console.log calls, keep console.error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
4.2 KiB
TypeScript
159 lines
4.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, createContext, useContext } from 'react';
|
|
import { getAccessTokenFromStorage, authApi, setAccessToken, setRefreshToken, clearAuthTokens, type MeResponse } from './api';
|
|
import { productId } from './product-config';
|
|
import { useRouter, usePathname } from 'next/navigation';
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
role: string;
|
|
plan: string;
|
|
displayName: string;
|
|
products?: Array<{
|
|
productId: string;
|
|
plan: string;
|
|
role: string;
|
|
}>;
|
|
}
|
|
|
|
interface AuthContextType {
|
|
user: User | null;
|
|
loading: boolean;
|
|
login: (email: string, password: string, productId: string) => Promise<void>;
|
|
logout: () => void;
|
|
isAdmin: boolean;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
|
|
export function useAuth() {
|
|
const context = useContext(AuthContext);
|
|
if (context === undefined) {
|
|
throw new Error('useAuth must be used within an AuthProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
// Skip auth check for login page
|
|
if (pathname === '/login') {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
checkAuth();
|
|
}, [pathname]);
|
|
|
|
useEffect(() => {
|
|
// Skip redirect for login page
|
|
if (pathname === '/login') {
|
|
return;
|
|
}
|
|
if (!loading && mounted && !user) {
|
|
router.push('/login');
|
|
}
|
|
}, [loading, mounted, user, router, pathname]);
|
|
|
|
// If on login page, render children without auth context
|
|
if (pathname === '/login') {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
async function checkAuth() {
|
|
try {
|
|
const token = getAccessTokenFromStorage();
|
|
if (!token) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Add timeout to prevent hanging
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('Auth check timeout')), 10000)
|
|
);
|
|
|
|
const userData = await Promise.race([
|
|
authApi.me(token),
|
|
timeoutPromise
|
|
]) as MeResponse;
|
|
|
|
setUser(userData);
|
|
|
|
// Simplified admin check - just check global admin role
|
|
const globalRole = userData.role;
|
|
const hasAdminAccess = globalRole === 'admin';
|
|
setIsAdmin(hasAdminAccess);
|
|
} catch (error) {
|
|
console.error('Auth check failed:', error);
|
|
clearAuthTokens();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function login(email: string, password: string, productId: string) {
|
|
try {
|
|
const response = await authApi.login({ email, password, productId });
|
|
|
|
setAccessToken(response.accessToken);
|
|
setRefreshToken(response.refreshToken);
|
|
setUser(response.user);
|
|
|
|
// Check if user has admin access (global admin role)
|
|
const hasAdminAccess = response.user.role === 'admin';
|
|
setIsAdmin(hasAdminAccess);
|
|
} catch (error) {
|
|
console.error('Login failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
clearAuthTokens();
|
|
setUser(null);
|
|
setIsAdmin(false);
|
|
router.push('/');
|
|
}
|
|
|
|
// Prevent hydration mismatch by not rendering until mounted
|
|
if (!mounted) {
|
|
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
|
}
|
|
|
|
if (loading) {
|
|
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
|
}
|
|
|
|
if (!user) {
|
|
return <div className="min-h-screen flex items-center justify-center">Redirecting to login...</div>;
|
|
}
|
|
|
|
if (!isAdmin) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
|
<p className="text-gray-600">You need admin privileges to access the DevOps dashboard.</p>
|
|
<p className="text-sm text-gray-500 mt-2">Your role: {user.role}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|