feat(broadcasts): add admin broadcast page and user opt-out switch

Admin page /admin/broadcasts to write, test and send broadcasts in both
languages; confirms the recipient count before send. History shows the
counts. A new switch in settings lets users opt out of platform mails;
account mails always come. Subject and body are plain text only.
This commit is contained in:
lobo committed 2026-10-01 14:12:34 +02:00
1 parent 0154202ddc
commit fa0857f55d
13 files changed
+642 -4

No files matched your search

+3
View File
@@ -107,6 +107,9 @@ To Be Played is split into three repositories:
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" - **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new` marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`. (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
Users turn platform mails off in the settings; account mails always come.
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the - **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page `/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
+33 -1
View File
@@ -130,7 +130,9 @@
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM": "Lieblingsplattform",
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.", "FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine" "FAVORITE_PLATFORM_NONE": "Keine",
"PLATFORM_MAILS": "Mails von der Plattform",
"PLATFORM_MAILS_HINT": "Neuigkeiten und Ankündigungen per Mail. Mails zu deinem Konto kommen immer."
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -304,6 +306,7 @@
"ADMIN_HINTS": "Hinweise", "ADMIN_HINTS": "Hinweise",
"WHATS_NEW": "Neuigkeiten", "WHATS_NEW": "Neuigkeiten",
"ADMIN_NEWS": "Neuigkeiten verwalten", "ADMIN_NEWS": "Neuigkeiten verwalten",
"ADMIN_BROADCASTS": "Rundmail",
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten" "PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
}, },
@@ -691,6 +694,35 @@
"LOAD_ERROR": "Die Einträge konnten nicht geladen werden.", "LOAD_ERROR": "Die Einträge konnten nicht geladen werden.",
"NO_ACCESS": "Nur Admins können diese Seite öffnen." "NO_ACCESS": "Nur Admins können diese Seite öffnen."
}, },
"ADMIN_BROADCAST": {
"NEW": "Neue Rundmail",
"SUBJECT_EN": "Betreff (Englisch)",
"SUBJECT_DE": "Betreff (Deutsch)",
"BODY_EN": "Text (Englisch)",
"BODY_DE": "Text (Deutsch)",
"TEXT_HELP": "Nur Text, in beiden Sprachen. Betreff höchstens 150, Text höchstens 5000 Zeichen. Eine Fußzeile mit dem Link zum Abschalten wird angehängt.",
"RECIPIENTS": "Empfänger",
"ALL": "Alle aktiven Nutzer",
"PICKED": "Ausgewählte Nutzer",
"SEARCH": "E-Mail oder Name suchen",
"PICKED_COUNT": "{{count}} ausgewählt",
"REMOVE": "Entfernen",
"TEST": "Test an mich senden",
"TEST_SENT": "Testmail für dich eingereiht.",
"SEND": "Senden...",
"CONFIRM_TITLE": "Diese Mail senden?",
"CONFIRM_TEXT": "An {{count}} Nutzer senden? Das kann nicht rückgängig gemacht werden.",
"CONFIRM": "Senden",
"CANCEL": "Abbrechen",
"NO_RECIPIENTS": "Kein Empfänger: niemand ist aktiv und hat Mails an.",
"SENT": "Mail für {{count}} Nutzer eingereiht.",
"ERROR": "Die Aktion ist fehlgeschlagen.",
"LIST": "Gesendete Mails",
"COUNTS": "{{recipients}} Empfänger, {{sent}} gesendet, {{failed}} fehlgeschlagen",
"EMPTY": "Noch keine Mails gesendet.",
"LOAD_ERROR": "Die Mails konnten nicht geladen werden.",
"NO_ACCESS": "Nur Admins können diese Seite öffnen."
},
"TILES": { "TILES": {
"CUSTOMIZE": "Anpassen", "CUSTOMIZE": "Anpassen",
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".", "EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
+33 -1
View File
@@ -130,7 +130,9 @@
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM": "Favorite platform",
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.", "FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None" "FAVORITE_PLATFORM_NONE": "None",
"PLATFORM_MAILS": "Mails from the platform",
"PLATFORM_MAILS_HINT": "News and announcements by mail. Mails about your account always come."
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -304,6 +306,7 @@
"ADMIN_HINTS": "Hints", "ADMIN_HINTS": "Hints",
"WHATS_NEW": "What's new", "WHATS_NEW": "What's new",
"ADMIN_NEWS": "News", "ADMIN_NEWS": "News",
"ADMIN_BROADCASTS": "Mail",
"PENDING_REQUESTS_one": "{{count}} registration waits", "PENDING_REQUESTS_one": "{{count}} registration waits",
"PENDING_REQUESTS_other": "{{count}} registrations wait" "PENDING_REQUESTS_other": "{{count}} registrations wait"
}, },
@@ -691,6 +694,35 @@
"LOAD_ERROR": "The entries could not be loaded.", "LOAD_ERROR": "The entries could not be loaded.",
"NO_ACCESS": "Only admins can open this page." "NO_ACCESS": "Only admins can open this page."
}, },
"ADMIN_BROADCAST": {
"NEW": "New mail",
"SUBJECT_EN": "Subject (English)",
"SUBJECT_DE": "Subject (German)",
"BODY_EN": "Text (English)",
"BODY_DE": "Text (German)",
"TEXT_HELP": "Plain text, both languages. Subject max 150, text max 5000 characters. A footer with the opt-out link is added.",
"RECIPIENTS": "Recipients",
"ALL": "All active users",
"PICKED": "Picked users",
"SEARCH": "Search email or name",
"PICKED_COUNT": "{{count}} picked",
"REMOVE": "Remove",
"TEST": "Send test to me",
"TEST_SENT": "Test mail queued for you.",
"SEND": "Send...",
"CONFIRM_TITLE": "Send this mail?",
"CONFIRM_TEXT": "Send to {{count}} users? This cannot be undone.",
"CONFIRM": "Send",
"CANCEL": "Cancel",
"NO_RECIPIENTS": "No recipient: nobody active with mails on.",
"SENT": "Mail queued for {{count}} users.",
"ERROR": "The action failed.",
"LIST": "Sent mails",
"COUNTS": "{{recipients}} recipients, {{sent}} sent, {{failed}} failed",
"EMPTY": "No mails sent yet.",
"LOAD_ERROR": "The mails could not be loaded.",
"NO_ACCESS": "Only admins can open this page."
},
"TILES": { "TILES": {
"CUSTOMIZE": "Customize", "CUSTOMIZE": "Customize",
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".", "EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
+2
View File
@@ -9,6 +9,7 @@ import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx'; import RegistrationPage from './pages/RegistrationPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx'; import WelcomePage from './pages/WelcomePage.jsx';
@@ -82,6 +83,7 @@ function App() {
<Route path="/admin/users" element={<AdminUsersPage/>}/> <Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/> <Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/> <Route path="/admin/news" element={<AdminNewsPage/>}/>
<Route path="/admin/broadcasts" element={<AdminBroadcastPage/>}/>
</Route> </Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/> <Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/> <Route path="/impressum" element={<ImprintPage/>}/>
+4 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react'; import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Mail, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -169,6 +169,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link> <Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/broadcasts"><Mail/>{t('NAV.ADMIN_BROADCASTS')}</Link>
</DropdownMenuItem>
</> </>
)} )}
{isAdmin && ( {isAdmin && (
+3
View File
@@ -55,6 +55,7 @@ describe('AppHeader', () => {
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy(); expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy(); expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_BROADCASTS')).toBeTruthy();
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy(); expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull(); expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
@@ -84,6 +85,7 @@ describe('AppHeader', () => {
openMenu(); openMenu();
expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy(); expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull(); expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
}); });
it('offers nothing of that to a user', async () => { it('offers nothing of that to a user', async () => {
@@ -96,6 +98,7 @@ describe('AppHeader', () => {
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy(); expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull(); expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull(); expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
}); });
}); });
+16 -1
View File
@@ -11,7 +11,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js'; import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js'; import {LANGUAGES, savePlatformMails, saveSettings} from '../utils/settings.js';
// Radix Select has no empty value. // Radix Select has no empty value.
const NO_FAVORITE = 'NONE'; const NO_FAVORITE = 'NONE';
@@ -41,6 +41,13 @@ export default function PreferencesCard() {
} }
}; };
const handlePlatformMailsChange = async (next) => {
if (await runSave(() => savePlatformMails(next))) {
updateProfile((p) => ({...p, settings: {...p.settings, platformMails: next}}));
toast.success(t('SETTINGS.SAVED'));
}
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -83,6 +90,14 @@ export default function PreferencesCard() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="platformMails">{t('SETTINGS.PLATFORM_MAILS')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.PLATFORM_MAILS_HINT')}</p>
</div>
<Switch id="platformMails" checked={profile?.settings?.platformMails ?? true}
onCheckedChange={handlePlatformMailsChange}/>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
+322
View File
@@ -0,0 +1,322 @@
import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Search, Send, X} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import PageContainer from '../components/PageContainer.jsx';
import {fetchUsers} from '../utils/admin.js';
import {fetchBroadcasts, fetchRecipientCount, sendBroadcast, sendTestBroadcast} from '../utils/broadcasts.js';
import {formatDateTime} from '../utils/format.js';
import {MAX_BROADCAST_BODY_LENGTH, MAX_BROADCAST_SUBJECT_LENGTH} from '../utils/limits.js';
const emptyForm = () => ({subjectEn: '', subjectDe: '', bodyEn: '', bodyDe: ''});
const SEARCH_SIZE = 20;
/**
* PLATFORM_ADMIN only: write a mail in both languages, send it to all active users or to picked ones, and see the
* history with the counts. Auth checks the role.
*/
export default function AdminBroadcastPage() {
const {t, i18n} = useTranslation();
const [entries, setEntries] = useState([]);
const [status, setStatus] = useState('loading');
const [form, setForm] = useState(emptyForm);
const [mode, setMode] = useState('all');
const [picked, setPicked] = useState([]);
const [query, setQuery] = useState('');
const [found, setFound] = useState([]);
const [confirm, setConfirm] = useState(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const response = await fetchBroadcasts();
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
setEntries((await response.json()).results);
setStatus('ready');
} catch (err) {
console.error('Admin broadcast error:', err);
setStatus('error');
}
}, []);
useEffect(() => {
load();
}, [load]);
const trimmed = () => ({
subjectEn: form.subjectEn.trim(),
subjectDe: form.subjectDe.trim(),
bodyEn: form.bodyEn.trim(),
bodyDe: form.bodyDe.trim(),
});
const incomplete = Object.values(trimmed()).some((value) => !value);
const userIds = () => (mode === 'all' ? null : picked.map((user) => user.id));
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const sendTest = async () => {
setBusy(true);
try {
const response = await sendTestBroadcast(trimmed());
if (response.status === 401) return;
if (response.ok) toast.success(t('ADMIN_BROADCAST.TEST_SENT'));
else toast.error(t('ADMIN_BROADCAST.ERROR'));
} catch (err) {
console.error('Admin broadcast test error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const submit = async (event) => {
event.preventDefault();
setConfirm({count: null});
try {
const response = await fetchRecipientCount(userIds());
if (response.status === 401) return;
if (!response.ok) throw new Error(`count ${response.status}`);
const {count} = await response.json();
setConfirm((current) => (current ? {count} : current));
} catch (err) {
console.error('Admin broadcast count error:', err);
toast.error(t('ADMIN_BROADCAST.ERROR'));
setConfirm(null);
}
};
const send = async () => {
setBusy(true);
try {
const response = await sendBroadcast({...trimmed(), userIds: userIds()});
if (response.status === 401) return;
if (response.ok) {
const data = await response.json();
toast.success(t('ADMIN_BROADCAST.SENT', {count: data.recipientCount}));
setConfirm(null);
setForm(emptyForm());
setPicked([]);
setFound([]);
await load();
} else {
toast.error(t(response.status === 409 ? 'ADMIN_BROADCAST.NO_RECIPIENTS' : 'ADMIN_BROADCAST.ERROR'));
}
} catch (err) {
console.error('Admin broadcast send error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const search = async () => {
try {
const response = await fetchUsers(query.trim(), 'ACTIVE', 0, SEARCH_SIZE);
if (response.status === 401) return;
if (!response.ok) throw new Error(`search ${response.status}`);
const {content} = await response.json();
setFound(content.filter((user) => !user.blocked && !user.deletedAt));
} catch (err) {
console.error('Admin broadcast search error:', err);
toast.error(t('ADMIN_BROADCAST.ERROR'));
}
};
const toggle = (user) => setPicked((current) => (current.some((p) => p.id === user.id)
? current.filter((p) => p.id !== user.id)
: [...current, {id: user.id, email: user.email, displayName: user.displayName}]));
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('ADMIN_BROADCAST.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_BROADCAST.NEW')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="broadcastSubjectEn">{t('ADMIN_BROADCAST.SUBJECT_EN')}</Label>
<Input id="broadcastSubjectEn" required maxLength={MAX_BROADCAST_SUBJECT_LENGTH}
value={form.subjectEn} onChange={change('subjectEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="broadcastBodyEn">{t('ADMIN_BROADCAST.BODY_EN')}</Label>
<Textarea id="broadcastBodyEn" required maxLength={MAX_BROADCAST_BODY_LENGTH}
value={form.bodyEn} onChange={change('bodyEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="broadcastSubjectDe">{t('ADMIN_BROADCAST.SUBJECT_DE')}</Label>
<Input id="broadcastSubjectDe" required maxLength={MAX_BROADCAST_SUBJECT_LENGTH}
value={form.subjectDe} onChange={change('subjectDe')}/>
</div>
<div className="space-y-2">
<Label htmlFor="broadcastBodyDe">{t('ADMIN_BROADCAST.BODY_DE')}</Label>
<Textarea id="broadcastBodyDe" required maxLength={MAX_BROADCAST_BODY_LENGTH}
value={form.bodyDe} onChange={change('bodyDe')}/>
<p className="text-xs text-muted-foreground">{t('ADMIN_BROADCAST.TEXT_HELP')}</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('ADMIN_BROADCAST.RECIPIENTS')}</p>
<div className="flex gap-2">
{['all', 'picked'].map((value) => (
<Button key={value} type="button" variant={mode === value ? 'default' : 'outline'}
aria-pressed={mode === value} onClick={() => setMode(value)}>
{t(`ADMIN_BROADCAST.${value.toUpperCase()}`)}
</Button>
))}
</div>
</div>
{mode === 'picked' && (
<div className="space-y-3">
<div className="flex gap-2">
<Input aria-label={t('ADMIN_BROADCAST.SEARCH')} placeholder={t('ADMIN_BROADCAST.SEARCH')}
value={query} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
search();
}
}}/>
<Button type="button" variant="outline" size="icon"
aria-label={t('ADMIN_BROADCAST.SEARCH')} onClick={search}>
<Search/>
</Button>
</div>
{found.length > 0 && (
<ul className="space-y-1">
{found.map((user) => (
<li key={user.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary"
checked={picked.some((p) => p.id === user.id)}
onChange={() => toggle(user)}/>
{[user.email, user.displayName].filter(Boolean).join(' · ')}
</label>
</li>
))}
</ul>
)}
<p className="text-sm text-muted-foreground">
{t('ADMIN_BROADCAST.PICKED_COUNT', {count: picked.length})}
</p>
{picked.length > 0 && (
<ul className="flex flex-wrap gap-2">
{picked.map((user) => (
<li key={user.id} className="flex items-center gap-1 rounded-md border px-2 py-1 text-sm">
{user.email}
<button type="button" aria-label={`${t('ADMIN_BROADCAST.REMOVE')} ${user.email}`}
onClick={() => toggle(user)}>
<X className="size-3"/>
</button>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy || incomplete} onClick={sendTest}>
{t('ADMIN_BROADCAST.TEST')}
</Button>
<Button type="submit" disabled={busy || incomplete || (mode === 'picked' && picked.length === 0)}>
<Send/>{t('ADMIN_BROADCAST.SEND')}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_BROADCAST.LIST')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('ADMIN_BROADCAST.LOAD_ERROR')}</p>}
{status === 'ready' && entries.length === 0 && (
<p className="text-sm text-muted-foreground">{t('ADMIN_BROADCAST.EMPTY')}</p>
)}
{status === 'ready' && entries.length > 0 && (
<ul className="divide-y">
{entries.map((entry) => (
<li key={entry.id} className="space-y-1 py-3">
<p className="text-xs text-muted-foreground">
{formatDateTime(entry.createdAt, i18n.language)}
{' · '}
{t('ADMIN_BROADCAST.COUNTS', {
recipients: entry.recipientCount, sent: entry.sentCount, failed: entry.failedCount,
})}
</p>
<p className="font-medium">{entry.subjectEn}</p>
<p className="font-medium">{entry.subjectDe}</p>
<p className="whitespace-pre-line break-words text-sm text-muted-foreground">{entry.bodyEn}</p>
<p className="whitespace-pre-line break-words text-sm text-muted-foreground">{entry.bodyDe}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Dialog open={!!confirm} onOpenChange={(open) => { if (!open && !busy) setConfirm(null); }}>
{confirm && (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ADMIN_BROADCAST.CONFIRM_TITLE')}</DialogTitle>
<DialogDescription asChild>
<div>
{confirm.count === null && <Loader2 className="size-5 animate-spin"/>}
{confirm.count === 0 && t('ADMIN_BROADCAST.NO_RECIPIENTS')}
{confirm.count > 0 && t('ADMIN_BROADCAST.CONFIRM_TEXT', {count: confirm.count})}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t('ADMIN_BROADCAST.CANCEL')}</Button>
</DialogClose>
<Button type="button" disabled={busy || !confirm.count} onClick={send}>
{busy && <Loader2 className="animate-spin"/>}
{t('ADMIN_BROADCAST.CONFIRM')}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</PageContainer>
);
}
+161
View File
@@ -0,0 +1,161 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import AdminBroadcastPage from './AdminBroadcastPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const ENTRY = {id: 1, subjectEn: 'Maintenance', subjectDe: 'Wartung', bodyEn: 'We pause.', bodyDe: 'Wir pausieren.',
createdBy: 1, createdAt: '2026-10-01T10:00:00Z', recipientCount: 5, sentCount: 4, failedCount: 1};
const USERS = {content: [
{id: 7, email: 'ann@example.com', displayName: 'Ann', blocked: false, deletedAt: null},
{id: 8, email: 'bob@example.com', displayName: 'Bob', blocked: true, deletedAt: null},
]};
const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/broadcasts': json(200, {results: [ENTRY]}),
'GET /api/admin/broadcasts/recipient-count': json(200, {count: 42}),
'POST /api/admin/broadcasts': json(201, {recipientCount: 42}),
'POST /api/admin/broadcasts/test': json(204),
'GET /api/admin/users': json(200, USERS),
...routes,
});
const posts = (suffix) => fetch.mock.calls.filter(([url, init]) => init?.method === 'POST' && url.endsWith(suffix));
const body = (call) => JSON.parse(call[1].body);
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminBroadcastPage/></PreferencesProvider></MemoryRouter>);
const fill = () => {
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN'), {target: {value: ' Hello '}});
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_EN'), {target: {value: ' Body en '}});
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_DE'), {target: {value: ' Hallo '}});
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_DE'), {target: {value: ' Inhalt '}});
};
const TRIMMED = {subjectEn: 'Hello', subjectDe: 'Hallo', bodyEn: 'Body en', bodyDe: 'Inhalt'};
describe('AdminBroadcastPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
withRoutes({});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('lists the history with the counts', async () => {
renderPage();
expect(await screen.findByText('Maintenance')).toBeTruthy();
expect(screen.getByText('Wartung')).toBeTruthy();
expect(screen.getByText(/ADMIN_BROADCAST\.COUNTS/)).toBeTruthy();
});
it('sends the trimmed test mail and keeps the form', async () => {
renderPage();
await screen.findByText('Maintenance');
const test = screen.getByText('ADMIN_BROADCAST.TEST').closest('button');
expect(test.disabled).toBe(true);
fill();
fireEvent.click(test);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_BROADCAST.TEST_SENT'));
expect(body(posts('/test')[0])).toEqual(TRIMMED);
expect(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN').value).toBe(' Hello ');
});
it('sends to all after the confirmation with the count, then empties the form', async () => {
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND'));
expect(await screen.findByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeTruthy();
expect(posts('/broadcasts')).toEqual([]);
fireEvent.click(screen.getByText('ADMIN_BROADCAST.CONFIRM'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_BROADCAST.SENT:42'));
expect(body(posts('/broadcasts')[0])).toEqual({...TRIMMED, userIds: null});
await waitFor(() => expect(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN').value).toBe(''));
expect(screen.queryByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeNull();
});
it('sends to the picked users only and leaves a blocked one out of the list', async () => {
renderPage();
await screen.findByText('Maintenance');
fill();
const send = screen.getByText('ADMIN_BROADCAST.SEND').closest('button');
fireEvent.click(screen.getByText('ADMIN_BROADCAST.PICKED'));
expect(send.disabled).toBe(true);
fireEvent.change(screen.getByRole('textbox', {name: 'ADMIN_BROADCAST.SEARCH'}), {target: {value: 'a'}});
fireEvent.click(screen.getByRole('button', {name: 'ADMIN_BROADCAST.SEARCH'}));
const ann = await screen.findByLabelText(/ann@example\.com/);
expect(screen.queryByLabelText(/bob@example\.com/)).toBeNull();
fireEvent.click(ann);
expect(screen.getByText('ADMIN_BROADCAST.PICKED_COUNT:1')).toBeTruthy();
fireEvent.click(send);
await screen.findByText('ADMIN_BROADCAST.CONFIRM_TEXT:42');
expect(fetch.mock.calls.some(([url]) => url.includes('/recipient-count?userIds=7'))).toBe(true);
fireEvent.click(screen.getByText('ADMIN_BROADCAST.CONFIRM'));
await waitFor(() => expect(posts('/broadcasts')).toHaveLength(1));
expect(body(posts('/broadcasts')[0]).userIds).toEqual([7]);
});
it('cannot send when nobody would get the mail', async () => {
withRoutes({'GET /api/admin/broadcasts/recipient-count': json(200, {count: 0})});
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND'));
expect(await screen.findByText('ADMIN_BROADCAST.NO_RECIPIENTS')).toBeTruthy();
expect(screen.getByText('ADMIN_BROADCAST.CONFIRM').closest('button').disabled).toBe(true);
});
it('keeps the confirmation closed when it was cancelled before the count arrived', async () => {
let answer;
withRoutes({'GET /api/admin/broadcasts/recipient-count': () => new Promise((resolve) => { answer = resolve; })});
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND'));
fireEvent.click(await screen.findByText('ADMIN_BROADCAST.CANCEL'));
await waitFor(() => expect(screen.queryByText('ADMIN_BROADCAST.CANCEL')).toBeNull());
answer(json(200, {count: 42}));
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/recipient-count'))).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeNull();
});
it('does not offer the send while a field is blank', async () => {
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_DE'), {target: {value: ' '}});
expect(screen.getByText('ADMIN_BROADCAST.SEND').closest('button').disabled).toBe(true);
});
it('shows "no access" to a non-admin', async () => {
withRoutes({'GET /api/admin/broadcasts': json(403, {})});
renderPage();
expect(await screen.findByText('ADMIN_BROADCAST.NO_ACCESS')).toBeTruthy();
expect(screen.queryByText('ADMIN_BROADCAST.SEND')).toBeNull();
});
});
+24
View File
@@ -31,6 +31,7 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/appearance': noBody, 'PUT /api/users/settings/appearance': noBody,
'PUT /api/users/settings/profile-style': noBody, 'PUT /api/users/settings/profile-style': noBody,
'PUT /api/users/settings/favorite-platform': noBody, 'PUT /api/users/settings/favorite-platform': noBody,
'PUT /api/users/settings/platform-mails': noBody,
'GET /api/version': json(404, {}), 'GET /api/version': json(404, {}),
}); });
@@ -247,6 +248,29 @@ describe('SettingsPage favorite platform', () => {
}); });
}); });
describe('SettingsPage platform mails', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('is on by default and saves the opt-out', async () => {
toast.success.mockClear();
renderPage(profile());
const mails = await screen.findByRole('switch', {name: 'SETTINGS.PLATFORM_MAILS'});
expect(mails.getAttribute('aria-checked')).toBe('true');
fireEvent.click(mails);
await waitFor(() => expect(puts()).toEqual([{enabled: false}]));
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toContain('/api/users/settings/platform-mails');
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.SAVED'));
expect(mails.getAttribute('aria-checked')).toBe('false');
});
});
describe('SettingsPage session', () => { describe('SettingsPage session', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
+30
View File
@@ -0,0 +1,30 @@
import {apiFetch} from './api.js';
const AUTH = () => import.meta.env.VITE_API_URL;
const JSON_HEADERS = {'Content-Type': 'application/json'};
/**
* PLATFORM_ADMIN only (403 otherwise): {results: [{id, subjectEn, subjectDe, bodyEn, bodyDe, createdBy, createdAt,
* recipientCount, sentCount, failedCount}]}, newest first.
*/
export const fetchBroadcasts = () => apiFetch(`${AUTH()}/api/admin/broadcasts`);
/**
* {count}: the active users with platform mails on, among `userIds`; all of them when `userIds` is null.
*/
export const fetchRecipientCount = (userIds) =>
apiFetch(`${AUTH()}/api/admin/broadcasts/recipient-count${userIds ? `?userIds=${userIds.join(',')}` : ''}`);
/**
* `broadcast` is {subjectEn, subjectDe, bodyEn, bodyDe, userIds}. 201 {recipientCount}, 409 NO_RECIPIENTS, or 400 for a
* blank or too long field.
*/
export const sendBroadcast = (broadcast) =>
apiFetch(`${AUTH()}/api/admin/broadcasts`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(broadcast)});
/**
* 204: the draft goes to the admin's own address only, in the admin's language.
*/
export const sendTestBroadcast = (broadcast) =>
apiFetch(`${AUTH()}/api/admin/broadcasts/test`,
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(broadcast)});
+3
View File
@@ -33,3 +33,6 @@ export const MAX_HINT_LENGTH = 500;
// Auth NewsRequest @Size(max = 100) and @Size(max = 2000) // Auth NewsRequest @Size(max = 100) and @Size(max = 2000)
export const MAX_NEWS_TITLE_LENGTH = 100; export const MAX_NEWS_TITLE_LENGTH = 100;
export const MAX_NEWS_TEXT_LENGTH = 2000; export const MAX_NEWS_TEXT_LENGTH = 2000;
// Auth BroadcastRequest @Size(max = 150) and @Size(max = 5000)
export const MAX_BROADCAST_SUBJECT_LENGTH = 150;
export const MAX_BROADCAST_BODY_LENGTH = 5000;
+8
View File
@@ -58,3 +58,11 @@ export const markWelcomeSeen = () =>
method: 'PUT', method: 'PUT',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}); });
/** PUT on Auth, 204: turns the broadcast mails on or off. The caller handles the response status. */
export const savePlatformMails = (enabled) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/platform-mails`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled}),
});