feat: policy-controlled push relay URL with optional user lock

This commit is contained in:
Linus Rath
2026-05-28 19:45:14 +02:00
parent 4ff05bd4ec
commit 8ae5ecba41
6 changed files with 77 additions and 5 deletions
+40
View File
@@ -74,6 +74,18 @@ export function PolicyTab() {
setMessage(null); setMessage(null);
} }
function setPushRelayUrl(value: string) {
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
setDirty(true);
setMessage(null);
}
function togglePushRelayLocked() {
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) { function toggleLocked(settingKey: string) {
setPolicy(prev => { setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {}; const existing = prev.restrictions[settingKey] || {};
@@ -183,6 +195,34 @@ export function PolicyTab() {
</div> </div>
</div> </div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Push Relay</h2>
<p className="text-xs text-muted-foreground mt-0.5">Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.</p>
</div>
<div className="px-4 py-3 space-y-3">
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={policy.pushRelayUrl ?? ''}
onChange={(e) => setPushRelayUrl(e.target.value)}
placeholder="https://notifications.relay.example.com"
className="w-full rounded border border-input bg-background px-3 py-2 text-sm"
/>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={!!policy.pushRelayUrlLocked}
onChange={togglePushRelayLocked}
className="rounded border-input"
/>
<Lock className="w-3 h-3" /> Lock - users cannot change this URL
</label>
</div>
</div>
{categories.map(category => ( {categories.map(category => (
<div key={category} className="border border-border rounded-lg"> <div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
+25 -5
View File
@@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CheckCircle2, Volume2, XCircle } from 'lucide-react'; import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -40,16 +40,30 @@ export function NotificationSettings() {
updateSetting, updateSetting,
} = useSettingsStore(); } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore();
const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl);
const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true;
const client = useAuthStore((s) => s.client); const client = useAuthStore((s) => s.client);
const username = useAuthStore((s) => s.username); const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported(); const supported = typeof window !== 'undefined' && isWebPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); const adminUrl = (adminPushRelayUrl ?? '').trim();
const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL);
const [pushStatus, setPushStatus] = useState<PushStatus>( const [pushStatus, setPushStatus] = useState<PushStatus>(
supported ? { kind: 'idle' } : { kind: 'unsupported' }, supported ? { kind: 'idle' } : { kind: 'unsupported' },
); );
// Pull the admin-configured URL into local state when policy loads/changes.
// When locked, the admin value always wins; when only set (not locked), use
// it as the initial default but let the user override.
useEffect(() => {
if (pushRelayLocked && adminUrl) {
setRelayUrl(adminUrl);
} else if (adminUrl) {
setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current));
}
}, [adminUrl, pushRelayLocked]);
useEffect(() => { useEffect(() => {
if (!supported) return; if (!supported) return;
if (!client) return; if (!client) return;
@@ -125,12 +139,17 @@ export function NotificationSettings() {
<SettingsSection title={t('push.title')} description={t('push.description')}> <SettingsSection title={t('push.title')} description={t('push.description')}>
<div className="rounded-md border p-4 space-y-3"> <div className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<label className="text-sm font-medium" htmlFor="push-relay-url"> <label className="text-sm font-medium inline-flex items-center gap-1.5" htmlFor="push-relay-url">
{t('push.relay_label')} {t('push.relay_label')}
{pushRelayLocked && (
<Lock className="w-3 h-3 text-muted-foreground" aria-label={t('push.relay_locked')} />
)}
</label> </label>
<PushStatusBadge status={pushStatus} t={t} /> <PushStatusBadge status={pushStatus} t={t} />
</div> </div>
<p className="text-xs text-muted-foreground">{t('push.relay_desc')}</p> <p className="text-xs text-muted-foreground">
{pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
</p>
<input <input
id="push-relay-url" id="push-relay-url"
type="url" type="url"
@@ -140,7 +159,8 @@ export function NotificationSettings() {
value={relayUrl} value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)} onChange={(e) => setRelayUrl(e.target.value)}
placeholder={t('push.relay_placeholder')} placeholder={t('push.relay_placeholder')}
disabled={busy || pushStatus.kind === 'unsupported'} disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked}
readOnly={pushRelayLocked}
className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50" className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50"
/> />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
+6
View File
@@ -106,6 +106,10 @@ export interface SettingsPolicy {
approvedPlugins: string[]; approvedPlugins: string[];
/** Theme IDs that are force-enabled (users cannot deactivate) */ /** Theme IDs that are force-enabled (users cannot deactivate) */
forceEnabledThemes: string[]; forceEnabledThemes: string[];
/** Web Push relay base URL shown to users. Empty means the built-in default. */
pushRelayUrl?: string;
/** When true, users cannot change pushRelayUrl in notification settings. */
pushRelayUrlLocked?: boolean;
} }
export const DEFAULT_POLICY: SettingsPolicy = { export const DEFAULT_POLICY: SettingsPolicy = {
@@ -116,6 +120,8 @@ export const DEFAULT_POLICY: SettingsPolicy = {
forceEnabledPlugins: [], forceEnabledPlugins: [],
approvedPlugins: [], approvedPlugins: [],
forceEnabledThemes: [], forceEnabledThemes: [],
pushRelayUrl: '',
pushRelayUrlLocked: false,
}; };
export interface AuditEntry { export interface AuditEntry {
+2
View File
@@ -955,6 +955,8 @@
"reenable": "Znovu zaregistrovat", "reenable": "Znovu zaregistrovat",
"relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.", "relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.",
"relay_label": "Push relay", "relay_label": "Push relay",
"relay_locked": "Nastaveno administrátorem",
"relay_locked_desc": "URL push relay byla nastavena administrátorem a nelze ji změnit.",
"relay_placeholder": "https://notifications.relay.example.com", "relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Aktivní na tomto zařízení", "status_active": "Aktivní na tomto zařízení",
"status_busy": "Pracuji…", "status_busy": "Pracuji…",
+2
View File
@@ -927,6 +927,8 @@
"description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", "description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"relay_label": "Push relay", "relay_label": "Push relay",
"relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.", "relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.",
"relay_locked": "Set by administrator",
"relay_locked_desc": "The push relay URL has been set by your administrator and cannot be changed.",
"relay_placeholder": "https://notifications.relay.example.com", "relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Active on this device", "status_active": "Active on this device",
"status_inactive": "Not enabled on this device", "status_inactive": "Not enabled on this device",
+2
View File
@@ -955,6 +955,8 @@
"reenable": "Opnieuw registreren", "reenable": "Opnieuw registreren",
"relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.", "relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.",
"relay_label": "Push-relay", "relay_label": "Push-relay",
"relay_locked": "Ingesteld door beheerder",
"relay_locked_desc": "De push-relay-URL is ingesteld door je beheerder en kan niet worden gewijzigd.",
"relay_placeholder": "https://notifications.relay.example.com", "relay_placeholder": "https://notifications.relay.example.com",
"status_active": "Actief op dit apparaat", "status_active": "Actief op dit apparaat",
"status_busy": "Bezig…", "status_busy": "Bezig…",