@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.
- package/dist/auth/AuthContext.js +27 -2
- package/dist/auth/authApi.js +4 -0
- package/dist/components/AccessCodeManager.js +8 -2
- package/dist/components/BulkInviteCsvTab.js +7 -1
- package/dist/components/LoginForm.js +2 -2
- package/dist/components/SecurityComponent.js +23 -6
- package/dist/components/SocialLoginButtons.js +7 -4
- package/dist/components/UserInviteComponent.js +8 -2
- package/dist/pages/AccountPage.js +3 -8
- package/dist/pages/LoginPage.js +16 -2
- package/dist/utils/authService.js +41 -2
- package/package.json +2 -1
- package/src/auth/AuthContext.jsx +28 -1
- package/src/auth/authApi.jsx +5 -0
- package/src/components/AccessCodeManager.jsx +13 -3
- package/src/components/BulkInviteCsvTab.jsx +8 -2
- package/src/components/LoginForm.jsx +58 -48
- package/src/components/SecurityComponent.jsx +58 -28
- package/src/components/SocialLoginButtons.jsx +57 -49
- package/src/components/UserInviteComponent.jsx +9 -2
- package/src/pages/AccountPage.jsx +8 -14
- package/src/pages/LoginPage.jsx +25 -6
- package/src/utils/authService.js +51 -3
package/dist/auth/AuthContext.js
CHANGED
|
@@ -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
|
|
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,
|
package/dist/auth/authApi.js
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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:
|
|
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
|
|
8
|
-
|
|
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.
|
|
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
|
}
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/src/auth/AuthContext.jsx
CHANGED
|
@@ -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
|
|
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,
|
package/src/auth/authApi.jsx
CHANGED
|
@@ -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
|
-
|
|
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="
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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,
|
|
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
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
{
|
|
95
|
-
|
|
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
|
-
|
|
123
|
+
{passkeysEnabled && (
|
|
124
|
+
<>
|
|
125
|
+
<PasskeysComponent />
|
|
126
|
+
{needsDividerAfter('passkeys') && <Divider sx={{ my: 3 }} />}
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
98
129
|
|
|
99
|
-
{
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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: '
|
|
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={
|
|
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
|
|
17
|
-
|
|
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
|
)}
|
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -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={
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
/>
|
package/src/utils/authService.js
CHANGED
|
@@ -135,12 +135,60 @@ export async function authenticateMfaWithPasskey() {
|
|
|
135
135
|
return authenticateWithMFA({ credential: credentialJson });
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
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
|
-
|
|
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
|
+
}
|