01527ae4f0
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.
130 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|