feat: implement OAuth 2.0 / OpenID Connect support
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m12s

- Added OAuth configuration management in the admin panel.
- Implemented OAuth authorization flow with PKCE for enhanced security.
- Created routes for handling OAuth provider discovery, authorization, and callback.
- Integrated OAuth login and registration options in the frontend.
- Updated UI components to support OAuth login and registration.
- Added internationalization strings for OAuth-related messages.
- Implemented encryption for client secrets and secure state management.
- Added error handling and user feedback for OAuth processes.
This commit is contained in:
2026-03-04 08:54:25 +01:00
parent e22a895672
commit cdfc585c8a
14 changed files with 1039 additions and 10 deletions

View File

@@ -4,7 +4,7 @@ import {
Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
ShieldCheck, Globe, Link as LinkIcon,
ShieldCheck, Globe, Link as LinkIcon, LogIn,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -48,6 +48,12 @@ export default function Admin() {
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
// OAuth state
const [oauthConfig, setOauthConfig] = useState(null);
const [oauthLoading, setOauthLoading] = useState(true);
const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
const [savingOauth, setSavingOauth] = useState(false);
useEffect(() => {
if (user?.role !== 'admin') {
navigate('/dashboard');
@@ -55,6 +61,7 @@ export default function Admin() {
}
fetchUsers();
fetchInvites();
fetchOauthConfig();
}, [user]);
useEffect(() => {
@@ -275,6 +282,58 @@ export default function Admin() {
}
};
// ── OAuth handlers ──────────────────────────────────────────────────────
const fetchOauthConfig = async () => {
setOauthLoading(true);
try {
const res = await api.get('/admin/oauth');
if (res.data.configured) {
setOauthConfig(res.data.config);
setOauthForm({
issuer: res.data.config.issuer || '',
clientId: res.data.config.clientId || '',
clientSecret: '',
displayName: res.data.config.displayName || 'SSO',
autoRegister: res.data.config.autoRegister ?? true,
});
} else {
setOauthConfig(null);
}
} catch {
// silently fail
} finally {
setOauthLoading(false);
}
};
const handleOauthSave = async (e) => {
e.preventDefault();
setSavingOauth(true);
try {
await api.put('/admin/oauth', oauthForm);
toast.success(t('admin.oauthSaved'));
fetchOauthConfig();
refreshBranding();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.oauthSaveFailed'));
} finally {
setSavingOauth(false);
}
};
const handleOauthRemove = async () => {
if (!confirm(t('admin.oauthRemoveConfirm'))) return;
try {
await api.delete('/admin/oauth');
toast.success(t('admin.oauthRemoved'));
setOauthConfig(null);
setOauthForm({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true });
refreshBranding();
} catch {
toast.error(t('admin.oauthRemoveFailed'));
}
};
const filteredUsers = users.filter(u =>
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
@@ -596,6 +655,106 @@ export default function Admin() {
)}
</div>
{/* OAuth / SSO Configuration */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<LogIn size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.oauthTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.oauthDescription')}</p>
{oauthLoading ? (
<div className="flex justify-center py-4">
<Loader2 size={20} className="animate-spin text-th-accent" />
</div>
) : (
<form onSubmit={handleOauthSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
<input
type="url"
value={oauthForm.issuer}
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
className="input-field text-sm"
placeholder="https://auth.example.com/realms/main"
required
/>
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
<input
type="text"
value={oauthForm.clientId}
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
className="input-field text-sm"
placeholder="redlight"
required
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
<input
type="password"
value={oauthForm.clientSecret}
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
className="input-field text-sm"
placeholder={oauthConfig?.hasClientSecret ? '••••••••' : ''}
/>
{oauthConfig?.hasClientSecret && (
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthClientSecretHint')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
<input
type="text"
value={oauthForm.displayName}
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
className="input-field text-sm"
placeholder="Company SSO"
maxLength={50}
/>
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthDisplayNameHint')}</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={oauthForm.autoRegister}
onChange={e => setOauthForm(f => ({ ...f, autoRegister: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-th-border rounded-full peer peer-checked:bg-th-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
</label>
<div>
<span className="text-sm font-medium text-th-text">{t('admin.oauthAutoRegister')}</span>
<p className="text-xs text-th-text-s">{t('admin.oauthAutoRegisterHint')}</p>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button type="submit" disabled={savingOauth} className="btn-primary text-sm px-5">
{savingOauth ? <Loader2 size={14} className="animate-spin" /> : null}
{t('admin.oauthSave')}
</button>
{oauthConfig && (
<button type="button" onClick={handleOauthRemove} className="btn-secondary text-sm px-5 text-red-400 hover:text-red-300">
<Trash2 size={14} />
{t('admin.oauthRemove')}
</button>
)}
</div>
</form>
)}
</div>
{/* Search */}
<div className="card p-4 mb-6">
<div className="relative">

View File

@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
@@ -17,7 +17,7 @@ export default function Login() {
const [resending, setResending] = useState(false);
const { login } = useAuth();
const { t } = useLanguage();
const { registrationMode } = useBranding();
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate();
useEffect(() => {
@@ -135,6 +135,26 @@ export default function Login() {
</button>
</form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
{needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2">

View File

@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Loader2, AlertTriangle } from 'lucide-react';
import toast from 'react-hot-toast';
export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const [error, setError] = useState(null);
const { loginWithOAuth } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('token');
const errorMsg = searchParams.get('error');
const returnTo = searchParams.get('return_to') || '/dashboard';
if (errorMsg) {
setError(errorMsg);
return;
}
if (!token) {
setError(t('auth.oauthNoToken'));
return;
}
// Store token and redirect
loginWithOAuth(token)
.then(() => {
toast.success(t('auth.loginSuccess'));
navigate(returnTo, { replace: true });
})
.catch(() => {
setError(t('auth.oauthLoginFailed'));
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center">
<AlertTriangle size={24} className="text-red-400" />
</div>
</div>
<h2 className="text-xl font-bold text-th-text mb-2">{t('auth.oauthError')}</h2>
<p className="text-th-text-s mb-6">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full py-3"
>
{t('auth.backToLogin')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin text-th-accent" />
<p className="text-th-text-s">{t('auth.oauthRedirecting')}</p>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
@@ -19,7 +19,7 @@ export default function Register() {
const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth();
const { t } = useLanguage();
const { registrationMode } = useBranding();
const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding();
const navigate = useNavigate();
// Invite-only mode without a token → show blocked message
@@ -197,6 +197,26 @@ export default function Register() {
</button>
</form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.hasAccount')}{' '}
<Link to="/login" className="text-th-accent hover:underline font-medium">