@micha.bigler/ui-core-micha 2.1.17 → 2.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,24 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  // src/auth/AuthContext.jsx
3
3
  import React, { createContext, useState, useEffect, } from 'react';
4
4
  import { ensureCsrfToken } from './apiClient'; // <--- IMPORT ADDED
5
- import { fetchCurrentUser, logoutSession, } from './authApi';
5
+ import { fetchAuthMethods, fetchCurrentUser, logoutSession, } from './authApi';
6
6
  export const AuthContext = createContext(null);
7
+ const DEFAULT_AUTH_METHODS = {
8
+ password_login: true,
9
+ password_reset: true,
10
+ signup: true,
11
+ password_change: true,
12
+ social_login: true,
13
+ social_providers: ['google', 'microsoft'],
14
+ passkey_login: true,
15
+ passkeys_manage: true,
16
+ mfa_totp: true,
17
+ mfa_recovery_codes: true,
18
+ mfa_enabled: true,
19
+ };
7
20
  export const AuthProvider = ({ children }) => {
8
21
  const [user, setUser] = useState(null);
22
+ const [authMethods, setAuthMethods] = useState(DEFAULT_AUTH_METHODS);
9
23
  const [loading, setLoading] = useState(true);
10
24
  const mapUserFromApi = (data) => {
11
25
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
@@ -18,7 +32,17 @@ export const AuthProvider = ({ children }) => {
18
32
  try {
19
33
  // 1) Ensure CSRF cookie exists using the specific client
20
34
  await ensureCsrfToken();
21
- // 2) Load user
35
+ // 2) Load auth methods (public)
36
+ try {
37
+ const methods = await fetchAuthMethods();
38
+ if (isMounted && methods && typeof methods === 'object') {
39
+ setAuthMethods((prev) => (Object.assign(Object.assign({}, prev), methods)));
40
+ }
41
+ }
42
+ catch (_a) {
43
+ // Keep defaults; login UI remains usable.
44
+ }
45
+ // 3) Load user
22
46
  const data = await fetchCurrentUser();
23
47
  if (isMounted) {
24
48
  setUser(mapUserFromApi(data));
@@ -54,6 +78,7 @@ export const AuthProvider = ({ children }) => {
54
78
  };
55
79
  return (_jsx(AuthContext.Provider, { value: {
56
80
  user,
81
+ authMethods,
57
82
  loading,
58
83
  login,
59
84
  logout,
@@ -15,6 +15,10 @@ export async function fetchCurrentUser() {
15
15
  const res = await apiClient.get(`${USERS_BASE}/current/`);
16
16
  return res.data;
17
17
  }
18
+ export async function fetchAuthMethods() {
19
+ const res = await apiClient.get('/api/auth-methods/');
20
+ return res.data || {};
21
+ }
18
22
  export async function updateUserProfile(data) {
19
23
  try {
20
24
  const res = await apiClient.patch(`${USERS_BASE}/current/`, data);
@@ -8,6 +8,12 @@ import { useTranslation } from 'react-i18next';
8
8
  import { fetchAccessCodes, createAccessCode, deleteAccessCode, } from '../auth/authApi';
9
9
  export function AccessCodeManager() {
10
10
  const { t } = useTranslation();
11
+ const actionButtonSx = {
12
+ minWidth: 120,
13
+ height: 40,
14
+ textTransform: 'none',
15
+ whiteSpace: 'nowrap',
16
+ };
11
17
  const [codes, setCodes] = useState([]);
12
18
  const [loading, setLoading] = useState(true);
13
19
  const [submitting, setSubmitting] = useState(false);
@@ -90,7 +96,7 @@ export function AccessCodeManager() {
90
96
  if (loading) {
91
97
  return (_jsx(Box, { sx: { py: 3, display: 'flex', justifyContent: 'center' }, children: _jsx(CircularProgress, {}) }));
92
98
  }
93
- return (_jsxs(Box, { children: [errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), successKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(successKey) })), _jsxs(Box, { sx: { mb: 3 }, children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: t('Auth.ACCESS_CODE_SECTION_ACTIVE') }), codes.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Auth.ACCESS_CODE_NONE') })) : (_jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 1, children: codes.map((code) => (_jsx(Chip, { label: code.code, onDelete: () => handleDelete(code.id), deleteIcon: _jsx(CloseIcon, {}) }, code.id))) }))] }), _jsxs(Box, { sx: { mb: 3 }, children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: t('Auth.ACCESS_CODE_SECTION_GENERATE') }), _jsxs(Box, { sx: { maxWidth: 360 }, children: [_jsx(Typography, { variant: "body2", gutterBottom: true, children: t('Auth.ACCESS_CODE_LENGTH_LABEL', { length }) }), _jsx(Slider, { min: 6, max: 32, step: 1, value: length, onChange: (_, val) => setLength(val), valueLabelDisplay: "auto", disabled: submitting })] }), _jsx(Button, { variant: "contained", sx: { mt: 1 }, onClick: handleGenerateClick, disabled: submitting, children: submitting
99
+ return (_jsxs(Box, { children: [errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), successKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(successKey) })), _jsxs(Box, { sx: { mb: 3 }, children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: t('Auth.ACCESS_CODE_SECTION_ACTIVE') }), codes.length === 0 ? (_jsx(Typography, { variant: "body2", children: t('Auth.ACCESS_CODE_NONE') })) : (_jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 1, children: codes.map((code) => (_jsx(Chip, { label: code.code, onDelete: () => handleDelete(code.id), deleteIcon: _jsx(CloseIcon, {}) }, code.id))) }))] }), _jsxs(Box, { sx: { mb: 3 }, children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: t('Auth.ACCESS_CODE_SECTION_GENERATE') }), _jsxs(Box, { sx: { maxWidth: 360 }, children: [_jsx(Typography, { variant: "body2", gutterBottom: true, children: t('Auth.ACCESS_CODE_LENGTH_LABEL', { length }) }), _jsx(Slider, { min: 6, max: 32, step: 1, value: length, onChange: (_, val) => setLength(val), valueLabelDisplay: "auto", disabled: submitting })] }), _jsx(Button, { variant: "contained", size: "small", sx: Object.assign(Object.assign({}, actionButtonSx), { mt: 1 }), onClick: handleGenerateClick, disabled: submitting, children: submitting
94
100
  ? t('Auth.SAVE_BUTTON_LOADING')
95
- : t('Auth.ACCESS_CODE_GENERATE_BUTTON') })] }), _jsxs(Box, { sx: { mb: 2, maxWidth: 360 }, children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: t('Auth.ACCESS_CODE_SECTION_MANUAL') }), _jsxs(Box, { sx: { display: 'flex', gap: 1 }, children: [_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), fullWidth: true, value: manualCode, onChange: (e) => setManualCode(e.target.value), disabled: submitting }), _jsx(Button, { variant: "outlined", onClick: handleAddManual, disabled: submitting, children: t('Auth.ACCESS_CODE_ADD_BUTTON') })] })] })] }));
101
+ : t('Auth.ACCESS_CODE_GENERATE_BUTTON') })] }), _jsxs(Box, { sx: { mb: 2, maxWidth: 360 }, children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: t('Auth.ACCESS_CODE_SECTION_MANUAL') }), _jsxs(Box, { sx: { display: 'flex', gap: 1, alignItems: 'center' }, children: [_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), fullWidth: true, size: "small", value: manualCode, onChange: (e) => setManualCode(e.target.value), disabled: submitting }), _jsx(Button, { variant: "contained", size: "small", onClick: handleAddManual, disabled: submitting, sx: actionButtonSx, children: t('Auth.ACCESS_CODE_ADD_BUTTON') })] })] })] }));
96
102
  }
@@ -26,6 +26,12 @@ function parseEmailsFromCsv(text) {
26
26
  }
27
27
  export function BulkInviteCsvTab({ inviteFn = (email) => requestInviteWithCode(email, null), onCompleted, }) {
28
28
  const { t } = useTranslation();
29
+ const actionButtonSx = {
30
+ minWidth: 120,
31
+ height: 40,
32
+ textTransform: 'none',
33
+ whiteSpace: 'nowrap',
34
+ };
29
35
  const [emails, setEmails] = useState([]);
30
36
  const [results, setResults] = useState({});
31
37
  const [busy, setBusy] = useState(false);
@@ -89,7 +95,7 @@ export function BulkInviteCsvTab({ inviteFn = (email) => requestInviteWithCode(e
89
95
  if (onCompleted)
90
96
  onCompleted(nextResults);
91
97
  };
92
- return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Account.BULK_INVITE_TITLE', 'Bulk Invite via CSV') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.BULK_INVITE_HINT', 'Upload a CSV file containing email addresses. Header "email" is supported.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'center', mb: 2, flexWrap: 'wrap' }, children: [_jsxs(Button, { variant: "outlined", component: "label", disabled: busy, children: [t('Account.BULK_INVITE_UPLOAD', 'Upload CSV'), _jsx("input", { type: "file", accept: ".csv,text/csv", hidden: true, onChange: handleFile })] }), _jsx(Button, { variant: "contained", onClick: handleInviteAll, disabled: busy || emails.length === 0, children: t('Account.BULK_INVITE_SEND', 'Send Invites') }), _jsx(Typography, { variant: "body2", children: t('Account.BULK_INVITE_COUNT', '{{count}} emails loaded', { count: emails.length }) })] }), busy && (_jsx(Box, { sx: { mb: 2 }, children: _jsx(LinearProgress, { variant: "determinate", value: progress }) })), emails.length > 0 && (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Auth.EMAIL_LABEL', 'Email') }), _jsx(TableCell, { children: t('Common.STATUS', 'Status') }), _jsx(TableCell, { children: t('Common.DETAILS', 'Details') })] }) }), _jsx(TableBody, { children: emails.map((email) => {
98
+ return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Account.BULK_INVITE_TITLE', 'Bulk Invite via CSV') }), _jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.BULK_INVITE_HINT', 'Upload a CSV file containing email addresses. Header "email" is supported.') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), success && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: success }), _jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'center', mb: 2, flexWrap: 'wrap' }, children: [_jsxs(Button, { variant: "outlined", size: "small", component: "label", disabled: busy, sx: actionButtonSx, children: [t('Account.BULK_INVITE_UPLOAD', 'Upload CSV'), _jsx("input", { type: "file", accept: ".csv,text/csv", hidden: true, onChange: handleFile })] }), _jsx(Button, { variant: "contained", size: "small", sx: actionButtonSx, onClick: handleInviteAll, disabled: busy || emails.length === 0, children: t('Account.BULK_INVITE_SEND', 'Send Invites') }), _jsx(Typography, { variant: "body2", children: t('Account.BULK_INVITE_COUNT', '{{count}} emails loaded', { count: emails.length }) })] }), busy && (_jsx(Box, { sx: { mb: 2 }, children: _jsx(LinearProgress, { variant: "determinate", value: progress }) })), emails.length > 0 && (_jsx(TableContainer, { component: Paper, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('Auth.EMAIL_LABEL', 'Email') }), _jsx(TableCell, { children: t('Common.STATUS', 'Status') }), _jsx(TableCell, { children: t('Common.DETAILS', 'Details') })] }) }), _jsx(TableBody, { children: emails.map((email) => {
93
99
  const row = results[email];
94
100
  return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: email }), _jsx(TableCell, { children: row ? (row.ok ? t('Common.SUCCESS', 'Success') : t('Common.ERROR', 'Error')) : '-' }), _jsx(TableCell, { children: (row === null || row === void 0 ? void 0 : row.message) || '-' })] }, email));
95
101
  }) })] }) })), done > 0 && (_jsxs(Typography, { variant: "body2", sx: { mt: 2 }, children: [t('Account.BULK_INVITE_PROGRESS', '{{done}} / {{total}} processed', { done, total }), ' - ', t('Account.BULK_INVITE_SUCCESS_COUNT', '{{count}} successful', { count: successCount })] }))] }));
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
3
3
  import { Box, TextField, Button, Typography, Divider, } from '@mui/material';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { SocialLoginButtons } from './SocialLoginButtons';
6
- export function LoginForm({ onSubmit, onForgotPassword, onSocialLogin, onPasskeyLogin, onSignUp, error, // bereits übersetzter Text oder t(errorKey) aus dem Parent
6
+ export function LoginForm({ onSubmit, onForgotPassword, onSocialLogin, socialProviders, onPasskeyLogin, onSignUp, error, // bereits übersetzter Text oder t(errorKey) aus dem Parent
7
7
  disabled = false, initialIdentifier = '', }) {
8
8
  const { t } = useTranslation();
9
9
  const [identifier, setIdentifier] = useState(initialIdentifier);
@@ -21,6 +21,6 @@ disabled = false, initialIdentifier = '', }) {
21
21
  const supportsPasskey = !!onPasskeyLogin &&
22
22
  typeof window !== 'undefined' &&
23
23
  !!window.PublicKeyCredential;
24
- return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), supportsPasskey && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "contained", fullWidth: true, type: "button", onClick: onPasskeyLogin, disabled: disabled, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON') }), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') })] })), _jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: identifier, onChange: (e) => setIdentifier(e.target.value), disabled: disabled }), _jsx(TextField, { label: t('Auth.LOGIN_PASSWORD_LABEL'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: disabled }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: disabled, children: t('Auth.PAGE_LOGIN_TITLE') })] }), _jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", sx: { mb: 1 }, children: t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE') }), _jsxs(Box, { sx: { display: 'flex', gap: 1, flexWrap: 'wrap' }, children: [onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, children: t('Auth.LOGIN_SIGNUP_BUTTON') })), _jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, children: t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON') })] })] })] }));
24
+ return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 3 }, children: [error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), supportsPasskey && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "contained", fullWidth: true, type: "button", onClick: onPasskeyLogin, disabled: disabled, children: t('Auth.LOGIN_USE_PASSKEY_BUTTON') }), _jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') })] })), onSubmit && (_jsxs(Box, { component: "form", onSubmit: handleSubmit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL'), type: "email", required: true, fullWidth: true, value: identifier, onChange: (e) => setIdentifier(e.target.value), disabled: disabled }), _jsx(TextField, { label: t('Auth.LOGIN_PASSWORD_LABEL'), type: "password", required: true, fullWidth: true, value: password, onChange: (e) => setPassword(e.target.value), disabled: disabled }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: disabled, children: t('Auth.PAGE_LOGIN_TITLE') })] })), onSocialLogin && (_jsxs(Box, { children: [_jsx(Divider, { sx: { my: 2 }, children: t('Auth.LOGIN_OR') }), _jsx(SocialLoginButtons, { onProviderClick: onSocialLogin, providers: socialProviders })] })), (onSignUp || onForgotPassword) && (_jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle2", sx: { mb: 1 }, children: t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE') }), _jsxs(Box, { sx: { display: 'flex', gap: 1, flexWrap: 'wrap' }, children: [onSignUp && (_jsx(Button, { type: "button", variant: "outlined", onClick: onSignUp, disabled: disabled, children: t('Auth.LOGIN_SIGNUP_BUTTON') })), onForgotPassword && (_jsx(Button, { type: "button", variant: "outlined", onClick: onForgotPassword, disabled: disabled, children: t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON') }))] })] }))] }));
25
25
  }
26
26
  ;
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // src/auth/components/SecurityComponent.jsx
3
- import React, { useState } from 'react';
3
+ import React, { useContext, useMemo, useState } from 'react';
4
4
  import { Box, Typography, Divider, Alert, } from '@mui/material';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { PasswordChangeForm } from './PasswordChangeForm';
@@ -9,17 +9,34 @@ import { PasskeysComponent } from './PasskeysComponent';
9
9
  import { MFAComponent } from './MFAComponent';
10
10
  import { changePassword } from '../auth/authApi';
11
11
  import { startSocialLogin } from '../utils/authService';
12
- export function SecurityComponent({ fromRecovery = false, fromWeakLogin = false, // optional: wenn du später weak-login-Redirect nutzt
13
- }) {
12
+ import { AuthContext } from '../auth/AuthContext';
13
+ export function SecurityComponent({ fromRecovery = false, fromWeakLogin = false, }) {
14
14
  const { t } = useTranslation();
15
+ const { authMethods } = useContext(AuthContext);
15
16
  const [messageKey, setMessageKey] = useState(null);
16
17
  const [errorKey, setErrorKey] = useState(null);
18
+ const socialProviders = Array.isArray(authMethods === null || authMethods === void 0 ? void 0 : authMethods.social_providers)
19
+ ? authMethods.social_providers
20
+ : [];
21
+ const canChangePassword = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.password_change);
22
+ const socialLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.social_login) && socialProviders.length > 0;
23
+ const passkeysEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.passkeys_manage);
24
+ const mfaEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.mfa_enabled);
25
+ const sectionOrder = useMemo(() => [
26
+ canChangePassword ? 'password' : null,
27
+ socialLoginEnabled ? 'social' : null,
28
+ passkeysEnabled ? 'passkeys' : null,
29
+ mfaEnabled ? 'mfa' : null,
30
+ ].filter(Boolean), [canChangePassword, socialLoginEnabled, passkeysEnabled, mfaEnabled]);
31
+ const needsDividerAfter = (section) => {
32
+ const idx = sectionOrder.indexOf(section);
33
+ return idx !== -1 && idx < sectionOrder.length - 1;
34
+ };
17
35
  const handleSocialClick = async (provider) => {
18
36
  setMessageKey(null);
19
37
  setErrorKey(null);
20
38
  try {
21
39
  await startSocialLogin(provider);
22
- // Redirect läuft über den Provider-Flow, hier kein extra Text nötig
23
40
  }
24
41
  catch (err) {
25
42
  setErrorKey(err.code || 'Auth.SOCIAL_LOGIN_FAILED');
@@ -36,6 +53,6 @@ export function SecurityComponent({ fromRecovery = false, fromWeakLogin = false,
36
53
  setErrorKey(err.code || 'Auth.PASSWORD_CHANGE_FAILED');
37
54
  }
38
55
  };
39
- return (_jsxs(Box, { children: [fromRecovery && (_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: t('Security.RECOVERY_LOGIN_WARNING') })), fromWeakLogin && (_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: t('Security.WEAK_LOGIN_WARNING') })), messageKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(messageKey) })), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.LOGIN_PASSWORD_LABEL') }), _jsx(PasswordChangeForm, { onSubmit: handlePasswordChange }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Security.SOCIAL_SECTION_TITLE') }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: t('Security.SOCIAL_SECTION_DESCRIPTION') }), _jsx(SocialLoginButtons, { onProviderClick: handleSocialClick }), _jsx(Divider, { sx: { my: 3 } }), _jsx(PasskeysComponent, {}), _jsx(Divider, { sx: { my: 3 } }), _jsx(MFAComponent, {})] }));
56
+ return (_jsxs(Box, { children: [fromRecovery && (_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: t('Security.RECOVERY_LOGIN_WARNING') })), fromWeakLogin && (_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: t('Security.WEAK_LOGIN_WARNING') })), messageKey && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: t(messageKey) })), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), canChangePassword && (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.LOGIN_PASSWORD_LABEL') }), _jsx(PasswordChangeForm, { onSubmit: handlePasswordChange }), needsDividerAfter('password') && _jsx(Divider, { sx: { my: 3 } })] })), socialLoginEnabled && (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Security.SOCIAL_SECTION_TITLE') }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: t('Security.SOCIAL_SECTION_DESCRIPTION') }), _jsx(SocialLoginButtons, { onProviderClick: handleSocialClick, providers: socialProviders }), needsDividerAfter('social') && _jsx(Divider, { sx: { my: 3 } })] })), passkeysEnabled && (_jsxs(_Fragment, { children: [_jsx(PasskeysComponent, {}), needsDividerAfter('passkeys') && _jsx(Divider, { sx: { my: 3 } })] })), mfaEnabled && _jsx(MFAComponent, {})] }));
40
57
  }
41
58
  ;
@@ -8,14 +8,17 @@ import { SOCIAL_PROVIDERS } from '../auth/authConfig';
8
8
  * Renders buttons for social login providers.
9
9
  * The caller passes a handler that receives the provider key.
10
10
  */
11
- export function SocialLoginButtons({ onProviderClick }) {
11
+ export function SocialLoginButtons({ onProviderClick, providers }) {
12
12
  const { t } = useTranslation();
13
13
  const handleClick = (provider) => {
14
14
  if (onProviderClick) {
15
15
  onProviderClick(provider);
16
16
  }
17
17
  };
18
- return (_jsxs(Stack, { spacing: 1.5, children: [_jsx(Button, { variant: "outlined", fullWidth: true, onClick: () => handleClick(SOCIAL_PROVIDERS.google), startIcon: _jsx(Box, { sx: {
18
+ const activeProviders = Array.isArray(providers) && providers.length > 0
19
+ ? providers
20
+ : [SOCIAL_PROVIDERS.google, SOCIAL_PROVIDERS.microsoft];
21
+ return (_jsxs(Stack, { spacing: 1.5, children: [activeProviders.includes(SOCIAL_PROVIDERS.google) && (_jsx(Button, { variant: "outlined", fullWidth: true, onClick: () => handleClick(SOCIAL_PROVIDERS.google), startIcon: _jsx(Box, { sx: {
19
22
  width: 24,
20
23
  height: 24,
21
24
  borderRadius: '50%',
@@ -25,13 +28,13 @@ export function SocialLoginButtons({ onProviderClick }) {
25
28
  justifyContent: 'center',
26
29
  fontWeight: 700,
27
30
  fontSize: 14,
28
- }, children: "G" }), children: t('Auth.LOGIN_SOCIAL_GOOGLE') }), _jsx(Button, { variant: "outlined", fullWidth: true, onClick: () => handleClick(SOCIAL_PROVIDERS.microsoft), startIcon: _jsxs(Box, { sx: {
31
+ }, children: "G" }), children: t('Auth.LOGIN_SOCIAL_GOOGLE') })), activeProviders.includes(SOCIAL_PROVIDERS.microsoft) && (_jsx(Button, { variant: "outlined", fullWidth: true, onClick: () => handleClick(SOCIAL_PROVIDERS.microsoft), startIcon: _jsxs(Box, { sx: {
29
32
  width: 24,
30
33
  height: 24,
31
34
  display: 'grid',
32
35
  gridTemplateColumns: '1fr 1fr',
33
36
  gridTemplateRows: '1fr 1fr',
34
37
  gap: '1px',
35
- }, children: [_jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.9 } }), _jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.7 } }), _jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.7 } }), _jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.9 } })] }), children: t('Auth.LOGIN_SOCIAL_MICROSOFT') })] }));
38
+ }, children: [_jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.9 } }), _jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.7 } }), _jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.7 } }), _jsx(Box, { sx: { bgcolor: 'primary.main', opacity: 0.9 } })] }), children: t('Auth.LOGIN_SOCIAL_MICROSOFT') }))] }));
36
39
  }
37
40
  ;
@@ -9,6 +9,12 @@ export function UserInviteComponent() {
9
9
  const [message, setMessage] = useState('');
10
10
  const [error, setError] = useState('');
11
11
  const [loading, setLoading] = useState(false);
12
+ const actionButtonSx = {
13
+ minWidth: 120,
14
+ height: 40,
15
+ textTransform: 'none',
16
+ whiteSpace: 'nowrap',
17
+ };
12
18
  const inviteUser = async () => {
13
19
  setMessage('');
14
20
  setError('');
@@ -32,8 +38,8 @@ export function UserInviteComponent() {
32
38
  setLoading(false);
33
39
  }
34
40
  };
35
- return (_jsxs(Box, { sx: { maxWidth: 600 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.INVITE_TITLE', 'Invite a new user') }), message && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), _jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'flex-start' }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL', 'Email address'), type: "email", variant: "outlined", fullWidth: true, size: "small", value: inviteEmail, onChange: (e) => setInviteEmail(e.target.value), disabled: loading, onKeyPress: (e) => {
41
+ return (_jsxs(Box, { sx: { maxWidth: 600 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('Auth.INVITE_TITLE', 'Invite a new user') }), message && _jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), _jsxs(Box, { sx: { display: 'flex', gap: 2, alignItems: 'center' }, children: [_jsx(TextField, { label: t('Auth.EMAIL_LABEL', 'Email address'), type: "email", variant: "outlined", fullWidth: true, size: "small", value: inviteEmail, onChange: (e) => setInviteEmail(e.target.value), disabled: loading, onKeyPress: (e) => {
36
42
  if (e.key === 'Enter')
37
43
  inviteUser();
38
- } }), _jsx(Button, { variant: "contained", onClick: inviteUser, disabled: loading || !inviteEmail, sx: { minWidth: 100, height: 40 }, children: loading ? _jsx(CircularProgress, { size: 24, color: "inherit" }) : t('Auth.INVITE_BUTTON', 'Invite') })] })] }));
44
+ } }), _jsx(Button, { variant: "contained", size: "small", onClick: inviteUser, disabled: loading || !inviteEmail, sx: actionButtonSx, children: loading ? _jsx(CircularProgress, { size: 24, color: "inherit" }) : t('Auth.INVITE_BUTTON', 'Invite') })] })] }));
39
45
  }
@@ -4,13 +4,8 @@ import { Helmet } from 'react-helmet';
4
4
  import { useSearchParams } from 'react-router-dom';
5
5
  import { Tabs, Tab, Box, Typography, Alert, CircularProgress, Paper, Stack, } from '@mui/material';
6
6
  import { useTranslation } from 'react-i18next';
7
- // Internal Library Components
8
- // Stellen Sie sicher, dass diese Pfade korrekt auf Ihre Library zeigen!
9
- // Da Sie '@micha.bigler/ui-core-micha' nutzen, sollten die Imports ggf. so aussehen:
10
- import { AuthContext,
11
- // ... andere Komponenten aus der Lib importieren, falls sie dort exportiert sind
12
- // Wenn sie lokal sind, lassen Sie die relativen Pfade.
13
- } from '@micha.bigler/ui-core-micha';
7
+ // Internal context
8
+ import { AuthContext } from '../auth/AuthContext';
14
9
  // Falls die Komponenten noch lokal sind:
15
10
  import { WidePage } from '../layout/PageLayout';
16
11
  import { ProfileComponent } from '../components/ProfileComponent';
@@ -92,5 +87,5 @@ export function AccountPage({ userListExtraColumns = [], userListExtraRowActions
92
87
  const activeExtraTab = builtInTabValues.has(safeTab)
93
88
  ? null
94
89
  : extraTabs.find((tab) => tab.value === safeTab);
95
- return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [(isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), (isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) })), (isSuperUser || perms.can_manage_access_codes) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
90
+ return (_jsxs(WidePage, { title: t('Account.TITLE', 'Account & Administration'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('Account.PAGE_TITLE', 'Account'), " \u2013 ", user.email] }) }), _jsx(Tabs, { value: safeTab, onChange: handleTabChange, variant: "scrollable", scrollButtons: "auto", sx: { mb: 3, borderBottom: 1, borderColor: 'divider' }, children: tabs.map((tab) => (_jsx(Tab, { label: tab.label, value: tab.value }, tab.value))) }), safeTab === 'profile' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(ProfileComponent, { onSubmit: handleProfileSubmit, showName: true, showPrivacy: true, showCookies: true }) })), safeTab === 'security' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SecurityComponent, { fromRecovery: fromRecovery, fromWeakLogin: fromWeakLogin }) })), safeTab === 'users' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(UserListComponent, { roles: activeRoles, currentUser: user, extraColumns: userListExtraColumns, extraRowActions: userListExtraRowActions, extraContext: userListExtraContext, refreshTrigger: userListRefreshTrigger, canEditUser: userListCanEditUser }) })), safeTab === 'invite' && (_jsx(Box, { sx: { mt: 2 }, children: _jsxs(Stack, { spacing: 2.5, children: [(isSuperUser || perms.can_invite) && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(UserInviteComponent, {}) })), (isSuperUser || perms.can_manage_access_codes) && (_jsxs(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: [_jsx(Typography, { variant: "body2", sx: { mb: 2, color: 'text.secondary' }, children: t('Account.ACCESS_CODES_HINT', 'Manage access codes for self-registration.') }), _jsx(AccessCodeManager, {})] })), (isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (_jsx(Paper, { variant: "outlined", sx: { p: 2.5, borderRadius: 2 }, children: _jsx(BulkInviteCsvTab, Object.assign({}, bulkInviteCsvProps)) }))] }) })), safeTab === 'support' && (_jsx(Box, { sx: { mt: 2 }, children: _jsx(SupportRecoveryRequestsTab, {}) })), activeExtraTab && (_jsx(Box, { sx: { mt: 2 }, children: (_a = activeExtraTab.render) === null || _a === void 0 ? void 0 : _a.call(activeExtraTab, { user, perms, isSuperUser, t }) }))] }));
96
91
  }
@@ -16,7 +16,7 @@ import { MfaLoginComponent } from '../components/MfaLoginComponent';
16
16
  export function LoginPage() {
17
17
  const navigate = useNavigate();
18
18
  const location = useLocation();
19
- const { login } = useContext(AuthContext);
19
+ const { login, authMethods } = useContext(AuthContext);
20
20
  const { t } = useTranslation();
21
21
  // State
22
22
  const [step, setStep] = useState('credentials'); // 'credentials' | 'mfa'
@@ -32,6 +32,12 @@ export function LoginPage() {
32
32
  : recoveryTokenRaw;
33
33
  // Backward-compatible fallback for legacy links using query parameters.
34
34
  const recoveryEmail = hashParams.get('email') || params.get('email') || '';
35
+ useEffect(() => {
36
+ const socialError = params.get('error') || params.get('social');
37
+ if (socialError) {
38
+ setErrorKey('Auth.SOCIAL_LOGIN_FAILED');
39
+ }
40
+ }, [location.search]);
35
41
  // --- Helper: Central Success Logic ---
36
42
  const handleLoginSuccess = (user) => {
37
43
  var _a;
@@ -113,6 +119,14 @@ export function LoginPage() {
113
119
  setMfaState(null);
114
120
  setErrorKey(null);
115
121
  };
122
+ const socialProviders = Array.isArray(authMethods === null || authMethods === void 0 ? void 0 : authMethods.social_providers)
123
+ ? authMethods.social_providers
124
+ : [];
125
+ const passwordLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.password_login) || Boolean(recoveryToken);
126
+ const socialLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.social_login) && socialProviders.length > 0;
127
+ const passkeyLoginEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.passkey_login);
128
+ const signupEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.signup);
129
+ const passwordResetEnabled = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.password_reset);
116
130
  // --- Render ---
117
- return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: handleSubmitCredentials, onForgotPassword: () => navigate('/reset-request-password'), onSocialLogin: (provider) => startSocialLogin(provider), onPasskeyLogin: handlePasskeyLoginInitial, onSignUp: () => navigate('/signup'), disabled: submitting, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
131
+ return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: passwordLoginEnabled ? handleSubmitCredentials : null, onForgotPassword: passwordResetEnabled ? () => navigate('/reset-request-password') : null, onSocialLogin: socialLoginEnabled ? (provider) => startSocialLogin(provider) : null, socialProviders: socialProviders, onPasskeyLogin: passkeyLoginEnabled ? handlePasskeyLoginInitial : null, onSignUp: signupEnabled ? () => navigate('/signup') : null, disabled: submitting, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
118
132
  }
@@ -101,9 +101,48 @@ export async function authenticateMfaWithPasskey() {
101
101
  const credentialJson = serializeCredential(assertion);
102
102
  return authenticateWithMFA({ credential: credentialJson });
103
103
  }
104
- export function startSocialLogin(provider) {
104
+ function getCsrfTokenFromCookie() {
105
+ if (typeof document === 'undefined' || !document.cookie)
106
+ return null;
107
+ const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
108
+ return match ? decodeURIComponent(match[1]) : null;
109
+ }
110
+ function submitSocialRedirectForm({ provider, callbackUrl, csrfToken }) {
111
+ const form = document.createElement('form');
112
+ form.method = 'POST';
113
+ form.action = `${HEADLESS_BASE}/auth/provider/redirect`;
114
+ form.style.display = 'none';
115
+ const fields = {
116
+ provider,
117
+ process: 'login',
118
+ callback_url: callbackUrl,
119
+ csrfmiddlewaretoken: csrfToken,
120
+ };
121
+ Object.entries(fields).forEach(([name, value]) => {
122
+ const input = document.createElement('input');
123
+ input.type = 'hidden';
124
+ input.name = name;
125
+ input.value = String(value);
126
+ form.appendChild(input);
127
+ });
128
+ document.body.appendChild(form);
129
+ form.submit();
130
+ }
131
+ export async function startSocialLogin(provider) {
105
132
  if (typeof window === 'undefined') {
106
133
  throw normaliseApiError(new Error('Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'), 'Auth.SOCIAL_LOGIN_NOT_IN_BROWSER');
107
134
  }
108
- window.location.href = `/accounts/${provider}/login/?process=login`;
135
+ try {
136
+ // Ensures csrftoken cookie exists before form POST.
137
+ await apiClient.get('/api/csrf/');
138
+ }
139
+ catch (_a) {
140
+ // Continue; token might already be present.
141
+ }
142
+ const csrfToken = getCsrfTokenFromCookie();
143
+ if (!csrfToken) {
144
+ throw normaliseApiError(new Error('Auth.SOCIAL_LOGIN_FAILED'), 'Auth.SOCIAL_LOGIN_FAILED');
145
+ }
146
+ const callbackUrl = `${window.location.origin}/login`;
147
+ submitSocialRedirectForm({ provider, callbackUrl, csrfToken });
109
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.1.17",
3
+ "version": "2.1.18",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "private": false,
@@ -24,3 +24,4 @@
24
24
  "react-i18next": "^16.3.5"
25
25
  }
26
26
  }
27
+
@@ -6,14 +6,30 @@ import React, {
6
6
  } from 'react';
7
7
  import { ensureCsrfToken } from './apiClient'; // <--- IMPORT ADDED
8
8
  import {
9
+ fetchAuthMethods,
9
10
  fetchCurrentUser,
10
11
  logoutSession,
11
12
  } from './authApi';
12
13
 
13
14
  export const AuthContext = createContext(null);
14
15
 
16
+ const DEFAULT_AUTH_METHODS = {
17
+ password_login: true,
18
+ password_reset: true,
19
+ signup: true,
20
+ password_change: true,
21
+ social_login: true,
22
+ social_providers: ['google', 'microsoft'],
23
+ passkey_login: true,
24
+ passkeys_manage: true,
25
+ mfa_totp: true,
26
+ mfa_recovery_codes: true,
27
+ mfa_enabled: true,
28
+ };
29
+
15
30
  export const AuthProvider = ({ children }) => {
16
31
  const [user, setUser] = useState(null);
32
+ const [authMethods, setAuthMethods] = useState(DEFAULT_AUTH_METHODS);
17
33
  const [loading, setLoading] = useState(true);
18
34
 
19
35
  const mapUserFromApi = (data) => {
@@ -57,7 +73,17 @@ export const AuthProvider = ({ children }) => {
57
73
  // 1) Ensure CSRF cookie exists using the specific client
58
74
  await ensureCsrfToken();
59
75
 
60
- // 2) Load user
76
+ // 2) Load auth methods (public)
77
+ try {
78
+ const methods = await fetchAuthMethods();
79
+ if (isMounted && methods && typeof methods === 'object') {
80
+ setAuthMethods((prev) => ({ ...prev, ...methods }));
81
+ }
82
+ } catch {
83
+ // Keep defaults; login UI remains usable.
84
+ }
85
+
86
+ // 3) Load user
61
87
  const data = await fetchCurrentUser();
62
88
 
63
89
  if (isMounted) {
@@ -98,6 +124,7 @@ export const AuthProvider = ({ children }) => {
98
124
  <AuthContext.Provider
99
125
  value={{
100
126
  user,
127
+ authMethods,
101
128
  loading,
102
129
  login,
103
130
  logout,
@@ -18,6 +18,11 @@ export async function fetchCurrentUser() {
18
18
  return res.data;
19
19
  }
20
20
 
21
+ export async function fetchAuthMethods() {
22
+ const res = await apiClient.get('/api/auth-methods/');
23
+ return res.data || {};
24
+ }
25
+
21
26
  export async function updateUserProfile(data) {
22
27
  try {
23
28
  const res = await apiClient.patch(`${USERS_BASE}/current/`, data);
@@ -23,6 +23,12 @@ import {
23
23
 
24
24
  export function AccessCodeManager() {
25
25
  const { t } = useTranslation();
26
+ const actionButtonSx = {
27
+ minWidth: 120,
28
+ height: 40,
29
+ textTransform: 'none',
30
+ whiteSpace: 'nowrap',
31
+ };
26
32
 
27
33
  const [codes, setCodes] = useState([]);
28
34
  const [loading, setLoading] = useState(true);
@@ -176,7 +182,8 @@ export function AccessCodeManager() {
176
182
 
177
183
  <Button
178
184
  variant="contained"
179
- sx={{ mt: 1 }}
185
+ size="small"
186
+ sx={{ ...actionButtonSx, mt: 1 }}
180
187
  onClick={handleGenerateClick}
181
188
  disabled={submitting}
182
189
  >
@@ -191,18 +198,21 @@ export function AccessCodeManager() {
191
198
  <Typography variant="subtitle1" gutterBottom>
192
199
  {t('Auth.ACCESS_CODE_SECTION_MANUAL')}
193
200
  </Typography>
194
- <Box sx={{ display: 'flex', gap: 1 }}>
201
+ <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
195
202
  <TextField
196
203
  label={t('Auth.ACCESS_CODE_LABEL')}
197
204
  fullWidth
205
+ size="small"
198
206
  value={manualCode}
199
207
  onChange={(e) => setManualCode(e.target.value)}
200
208
  disabled={submitting}
201
209
  />
202
210
  <Button
203
- variant="outlined"
211
+ variant="contained"
212
+ size="small"
204
213
  onClick={handleAddManual}
205
214
  disabled={submitting}
215
+ sx={actionButtonSx}
206
216
  >
207
217
  {t('Auth.ACCESS_CODE_ADD_BUTTON')}
208
218
  </Button>
@@ -45,6 +45,12 @@ export function BulkInviteCsvTab({
45
45
  onCompleted,
46
46
  }) {
47
47
  const { t } = useTranslation();
48
+ const actionButtonSx = {
49
+ minWidth: 120,
50
+ height: 40,
51
+ textTransform: 'none',
52
+ whiteSpace: 'nowrap',
53
+ };
48
54
  const [emails, setEmails] = useState([]);
49
55
  const [results, setResults] = useState({});
50
56
  const [busy, setBusy] = useState(false);
@@ -132,11 +138,11 @@ export function BulkInviteCsvTab({
132
138
  {success && <Alert severity="success" sx={{ mb: 2 }}>{success}</Alert>}
133
139
 
134
140
  <Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 2, flexWrap: 'wrap' }}>
135
- <Button variant="outlined" component="label" disabled={busy}>
141
+ <Button variant="outlined" size="small" component="label" disabled={busy} sx={actionButtonSx}>
136
142
  {t('Account.BULK_INVITE_UPLOAD', 'Upload CSV')}
137
143
  <input type="file" accept=".csv,text/csv" hidden onChange={handleFile} />
138
144
  </Button>
139
- <Button variant="contained" onClick={handleInviteAll} disabled={busy || emails.length === 0}>
145
+ <Button variant="contained" size="small" sx={actionButtonSx} onClick={handleInviteAll} disabled={busy || emails.length === 0}>
140
146
  {t('Account.BULK_INVITE_SEND', 'Send Invites')}
141
147
  </Button>
142
148
  <Typography variant="body2">
@@ -13,6 +13,7 @@ export function LoginForm({
13
13
  onSubmit,
14
14
  onForgotPassword,
15
15
  onSocialLogin,
16
+ socialProviders,
16
17
  onPasskeyLogin,
17
18
  onSignUp,
18
19
  error, // bereits übersetzter Text oder t(errorKey) aus dem Parent
@@ -68,52 +69,58 @@ export function LoginForm({
68
69
  )}
69
70
 
70
71
  {/* Sign in: E-Mail + Passwort */}
71
- <Box
72
- component="form"
73
- onSubmit={handleSubmit}
74
- sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
75
- >
76
- <TextField
77
- label={t('Auth.EMAIL_LABEL')}
78
- type="email"
79
- required
80
- fullWidth
81
- value={identifier}
82
- onChange={(e) => setIdentifier(e.target.value)}
83
- disabled={disabled}
84
- />
72
+ {onSubmit && (
73
+ <Box
74
+ component="form"
75
+ onSubmit={handleSubmit}
76
+ sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
77
+ >
78
+ <TextField
79
+ label={t('Auth.EMAIL_LABEL')}
80
+ type="email"
81
+ required
82
+ fullWidth
83
+ value={identifier}
84
+ onChange={(e) => setIdentifier(e.target.value)}
85
+ disabled={disabled}
86
+ />
85
87
 
86
- <TextField
87
- label={t('Auth.LOGIN_PASSWORD_LABEL')}
88
- type="password"
89
- required
90
- fullWidth
91
- value={password}
92
- onChange={(e) => setPassword(e.target.value)}
93
- disabled={disabled}
94
- />
88
+ <TextField
89
+ label={t('Auth.LOGIN_PASSWORD_LABEL')}
90
+ type="password"
91
+ required
92
+ fullWidth
93
+ value={password}
94
+ onChange={(e) => setPassword(e.target.value)}
95
+ disabled={disabled}
96
+ />
95
97
 
96
- <Button
97
- type="submit"
98
- variant="contained"
99
- fullWidth
100
- disabled={disabled}
101
- >
102
- {t('Auth.PAGE_LOGIN_TITLE')}
103
- </Button>
104
- </Box>
98
+ <Button
99
+ type="submit"
100
+ variant="contained"
101
+ fullWidth
102
+ disabled={disabled}
103
+ >
104
+ {t('Auth.PAGE_LOGIN_TITLE')}
105
+ </Button>
106
+ </Box>
107
+ )}
105
108
 
106
109
  {/* Other ways to sign in */}
107
- {/*
108
- <Box>
109
- <Divider sx={{ my: 2 }}>
110
- {t('Auth.LOGIN_OR')}
111
- </Divider>
112
- <SocialLoginButtons onProviderClick={onSocialLogin} />
113
- </Box>
114
- */}
110
+ {onSocialLogin && (
111
+ <Box>
112
+ <Divider sx={{ my: 2 }}>
113
+ {t('Auth.LOGIN_OR')}
114
+ </Divider>
115
+ <SocialLoginButtons
116
+ onProviderClick={onSocialLogin}
117
+ providers={socialProviders}
118
+ />
119
+ </Box>
120
+ )}
115
121
  {/* Account & Recovery */}
116
122
 
123
+ {(onSignUp || onForgotPassword) && (
117
124
  <Box>
118
125
  <Typography variant="subtitle2" sx={{ mb: 1 }}>
119
126
  {t('Auth.LOGIN_ACCOUNT_RECOVERY_TITLE')}
@@ -131,16 +138,19 @@ export function LoginForm({
131
138
  </Button>
132
139
  )}
133
140
 
134
- <Button
135
- type="button"
136
- variant="outlined"
137
- onClick={onForgotPassword}
138
- disabled={disabled}
139
- >
140
- {t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON')}
141
- </Button>
141
+ {onForgotPassword && (
142
+ <Button
143
+ type="button"
144
+ variant="outlined"
145
+ onClick={onForgotPassword}
146
+ disabled={disabled}
147
+ >
148
+ {t('Auth.LOGIN_FORGOT_PASSWORD_BUTTON')}
149
+ </Button>
150
+ )}
142
151
  </Box>
143
152
  </Box>
153
+ )}
144
154
  </Box>
145
155
  );
146
156
  };
@@ -1,5 +1,5 @@
1
1
  // src/auth/components/SecurityComponent.jsx
2
- import React, { useState } from 'react';
2
+ import React, { useContext, useMemo, useState } from 'react';
3
3
  import {
4
4
  Box,
5
5
  Typography,
@@ -13,22 +13,46 @@ import { PasskeysComponent } from './PasskeysComponent';
13
13
  import { MFAComponent } from './MFAComponent';
14
14
  import { changePassword } from '../auth/authApi';
15
15
  import { startSocialLogin } from '../utils/authService';
16
+ import { AuthContext } from '../auth/AuthContext';
16
17
 
17
18
  export function SecurityComponent({
18
19
  fromRecovery = false,
19
- fromWeakLogin = false, // optional: wenn du später weak-login-Redirect nutzt
20
+ fromWeakLogin = false,
20
21
  }) {
21
22
  const { t } = useTranslation();
23
+ const { authMethods } = useContext(AuthContext);
22
24
 
23
25
  const [messageKey, setMessageKey] = useState(null);
24
26
  const [errorKey, setErrorKey] = useState(null);
25
27
 
28
+ const socialProviders = Array.isArray(authMethods?.social_providers)
29
+ ? authMethods.social_providers
30
+ : [];
31
+ const canChangePassword = Boolean(authMethods?.password_change);
32
+ const socialLoginEnabled = Boolean(authMethods?.social_login) && socialProviders.length > 0;
33
+ const passkeysEnabled = Boolean(authMethods?.passkeys_manage);
34
+ const mfaEnabled = Boolean(authMethods?.mfa_enabled);
35
+
36
+ const sectionOrder = useMemo(
37
+ () => [
38
+ canChangePassword ? 'password' : null,
39
+ socialLoginEnabled ? 'social' : null,
40
+ passkeysEnabled ? 'passkeys' : null,
41
+ mfaEnabled ? 'mfa' : null,
42
+ ].filter(Boolean),
43
+ [canChangePassword, socialLoginEnabled, passkeysEnabled, mfaEnabled],
44
+ );
45
+
46
+ const needsDividerAfter = (section) => {
47
+ const idx = sectionOrder.indexOf(section);
48
+ return idx !== -1 && idx < sectionOrder.length - 1;
49
+ };
50
+
26
51
  const handleSocialClick = async (provider) => {
27
52
  setMessageKey(null);
28
53
  setErrorKey(null);
29
54
  try {
30
55
  await startSocialLogin(provider);
31
- // Redirect läuft über den Provider-Flow, hier kein extra Text nötig
32
56
  } catch (err) {
33
57
  setErrorKey(err.code || 'Auth.SOCIAL_LOGIN_FAILED');
34
58
  }
@@ -47,14 +71,12 @@ export function SecurityComponent({
47
71
 
48
72
  return (
49
73
  <Box>
50
- {/* Hinweis nach Recovery-Login */}
51
74
  {fromRecovery && (
52
75
  <Alert severity="warning" sx={{ mb: 2 }}>
53
76
  {t('Security.RECOVERY_LOGIN_WARNING')}
54
77
  </Alert>
55
78
  )}
56
79
 
57
- {/* Optional: Hinweis nach „schwachem“ Login, wenn du das nutzt */}
58
80
  {fromWeakLogin && (
59
81
  <Alert severity="warning" sx={{ mb: 2 }}>
60
82
  {t('Security.WEAK_LOGIN_WARNING')}
@@ -72,32 +94,40 @@ export function SecurityComponent({
72
94
  </Alert>
73
95
  )}
74
96
 
75
- {/* Password section */}
76
- <Typography variant="h6" gutterBottom>
77
- {t('Auth.LOGIN_PASSWORD_LABEL')}
78
- </Typography>
79
- <PasswordChangeForm onSubmit={handlePasswordChange} />
80
-
81
- <Divider sx={{ my: 3 }} />
82
-
83
- {/* Social logins section */}
84
- <Typography variant="h6" gutterBottom>
85
- {t('Security.SOCIAL_SECTION_TITLE')}
86
- </Typography>
87
- <Typography variant="body2" sx={{ mb: 1 }}>
88
- {t('Security.SOCIAL_SECTION_DESCRIPTION')}
89
- </Typography>
90
- <SocialLoginButtons onProviderClick={handleSocialClick} />
91
-
92
- <Divider sx={{ my: 3 }} />
97
+ {canChangePassword && (
98
+ <>
99
+ <Typography variant="h6" gutterBottom>
100
+ {t('Auth.LOGIN_PASSWORD_LABEL')}
101
+ </Typography>
102
+ <PasswordChangeForm onSubmit={handlePasswordChange} />
103
+ {needsDividerAfter('password') && <Divider sx={{ my: 3 }} />}
104
+ </>
105
+ )}
93
106
 
94
- {/* Passkeys */}
95
- <PasskeysComponent />
107
+ {socialLoginEnabled && (
108
+ <>
109
+ <Typography variant="h6" gutterBottom>
110
+ {t('Security.SOCIAL_SECTION_TITLE')}
111
+ </Typography>
112
+ <Typography variant="body2" sx={{ mb: 1 }}>
113
+ {t('Security.SOCIAL_SECTION_DESCRIPTION')}
114
+ </Typography>
115
+ <SocialLoginButtons
116
+ onProviderClick={handleSocialClick}
117
+ providers={socialProviders}
118
+ />
119
+ {needsDividerAfter('social') && <Divider sx={{ my: 3 }} />}
120
+ </>
121
+ )}
96
122
 
97
- <Divider sx={{ my: 3 }} />
123
+ {passkeysEnabled && (
124
+ <>
125
+ <PasskeysComponent />
126
+ {needsDividerAfter('passkeys') && <Divider sx={{ my: 3 }} />}
127
+ </>
128
+ )}
98
129
 
99
- {/* MFA (TOTP + Recovery-Codes) */}
100
- <MFAComponent />
130
+ {mfaEnabled && <MFAComponent />}
101
131
  </Box>
102
132
  );
103
133
  };
@@ -8,7 +8,7 @@ import { SOCIAL_PROVIDERS } from '../auth/authConfig';
8
8
  * Renders buttons for social login providers.
9
9
  * The caller passes a handler that receives the provider key.
10
10
  */
11
- export function SocialLoginButtons({ onProviderClick }) {
11
+ export function SocialLoginButtons({ onProviderClick, providers }) {
12
12
  const { t } = useTranslation();
13
13
 
14
14
  const handleClick = (provider) => {
@@ -17,57 +17,65 @@ export function SocialLoginButtons({ onProviderClick }) {
17
17
  }
18
18
  };
19
19
 
20
+ const activeProviders = Array.isArray(providers) && providers.length > 0
21
+ ? providers
22
+ : [SOCIAL_PROVIDERS.google, SOCIAL_PROVIDERS.microsoft];
23
+
20
24
  return (
21
25
  <Stack spacing={1.5}>
22
- <Button
23
- variant="outlined"
24
- fullWidth
25
- onClick={() => handleClick(SOCIAL_PROVIDERS.google)}
26
- startIcon={
27
- <Box
28
- sx={{
29
- width: 24,
30
- height: 24,
31
- borderRadius: '50%',
32
- border: '1px solid rgba(0,0,0,0.2)',
33
- display: 'flex',
34
- alignItems: 'center',
35
- justifyContent: 'center',
36
- fontWeight: 700,
37
- fontSize: 14,
38
- }}
39
- >
40
- G
41
- </Box>
42
- }
43
- >
44
- {t('Auth.LOGIN_SOCIAL_GOOGLE')}
45
- </Button>
26
+ {activeProviders.includes(SOCIAL_PROVIDERS.google) && (
27
+ <Button
28
+ variant="outlined"
29
+ fullWidth
30
+ onClick={() => handleClick(SOCIAL_PROVIDERS.google)}
31
+ startIcon={
32
+ <Box
33
+ sx={{
34
+ width: 24,
35
+ height: 24,
36
+ borderRadius: '50%',
37
+ border: '1px solid rgba(0,0,0,0.2)',
38
+ display: 'flex',
39
+ alignItems: 'center',
40
+ justifyContent: 'center',
41
+ fontWeight: 700,
42
+ fontSize: 14,
43
+ }}
44
+ >
45
+ G
46
+ </Box>
47
+ }
48
+ >
49
+ {t('Auth.LOGIN_SOCIAL_GOOGLE')}
50
+ </Button>
51
+ )}
46
52
 
47
- <Button
48
- variant="outlined"
49
- fullWidth
50
- onClick={() => handleClick(SOCIAL_PROVIDERS.microsoft)}
51
- startIcon={
52
- <Box
53
- sx={{
54
- width: 24,
55
- height: 24,
56
- display: 'grid',
57
- gridTemplateColumns: '1fr 1fr',
58
- gridTemplateRows: '1fr 1fr',
59
- gap: '1px',
60
- }}
61
- >
62
- <Box sx={{ bgcolor: 'primary.main', opacity: 0.9 }} />
63
- <Box sx={{ bgcolor: 'primary.main', opacity: 0.7 }} />
64
- <Box sx={{ bgcolor: 'primary.main', opacity: 0.7 }} />
65
- <Box sx={{ bgcolor: 'primary.main', opacity: 0.9 }} />
66
- </Box>
67
- }
68
- >
69
- {t('Auth.LOGIN_SOCIAL_MICROSOFT')}
70
- </Button>
53
+ {activeProviders.includes(SOCIAL_PROVIDERS.microsoft) && (
54
+ <Button
55
+ variant="outlined"
56
+ fullWidth
57
+ onClick={() => handleClick(SOCIAL_PROVIDERS.microsoft)}
58
+ startIcon={
59
+ <Box
60
+ sx={{
61
+ width: 24,
62
+ height: 24,
63
+ display: 'grid',
64
+ gridTemplateColumns: '1fr 1fr',
65
+ gridTemplateRows: '1fr 1fr',
66
+ gap: '1px',
67
+ }}
68
+ >
69
+ <Box sx={{ bgcolor: 'primary.main', opacity: 0.9 }} />
70
+ <Box sx={{ bgcolor: 'primary.main', opacity: 0.7 }} />
71
+ <Box sx={{ bgcolor: 'primary.main', opacity: 0.7 }} />
72
+ <Box sx={{ bgcolor: 'primary.main', opacity: 0.9 }} />
73
+ </Box>
74
+ }
75
+ >
76
+ {t('Auth.LOGIN_SOCIAL_MICROSOFT')}
77
+ </Button>
78
+ )}
71
79
  </Stack>
72
80
  );
73
81
  };
@@ -9,6 +9,12 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
9
9
  const [message, setMessage] = useState('');
10
10
  const [error, setError] = useState('');
11
11
  const [loading, setLoading] = useState(false);
12
+ const actionButtonSx = {
13
+ minWidth: 120,
14
+ height: 40,
15
+ textTransform: 'none',
16
+ whiteSpace: 'nowrap',
17
+ };
12
18
 
13
19
  const inviteUser = async () => {
14
20
  setMessage('');
@@ -42,7 +48,7 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
42
48
  {message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
43
49
  {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
44
50
 
45
- <Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-start' }}>
51
+ <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
46
52
  <TextField
47
53
  label={t('Auth.EMAIL_LABEL', 'Email address')}
48
54
  type="email"
@@ -58,9 +64,10 @@ export function UserInviteComponent() { // FIX: Removed apiUrl prop
58
64
  />
59
65
  <Button
60
66
  variant="contained"
67
+ size="small"
61
68
  onClick={inviteUser}
62
69
  disabled={loading || !inviteEmail}
63
- sx={{ minWidth: 100, height: 40 }}
70
+ sx={actionButtonSx}
64
71
  >
65
72
  {loading ? <CircularProgress size={24} color="inherit" /> : t('Auth.INVITE_BUTTON', 'Invite')}
66
73
  </Button>
@@ -13,14 +13,8 @@ import {
13
13
  } from '@mui/material';
14
14
  import { useTranslation } from 'react-i18next';
15
15
 
16
- // Internal Library Components
17
- // Stellen Sie sicher, dass diese Pfade korrekt auf Ihre Library zeigen!
18
- // Da Sie '@micha.bigler/ui-core-micha' nutzen, sollten die Imports ggf. so aussehen:
19
- import {
20
- AuthContext,
21
- // ... andere Komponenten aus der Lib importieren, falls sie dort exportiert sind
22
- // Wenn sie lokal sind, lassen Sie die relativen Pfade.
23
- } from '@micha.bigler/ui-core-micha';
16
+ // Internal context
17
+ import { AuthContext } from '../auth/AuthContext';
24
18
 
25
19
  // Falls die Komponenten noch lokal sind:
26
20
  import { WidePage } from '../layout/PageLayout';
@@ -200,12 +194,6 @@ export function AccountPage({
200
194
  </Paper>
201
195
  )}
202
196
 
203
- {(isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (
204
- <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
205
- <BulkInviteCsvTab {...bulkInviteCsvProps} />
206
- </Paper>
207
- )}
208
-
209
197
  {(isSuperUser || perms.can_manage_access_codes) && (
210
198
  <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
211
199
  <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
@@ -214,6 +202,12 @@ export function AccountPage({
214
202
  <AccessCodeManager />
215
203
  </Paper>
216
204
  )}
205
+
206
+ {(isSuperUser || perms.can_invite) && showBulkInviteCsvTab && (
207
+ <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 2 }}>
208
+ <BulkInviteCsvTab {...bulkInviteCsvProps} />
209
+ </Paper>
210
+ )}
217
211
  </Stack>
218
212
  </Box>
219
213
  )}
@@ -19,7 +19,7 @@ import { MfaLoginComponent } from '../components/MfaLoginComponent';
19
19
  export function LoginPage() {
20
20
  const navigate = useNavigate();
21
21
  const location = useLocation();
22
- const { login } = useContext(AuthContext);
22
+ const { login, authMethods } = useContext(AuthContext);
23
23
  const { t } = useTranslation();
24
24
 
25
25
  // State
@@ -40,6 +40,13 @@ export function LoginPage() {
40
40
  // Backward-compatible fallback for legacy links using query parameters.
41
41
  const recoveryEmail = hashParams.get('email') || params.get('email') || '';
42
42
 
43
+ useEffect(() => {
44
+ const socialError = params.get('error') || params.get('social');
45
+ if (socialError) {
46
+ setErrorKey('Auth.SOCIAL_LOGIN_FAILED');
47
+ }
48
+ }, [location.search]);
49
+
43
50
  // --- Helper: Central Success Logic ---
44
51
  const handleLoginSuccess = (user) => {
45
52
  login(user); // Update Context
@@ -127,6 +134,15 @@ export function LoginPage() {
127
134
  setErrorKey(null);
128
135
  };
129
136
 
137
+ const socialProviders = Array.isArray(authMethods?.social_providers)
138
+ ? authMethods.social_providers
139
+ : [];
140
+ const passwordLoginEnabled = Boolean(authMethods?.password_login) || Boolean(recoveryToken);
141
+ const socialLoginEnabled = Boolean(authMethods?.social_login) && socialProviders.length > 0;
142
+ const passkeyLoginEnabled = Boolean(authMethods?.passkey_login);
143
+ const signupEnabled = Boolean(authMethods?.signup);
144
+ const passwordResetEnabled = Boolean(authMethods?.password_reset);
145
+
130
146
  // --- Render ---
131
147
 
132
148
  return (
@@ -152,11 +168,14 @@ export function LoginPage() {
152
168
 
153
169
  {step === 'credentials' && (
154
170
  <LoginForm
155
- onSubmit={handleSubmitCredentials}
156
- onForgotPassword={() => navigate('/reset-request-password')}
157
- onSocialLogin={(provider) => startSocialLogin(provider)}
158
- onPasskeyLogin={handlePasskeyLoginInitial}
159
- onSignUp={() => navigate('/signup')}
171
+ onSubmit={passwordLoginEnabled ? handleSubmitCredentials : null}
172
+ onForgotPassword={
173
+ passwordResetEnabled ? () => navigate('/reset-request-password') : null
174
+ }
175
+ onSocialLogin={socialLoginEnabled ? (provider) => startSocialLogin(provider) : null}
176
+ socialProviders={socialProviders}
177
+ onPasskeyLogin={passkeyLoginEnabled ? handlePasskeyLoginInitial : null}
178
+ onSignUp={signupEnabled ? () => navigate('/signup') : null}
160
179
  disabled={submitting}
161
180
  initialIdentifier={recoveryEmail}
162
181
  />
@@ -135,12 +135,60 @@ export async function authenticateMfaWithPasskey() {
135
135
  return authenticateWithMFA({ credential: credentialJson });
136
136
  }
137
137
 
138
- export function startSocialLogin(provider) {
138
+ function getCsrfTokenFromCookie() {
139
+ if (typeof document === 'undefined' || !document.cookie) return null;
140
+ const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
141
+ return match ? decodeURIComponent(match[1]) : null;
142
+ }
143
+
144
+ function submitSocialRedirectForm({ provider, callbackUrl, csrfToken }) {
145
+ const form = document.createElement('form');
146
+ form.method = 'POST';
147
+ form.action = `${HEADLESS_BASE}/auth/provider/redirect`;
148
+ form.style.display = 'none';
149
+
150
+ const fields = {
151
+ provider,
152
+ process: 'login',
153
+ callback_url: callbackUrl,
154
+ csrfmiddlewaretoken: csrfToken,
155
+ };
156
+
157
+ Object.entries(fields).forEach(([name, value]) => {
158
+ const input = document.createElement('input');
159
+ input.type = 'hidden';
160
+ input.name = name;
161
+ input.value = String(value);
162
+ form.appendChild(input);
163
+ });
164
+
165
+ document.body.appendChild(form);
166
+ form.submit();
167
+ }
168
+
169
+ export async function startSocialLogin(provider) {
139
170
  if (typeof window === 'undefined') {
140
171
  throw normaliseApiError(
141
172
  new Error('Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'),
142
173
  'Auth.SOCIAL_LOGIN_NOT_IN_BROWSER'
143
174
  );
144
175
  }
145
- window.location.href = `/accounts/${provider}/login/?process=login`;
146
- }
176
+
177
+ try {
178
+ // Ensures csrftoken cookie exists before form POST.
179
+ await apiClient.get('/api/csrf/');
180
+ } catch {
181
+ // Continue; token might already be present.
182
+ }
183
+
184
+ const csrfToken = getCsrfTokenFromCookie();
185
+ if (!csrfToken) {
186
+ throw normaliseApiError(
187
+ new Error('Auth.SOCIAL_LOGIN_FAILED'),
188
+ 'Auth.SOCIAL_LOGIN_FAILED',
189
+ );
190
+ }
191
+
192
+ const callbackUrl = `${window.location.origin}/login`;
193
+ submitSocialRedirectForm({ provider, callbackUrl, csrfToken });
194
+ }