Feat(UI): hide UI login when ALLOW_LOCAL_AUTH is set to false
This commit is contained in:
parent
d4eaad8162
commit
58652bc300
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
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
|
||||||
// More complex auth tests require comprehensive context mocking which
|
// More complex auth tests require comprehensive context mocking which
|
||||||
// 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue