This commit is contained in:
Rajpreet0 2026-09-14 18:27:15 +02:00
parent 3170779602
commit 16728da422
5 changed files with 127 additions and 64 deletions

View file

@ -1,14 +1,20 @@
import { Suspense } from "react";
import { AlertTriangle } from "lucide-react";
import { requireManager } from "@/src/lib/auth/getUser";
import { isManager, requireAuthWithData } from "@/src/lib/auth/getUser";
import { getNotificationSettings } from "@/src/modules/settings/actions/notification-settings-actions";
import { resolveNotificationSettings } from "@/src/modules/notifications/lib/notification-types";
import SettingsNotificationPageView from "@/src/modules/settings/ui/views/settings-notification-page-view";
import SettingsLoading from "../loading";
export default async function SettingsNotificationPage() {
const manager = await requireManager();
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
// requireAuthWithData() statt requireManager(): der Push-Teil dieser Seite
// gilt für alle Rollen (siehe NotificationPermissionSettings unten), nur
// die Business-Opt-out-Kategorien (In-App-Benachrichtigungstypen) bleiben
// manager-/business-spezifisch.
const userData = await requireAuthWithData();
const businesses = isManager(userData)
? userData.businesses.map((b) => ({ id: b.id, name: b.name }))
: [];
const firstBusinessId = businesses[0]?.id;
const result = firstBusinessId ? await getNotificationSettings(firstBusinessId) : null;
@ -26,7 +32,11 @@ export default async function SettingsNotificationPage() {
return (
<Suspense fallback={<SettingsLoading />}>
<SettingsNotificationPageView businesses={businesses} initialSettings={initialSettings} />
<SettingsNotificationPageView
businesses={businesses}
initialSettings={initialSettings}
showBusinessSettings={isManager(userData)}
/>
</Suspense>
);
}

View file

@ -34,6 +34,27 @@ export function isPushBaseSupported(): boolean {
export type EnablePushResult = { success: true } | { success: false; error: string };
// navigator.serviceWorker.ready löst sich NIE auf, wenn die SW-Registrierung
// fehlschlägt oder gar nicht erst versucht wird (z.B. kein sicherer Kontext -
// http://<lan-ip>:3000 auf dem Handy statt https/localhost) - ohne Timeout
// bleibt enablePushOnThisDevice() dann für immer pending, nachdem der Nutzer
// die Permission bereits erteilt hat ("stuck"-Symptom).
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
}
);
});
}
/**
* Fragt die Browser-Permission an (falls nötig), erstellt eine
* PushSubscription und registriert sie serverseitig. Wirft nie - Fehler
@ -50,7 +71,11 @@ export async function enablePushOnThisDevice(): Promise<EnablePushResult> {
return { success: false, error: "Berechtigung wurde nicht erteilt." };
}
const registration = await navigator.serviceWorker.ready;
const registration = await withTimeout(
navigator.serviceWorker.ready,
10_000,
"Der Service Worker wurde nicht rechtzeitig aktiv. Läuft die Seite über HTTPS (oder localhost)?"
);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
@ -82,12 +107,20 @@ export async function enablePushOnThisDevice(): Promise<EnablePushResult> {
export async function disablePushOnThisDevice(): Promise<void> {
if (!isPushSupported()) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
try {
const registration = await withTimeout(
navigator.serviceWorker.ready,
10_000,
"Service Worker nicht rechtzeitig aktiv."
);
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
await unsubscribeFromPush(subscription.endpoint).catch(() => {});
await subscription.unsubscribe().catch(() => {});
await unsubscribeFromPush(subscription.endpoint).catch(() => {});
await subscription.unsubscribe().catch(() => {});
} catch (error) {
console.error("disablePushOnThisDevice fehlgeschlagen:", error);
}
}
export type PushDeviceStatus = "unsupported" | "denied" | "disabled" | "enabled";
@ -103,7 +136,16 @@ export async function getPushDeviceStatus(): Promise<PushDeviceStatus> {
if (Notification.permission === "denied") return "denied";
if (Notification.permission !== "granted") return "disabled";
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return subscription ? "enabled" : "disabled";
try {
const registration = await withTimeout(
navigator.serviceWorker.ready,
10_000,
"Service Worker nicht rechtzeitig aktiv."
);
const subscription = await registration.pushManager.getSubscription();
return subscription ? "enabled" : "disabled";
} catch (error) {
console.error("getPushDeviceStatus fehlgeschlagen:", error);
return "disabled";
}
}

View file

@ -18,6 +18,7 @@ import {
type NotificationType,
} from "@/src/modules/notifications/lib/notification-types";
import NotificationTypeIcon from "@/src/modules/notifications/ui/components/NotificationTypeIcon";
import NotificationPermissionSettings from "../components/NotificationPermissionSettings";
import {
getNotificationSettings,
saveNotificationSettings,
@ -27,11 +28,14 @@ import {
interface SettingsNotificationPageViewProps {
businesses: { id: string; name: string }[];
initialSettings: NotificationSettings;
/** Business-Opt-out-Kategorien gelten nur für Manager. */
showBusinessSettings: boolean;
}
const SettingsNotificationPageView = ({
businesses,
initialSettings,
showBusinessSettings,
}: SettingsNotificationPageViewProps) => {
const [selectedBusinessId, setSelectedBusinessId] = useState(businesses[0]?.id ?? "");
const [settings, setSettings] = useState<NotificationSettings>(initialSettings);
@ -75,57 +79,60 @@ const SettingsNotificationPageView = ({
</p>
</div>
{businesses.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<NotificationPermissionSettings />
<ul
className={cn(
"divide-y divide-white/50 rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl",
loading && "opacity-60 transition-opacity"
{showBusinessSettings &&
(businesses.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-4">
{businesses.length > 1 && (
<Select value={selectedBusinessId} onValueChange={handleBusinessChange}>
<SelectTrigger className="bg-white/60 backdrop-blur-xl border-white/40 w-full sm:w-64">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
>
{NOTIFICATION_TYPE_KEYS.map((type) => {
const def = NOTIFICATION_TYPES[type];
return (
<li key={type} className="flex items-start gap-4 p-4">
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<NotificationTypeIcon type={type} className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{def.label}</p>
<p className="mt-0.5 text-sm text-gray-500">{def.description}</p>
</div>
<Switch
checked={settings[type]}
onCheckedChange={(next) => handleToggle(type, next)}
disabled={loading}
aria-label={def.label}
className="mt-1"
/>
</li>
);
})}
</ul>
</div>
)}
<ul
className={cn(
"divide-y divide-white/50 rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl",
loading && "opacity-60 transition-opacity"
)}
>
{NOTIFICATION_TYPE_KEYS.map((type) => {
const def = NOTIFICATION_TYPES[type];
return (
<li key={type} className="flex items-start gap-4 p-4">
<span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<NotificationTypeIcon type={type} className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{def.label}</p>
<p className="mt-0.5 text-sm text-gray-500">{def.description}</p>
</div>
<Switch
checked={settings[type]}
onCheckedChange={(next) => handleToggle(type, next)}
disabled={loading}
aria-label={def.label}
className="mt-1"
/>
</li>
);
})}
</ul>
</div>
))}
</div>
);
};

View file

@ -90,6 +90,12 @@ const SettingsOverviewView = async () => {
icon={User}
href="/settings/personal"
/>
<SettingsMenuItem
name="Benachrichtigungen"
description="Push-Benachrichtigungen für dieses Gerät verwalten"
icon={Bell}
href="/settings/notifications"
/>
</div>
</div>
);

View file

@ -1,6 +1,5 @@
import { requireAuthWithData, isManager, isEmployee, isUser } from "@/src/lib/auth/getUser";
import PersonalSettingsForm, { PersonalSettingsProps } from "../components/PersonalSettingsForm";
import NotificationPermissionSettings from "../components/NotificationPermissionSettings";
const SettingsPersonalPageView = async () => {
const userData = await requireAuthWithData();
@ -52,7 +51,6 @@ const SettingsPersonalPageView = async () => {
</p>
</div>
<PersonalSettingsForm {...props} />
<NotificationPermissionSettings />
</div>
);
};