@micha.bigler/ui-core-micha 3.5.1 → 3.7.0
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/CHANGELOG.md +18 -0
- package/DESIGN.md +1 -1
- package/README.md +2 -0
- package/WORK_ORDERS.md +8 -1
- package/dist/auth/UserMenu.js +23 -1
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/UserListComponent.js +2 -1
- package/dist/i18n/authTranslations.js +238 -0
- package/dist/i18n/uiCoreTranslations.js +19 -0
- package/dist/index.js +2 -1
- package/dist/messaging/Composer.js +4 -2
- package/dist/messaging/MessageBubble.js +2 -1
- package/dist/messaging/ReadTicks.js +5 -3
- package/dist/messaging/linkifyText.js +39 -0
- package/dist/pages/PasswordInvitePage.js +23 -8
- package/dist/pages/SignUpPage.js +4 -4
- package/dist/theme/kitSxRegistry.js +5 -1
- package/dist/utils/auth-errors.js +1 -1
- package/package.json +1 -1
- package/src/auth/UserMenu.jsx +26 -1
- package/src/components/EmptyState.jsx +38 -0
- package/src/components/UserListComponent.jsx +2 -0
- package/src/i18n/authTranslations.ts +243 -0
- package/src/i18n/uiCoreTranslations.js +10 -0
- package/src/index.js +2 -1
- package/src/messaging/Composer.jsx +7 -3
- package/src/messaging/MessageBubble.jsx +2 -1
- package/src/messaging/ReadTicks.jsx +4 -3
- package/src/messaging/linkifyText.jsx +37 -0
- package/src/pages/PasswordInvitePage.jsx +36 -7
- package/src/pages/SignUpPage.jsx +79 -68
- package/src/theme/kitSxRegistry.js +9 -1
- package/src/utils/auth-errors.js +2 -2
- package/tests/EmptyState.test.jsx +62 -0
- package/tests/PasswordInvitePage.test.jsx +135 -0
- package/tests/SignUpPage.test.jsx +15 -2
- package/tests/UserListComponent.test.jsx +85 -0
- package/tests/UserMenu.test.jsx +40 -4
- package/tests/authErrors.test.js +43 -0
- package/tests/i18nGermanVariant.test.js +65 -0
- package/tests/messagingComposer.test.jsx +25 -0
- package/tests/messagingLinkify.test.jsx +51 -0
- package/tests/messagingReadTicksTooltip.test.jsx +36 -0
- package/tests/userScreenTranslations.test.jsx +165 -0
- package/work-orders/AUTH-8.md +184 -0
- package/work-orders/UCM-AUTH-9.md +207 -0
- package/work-orders/UCM-I18N-4.md +226 -0
- package/work-orders/UCM-MSG-20.md +192 -0
- package/work-orders/UCM-PRIM-2.md +114 -0
- package/work-orders/UCM-SHELL-7.md +134 -0
- package/work-orders/UCM-THEME-13.md +69 -0
- package/work-orders/UCM-THEME-14.md +140 -0
|
@@ -8,6 +8,7 @@ import { AttachmentList } from './AttachmentList';
|
|
|
8
8
|
import { extractApiErrorMessage, useOptionalMessaging } from './MessagingProvider';
|
|
9
9
|
import { ReactionBar } from './ReactionBar';
|
|
10
10
|
import { PollCard } from './PollCard';
|
|
11
|
+
import { linkifyText } from './linkifyText';
|
|
11
12
|
export const MESSAGE_BUBBLE_ROOT_SX = {
|
|
12
13
|
position: 'relative',
|
|
13
14
|
p: 1.25,
|
|
@@ -155,5 +156,5 @@ export function MessageBubble({ message, replyTo, conversation, onReply, onJumpT
|
|
|
155
156
|
bgcolor: isOwn ? (theme) => alpha(theme.palette.primary.light, 0.24) : 'background.paper',
|
|
156
157
|
borderColor: isOwn ? (theme) => alpha(theme.palette.primary.main, 0.35) : 'divider',
|
|
157
158
|
},
|
|
158
|
-
], children: [canOpenMenu && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, sx: MESSAGE_BUBBLE_ACTION_SX, children: _jsx(MoreVertIcon, { fontSize: "small" }) }), _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(ButtonBase, { type: "button", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: deleted ? t('MessagingThread.DELETED') : (message.kind === 'announcement' ? message.body : (message.body || message.title)) || '' }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), message.poll && _jsx(PollCard, { message: message })] }), hasMeta && _jsxs(Stack, { "data-testid": "message-meta", direction: "row", spacing: 0.5, justifyContent: "flex-end", alignItems: "center", flexWrap: "wrap", sx: { color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", sx: { whiteSpace: 'nowrap' }, children: new Date(message.created_at).toLocaleTimeString(i18n === null || i18n === void 0 ? void 0 : i18n.language, { hour: '2-digit', minute: '2-digit' }) }), message.edited_at && !deleted && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", children: t('MessagingThread.EDITED') }), children] })] })] }), !deleted && (reactions.length > 0 || reactionPickerVisible) && _jsx(ReactionBar, { message: message, expanded: reactionPickerVisible, onExpandedChange: setReactionPickerVisible, sx: { mt: 0.5, ml: 0.75 } }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startReply, children: t('MessagingThread.REPLY') }), _jsx(MenuItem, { onClick: startReact, children: t('MessagingReactions.ADD') }), canAct && _jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), canAct && _jsx(MenuItem, { onClick: () => { setConfirmingDelete(true); closeMenu(); }, children: t('MessagingActions.DELETE') }), _jsx(MenuItem, { onClick: copyMessage, children: t('MessagingActions.COPY') })] }), _jsxs(Dialog, { open: confirmingDelete, onClose: () => !saving && setConfirmingDelete(false), children: [_jsx(DialogTitle, { children: t('MessagingActions.DELETE_CONFIRM_TITLE') }), _jsx(DialogContent, { children: t('MessagingActions.DELETE_CONFIRM_BODY') }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setConfirmingDelete(false), disabled: saving, children: t('MessagingActions.CANCEL') }), _jsx(Button, { onClick: confirmDelete, disabled: saving, color: "error", children: t('MessagingActions.DELETE') })] })] })] }));
|
|
159
|
+
], children: [canOpenMenu && _jsx(IconButton, { className: "message-actions", size: "small", "aria-label": t('MessagingActions.MENU'), onClick: openMenu, sx: MESSAGE_BUBBLE_ACTION_SX, children: _jsx(MoreVertIcon, { fontSize: "small" }) }), _jsxs(Stack, { spacing: 0.75, children: [replyTo && _jsx(ButtonBase, { type: "button", onClick: () => onJumpToMessage === null || onJumpToMessage === void 0 ? void 0 : onJumpToMessage(replyTo.id), sx: { display: 'block', width: '100%', px: 0.75, py: 0.5, textAlign: 'left', borderLeft: 3, borderColor: 'primary.main', bgcolor: 'action.hover', borderRadius: 0.5 }, children: _jsxs(Stack, { spacing: 0, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('MessagingThread.REPLY_TO', { sender: senderName(replyTo) || t('MessagingThread.UNKNOWN_SENDER') }) }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: quoteLabel(replyTo, t) })] }) }), (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct' && _jsx(Typography, { variant: "subtitle2", children: senderName(message) || t('MessagingThread.UNKNOWN_SENDER') }), !deleted && message.kind === 'announcement' && message.title && _jsx(Typography, { variant: "subtitle1", fontWeight: 700, children: message.title }), !deleted && message.kind === 'announcement' && message.link_target && onAnnouncementLink && _jsx(Button, { type: "button", size: "small", onClick: () => onAnnouncementLink(message.link_target), children: t('MessagingAnnouncement.OPEN_LINK') }), error && _jsx(Alert, { severity: "error", role: "alert", children: error }), editing ? _jsxs(Stack, { spacing: 0.75, children: [_jsx(TextField, { label: t('MessagingActions.EDIT'), value: draft, onChange: (event) => setDraft(event.target.value), multiline: true, minRows: 2, autoFocus: true, disabled: saving }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "button", onClick: saveEdit, disabled: saving, children: t('MessagingActions.SAVE') }), _jsx(Button, { type: "button", onClick: () => setEditing(false), disabled: saving, children: t('MessagingActions.CANCEL') })] })] }) : _jsx(Typography, { sx: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: deleted ? t('MessagingThread.DELETED') : linkifyText((message.kind === 'announcement' ? message.body : (message.body || message.title)) || '') }), !deleted && _jsxs(Stack, { spacing: 0.75, children: [attachments.length > 0 && _jsx(AttachmentList, { attachments: attachments }), message.poll && _jsx(PollCard, { message: message })] }), hasMeta && _jsxs(Stack, { "data-testid": "message-meta", direction: "row", spacing: 0.5, justifyContent: "flex-end", alignItems: "center", flexWrap: "wrap", sx: { color: 'text.disabled', minWidth: 0, '& .MuiSvgIcon-root': { fontSize: '1rem' } }, children: [message.created_at && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", sx: { whiteSpace: 'nowrap' }, children: new Date(message.created_at).toLocaleTimeString(i18n === null || i18n === void 0 ? void 0 : i18n.language, { hour: '2-digit', minute: '2-digit' }) }), message.edited_at && !deleted && _jsx(Typography, { component: "span", variant: "caption", color: "inherit", children: t('MessagingThread.EDITED') }), children] })] })] }), !deleted && (reactions.length > 0 || reactionPickerVisible) && _jsx(ReactionBar, { message: message, expanded: reactionPickerVisible, onExpandedChange: setReactionPickerVisible, sx: { mt: 0.5, ml: 0.75 } }), _jsxs(Menu, { anchorEl: menuAnchor, open: Boolean(menuAnchor), onClose: closeMenu, children: [_jsx(MenuItem, { onClick: startReply, children: t('MessagingThread.REPLY') }), _jsx(MenuItem, { onClick: startReact, children: t('MessagingReactions.ADD') }), canAct && _jsx(MenuItem, { onClick: startEdit, children: t('MessagingActions.EDIT') }), canAct && _jsx(MenuItem, { onClick: () => { setConfirmingDelete(true); closeMenu(); }, children: t('MessagingActions.DELETE') }), _jsx(MenuItem, { onClick: copyMessage, children: t('MessagingActions.COPY') })] }), _jsxs(Dialog, { open: confirmingDelete, onClose: () => !saving && setConfirmingDelete(false), children: [_jsx(DialogTitle, { children: t('MessagingActions.DELETE_CONFIRM_TITLE') }), _jsx(DialogContent, { children: t('MessagingActions.DELETE_CONFIRM_BODY') }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: () => setConfirmingDelete(false), disabled: saving, children: t('MessagingActions.CANCEL') }), _jsx(Button, { onClick: confirmDelete, disabled: saving, color: "error", children: t('MessagingActions.DELETE') })] })] })] }));
|
|
159
160
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import DoneAllOutlinedIcon from '@mui/icons-material/DoneAllOutlined';
|
|
3
3
|
import DoneOutlinedIcon from '@mui/icons-material/DoneOutlined';
|
|
4
|
-
import { ButtonBase, List, ListItem, Popover, Typography } from '@mui/material';
|
|
4
|
+
import { ButtonBase, List, ListItem, Popover, Tooltip, Typography } from '@mui/material';
|
|
5
5
|
import { useEffect, useState } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { useMessaging } from './MessagingProvider';
|
|
@@ -49,6 +49,8 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
49
49
|
const hasCounts = typeof status.read_count === 'number' && typeof status.recipient_count === 'number';
|
|
50
50
|
if (!hasCounts && (conversation === null || conversation === void 0 ? void 0 : conversation.kind) !== 'direct')
|
|
51
51
|
return null;
|
|
52
|
+
if (hasCounts && status.recipient_count === 0)
|
|
53
|
+
return null;
|
|
52
54
|
// The server never returns DM detail; enforce the same carve-out defensively.
|
|
53
55
|
const detail = (conversation === null || conversation === void 0 ? void 0 : conversation.kind) === 'direct' ? null : recipientDetail;
|
|
54
56
|
const recipients = (detail === null || detail === void 0 ? void 0 : detail.map((recipient) => recipient.display_name || recipient.username || recipient.user_id).filter(Boolean)) || [];
|
|
@@ -71,8 +73,8 @@ export function ReadTicks({ messageId, conversation }) {
|
|
|
71
73
|
const label = t('MessagingReadTicks.READ_RATIO', { read: status.read_count, total: status.recipient_count });
|
|
72
74
|
const ratio = _jsxs(Typography, { component: "span", variant: "caption", color: "inherit", children: [status.read_count, "/", status.recipient_count] });
|
|
73
75
|
if (!recipients.length)
|
|
74
|
-
return _jsx("span", { role: "img", "aria-label": label, children: ratio });
|
|
75
|
-
return _jsxs(_Fragment, { children: [_jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex' }, children: ratio }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
|
|
76
|
+
return _jsx(Tooltip, { title: label, describeChild: true, children: _jsx("span", { role: "img", "aria-label": label, children: ratio }) });
|
|
77
|
+
return _jsxs(_Fragment, { children: [_jsx(Tooltip, { title: label, describeChild: true, children: _jsx(ButtonBase, { "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": Boolean(anchorEl), onClick: (event) => setAnchorEl(event.currentTarget), sx: { display: 'inline-flex' }, children: ratio }) }), _jsx(Popover, { open: Boolean(anchorEl), anchorEl: anchorEl, onClose: () => setAnchorEl(null), children: _jsx(List, { "aria-label": t('MessagingReadTicks.RECIPIENTS'), children: recipients.map((recipient) => _jsx(ListItem, { children: recipient }, recipient)) }) })] });
|
|
76
78
|
}
|
|
77
79
|
const label = allRead ? t('MessagingReadTicks.ALL_READ') : t('MessagingReadTicks.SENT');
|
|
78
80
|
const StatusIcon = allRead ? DoneAllOutlinedIcon : DoneOutlinedIcon;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Link } from '@mui/material';
|
|
4
|
+
const HTTP_URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g;
|
|
5
|
+
function splitTrailingPunctuation(rawUrl) {
|
|
6
|
+
let url = rawUrl;
|
|
7
|
+
let trailing = '';
|
|
8
|
+
while (/[.,!?]$/.test(url)) {
|
|
9
|
+
trailing = `${url.slice(-1)}${trailing}`;
|
|
10
|
+
url = url.slice(0, -1);
|
|
11
|
+
}
|
|
12
|
+
const openingParentheses = (url.match(/\(/g) || []).length;
|
|
13
|
+
const closingParentheses = (url.match(/\)/g) || []).length;
|
|
14
|
+
if (closingParentheses > openingParentheses) {
|
|
15
|
+
trailing = `)${trailing}`;
|
|
16
|
+
url = url.slice(0, -1);
|
|
17
|
+
}
|
|
18
|
+
return { url, trailing };
|
|
19
|
+
}
|
|
20
|
+
export function linkifyText(text) {
|
|
21
|
+
var _a;
|
|
22
|
+
const value = String(text !== null && text !== void 0 ? text : '');
|
|
23
|
+
const parts = [];
|
|
24
|
+
let lastIndex = 0;
|
|
25
|
+
for (const match of value.matchAll(HTTP_URL_PATTERN)) {
|
|
26
|
+
const rawUrl = match[0];
|
|
27
|
+
const start = (_a = match.index) !== null && _a !== void 0 ? _a : 0;
|
|
28
|
+
if (start > lastIndex)
|
|
29
|
+
parts.push(value.slice(lastIndex, start));
|
|
30
|
+
const { url, trailing } = splitTrailingPunctuation(rawUrl);
|
|
31
|
+
parts.push(_jsx(Link, { href: url, target: "_blank", rel: "noopener noreferrer", children: url }, `url-${start}`));
|
|
32
|
+
if (trailing)
|
|
33
|
+
parts.push(trailing);
|
|
34
|
+
lastIndex = start + rawUrl.length;
|
|
35
|
+
}
|
|
36
|
+
if (lastIndex < value.length)
|
|
37
|
+
parts.push(value.slice(lastIndex));
|
|
38
|
+
return parts.length ? parts : [value];
|
|
39
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// src/auth/pages/PasswordInvitePage.jsx
|
|
3
3
|
import React, { useState, useEffect } from 'react';
|
|
4
4
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
|
5
5
|
import { Helmet } from 'react-helmet';
|
|
6
|
-
import { Typography } from '@mui/material';
|
|
6
|
+
import { Button, Typography } from '@mui/material';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { NarrowPage } from '../layout/PageLayout';
|
|
9
9
|
import { PasswordSetForm } from '../components/PasswordSetForm';
|
|
@@ -17,7 +17,9 @@ export function PasswordInvitePage() {
|
|
|
17
17
|
const nextPath = searchParams.get('next');
|
|
18
18
|
const [submitting, setSubmitting] = useState(false);
|
|
19
19
|
const [errorKey, setErrorKey] = useState(null);
|
|
20
|
+
const [submitErrorKey, setSubmitErrorKey] = useState(null);
|
|
20
21
|
const [successKey, setSuccessKey] = useState(null);
|
|
22
|
+
const [continueTarget, setContinueTarget] = useState(null);
|
|
21
23
|
const [checked, setChecked] = useState(false);
|
|
22
24
|
// Unterscheidung Invite-Link vs klassischer Reset
|
|
23
25
|
const isInvite = location.pathname.startsWith('/invite/');
|
|
@@ -47,12 +49,12 @@ export function PasswordInvitePage() {
|
|
|
47
49
|
check();
|
|
48
50
|
}, [uid, token]);
|
|
49
51
|
const handleSubmit = async (newPassword) => {
|
|
52
|
+
var _a, _b, _c;
|
|
50
53
|
if (!uid || !token) {
|
|
51
|
-
setErrorKey('Auth.RESET_LINK_INVALID');
|
|
52
54
|
return;
|
|
53
55
|
}
|
|
54
56
|
setSubmitting(true);
|
|
55
|
-
|
|
57
|
+
setSubmitErrorKey(null);
|
|
56
58
|
setSuccessKey(null);
|
|
57
59
|
try {
|
|
58
60
|
await setNewPassword(uid, token, newPassword);
|
|
@@ -80,17 +82,30 @@ export function PasswordInvitePage() {
|
|
|
80
82
|
safeNextPath = parsed.pathname + parsed.search + parsed.hash;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
|
-
catch (
|
|
85
|
+
catch (_d) {
|
|
84
86
|
// fall through to default redirect
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
const target = safeNextPath
|
|
88
90
|
? `/login?next=${encodeURIComponent(safeNextPath)}`
|
|
89
91
|
: '/login';
|
|
90
|
-
|
|
92
|
+
setContinueTarget(target);
|
|
91
93
|
}
|
|
92
94
|
catch (err) {
|
|
93
|
-
|
|
95
|
+
// Told apart by CODE, not by the presence of `messages` (a submission
|
|
96
|
+
// can fail with a link problem too, e.g. the token expires in the gap
|
|
97
|
+
// between the page's initial verifyResetToken check and this submit) —
|
|
98
|
+
// only a password rejection keeps the form; a link problem must still
|
|
99
|
+
// end in the same invalid-link state the initial check produces.
|
|
100
|
+
if (err.code === 'Auth.RESET_LINK_INVALID') {
|
|
101
|
+
setErrorKey(err.code);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const responseMessages = ((_a = err.raw) === null || _a === void 0 ? void 0 : _a.messages) || ((_c = (_b = err.response) === null || _b === void 0 ? void 0 : _b.data) === null || _c === void 0 ? void 0 : _c.messages);
|
|
105
|
+
setSubmitErrorKey(Array.isArray(responseMessages) && responseMessages.length > 0
|
|
106
|
+
? responseMessages.join(' ')
|
|
107
|
+
: t(err.code || 'Auth.RESET_PASSWORD_FAILED'));
|
|
108
|
+
}
|
|
94
109
|
}
|
|
95
110
|
finally {
|
|
96
111
|
setSubmitting(false);
|
|
@@ -100,5 +115,5 @@ export function PasswordInvitePage() {
|
|
|
100
115
|
if (!checked && !errorKey) {
|
|
101
116
|
return (_jsx(NarrowPage, { title: t('Auth.PAGE_CHECKING_LINK_TITLE'), children: _jsx(Typography, { children: t('Auth.PAGE_CHECKING_LINK_TEXT') }) }));
|
|
102
117
|
}
|
|
103
|
-
return (_jsxs(NarrowPage, { title: t(pageTitleKey), subtitle: t(pageSubtitleKey), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t(pageTitleKey)] }) }), errorKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: t(errorKey) })), successKey && (_jsx(Typography, { color: "primary", gutterBottom: true, children: t(successKey) })), !successKey && !errorKey && (_jsx(PasswordSetForm, { onSubmit: handleSubmit, submitting: submitting }))] }));
|
|
118
|
+
return (_jsxs(NarrowPage, { title: t(pageTitleKey), subtitle: t(pageSubtitleKey), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t(pageTitleKey)] }) }), errorKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: t(errorKey) })), successKey && (_jsxs(_Fragment, { children: [_jsx(Typography, { color: "primary", gutterBottom: true, children: t(successKey) }), _jsx(Button, { variant: "contained", onClick: () => navigate(continueTarget), children: t('Auth.SIGNUP_GO_TO_LOGIN') })] })), submitErrorKey && !successKey && (_jsx(Typography, { color: "error", gutterBottom: true, children: submitErrorKey })), !successKey && !errorKey && (_jsx(PasswordSetForm, { onSubmit: handleSubmit, submitting: submitting }))] }));
|
|
104
119
|
}
|
package/dist/pages/SignUpPage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
4
4
|
import { Alert, Box, Button, Stack, TextField, Typography, } from '@mui/material';
|
|
@@ -117,7 +117,7 @@ export function SignUpPage() {
|
|
|
117
117
|
const handleGoToLogin = () => {
|
|
118
118
|
navigate('/login');
|
|
119
119
|
};
|
|
120
|
-
return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), successKey && (_jsx(Alert, { severity: "success", sx: SIGN_UP_PAGE_ALERT_SX, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: SIGN_UP_PAGE_ALERT_SX, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _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: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting ||
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
return (_jsxs(NarrowPage, { title: t('Auth.LOGIN_SIGNUP_BUTTON'), subtitle: pageSubtitle, children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.LOGIN_SIGNUP_BUTTON')] }) }), signupModes.length === 0 ? (_jsx(Alert, { severity: "info", sx: SIGN_UP_PAGE_ALERT_SX, children: t('Auth.SELF_SIGNUP_DISABLED', 'Self sign-up is currently disabled.') })) : (_jsxs(_Fragment, { children: [successKey && (_jsx(Alert, { severity: "success", sx: SIGN_UP_PAGE_ALERT_SX, children: t(successKey, { email }) })), errorKey && (_jsx(Alert, { severity: "error", sx: SIGN_UP_PAGE_ALERT_SX, children: t(errorKey, t('Auth.INVITE_FAILED', 'Could not complete signup.')) })), signupModes.length > 1 && (_jsx(Stack, { spacing: 1, sx: { mb: 2 }, children: _jsx(Stack, { spacing: 1, children: signupModes.map((entry) => (_jsx(Button, { variant: mode === entry ? 'contained' : 'outlined', onClick: () => setMode(entry), disabled: submitting, fullWidth: true, children: t(MODE_LABELS[entry] || entry, entry) }, entry))) }) })), _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: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }), mode === 'self_signup_access_code' && (_jsx(TextField, { label: t('Auth.ACCESS_CODE_LABEL'), type: "text", required: true, fullWidth: true, value: accessCode, onChange: (e) => setAccessCode(e.target.value), disabled: submitting })), turnstileRequired && (_jsx(TurnstileWidget, { ref: turnstileWidgetRef, siteKey: authMethods.turnstile_site_key, onToken: setTurnstileToken })), _jsx(Button, { type: "submit", variant: "contained", disabled: submitting || (turnstileRequired && !turnstileToken), children: submitting
|
|
121
|
+
? t('Auth.SIGNUP_SUBMITTING')
|
|
122
|
+
: t('Auth.SIGNUP_SUBMIT') })] })] })), _jsx(Box, { sx: { mt: 3 }, children: _jsxs(Typography, { variant: "body2", children: [t('Auth.SIGNUP_ALREADY_HAVE_ACCOUNT'), ' ', _jsx(Button, { onClick: handleGoToLogin, variant: "text", size: "small", children: t('Auth.SIGNUP_GO_TO_LOGIN') })] }) })] }));
|
|
123
123
|
}
|
|
@@ -20,7 +20,7 @@ import { CHART_FRAME_ALERT_SX, CHART_FRAME_ROOT_SX } from '../components/charts/
|
|
|
20
20
|
import { PAGE_LAYOUT_CONTAINER_SX } from '../layout/PageLayout';
|
|
21
21
|
import { SECTION_NAV_DRAWER_PAPER_SX, SECTION_NAV_GROUP_PAPER_SX, } from '../layout/SectionNav';
|
|
22
22
|
import { ATTACHMENT_LIST_ALERT_SX, ATTACHMENT_LIST_LIGHTBOX_ACTION_SX, } from '../messaging/AttachmentList';
|
|
23
|
-
import { COMPOSER_EMOJI_BUTTON_SX } from '../messaging/Composer';
|
|
23
|
+
import { COMPOSER_EMOJI_BUTTON_SX, COMPOSER_MESSAGE_FIELD_SX, COMPOSER_SEND_BUTTON_SX, } from '../messaging/Composer';
|
|
24
24
|
import { DIRECT_MESSAGE_LAUNCHER_ALERT_SX } from '../messaging/DirectMessageLauncher';
|
|
25
25
|
import { MESSAGE_BUBBLE_ACTION_SX, MESSAGE_BUBBLE_ROOT_SX } from '../messaging/MessageBubble';
|
|
26
26
|
import { POLL_CARD_CHECKBOX_SX, POLL_CARD_FORM_CONTROL_SX, POLL_CARD_ROOT_SX, } from '../messaging/PollCard';
|
|
@@ -51,6 +51,8 @@ const SX_EXPORTS = {
|
|
|
51
51
|
CHART_FRAME_ALERT_SX,
|
|
52
52
|
CHART_FRAME_ROOT_SX,
|
|
53
53
|
COMPOSER_EMOJI_BUTTON_SX,
|
|
54
|
+
COMPOSER_MESSAGE_FIELD_SX,
|
|
55
|
+
COMPOSER_SEND_BUTTON_SX,
|
|
54
56
|
DIRECT_MESSAGE_LAUNCHER_ALERT_SX,
|
|
55
57
|
LOGIN_FORM_DIVIDER_SX,
|
|
56
58
|
LOGIN_PAGE_ALERT_SX,
|
|
@@ -177,6 +179,8 @@ export const KIT_COMPONENT_SX_REGISTRY = [
|
|
|
177
179
|
entry('AttachmentList.lightboxAction', 'MuiIconButton', ATTACHMENT_LIST_LIGHTBOX_ACTION_SX),
|
|
178
180
|
entry('AttachmentList.alert', 'MuiAlert', ATTACHMENT_LIST_ALERT_SX),
|
|
179
181
|
entry('Composer.emojiAction', 'MuiIconButton', COMPOSER_EMOJI_BUTTON_SX),
|
|
182
|
+
entry('Composer.messageField', 'MuiTextField', COMPOSER_MESSAGE_FIELD_SX),
|
|
183
|
+
entry('Composer.sendAction', 'MuiButton', COMPOSER_SEND_BUTTON_SX),
|
|
180
184
|
entry('DirectMessageLauncher.alert', 'MuiAlert', DIRECT_MESSAGE_LAUNCHER_ALERT_SX),
|
|
181
185
|
entry('MessageBubble.root', 'MuiPaper', MESSAGE_BUBBLE_ROOT_SX),
|
|
182
186
|
entry('MessageBubble.action', 'MuiIconButton', MESSAGE_BUBBLE_ACTION_SX),
|
|
@@ -17,7 +17,7 @@ export function extractErrorInfo(error) {
|
|
|
17
17
|
}
|
|
18
18
|
// Fallback for generic Django errors
|
|
19
19
|
if (typeof data.detail === 'string') {
|
|
20
|
-
return { status, code:
|
|
20
|
+
return { status, code: null, message: data.detail, raw: data };
|
|
21
21
|
}
|
|
22
22
|
return { status, code: null, message: null, raw: data };
|
|
23
23
|
}
|
package/package.json
CHANGED
package/src/auth/UserMenu.jsx
CHANGED
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
MenuItem,
|
|
10
10
|
Typography,
|
|
11
11
|
} from '@mui/material';
|
|
12
|
+
import { useTheme } from '@mui/material/styles';
|
|
12
13
|
import { useTranslation } from 'react-i18next';
|
|
13
14
|
|
|
14
15
|
import { AuthContext } from './AuthContext';
|
|
16
|
+
import { calculateContrastRatio } from '../theme';
|
|
15
17
|
|
|
16
18
|
function getInitials(user) {
|
|
17
19
|
if (!user) return '';
|
|
@@ -30,8 +32,22 @@ function getDisplayName(user) {
|
|
|
30
32
|
export function UserMenu({ resolveLink, items = [], profileLink = '/account', avatarSrc }) {
|
|
31
33
|
const { t } = useTranslation();
|
|
32
34
|
const { user, logout } = useContext(AuthContext);
|
|
35
|
+
const theme = useTheme();
|
|
33
36
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
34
37
|
const open = Boolean(anchorEl);
|
|
38
|
+
const avatarBackground = theme.palette.primary.main;
|
|
39
|
+
// True white/black (theme.palette.common.*, always present on any MUI theme,
|
|
40
|
+
// never a source-level literal), not background.paper/ink.primary: contrast
|
|
41
|
+
// against true white and true black multiplies to a constant 21 regardless
|
|
42
|
+
// of the background's luminance, so max(contrastWhite, contrastBlack) is
|
|
43
|
+
// mathematically guaranteed to be >= sqrt(21) =~ 4.58 for ANY primary.main a
|
|
44
|
+
// consumer sets -- a near-black token like ink.primary (#212529, luminance
|
|
45
|
+
// ~0.018, not 0) does not carry that guarantee and left a real gap for a
|
|
46
|
+
// medium-luminance primary (caught by ui_reviewer, neither required test
|
|
47
|
+
// exercised the fallback branch that gap lived in).
|
|
48
|
+
const avatarText = calculateContrastRatio(theme.palette.common.white, avatarBackground) >= 4.5
|
|
49
|
+
? theme.palette.common.white
|
|
50
|
+
: theme.palette.common.black;
|
|
35
51
|
|
|
36
52
|
const handleClose = () => setAnchorEl(null);
|
|
37
53
|
|
|
@@ -57,7 +73,16 @@ export function UserMenu({ resolveLink, items = [], profileLink = '/account', av
|
|
|
57
73
|
color="inherit"
|
|
58
74
|
onClick={(event) => setAnchorEl(event.currentTarget)}
|
|
59
75
|
>
|
|
60
|
-
<Avatar
|
|
76
|
+
<Avatar
|
|
77
|
+
src={avatarSrc}
|
|
78
|
+
sx={{
|
|
79
|
+
width: 32,
|
|
80
|
+
height: 32,
|
|
81
|
+
fontSize: '0.8rem',
|
|
82
|
+
bgcolor: avatarBackground,
|
|
83
|
+
color: avatarText,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
61
86
|
{avatarSrc ? null : getInitials(user)}
|
|
62
87
|
</Avatar>
|
|
63
88
|
</IconButton>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Box, Button, Typography } from '@mui/material';
|
|
2
|
+
import { cloneElement } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A shared, compact presentation for an empty section: optional decorative icon,
|
|
6
|
+
* title, supporting description, and an optional outlined action.
|
|
7
|
+
*/
|
|
8
|
+
export default function EmptyState({ icon, title, description, action, sx }) {
|
|
9
|
+
return (
|
|
10
|
+
<Box
|
|
11
|
+
sx={[{
|
|
12
|
+
display: 'flex',
|
|
13
|
+
flexDirection: 'column',
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
gap: 1,
|
|
16
|
+
p: 2,
|
|
17
|
+
textAlign: 'center',
|
|
18
|
+
}, sx]}
|
|
19
|
+
>
|
|
20
|
+
{icon && (
|
|
21
|
+
<Box sx={{ color: 'text.secondary', display: 'flex' }}>
|
|
22
|
+
{cloneElement(icon, { 'aria-hidden': true, focusable: false })}
|
|
23
|
+
</Box>
|
|
24
|
+
)}
|
|
25
|
+
<Typography component="p">{title}</Typography>
|
|
26
|
+
{description && (
|
|
27
|
+
<Typography component="p" variant="body2" color="text.secondary">
|
|
28
|
+
{description}
|
|
29
|
+
</Typography>
|
|
30
|
+
)}
|
|
31
|
+
{action && (
|
|
32
|
+
<Button type="button" variant="outlined" onClick={action.onClick}>
|
|
33
|
+
{action.label}
|
|
34
|
+
</Button>
|
|
35
|
+
)}
|
|
36
|
+
</Box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -118,6 +118,7 @@ export function UserListComponent({
|
|
|
118
118
|
await updateUserRole(userId, newRole);
|
|
119
119
|
}
|
|
120
120
|
await loadUsers();
|
|
121
|
+
alert(t('UserList.ROLE_UPDATE_SUCCESS', 'Role updated.'));
|
|
121
122
|
} catch (err) {
|
|
122
123
|
alert(t(err.code || 'Auth.USER_ROLE_UPDATE_FAILED'));
|
|
123
124
|
}
|
|
@@ -588,6 +589,7 @@ export function UserListComponent({
|
|
|
588
589
|
setPageSize(Number(event.target.value));
|
|
589
590
|
setPage(0);
|
|
590
591
|
}}
|
|
592
|
+
labelRowsPerPage={t('UserList.ROWS_PER_PAGE', 'Rows per page:')}
|
|
591
593
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
|
592
594
|
/>
|
|
593
595
|
</Box>
|