Files
paliad/frontend/src/admin.tsx
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00

114 lines
6.8 KiB
TypeScript

import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
interface PlannedCard {
icon: string;
i18nTitle: string;
i18nDesc: string;
fallbackTitle: string;
fallbackDesc: string;
}
const PLANNED: PlannedCard[] = [
{
icon: ICON_FLAG,
i18nTitle: "admin.card.feature_flags.title",
i18nDesc: "admin.card.feature_flags.desc",
fallbackTitle: "Feature-Flags",
fallbackDesc: "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
},
];
export function renderAdmin(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.title">Admin-Bereich &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin" />
<BottomNav currentPath="/admin" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="admin.heading">Admin-Bereich</h1>
<p className="tool-subtitle" data-i18n="admin.subtitle">
Werkzeuge zur Verwaltung von Paliad. Nur f&uuml;r Administrator:innen sichtbar.
</p>
</div>
<h3 className="section-heading" data-i18n="admin.section.available">Verf&uuml;gbar</h3>
<div className="grid grid-2">
<a href="/admin/team" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_USERS }} />
<h2 data-i18n="admin.card.team.title">Team-Verwaltung</h2>
<p data-i18n="admin.card.team.desc">Benutzer:innen anlegen, bearbeiten, l&ouml;schen.</p>
</a>
<a href="/admin/partner-units" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BUILDING }} />
<h2 data-i18n="admin.card.partner_units.title">Partner Units</h2>
<p data-i18n="admin.card.partner_units.desc">Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.</p>
</a>
<a href="/admin/audit-log" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_LOG }} />
<h2 data-i18n="admin.card.audit.title">Audit-Log</h2>
<p data-i18n="admin.card.audit.desc">Wer hat wann was ge&auml;ndert? Nachvollziehbarkeit f&uuml;r sicherheitsrelevante Aktionen.</p>
</a>
<a href="/admin/email-templates" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<h2 data-i18n="admin.card.email_templates.title">Email-Templates</h2>
<p data-i18n="admin.card.email_templates.desc">Vorlagen f&uuml;r Einladungen, Erinnerungen und Layout anpassen.</p>
</a>
<a href="/admin/event-types" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenf&uuml;hren, bef&ouml;rdern.</p>
</a>
<a href="/admin/broadcasts" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
<div className="grid grid-2">
{PLANNED.map((c) => (
<div className="card admin-card-soon" title="Kommt bald" data-i18n-title="admin.coming_soon">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: c.icon }} />
<h2 data-i18n={c.i18nTitle}>{c.fallbackTitle}</h2>
<p data-i18n={c.i18nDesc}>{c.fallbackDesc}</p>
<span className="admin-soon-badge" data-i18n="admin.coming_soon">Kommt bald</span>
</div>
))}
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin.js"></script>
</body>
</html>
);
}