Files
manage/frontend/src/pages/service-tabs/MessagingTab.tsx
T
Developer 01527ae4f0 Rebase services-as-hub-ia onto mobile-responsive-parity
Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
2026-06-26 21:08:51 +00:00

130 lines
3.6 KiB
TypeScript

/** MessagingTab — compose email to Authentik users via the mail queue. */
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
useAuthentikUsers,
useSendAuthentikMessage,
} from "../../hooks/useAuthentik";
import type { ServiceInstance } from "../../types";
const DEFAULT_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
const [subject, setSubject] = useState("");
const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
const { data } = useAuthentikUsers(instance.id, {
search,
page: 1,
page_size: 100,
});
const sendMessage = useSendAuthentikMessage(instance.id);
const users = (data?.items ?? []).filter((u) => u.email);
const error = data?.error;
function toggleEmail(email: string) {
setSelectedEmails((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}
function handleSend() {
if (!subject.trim() || selectedEmails.size === 0) return;
sendMessage.mutate({
recipient_emails: Array.from(selectedEmails),
subject: subject.trim(),
html_body: htmlBody,
});
}
const canSend =
subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
return (
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{sendMessage.data ? (
<Alert>
<AlertDescription>
{sendMessage.data.status === "queued"
? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
: `Error: ${sendMessage.data.error ?? "unknown"}`}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="msg-search">Find recipients</Label>
<Input
id="msg-search"
placeholder="Search users to add as recipients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
{users.length > 0 ? (
<div className="flex flex-wrap gap-2">
{users.slice(0, 20).map((user) => (
<Button
key={user.pk}
variant={selectedEmails.has(user.email) ? "default" : "outline"}
size="sm"
onClick={() => toggleEmail(user.email)}
>
{user.name || user.username}
</Button>
))}
</div>
) : null}
{selectedEmails.size > 0 ? (
<p className="text-sm text-muted-foreground">
{selectedEmails.size} recipient
{selectedEmails.size === 1 ? "" : "s"} selected.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-subject">Subject</Label>
<Input
id="msg-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-body">Message (HTML)</Label>
<Textarea
id="msg-body"
rows={8}
value={htmlBody}
onChange={(e) => setHtmlBody(e.target.value)}
className="font-mono text-xs"
/>
</div>
<div>
<Button onClick={handleSend} disabled={!canSend}>
{sendMessage.isPending ? "Sending…" : "Send message"}
</Button>
</div>
</div>
);
}