Feat(UI): hide UI login when ALLOW_LOCAL_AUTH is set to false

This commit is contained in:
aaldebs99 2025-10-30 22:10:04 +00:00
parent d4eaad8162
commit 58652bc300
4 changed files with 272 additions and 147 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Box, Box,
Card, Card,
@ -12,6 +12,7 @@ import {
IconButton, IconButton,
Fade, Fade,
Grow, Grow,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { import {
Visibility, Visibility,
@ -34,7 +35,14 @@ interface LoginFormData {
password: string; password: string;
} }
interface AuthConfig {
allow_local_auth: boolean;
oidc_enabled: boolean;
}
const Login: React.FC = () => { const Login: React.FC = () => {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
const [configLoading, setConfigLoading] = useState<boolean>(true);
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
@ -45,6 +53,30 @@ const Login: React.FC = () => {
const theme = useMuiTheme(); const theme = useMuiTheme();
const { t } = useTranslation(); const { t } = useTranslation();
// Fetch authentication configuration from backend
useEffect(() => {
const fetchAuthConfig = async () => {
try {
const response = await fetch('/api/auth/config');
if (response.ok) {
const config = await response.json();
setAuthConfig(config);
} else {
// Default to allowing both if config fetch fails
setAuthConfig({ allow_local_auth: true, oidc_enabled: true });
}
} catch (err) {
console.error('Failed to fetch auth config:', err);
// Default to allowing both if config fetch fails
setAuthConfig({ allow_local_auth: true, oidc_enabled: true });
} finally {
setConfigLoading(false);
}
};
fetchAuthConfig();
}, []);
const { const {
register, register,
handleSubmit, handleSubmit,
@ -178,18 +210,26 @@ const Login: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
{/* Loading state while fetching config */}
{configLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<CircularProgress sx={{ color: 'white' }} />
</Box>
)}
{/* Login Card */} {/* Login Card */}
<Grow in={true} timeout={1200}> {!configLoading && authConfig && (
<Card <Grow in={true} timeout={1200}>
elevation={0} <Card
sx={{ elevation={0}
borderRadius: 4, sx={{
backdropFilter: 'blur(20px)', borderRadius: 4,
backgroundColor: mode === 'light' backdropFilter: 'blur(20px)',
? 'rgba(255, 255, 255, 0.95)' backgroundColor: mode === 'light'
: 'rgba(30, 30, 30, 0.95)', ? 'rgba(255, 255, 255, 0.95)'
border: mode === 'light' : 'rgba(30, 30, 30, 0.95)',
? '1px solid rgba(255, 255, 255, 0.2)' border: mode === 'light'
? '1px solid rgba(255, 255, 255, 0.2)'
: '1px solid rgba(255, 255, 255, 0.1)', : '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: mode === 'light' boxShadow: mode === 'light'
? '0 25px 50px -12px rgba(0, 0, 0, 0.25)' ? '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
@ -216,142 +256,148 @@ const Login: React.FC = () => {
)} )}
<Box component="form" onSubmit={handleSubmit(onSubmit)}> <Box component="form" onSubmit={handleSubmit(onSubmit)}>
<TextField {authConfig.allow_local_auth && (
fullWidth <>
label={t('auth.username')} <TextField
margin="normal" fullWidth
{...register('username', { label={t('auth.username')}
required: t('auth.usernameRequired'), margin="normal"
})} {...register('username', {
error={!!errors.username} required: t('auth.usernameRequired'),
helperText={errors.username?.message} })}
InputProps={{ error={!!errors.username}
startAdornment: ( helperText={errors.username?.message}
<InputAdornment position="start"> InputProps={{
<EmailIcon sx={{ color: 'text.secondary' }} /> startAdornment: (
</InputAdornment> <InputAdornment position="start">
), <EmailIcon sx={{ color: 'text.secondary' }} />
}} </InputAdornment>
sx={{ mb: 2 }} ),
/> }}
sx={{ mb: 2 }}
/>
<TextField <TextField
fullWidth fullWidth
label={t('auth.password')} label={t('auth.password')}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
margin="normal" margin="normal"
{...register('password', { {...register('password', {
required: t('auth.passwordRequired'), required: t('auth.passwordRequired'),
})} })}
error={!!errors.password} error={!!errors.password}
helperText={errors.password?.message} helperText={errors.password?.message}
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<InputAdornment position="start"> <InputAdornment position="start">
<LockIcon sx={{ color: 'text.secondary' }} /> <LockIcon sx={{ color: 'text.secondary' }} />
</InputAdornment> </InputAdornment>
), ),
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton <IconButton
onClick={handleClickShowPassword} onClick={handleClickShowPassword}
edge="end" edge="end"
sx={{ color: 'text.secondary' }} sx={{ color: 'text.secondary' }}
> >
{showPassword ? <VisibilityOff /> : <Visibility />} {showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
), ),
}} }}
sx={{ mb: 3 }} sx={{ mb: 3 }}
/> />
<Button <Button
type="submit" type="submit"
fullWidth fullWidth
variant="contained" variant="contained"
size="large" size="large"
disabled={loading || oidcLoading} disabled={loading || oidcLoading}
sx={{ sx={{
py: 1.5, py: 1.5,
mb: 2, mb: 2,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
borderRadius: 2, borderRadius: 2,
fontSize: '1rem', fontSize: '1rem',
fontWeight: 600, fontWeight: 600,
textTransform: 'none', textTransform: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
'&:hover': { '&:hover': {
background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)', background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
}, },
'&:disabled': { '&:disabled': {
background: 'rgba(0, 0, 0, 0.12)', background: 'rgba(0, 0, 0, 0.12)',
}, },
}} }}
> >
{loading ? t('auth.signingIn') : t('auth.signIn')} {loading ? t('auth.signingIn') : t('auth.signIn')}
</Button> </Button>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
my: 2, my: 2,
'&::before': { '&::before': {
content: '""', content: '""',
flex: 1, flex: 1,
height: '1px', height: '1px',
backgroundColor: 'divider', backgroundColor: 'divider',
}, },
'&::after': { '&::after': {
content: '""', content: '""',
flex: 1, flex: 1,
height: '1px', height: '1px',
backgroundColor: 'divider', backgroundColor: 'divider',
}, },
}} }}
> >
<Typography <Typography
variant="body2" variant="body2"
sx={{
px: 2,
color: 'text.secondary',
}}
>
{t('common.or')}
</Typography>
</Box>
</>
)}
{authConfig.oidc_enabled && (
<Button
fullWidth
variant="outlined"
size="large"
disabled={loading || oidcLoading}
onClick={handleOidcLogin}
startIcon={<SecurityIcon />}
sx={{ sx={{
px: 2, py: 1.5,
color: 'text.secondary', mb: 2,
borderRadius: 2,
fontSize: '1rem',
fontWeight: 600,
textTransform: 'none',
borderColor: 'primary.main',
color: 'primary.main',
'&:hover': {
backgroundColor: 'primary.main',
color: 'white',
borderColor: 'primary.main',
},
'&:disabled': {
borderColor: 'rgba(0, 0, 0, 0.12)',
color: 'rgba(0, 0, 0, 0.26)',
},
}} }}
> >
{t('common.or')} {oidcLoading ? t('auth.redirecting') : t('auth.signInWithOIDC')}
</Typography> </Button>
</Box> )}
<Button
fullWidth
variant="outlined"
size="large"
disabled={loading || oidcLoading}
onClick={handleOidcLogin}
startIcon={<SecurityIcon />}
sx={{
py: 1.5,
mb: 2,
borderRadius: 2,
fontSize: '1rem',
fontWeight: 600,
textTransform: 'none',
borderColor: 'primary.main',
color: 'primary.main',
'&:hover': {
backgroundColor: 'primary.main',
color: 'white',
borderColor: 'primary.main',
},
'&:disabled': {
borderColor: 'rgba(0, 0, 0, 0.12)',
color: 'rgba(0, 0, 0, 0.26)',
},
}}
>
{oidcLoading ? t('auth.redirecting') : t('auth.signInWithOIDC')}
</Button>
<Box sx={{ textAlign: 'center', mt: 2 }}> <Box sx={{ textAlign: 'center', mt: 2 }}>
</Box> </Box>
@ -359,6 +405,7 @@ const Login: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grow> </Grow>
)}
{/* Footer */} {/* Footer */}
<Box sx={{ textAlign: 'center', mt: 4 }}> <Box sx={{ textAlign: 'center', mt: 4 }}>

View File

@ -1,4 +1,4 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect, vi, beforeEach } from 'vitest';
import Login from '../Login'; import Login from '../Login';
// Basic existence test for Login component // Basic existence test for Login component
@ -6,6 +6,11 @@ import Login from '../Login';
// is causing infrastructure issues // is causing infrastructure issues
describe('Login - OIDC Features - Simplified', () => { describe('Login - OIDC Features - Simplified', () => {
beforeEach(() => {
// Mock fetch for auth config endpoint
global.fetch = vi.fn();
});
test('Test file exists and can run', () => { test('Test file exists and can run', () => {
// This is a basic test to ensure the test file is valid // This is a basic test to ensure the test file is valid
expect(true).toBe(true); expect(true).toBe(true);
@ -16,4 +21,11 @@ describe('Login - OIDC Features - Simplified', () => {
expect(Login).toBeDefined(); expect(Login).toBeDefined();
expect(typeof Login).toBe('function'); expect(typeof Login).toBe('function');
}); });
test('Component fetches auth config at runtime', () => {
// The component now fetches /api/auth/config to determine
// which authentication methods are available
// Actual rendering logic is tested in E2E tests
expect(global.fetch).toBeDefined();
});
}); });

View File

@ -1,17 +1,46 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
interface AuthConfig {
allow_local_auth: boolean;
oidc_enabled: boolean;
}
function Register() { function Register() {
const navigate = useNavigate()
const { t } = useTranslation() const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [configLoading, setConfigLoading] = useState(true)
const { register } = useAuth() const { register } = useAuth()
// Fetch authentication configuration and redirect if local auth is disabled
useEffect(() => {
const fetchAuthConfig = async () => {
try {
const response = await fetch('/api/auth/config');
if (response.ok) {
const config: AuthConfig = await response.json();
if (!config.allow_local_auth) {
// Redirect to login if local auth is disabled
navigate('/login');
}
}
} catch (err) {
console.error('Failed to fetch auth config:', err);
} finally {
setConfigLoading(false);
}
};
fetchAuthConfig();
}, [navigate])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@ -26,6 +55,16 @@ function Register() {
} }
} }
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center">
<p className="text-gray-600">{t('common.loading', 'Loading...')}</p>
</div>
</div>
)
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">

View File

@ -5,7 +5,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
@ -20,10 +20,17 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/register", post(register)) .route("/register", post(register))
.route("/login", post(login)) .route("/login", post(login))
.route("/me", get(me)) .route("/me", get(me))
.route("/config", get(get_auth_config))
.route("/oidc/login", get(oidc_login)) .route("/oidc/login", get(oidc_login))
.route("/oidc/callback", get(oidc_callback)) .route("/oidc/callback", get(oidc_callback))
} }
#[derive(Serialize, utoipa::ToSchema)]
struct AuthConfig {
allow_local_auth: bool,
oidc_enabled: bool,
}
#[utoipa::path( #[utoipa::path(
post, post,
@ -82,6 +89,26 @@ async fn register(
} }
} }
#[utoipa::path(
get,
path = "/api/auth/config",
tag = "auth",
responses(
(status = 200, description = "Authentication configuration", body = AuthConfig),
)
)]
async fn get_auth_config(
State(state): State<Arc<AppState>>,
) -> Json<AuthConfig> {
let allow_local_auth = state.config.allow_local_auth.unwrap_or(true);
let oidc_enabled = state.oidc_client.is_some();
Json(AuthConfig {
allow_local_auth,
oidc_enabled,
})
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/api/auth/login", path = "/api/auth/login",