@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/DESIGN.md +1 -1
  3. package/README.md +2 -0
  4. package/WORK_ORDERS.md +8 -1
  5. package/dist/auth/UserMenu.js +23 -1
  6. package/dist/components/EmptyState.js +17 -0
  7. package/dist/components/UserListComponent.js +2 -1
  8. package/dist/i18n/authTranslations.js +238 -0
  9. package/dist/i18n/uiCoreTranslations.js +19 -0
  10. package/dist/index.js +2 -1
  11. package/dist/messaging/Composer.js +4 -2
  12. package/dist/messaging/MessageBubble.js +2 -1
  13. package/dist/messaging/ReadTicks.js +5 -3
  14. package/dist/messaging/linkifyText.js +39 -0
  15. package/dist/pages/PasswordInvitePage.js +23 -8
  16. package/dist/pages/SignUpPage.js +4 -4
  17. package/dist/theme/kitSxRegistry.js +5 -1
  18. package/dist/utils/auth-errors.js +1 -1
  19. package/package.json +1 -1
  20. package/src/auth/UserMenu.jsx +26 -1
  21. package/src/components/EmptyState.jsx +38 -0
  22. package/src/components/UserListComponent.jsx +2 -0
  23. package/src/i18n/authTranslations.ts +243 -0
  24. package/src/i18n/uiCoreTranslations.js +10 -0
  25. package/src/index.js +2 -1
  26. package/src/messaging/Composer.jsx +7 -3
  27. package/src/messaging/MessageBubble.jsx +2 -1
  28. package/src/messaging/ReadTicks.jsx +4 -3
  29. package/src/messaging/linkifyText.jsx +37 -0
  30. package/src/pages/PasswordInvitePage.jsx +36 -7
  31. package/src/pages/SignUpPage.jsx +79 -68
  32. package/src/theme/kitSxRegistry.js +9 -1
  33. package/src/utils/auth-errors.js +2 -2
  34. package/tests/EmptyState.test.jsx +62 -0
  35. package/tests/PasswordInvitePage.test.jsx +135 -0
  36. package/tests/SignUpPage.test.jsx +15 -2
  37. package/tests/UserListComponent.test.jsx +85 -0
  38. package/tests/UserMenu.test.jsx +40 -4
  39. package/tests/authErrors.test.js +43 -0
  40. package/tests/i18nGermanVariant.test.js +65 -0
  41. package/tests/messagingComposer.test.jsx +25 -0
  42. package/tests/messagingLinkify.test.jsx +51 -0
  43. package/tests/messagingReadTicksTooltip.test.jsx +36 -0
  44. package/tests/userScreenTranslations.test.jsx +165 -0
  45. package/work-orders/AUTH-8.md +184 -0
  46. package/work-orders/UCM-AUTH-9.md +207 -0
  47. package/work-orders/UCM-I18N-4.md +226 -0
  48. package/work-orders/UCM-MSG-20.md +192 -0
  49. package/work-orders/UCM-PRIM-2.md +114 -0
  50. package/work-orders/UCM-SHELL-7.md +134 -0
  51. package/work-orders/UCM-THEME-13.md +69 -0
  52. 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
- setErrorKey(null);
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 (_a) {
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
- navigate(target);
92
+ setContinueTarget(target);
91
93
  }
92
94
  catch (err) {
93
- setErrorKey(err.code || 'Auth.RESET_PASSWORD_FAILED');
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
  }
@@ -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 || signupModes.length === 0 || (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') })] }) })] }));
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: 'GENERIC', message: data.detail, raw: data };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "3.5.1",
3
+ "version": "3.7.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -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 src={avatarSrc} sx={{ width: 32, height: 32, fontSize: '0.8rem' }}>
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>