feat: policy-controlled push relay URL with optional user lock
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
Reference in New Issue
Block a user