feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung

- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:23:34 +02:00
parent 6829c44464
commit faed0d760f
23 changed files with 1796 additions and 103 deletions
@@ -23,6 +23,11 @@ interface CloudTileProps {
checking?: boolean;
onCheck?: () => void;
onEdit?: () => void;
/** Glocke "Benachrichtigen" (persoenlich, fuer jeden mit Modulzugriff, quick-261002-kxc). */
subscribed?: boolean;
/** Das Umschalten der Glocke laeuft (Knopf gesperrt). */
toggling?: boolean;
onToggleSubscription?: () => void;
}
/**
@@ -39,6 +44,9 @@ export function CloudTile({
checking = false,
onCheck,
onEdit,
subscribed = false,
toggling = false,
onToggleSubscription,
}: CloudTileProps) {
const t = useTranslations('nextcloudStatus');
const locale = useLocale();
@@ -92,58 +100,86 @@ export function CloudTile({
{instance.baseUrl}
</a>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onCheck}
disabled={checking}
aria-label={t('cardActions.refresh')}
title={t('cardActions.refresh')}
className="btn btn-subtle"
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onToggleSubscription}
disabled={toggling}
aria-pressed={subscribed}
aria-label={t('bell.label')}
title={subscribed ? t('bell.titleOn') : t('bell.titleOff')}
data-testid="bell-toggle"
className={`btn btn-subtle ${subscribed ? 'text-primary' : ''}`}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill={subscribed ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
</button>
{canManage && (
<>
<button
type="button"
onClick={onCheck}
disabled={checking}
aria-label={t('cardActions.refresh')}
title={t('cardActions.refresh')}
className="btn btn-subtle"
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
</button>
<button
type="button"
onClick={onEdit}
aria-label={t('cardActions.edit')}
title={t('cardActions.edit')}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
</button>
<button
type="button"
onClick={onEdit}
aria-label={t('cardActions.edit')}
title={t('cardActions.edit')}
className="btn btn-subtle"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
</div>
)}
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
</>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -24,6 +24,9 @@ function render(ui: ReactElement) {
const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = vi.fn();
const mockSubscribe = vi.fn();
const mockUnsubscribe = vi.fn();
const mockRequestPermission = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
@@ -32,9 +35,15 @@ vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
listInstances: (...args: unknown[]) => mockListInstances(...args),
checkAll: (...args: unknown[]) => mockCheckAll(...args),
checkOne: (...args: unknown[]) => mockCheckOne(...args),
subscribe: (...args: unknown[]) => mockSubscribe(...args),
unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args),
};
});
vi.mock('@/lib/reminder-notify', () => ({
requestBrowserPermissionOnce: () => mockRequestPermission(),
}));
let mockCanManage: boolean | null = false;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
@@ -127,6 +136,9 @@ describe('NextcloudStatusPage', () => {
mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
mockSubscribe.mockReset().mockResolvedValue(undefined);
mockUnsubscribe.mockReset().mockResolvedValue(undefined);
mockRequestPermission.mockReset();
mockCanManage = false;
window.localStorage.clear();
});
@@ -285,4 +297,85 @@ describe('NextcloudStatusPage', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
describe('Glocke „Benachrichtigen“ (quick-261002-kxc)', () => {
const tileOf = (name: string) =>
screen
.getAllByTestId('cloud-tile')
.find((t) => t.getAttribute('aria-label')?.startsWith(name)) as HTMLElement;
const withBells = (subscribedIds: string[]): NextcloudList => ({
...LIST,
instances: LIST.instances.map((i) => ({ ...i, subscribed: subscribedIds.includes(i.id) })),
});
it('ist auf der Benutzen-Ebene an jeder Kachel sichtbar und zeigt den gemerkten Stand', async () => {
mockCanManage = false;
mockListInstances.mockResolvedValue(withBells(['b']));
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getAllByRole('button', { name: 'Benachrichtigen' })).toHaveLength(4);
expect(
within(tileOf('Kunde Gelb')).getByRole('button', { name: 'Benachrichtigen' }),
).toHaveAttribute('aria-pressed', 'true');
expect(
within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' }),
).toHaveAttribute('aria-pressed', 'false');
});
it('Klick schaltet ein (subscribe, Browser-Erlaubnis einmalig angefragt) und wieder aus (unsubscribe)', async () => {
mockListInstances.mockResolvedValue(withBells([]));
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
const bell = () =>
within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' });
fireEvent.click(bell());
await waitFor(() => expect(mockSubscribe).toHaveBeenCalledWith('a'));
await waitFor(() => expect(bell()).toHaveAttribute('aria-pressed', 'true'));
expect(mockRequestPermission).toHaveBeenCalledTimes(1);
expect(mockUnsubscribe).not.toHaveBeenCalled();
fireEvent.click(bell());
await waitFor(() => expect(mockUnsubscribe).toHaveBeenCalledWith('a'));
await waitFor(() => expect(bell()).toHaveAttribute('aria-pressed', 'false'));
expect(mockRequestPermission).toHaveBeenCalledTimes(1);
});
it('bei einem Fehler wird zurückgenommen und der Hinweis gezeigt', async () => {
mockListInstances.mockResolvedValue(withBells([]));
mockSubscribe.mockRejectedValue(new Error('nein'));
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
const bell = () =>
within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' });
fireEvent.click(bell());
expect(await screen.findByRole('alert')).toHaveTextContent(
'Die Benachrichtigung konnte nicht geändert werden.',
);
expect(bell()).toHaveAttribute('aria-pressed', 'false');
expect(mockRequestPermission).not.toHaveBeenCalled();
});
it('die Prüfung einer einzelnen Kachel behält den Stand der Glocke', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(withBells(['c']));
// Einzelantwort ohne `subscribed`
mockCheckOne.mockResolvedValue({ ...LIST.instances[2] });
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(
within(tileOf('Kunde Rot')).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }),
);
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
await waitFor(() =>
expect(
within(tileOf('Kunde Rot')).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }),
).not.toBeDisabled(),
);
expect(
within(tileOf('Kunde Rot')).getByRole('button', { name: 'Benachrichtigen' }),
).toHaveAttribute('aria-pressed', 'true');
});
});
});
@@ -17,7 +17,10 @@ import {
listInstances,
type NextcloudInstance,
type NextcloudList,
subscribe,
unsubscribe,
} from '@/lib/nextcloud-status-api';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { CloudForm } from './components/CloudForm';
@@ -83,6 +86,8 @@ export default function NextcloudStatusPage() {
const [sortKey, setSortKey] = useState<SortKey>('name');
const [checkingAll, setCheckingAll] = useState(false);
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
const [togglingIds, setTogglingIds] = useState<ReadonlySet<string>>(new Set());
const [bellError, setBellError] = useState(false);
// `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined);
@@ -133,7 +138,13 @@ export default function NextcloudStatusPage() {
const updated = await checkOne(id);
setData((prev) =>
prev
? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) }
? {
...prev,
// Die Einzelantwort traegt keine Glocke: der Stand der Kachel bleibt (D-K10).
instances: prev.instances.map((i) =>
i.id === id ? { ...updated, subscribed: i.subscribed } : i,
),
}
: prev,
);
setNow(Date.now());
@@ -148,6 +159,42 @@ export default function NextcloudStatusPage() {
}
};
const setSubscribed = (id: string, value: boolean) =>
setData((prev) =>
prev
? {
...prev,
instances: prev.instances.map((i) => (i.id === id ? { ...i, subscribed: value } : i)),
}
: prev,
);
// Glocke umschalten: sofort anzeigen, bei einem Fehler zuruecknehmen und den Hinweis zeigen.
const handleToggleSubscription = async (instance: NextcloudInstance) => {
const turnOn = !instance.subscribed;
setBellError(false);
setTogglingIds((prev) => new Set(prev).add(instance.id));
setSubscribed(instance.id, turnOn);
try {
if (turnOn) {
await subscribe(instance.id);
// Beim ersten Einschalten fragt der Browser einmalig nach der Erlaubnis fuer Meldungen.
requestBrowserPermissionOnce();
} else {
await unsubscribe(instance.id);
}
} catch {
setSubscribed(instance.id, !turnOn);
setBellError(true);
} finally {
setTogglingIds((prev) => {
const next = new Set(prev);
next.delete(instance.id);
return next;
});
}
};
// Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
// neueste Version kommen so wieder aus einer Quelle.
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
@@ -187,6 +234,15 @@ export default function NextcloudStatusPage() {
</p>
)}
{bellError && (
<p
role="alert"
className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground"
>
{t('bell.error')}
</p>
)}
{!error && data === null && (
<div
aria-busy="true"
@@ -242,6 +298,9 @@ export default function NextcloudStatusPage() {
checking={checkingAll || checkingIds.has(instance.id)}
onCheck={() => handleCheckOne(instance.id)}
onEdit={() => setEditing(instance)}
subscribed={instance.subscribed === true}
toggling={togglingIds.has(instance.id)}
onToggleSubscription={() => handleToggleSubscription(instance)}
/>
</li>
))}
+21
View File
@@ -55,6 +55,12 @@ export interface NextcloudInstance {
logoVersion: number;
status: NextcloudInstanceStatus;
rating: NextcloudRating;
/**
* Glocke des angemeldeten Benutzers (quick-261002-kxc). Nur die Listen-
* antworten tragen das Feld; fehlt es (Einzelantwort), gilt der bisherige
* Stand der Kachel. Ohne Angabe = aus.
*/
subscribed?: boolean;
}
export interface NextcloudList {
@@ -174,3 +180,18 @@ export async function removeLogo(id: string): Promise<NextcloudInstance> {
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
return res.json();
}
/** POST /instances/:id/subscription — Glocke "Benachrichtigen" einschalten (jeder mit Modulzugriff). */
export async function subscribe(id: string): Promise<void> {
const res = await fetch(`${BASE}/${id}/subscription`, { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to subscribe'));
}
/** DELETE /instances/:id/subscription — Glocke ausschalten. */
export async function unsubscribe(id: string): Promise<void> {
const res = await fetch(`${BASE}/${id}/subscription`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to unsubscribe'));
}
+6
View File
@@ -1872,6 +1872,12 @@
"refresh": "Diese Cloud jetzt prüfen",
"edit": "Cloud bearbeiten"
},
"bell": {
"label": "Benachrichtigen",
"titleOn": "Benachrichtigung ist eingeschaltet – Klick schaltet sie aus",
"titleOff": "Bei Störung und Wiederherstellung benachrichtigen",
"error": "Die Benachrichtigung konnte nicht geändert werden. Bitte versuchen Sie es erneut."
},
"form": {
"addTitle": "Cloud hinzufügen",
"editTitle": "Cloud bearbeiten",
+6
View File
@@ -1872,6 +1872,12 @@
"refresh": "Check this cloud now",
"edit": "Edit cloud"
},
"bell": {
"label": "Notify me",
"titleOn": "Notifications are on – click to turn them off",
"titleOff": "Notify me about outages and recovery",
"error": "The notification setting could not be changed. Please try again."
},
"form": {
"addTitle": "Add cloud",
"editTitle": "Edit cloud",