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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user