This commit is contained in:
2026-05-04 17:24:41 +02:00
parent f1694ac99b
commit 7950bc9f64
10 changed files with 255 additions and 96 deletions
@@ -328,6 +328,7 @@ def send_email_message(
attempts: list[dict[str, Any]] = [] attempts: list[dict[str, Any]] = []
last_error = "" last_error = ""
fallback_from_address = smtp_username if smtp_username and smtp_username != from_address else None
for mode in _smtp_mode_candidates(settings): for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode) meta = _smtp_attempt_metadata(mode)
logger.info( logger.info(
@@ -341,40 +342,6 @@ def send_email_message(
) )
try: try:
_send_email_via_mode(mode, message, recipients, from_address) _send_email_via_mode(mode, message, recipients, from_address)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
return {
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_list),
"subject": subject,
"authenticated_as": smtp_username or None,
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc: except Exception as exc:
last_error = describe_smtp_error(exc) last_error = describe_smtp_error(exc)
attempts.append( attempts.append(
@@ -388,9 +355,9 @@ def send_email_message(
"error": last_error, "error": last_error,
} }
) )
if _smtp_sender_not_authorized(exc): if _smtp_sender_not_authorized(exc) and fallback_from_address:
logger.warning( logger.warning(
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s", "SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s; retrying with smtp_username",
meta["label"], meta["label"],
meta["smtp_host"], meta["smtp_host"],
meta["smtp_port"], meta["smtp_port"],
@@ -399,16 +366,124 @@ def send_email_message(
from_address, from_address,
last_error, last_error,
) )
else: fallback_message, fallback_from = build_email_message(
logger.warning( settings,
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s", recipients,
subject,
html_body,
text_body,
attachment_list,
sender_address=fallback_from_address,
reply_to_address=from_address,
)
try:
_send_email_via_mode(mode, fallback_message, recipients, fallback_from)
except Exception as fallback_exc:
last_error = describe_smtp_error(fallback_exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
"sender_fallback": True,
}
)
logger.warning(
"SMTP send fallback failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
fallback_from,
last_error,
)
continue
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
"sender_fallback": True,
}
)
logger.info(
"SMTP send succeeded via smtp_username label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"], meta["label"],
meta["smtp_host"], meta["smtp_host"],
meta["smtp_port"], meta["smtp_port"],
meta["transport"], meta["transport"],
meta["auth_user"], meta["auth_user"],
from_address, fallback_from,
last_error,
) )
return {
"from_address": fallback_from,
"recipient_count": len(recipients),
"attachment_count": len(attachment_list),
"subject": subject,
"authenticated_as": smtp_username or None,
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
logger.warning(
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
continue
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
return {
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_list),
"subject": subject,
"authenticated_as": smtp_username or None,
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
raise RuntimeError(last_error or "SMTP delivery failed") raise RuntimeError(last_error or "SMTP delivery failed")
+18 -13
View File
@@ -103,7 +103,7 @@ class MailerTests(unittest.TestCase):
self.assertEqual(result["attempts"][1]["status"], "ok") self.assertEqual(result["attempts"][1]["status"], "ok")
fallback_smtp.send_message.assert_called_once() fallback_smtp.send_message.assert_called_once()
def test_send_email_message_rejects_unauthorized_from_address(self) -> None: def test_send_email_message_retries_with_smtp_username_when_from_is_rejected(self) -> None:
settings = SimpleNamespace( settings = SimpleNamespace(
smtp_host="smtp.example.com", smtp_host="smtp.example.com",
smtp_port=587, smtp_port=587,
@@ -116,25 +116,30 @@ class MailerTests(unittest.TestCase):
smtp_timeout=15, smtp_timeout=15,
) )
smtp = MagicMock() smtp = MagicMock()
smtp.send_message.side_effect = smtplib.SMTPDataError( smtp.send_message.side_effect = [
551, b"5.7.1 Not authorised to send from this header address" smtplib.SMTPDataError(551, b"5.7.1 Not authorised to send from this header address"),
) {},
]
smtp_factory = MagicMock(return_value=_SMTPContext(smtp)) smtp_factory = MagicMock(return_value=_SMTPContext(smtp))
with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch( with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch(
"media_library_viewer_api.services.mailer.smtplib.SMTP_SSL" "media_library_viewer_api.services.mailer.smtplib.SMTP_SSL"
) as smtp_ssl: ) as smtp_ssl:
with self.assertRaises(RuntimeError) as ctx: result = send_email_message(
send_email_message( settings,
settings, recipients=["alex@example.com"],
recipients=["alex@example.com"], subject="Hello",
subject="Hello", html_body="<p>Hello</p>",
html_body="<p>Hello</p>", )
)
smtp_ssl.assert_not_called() smtp_ssl.assert_not_called()
self.assertIn("authorized alias", str(ctx.exception).lower()) self.assertEqual(result["from_address"], "mailer@example.com")
self.assertEqual(smtp.send_message.call_count, 1) self.assertEqual(smtp.send_message.call_count, 2)
first_message = smtp.send_message.call_args_list[0].args[0]
second_message = smtp.send_message.call_args_list[1].args[0]
self.assertEqual(first_message["From"], "Manage <alias@example.com>")
self.assertEqual(second_message["From"], "Manage <mailer@example.com>")
self.assertEqual(second_message["Reply-To"], "alias@example.com")
def test_describe_smtp_error_handles_timeout(self) -> None: def test_describe_smtp_error_handles_timeout(self) -> None:
detail = describe_smtp_error(TimeoutError("timed out")) detail = describe_smtp_error(TimeoutError("timed out"))
+2
View File
@@ -61,6 +61,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts. - The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts.
- The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter. - The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter.
- The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented. - The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented.
- The frontend shell and primary pages should remain responsive and mobile-safe, with compact navigation, stacked controls on narrow screens, and reduced table column density where needed.
- Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys. - Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys.
### Remote Filesystem over SSH ### Remote Filesystem over SSH
@@ -173,3 +174,4 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample. - 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample.
- 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows. - 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows.
- 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image. - 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image.
- 2026-05-04: The frontend was adjusted to be more mobile-safe by making the app shell tabs scrollable, stacking header controls on narrow screens, and hiding low-priority table columns on smaller displays.
+65 -27
View File
@@ -49,20 +49,75 @@ function Shell({
}) { }) {
const location = useLocation(); const location = useLocation();
const current = location.pathname; const current = location.pathname;
const isMobile = useMediaQuery("(max-width: 900px)");
return ( return (
<> <>
<CssBaseline /> <CssBaseline />
<AppBar position="sticky" color="inherit" elevation={0}> <AppBar position="sticky" color="inherit" elevation={0}>
<Toolbar sx={{ display: "flex", gap: 2, minHeight: 68 }}> <Toolbar
<Typography variant="h6" sx={{ mr: 2, fontWeight: 700 }}> sx={{
Manage display: "flex",
</Typography> flexDirection: { xs: "column", md: "row" },
alignItems: { xs: "stretch", md: "center" },
gap: 1,
py: { xs: 1, md: 0 },
minHeight: { xs: "auto", md: 68 },
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
gap: 1.5,
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1 }}>
Manage
</Typography>
<Stack
direction="row"
spacing={0.75}
sx={{
alignItems: "center",
flexWrap: "wrap",
justifyContent: "flex-end",
}}
>
{authLabel && (
<Chip size="small" variant="outlined" label={authLabel} />
)}
<Chip
size="small"
variant="outlined"
label={darkMode ? "Dark" : "Light"}
/>
{onSignOut && (
<Button size="small" variant="text" onClick={onSignOut}>
Sign out
</Button>
)}
</Stack>
</Box>
<Tabs <Tabs
value={current} value={current}
textColor="primary" textColor="primary"
indicatorColor="primary" indicatorColor="primary"
sx={{ flex: 1 }} variant={isMobile ? "scrollable" : "standard"}
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
width: "100%",
minHeight: 40,
"& .MuiTab-root": {
minHeight: 40,
py: 1,
px: 1.25,
minWidth: { xs: 96, md: 120 },
},
}}
> >
<Tab value="/" label="Dashboard" component={NavLink} to="/" /> <Tab value="/" label="Dashboard" component={NavLink} to="/" />
<Tab <Tab
@@ -73,31 +128,14 @@ function Shell({
/> />
<Tab value="/media" label="Media" component={NavLink} to="/media" /> <Tab value="/media" label="Media" component={NavLink} to="/media" />
<Tab value="/users" label="Users" component={NavLink} to="/users" /> <Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab <Tab value="/files" label="Files" component={NavLink} to="/files" />
value="/files"
label="File Browser"
component={NavLink}
to="/files"
/>
</Tabs> </Tabs>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{authLabel && (
<Chip size="small" variant="outlined" label={authLabel} />
)}
<Chip
size="small"
variant="outlined"
label={darkMode ? "Dark" : "Light"}
/>
{onSignOut && (
<Button size="small" variant="text" onClick={onSignOut}>
Sign out
</Button>
)}
</Stack>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Container maxWidth={false} sx={{ py: 3 }}> <Container
maxWidth={false}
sx={{ py: { xs: 2, md: 3 }, px: { xs: 1.5, sm: 2.5, md: 3 } }}
>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} /> <Route path="/monitoring" element={<Monitoring />} />
+12 -4
View File
@@ -8,8 +8,8 @@ interface Props {
export function MetricCard({ label, value, subtext }: Props) { export function MetricCard({ label, value, subtext }: Props) {
return ( return (
<Card variant="outlined"> <Card variant="outlined" sx={{ height: "100%" }}>
<CardContent> <CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
@@ -17,14 +17,22 @@ export function MetricCard({ label, value, subtext }: Props) {
> >
{label} {label}
</Typography> </Typography>
<Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700 }}> <Typography
variant="h5"
sx={{
mt: 0.5,
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
lineHeight: 1.15,
}}
>
{value} {value}
</Typography> </Typography>
{subtext && ( {subtext && (
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ whiteSpace: "pre-line" }} sx={{ whiteSpace: "pre-line", display: "block", mt: 0.25 }}
> >
{subtext} {subtext}
</Typography> </Typography>
@@ -79,7 +79,7 @@ export function SessionActivityPanel({
<TableContainer <TableContainer
component={Paper} component={Paper}
variant="outlined" variant="outlined"
sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1 }} sx={{ maxHeight: 280, borderColor: "divider", borderRadius: 1, overflowX: "auto" }}
> >
<Table size="small" stickyHeader aria-label="Session activity details"> <Table size="small" stickyHeader aria-label="Session activity details">
<TableHead> <TableHead>
@@ -198,7 +198,7 @@ export function SessionActivityPanel({
} }
/> />
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}> <TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
<Typography <Typography
variant="body2" variant="body2"
noWrap noWrap
@@ -210,12 +210,12 @@ export function SessionActivityPanel({
{session.type || "—"} {session.type || "—"}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}> <TableCell sx={{ py: 0.75, minWidth: 140, display: { xs: "none", md: "table-cell" } }}>
<Typography variant="body2" noWrap> <Typography variant="body2" noWrap>
{session.device || "Unknown device"} {session.device || "Unknown device"}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}> <TableCell sx={{ py: 0.75, whiteSpace: "nowrap", display: { xs: "none", md: "table-cell" } }}>
<Typography variant="body2" noWrap> <Typography variant="body2" noWrap>
{session.transcoding === "yes" {session.transcoding === "yes"
? session.transcoding_type ? session.transcoding_type
+5
View File
@@ -4,4 +4,9 @@ body,
margin: 0; margin: 0;
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
overflow-x: hidden;
}
* {
box-sizing: border-box;
} }
+7 -4
View File
@@ -17,6 +17,7 @@ import {
Stack, Stack,
TextField, TextField,
Typography, Typography,
useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { import {
useDirectoryListing, useDirectoryListing,
@@ -538,6 +539,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
export function FileBrowser() { export function FileBrowser() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)");
const initialRequestedPath = searchParams.get("path") ?? "/"; const initialRequestedPath = searchParams.get("path") ?? "/";
const initialSelectedPath = const initialSelectedPath =
initialRequestedPath !== "/" && initialRequestedPath !== "/" &&
@@ -628,7 +630,7 @@ export function FileBrowser() {
<Stack spacing={2}> <Stack spacing={2}>
<Typography variant="h5">File Browser</Typography> <Typography variant="h5">File Browser</Typography>
<Stack direction="row" spacing={1}> <Stack direction={isMobile ? "column" : "row"} spacing={1}>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
@@ -637,10 +639,10 @@ export function FileBrowser() {
onChange={(e) => setPathInput(e.target.value)} onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit} onKeyDown={handlePathSubmit}
/> />
<Button variant="outlined" onClick={() => navigate(pathInput || "/")}> <Button fullWidth={isMobile} variant="outlined" onClick={() => navigate(pathInput || "/")}>
Open Open
</Button> </Button>
<Button variant="outlined" onClick={() => refetch()}> <Button fullWidth={isMobile} variant="outlined" onClick={() => refetch()}>
Refresh Refresh
</Button> </Button>
</Stack> </Stack>
@@ -667,6 +669,7 @@ export function FileBrowser() {
columns={columns} columns={columns}
loading={isLoading} loading={isLoading}
rowSelectionModel={rowSelectionModel} rowSelectionModel={rowSelectionModel}
columnVisibilityModel={isMobile ? { ext: false, modified: false } : undefined}
hideFooter hideFooter
sx={{ sx={{
"& .MuiDataGrid-columnHeaders": { "& .MuiDataGrid-columnHeaders": {
@@ -731,7 +734,7 @@ export function FileBrowser() {
</FormControl> </FormControl>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 8 }}> <Grid size={{ xs: 12, md: 8 }}>
<Stack direction="row" spacing={1}> <Stack direction={isMobile ? "column" : "row"} spacing={1}>
<Button <Button
variant="contained" variant="contained"
disabled={!selectedJob || runJob.isPending} disabled={!selectedJob || runJob.isPending}
+19 -2
View File
@@ -17,10 +17,11 @@ import {
Stack, Stack,
TextField, TextField,
Typography, Typography,
useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { import {
useMediaStatus, useMediaStatus,
useMediaQuery, useMediaQuery as useMediaDataQuery,
useBuildIndex, useBuildIndex,
useStopBuildIndex, useStopBuildIndex,
useForceStopBuildIndex, useForceStopBuildIndex,
@@ -40,6 +41,7 @@ function formatDuration(seconds: number | null | undefined): string {
export function Media() { export function Media() {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useMediaQuery("(max-width: 900px)");
const { data: status } = useMediaStatus(); const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex(); const buildIndex = useBuildIndex();
const stopBuildIndex = useStopBuildIndex(); const stopBuildIndex = useStopBuildIndex();
@@ -53,7 +55,7 @@ export function Media() {
const [limit] = useState(100); const [limit] = useState(100);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const { data: queryResult, isLoading } = useMediaQuery({ const { data: queryResult, isLoading } = useMediaDataQuery({
types, types,
search, search,
hdr_filter: hdrFilter, hdr_filter: hdrFilter,
@@ -365,6 +367,21 @@ export function Media() {
navigate(`/files?path=${encodeURIComponent(row.path)}`); navigate(`/files?path=${encodeURIComponent(row.path)}`);
}} }}
pageSizeOptions={[100]} pageSizeOptions={[100]}
columnVisibilityModel={
isMobile
? {
series: false,
season: false,
episode: false,
bitrate: false,
video: false,
resolution: false,
date_added: false,
library: false,
path: false,
}
: undefined
}
hideFooter hideFooter
sx={{ sx={{
"& .MuiDataGrid-columnHeaders": { "& .MuiDataGrid-columnHeaders": {
+7 -1
View File
@@ -36,6 +36,7 @@ import {
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { MetricCard } from "../components/MetricCard"; import { MetricCard } from "../components/MetricCard";
import { SessionActivityPanel } from "../components/SessionActivityPanel"; import { SessionActivityPanel } from "../components/SessionActivityPanel";
@@ -62,6 +63,7 @@ export function UsersPage() {
const { data: activity } = useActivity(); const { data: activity } = useActivity();
const queueStatusQuery = useUserMessageQueueStatus(); const queueStatusQuery = useUserMessageQueueStatus();
const sendUserMessage = useSendUserMessage(); const sendUserMessage = useSendUserMessage();
const isMobile = useMediaQuery("(max-width: 900px)");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
@@ -684,6 +686,7 @@ export function UsersPage() {
py: 1.25, py: 1.25,
verticalAlign: "middle", verticalAlign: "middle",
textAlign: "center", textAlign: "center",
display: { xs: "none", md: "table-cell" },
}} }}
> >
<Chip <Chip
@@ -715,6 +718,7 @@ export function UsersPage() {
py: 1.25, py: 1.25,
verticalAlign: "middle", verticalAlign: "middle",
textAlign: "center", textAlign: "center",
display: { xs: "none", md: "table-cell" },
}} }}
> >
<Chip <Chip
@@ -736,6 +740,7 @@ export function UsersPage() {
py: 1.25, py: 1.25,
verticalAlign: "middle", verticalAlign: "middle",
textAlign: "center", textAlign: "center",
display: { xs: "none", md: "table-cell" },
}} }}
> >
<Typography variant="body2" sx={{ fontWeight: 600 }}> <Typography variant="body2" sx={{ fontWeight: 600 }}>
@@ -747,6 +752,7 @@ export function UsersPage() {
py: 1.25, py: 1.25,
verticalAlign: "middle", verticalAlign: "middle",
textAlign: "center", textAlign: "center",
display: { xs: "none", md: "table-cell" },
}} }}
> >
<Chip <Chip
@@ -911,7 +917,7 @@ export function UsersPage() {
) : null} ) : null}
</Drawer> </Drawer>
<Dialog open={composeOpen} onClose={closeCompose} fullWidth maxWidth="md"> <Dialog open={composeOpen} onClose={closeCompose} fullWidth maxWidth="md" fullScreen={isMobile}>
<DialogTitle sx={{ pr: 6 }}> <DialogTitle sx={{ pr: 6 }}>
Message selected users Message selected users
<IconButton <IconButton