Public Access
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:
1 parent
0154202ddc
commit
fa0857f55d
13 files changed
+642
-4
No files matched your search
@@ -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
@@ -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
@@ -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\".",
|
||||||
|
|||||||
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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)});
|
||||||
@@ -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;
|
||||||
@@ -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}),
|
||||||
|
});
|
||||||
Reference in new issue
Block a user