feat: implement OAuth 2.0 / OpenID Connect support
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m12s
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:
@@ -17,6 +17,7 @@ import GuestJoin from './pages/GuestJoin';
|
||||
import FederationInbox from './pages/FederationInbox';
|
||||
import FederatedRoomDetail from './pages/FederatedRoomDetail';
|
||||
import Calendar from './pages/Calendar';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
export default function App() {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -50,6 +51,7 @@ export default function App() {
|
||||
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/join/:uid" element={<GuestJoin />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
|
||||
@@ -50,12 +50,24 @@ export function AuthProvider({ children }) {
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const loginWithOAuth = useCallback(async (token) => {
|
||||
localStorage.setItem('token', token);
|
||||
try {
|
||||
const res = await api.get('/auth/me');
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
} catch (err) {
|
||||
localStorage.removeItem('token');
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((updatedUser) => {
|
||||
setUser(updatedUser);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, loginWithOAuth, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
|
||||
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
|
||||
"inviteOnly": "Nur mit Einladung",
|
||||
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen."
|
||||
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
|
||||
"orContinueWith": "oder weiter mit",
|
||||
"loginWithOAuth": "Anmelden mit {provider}",
|
||||
"registerWithOAuth": "Registrieren mit {provider}",
|
||||
"backToLogin": "Zurück zum Login",
|
||||
"oauthError": "Anmeldung fehlgeschlagen",
|
||||
"oauthNoToken": "Kein Authentifizierungstoken erhalten.",
|
||||
"oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
|
||||
"oauthRedirecting": "Du wirst angemeldet..."
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
@@ -395,7 +403,26 @@
|
||||
"imprintUrlSaved": "Impressum-URL gespeichert",
|
||||
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
||||
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
||||
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden"
|
||||
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden",
|
||||
"oauthTitle": "OAuth / SSO",
|
||||
"oauthDescription": "OpenID-Connect-Anbieter verbinden (z. B. Keycloak, Authentik, Google) für Single Sign-On.",
|
||||
"oauthIssuer": "Issuer-URL",
|
||||
"oauthIssuerHint": "Die OIDC-Issuer-URL, z. B. https://auth.example.com/realms/main",
|
||||
"oauthClientId": "Client-ID",
|
||||
"oauthClientSecret": "Client-Secret",
|
||||
"oauthClientSecretHint": "Leer lassen, um das bestehende Secret beizubehalten",
|
||||
"oauthDisplayName": "Button-Beschriftung",
|
||||
"oauthDisplayNameHint": "Wird auf der Login-Seite angezeigt, z. B. „Firmen-SSO"",
|
||||
"oauthAutoRegister": "Neue Benutzer automatisch registrieren",
|
||||
"oauthAutoRegisterHint": "Erstellt automatisch Konten für Benutzer, die sich zum ersten Mal per OAuth anmelden.",
|
||||
"oauthSaved": "OAuth-Konfiguration gespeichert",
|
||||
"oauthSaveFailed": "OAuth-Konfiguration konnte nicht gespeichert werden",
|
||||
"oauthRemoved": "OAuth-Konfiguration entfernt",
|
||||
"oauthRemoveFailed": "OAuth-Konfiguration konnte nicht entfernt werden",
|
||||
"oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.",
|
||||
"oauthNotConfigured": "OAuth ist noch nicht konfiguriert.",
|
||||
"oauthSave": "OAuth speichern",
|
||||
"oauthRemove": "OAuth entfernen"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Benachrichtigungen",
|
||||
|
||||
@@ -91,7 +91,15 @@
|
||||
"emailVerificationResendSuccess": "Verification email sent!",
|
||||
"emailVerificationResendFailed": "Could not send verification email",
|
||||
"inviteOnly": "Invite Only",
|
||||
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account."
|
||||
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
|
||||
"orContinueWith": "or continue with",
|
||||
"loginWithOAuth": "Sign in with {provider}",
|
||||
"registerWithOAuth": "Sign up with {provider}",
|
||||
"backToLogin": "Back to login",
|
||||
"oauthError": "Authentication failed",
|
||||
"oauthNoToken": "No authentication token received.",
|
||||
"oauthLoginFailed": "Could not complete sign in. Please try again.",
|
||||
"oauthRedirecting": "Signing you in..."
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
@@ -395,7 +403,26 @@
|
||||
"imprintUrlSaved": "Imprint URL saved",
|
||||
"privacyUrlSaved": "Privacy Policy URL saved",
|
||||
"imprintUrlFailed": "Could not save Imprint URL",
|
||||
"privacyUrlFailed": "Could not save Privacy Policy URL"
|
||||
"privacyUrlFailed": "Could not save Privacy Policy URL",
|
||||
"oauthTitle": "OAuth / SSO",
|
||||
"oauthDescription": "Connect an OpenID Connect provider (e.g. Keycloak, Authentik, Google) to allow Single Sign-On.",
|
||||
"oauthIssuer": "Issuer URL",
|
||||
"oauthIssuerHint": "The OIDC issuer URL, e.g. https://auth.example.com/realms/main",
|
||||
"oauthClientId": "Client ID",
|
||||
"oauthClientSecret": "Client Secret",
|
||||
"oauthClientSecretHint": "Leave blank to keep the existing secret",
|
||||
"oauthDisplayName": "Button label",
|
||||
"oauthDisplayNameHint": "Shown on the login page, e.g. \"Company SSO\"",
|
||||
"oauthAutoRegister": "Auto-register new users",
|
||||
"oauthAutoRegisterHint": "Automatically create accounts for users signing in via OAuth for the first time.",
|
||||
"oauthSaved": "OAuth configuration saved",
|
||||
"oauthSaveFailed": "Could not save OAuth configuration",
|
||||
"oauthRemoved": "OAuth configuration removed",
|
||||
"oauthRemoveFailed": "Could not remove OAuth configuration",
|
||||
"oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.",
|
||||
"oauthNotConfigured": "OAuth is not configured yet.",
|
||||
"oauthSave": "Save OAuth",
|
||||
"oauthRemove": "Remove OAuth"
|
||||
},
|
||||
"notifications": {
|
||||
"bell": "Notifications",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
75
src/pages/OAuthCallback.jsx
Normal file
75
src/pages/OAuthCallback.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user