add push settings
This commit is contained in:
@@ -9,9 +9,11 @@ import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
|
||||
import { FirebaseSettingsSection } from '@/components/id/firebase-settings-section';
|
||||
import { SettingsFieldRow } from '@/components/id/settings-field-row';
|
||||
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
||||
import {
|
||||
FIREBASE_SETTING_KEYS,
|
||||
getSettingMeta,
|
||||
isSettingVisible,
|
||||
MESSAGING_SETTING_KEYS,
|
||||
@@ -63,6 +65,7 @@ export default function AdminSettingsPage() {
|
||||
const groups = new Map<string, SystemSetting[]>();
|
||||
for (const setting of settings) {
|
||||
if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue;
|
||||
if (FIREBASE_SETTING_KEYS.includes(setting.key)) continue;
|
||||
const meta = getSettingMeta(setting.key);
|
||||
const group = meta?.group ?? 'other';
|
||||
const list = groups.get(group) ?? [];
|
||||
@@ -217,6 +220,15 @@ export default function AdminSettingsPage() {
|
||||
showToast={showToast}
|
||||
/>
|
||||
|
||||
<FirebaseSettingsSection
|
||||
settings={settings}
|
||||
token={token}
|
||||
savingGroup={savingGroup}
|
||||
onUpdateValue={updateSettingValue}
|
||||
onSaveGroup={saveSettingsGroup}
|
||||
showToast={showToast}
|
||||
/>
|
||||
|
||||
<section className="mt-10">
|
||||
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { AuthProvider } from '@/components/id/auth-provider';
|
||||
import { RealtimeProvider } from '@/components/notifications/realtime-provider';
|
||||
import { FirebasePushBridge } from '@/components/notifications/firebase-push-bridge';
|
||||
import { PublicSettingsProvider } from '@/components/id/public-settings-provider';
|
||||
import { ProjectHead } from '@/components/id/project-head';
|
||||
import { AppToastProvider } from '@/components/id/toast-provider';
|
||||
@@ -12,6 +13,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<PublicSettingsProvider>
|
||||
<AuthProvider>
|
||||
<ProjectHead />
|
||||
<FirebasePushBridge />
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</AuthProvider>
|
||||
</PublicSettingsProvider>
|
||||
|
||||
123
apps/frontend/components/id/firebase-settings-section.tsx
Normal file
123
apps/frontend/components/id/firebase-settings-section.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { Bell, Loader2, Save, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch, SystemSetting } from '@/lib/api';
|
||||
import { FIREBASE_SETTING_KEYS, isSettingVisible } from '@/lib/system-settings-catalog';
|
||||
import { SettingsFieldRow } from '@/components/id/settings-field-row';
|
||||
|
||||
function parseBoolean(value: string) {
|
||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getSettingValue(settings: SystemSetting[], key: string) {
|
||||
return settings.find((item) => item.key === key)?.value ?? '';
|
||||
}
|
||||
|
||||
interface FirebaseSettingsSectionProps {
|
||||
settings: SystemSetting[];
|
||||
token: string | null;
|
||||
savingGroup: string | null;
|
||||
onUpdateValue: (key: string, value: string) => void;
|
||||
onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise<void>;
|
||||
showToast: (message: string) => void;
|
||||
}
|
||||
|
||||
export function FirebaseSettingsSection({
|
||||
settings,
|
||||
token,
|
||||
savingGroup,
|
||||
onUpdateValue,
|
||||
onSaveGroup,
|
||||
showToast
|
||||
}: FirebaseSettingsSectionProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const firebaseSettings = useMemo(
|
||||
() => settings.filter((item) => FIREBASE_SETTING_KEYS.includes(item.key)),
|
||||
[settings]
|
||||
);
|
||||
|
||||
const visibleSettings = firebaseSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||
const saving = savingGroup === 'firebase';
|
||||
const pushEnabled = parseBoolean(getSettingValue(firebaseSettings, 'FIREBASE_PUSH_ENABLED'));
|
||||
const hasServerCredentials =
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_PROJECT_ID').trim()) &&
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_CLIENT_EMAIL').trim()) &&
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_PRIVATE_KEY').trim());
|
||||
const hasWebConfig =
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_API_KEY').trim()) &&
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_APP_ID').trim()) &&
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_MESSAGING_SENDER_ID').trim()) &&
|
||||
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_VAPID_KEY').trim());
|
||||
const canTest = pushEnabled && hasServerCredentials && hasWebConfig;
|
||||
|
||||
async function sendTestPush() {
|
||||
if (!token) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const response = await apiFetch<{ message?: string }>('/admin/settings/firebase/test', { method: 'POST' }, token);
|
||||
showToast(response.message ?? 'Тестовое push-уведомление отправлено');
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить тестовое push-уведомление');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Bell className="h-6 w-6" />
|
||||
<div>
|
||||
<h3 className="text-xl font-medium">Firebase Push</h3>
|
||||
<p className="mt-1 text-sm text-[#667085]">
|
||||
Настройте Firebase Cloud Messaging для push-уведомлений в браузере и мобильных клиентах.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium">Параметры FCM</h4>
|
||||
<p className="mt-1 text-sm text-[#667085]">
|
||||
Сервисный аккаунт — для отправки с сервера. Web API Key, App ID, Sender ID и VAPID — для клиентского SDK.
|
||||
</p>
|
||||
</div>
|
||||
<Button disabled={saving || visibleSettings.length === 0} onClick={() => void onSaveGroup('firebase', firebaseSettings)}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Сохранить блок
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{visibleSettings.map((setting) => (
|
||||
<SettingsFieldRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
onChange={(value) => onUpdateValue(setting.key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-[#e4e7ec] bg-white p-5">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Send className="h-5 w-5" />
|
||||
Тест push-уведомления
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[#667085]">
|
||||
{canTest
|
||||
? 'Отправит тестовое уведомление на ваши зарегистрированные устройства. Сначала откройте IdP в браузере и разрешите уведомления.'
|
||||
: 'Сохраните блок Firebase с включённым push и заполненными ключами, затем зарегистрируйте устройство в браузере.'}
|
||||
</p>
|
||||
<Button className="mt-3" disabled={!canTest || testing} onClick={() => void sendTestPush()}>
|
||||
{testing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
Отправить тест
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { DEFAULT_PUBLIC_API_URL, DEFAULT_PUBLIC_FRONTEND_URL, inferApiBaseFromProjectDomain } from '@/lib/project-domains';
|
||||
import { FirebasePublicConfig, parseFirebasePublicConfig } from '@/lib/firebase-config';
|
||||
|
||||
interface PublicSettingsContextValue {
|
||||
projectName: string;
|
||||
@@ -12,6 +13,7 @@ interface PublicSettingsContextValue {
|
||||
ldapEnabled: boolean;
|
||||
ldapUseLdaps: boolean;
|
||||
pinLockTimeoutMinutes: number;
|
||||
firebaseConfig: FirebasePublicConfig | null;
|
||||
isLoading: boolean;
|
||||
refreshPublicSettings: () => Promise<void>;
|
||||
}
|
||||
@@ -24,6 +26,7 @@ const PublicSettingsContext = createContext<PublicSettingsContextValue>({
|
||||
ldapEnabled: false,
|
||||
ldapUseLdaps: false,
|
||||
pinLockTimeoutMinutes: 15,
|
||||
firebaseConfig: null,
|
||||
isLoading: true,
|
||||
refreshPublicSettings: async () => undefined
|
||||
});
|
||||
@@ -68,6 +71,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
|
||||
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
||||
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
||||
pinLockTimeoutMinutes: Math.max(1, Number.parseInt(settings.PIN_LOCK_TIMEOUT_MINUTES ?? '15', 10) || 15),
|
||||
firebaseConfig: parseFirebasePublicConfig(settings),
|
||||
isLoading,
|
||||
refreshPublicSettings
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||
import { registerFirebasePush, subscribeFirebaseForegroundMessages } from '@/lib/firebase-push';
|
||||
|
||||
export function FirebasePushBridge() {
|
||||
const { token, user, isPinLocked, isLoading, isContentReady } = useAuth();
|
||||
const { firebaseConfig } = usePublicSettings();
|
||||
const registeredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !user?.id || isPinLocked || isLoading || !isContentReady || !firebaseConfig) {
|
||||
registeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (registeredRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void registerFirebasePush(token, firebaseConfig)
|
||||
.then((fcmToken) => {
|
||||
if (!cancelled && fcmToken) {
|
||||
registeredRef.current = true;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, 1200);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [firebaseConfig, isContentReady, isLoading, isPinLocked, token, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!firebaseConfig || !token || isPinLocked) return undefined;
|
||||
|
||||
const unsubscribe = subscribeFirebaseForegroundMessages((payload) => {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
const notification = new Notification(payload.title, {
|
||||
body: payload.message
|
||||
});
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [firebaseConfig, isPinLocked, token]);
|
||||
|
||||
return null;
|
||||
}
|
||||
32
apps/frontend/lib/firebase-config.ts
Normal file
32
apps/frontend/lib/firebase-config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface FirebasePublicConfig {
|
||||
enabled: boolean;
|
||||
projectId: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
messagingSenderId: string;
|
||||
vapidKey: string;
|
||||
}
|
||||
|
||||
export function parseFirebasePublicConfig(settings: Record<string, string>): FirebasePublicConfig | null {
|
||||
const enabled = ['true', '1', 'yes'].includes((settings.FIREBASE_PUSH_ENABLED ?? '').trim().toLowerCase());
|
||||
if (!enabled) return null;
|
||||
|
||||
const projectId = settings.FIREBASE_PROJECT_ID?.trim() ?? '';
|
||||
const apiKey = settings.FIREBASE_WEB_API_KEY?.trim() ?? '';
|
||||
const appId = settings.FIREBASE_WEB_APP_ID?.trim() ?? '';
|
||||
const messagingSenderId = settings.FIREBASE_WEB_MESSAGING_SENDER_ID?.trim() ?? '';
|
||||
const vapidKey = settings.FIREBASE_WEB_VAPID_KEY?.trim() ?? '';
|
||||
|
||||
if (!projectId || !apiKey || !appId || !messagingSenderId || !vapidKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
projectId,
|
||||
apiKey,
|
||||
appId,
|
||||
messagingSenderId,
|
||||
vapidKey
|
||||
};
|
||||
}
|
||||
112
apps/frontend/lib/firebase-push.ts
Normal file
112
apps/frontend/lib/firebase-push.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { initializeApp, getApps, type FirebaseApp } from 'firebase/app';
|
||||
import { getMessaging, getToken, isSupported, onMessage, type Messaging } from 'firebase/messaging';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { FirebasePublicConfig } from '@/lib/firebase-config';
|
||||
|
||||
const SW_PATH = '/firebase-messaging-sw.js';
|
||||
const REGISTERED_TOKEN_KEY = 'lendry_fcm_token';
|
||||
|
||||
let messagingInstance: Messaging | null = null;
|
||||
|
||||
function getFirebaseApp(config: FirebasePublicConfig): FirebaseApp {
|
||||
const existing = getApps()[0];
|
||||
if (existing) return existing;
|
||||
|
||||
return initializeApp({
|
||||
apiKey: config.apiKey,
|
||||
authDomain: `${config.projectId}.firebaseapp.com`,
|
||||
projectId: config.projectId,
|
||||
messagingSenderId: config.messagingSenderId,
|
||||
appId: config.appId
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return null;
|
||||
const registration = await navigator.serviceWorker.register(SW_PATH, { scope: '/' });
|
||||
await navigator.serviceWorker.ready;
|
||||
return registration;
|
||||
}
|
||||
|
||||
export async function registerFirebasePush(authToken: string, config: FirebasePublicConfig) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const supported = await isSupported();
|
||||
if (!supported) return null;
|
||||
|
||||
if (!('Notification' in window)) return null;
|
||||
|
||||
const permission = Notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await Notification.requestPermission();
|
||||
|
||||
if (permission !== 'granted') return null;
|
||||
|
||||
const registration = await ensureServiceWorker();
|
||||
if (!registration) return null;
|
||||
|
||||
const app = getFirebaseApp(config);
|
||||
messagingInstance = getMessaging(app);
|
||||
|
||||
const fcmToken = await getToken(messagingInstance, {
|
||||
vapidKey: config.vapidKey,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
|
||||
if (!fcmToken) return null;
|
||||
|
||||
const previousToken = window.localStorage.getItem(REGISTERED_TOKEN_KEY);
|
||||
if (previousToken && previousToken !== fcmToken) {
|
||||
try {
|
||||
await apiFetch('/notifications/push/register', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ token: previousToken })
|
||||
}, authToken);
|
||||
} catch {
|
||||
// ignore stale unregister errors
|
||||
}
|
||||
}
|
||||
|
||||
await apiFetch('/notifications/push/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token: fcmToken,
|
||||
platform: 'WEB',
|
||||
deviceLabel: navigator.userAgent.slice(0, 120)
|
||||
})
|
||||
}, authToken);
|
||||
|
||||
window.localStorage.setItem(REGISTERED_TOKEN_KEY, fcmToken);
|
||||
return fcmToken;
|
||||
}
|
||||
|
||||
export function subscribeFirebaseForegroundMessages(listener: (payload: { title: string; message: string; data?: Record<string, string> }) => void) {
|
||||
if (!messagingInstance) return () => undefined;
|
||||
|
||||
return onMessage(messagingInstance, (payload) => {
|
||||
listener({
|
||||
title: payload.notification?.title ?? payload.data?.title ?? 'Уведомление',
|
||||
message: payload.notification?.body ?? payload.data?.message ?? '',
|
||||
data: payload.data
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterFirebasePush(authToken: string | null) {
|
||||
const stored = typeof window !== 'undefined' ? window.localStorage.getItem(REGISTERED_TOKEN_KEY) : null;
|
||||
if (!stored || !authToken) return;
|
||||
|
||||
try {
|
||||
await apiFetch('/notifications/push/register', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ token: stored })
|
||||
}, authToken);
|
||||
} catch {
|
||||
// ignore logout cleanup errors
|
||||
} finally {
|
||||
window.localStorage.removeItem(REGISTERED_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
||||
auth: 'Аутентификация и регистрация',
|
||||
ldap: 'LDAP / Active Directory',
|
||||
messaging: 'Email и SMS',
|
||||
firebase: 'Firebase Push',
|
||||
limits: 'Лимиты и ограничения',
|
||||
media: 'Медиа и файлы'
|
||||
};
|
||||
@@ -166,6 +167,69 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
||||
{ key: 'SMS_LOGIN', label: 'Логин smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||
{ key: 'SMS_PASSWORD', label: 'Пароль smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||
{ key: 'SMS_SENDER', label: 'Имя отправителя SMS', group: 'messaging', type: 'text', hint: 'Опционально, если одобрено провайдером', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||
{
|
||||
key: 'FIREBASE_PUSH_ENABLED',
|
||||
label: 'Push-уведомления включены',
|
||||
group: 'firebase',
|
||||
type: 'boolean',
|
||||
hint: 'Отправка push через Firebase Cloud Messaging на зарегистрированные устройства'
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_PROJECT_ID',
|
||||
label: 'Firebase Project ID',
|
||||
group: 'firebase',
|
||||
type: 'text',
|
||||
hint: 'project_id из консоли Firebase и JSON сервисного аккаунта',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_CLIENT_EMAIL',
|
||||
label: 'Service Account Email',
|
||||
group: 'firebase',
|
||||
type: 'text',
|
||||
hint: 'client_email из JSON ключа сервисного аккаунта',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_PRIVATE_KEY',
|
||||
label: 'Service Account Private Key',
|
||||
group: 'firebase',
|
||||
type: 'textarea',
|
||||
hint: 'private_key из JSON. Вставьте как есть — переносы строк сохранятся',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_WEB_API_KEY',
|
||||
label: 'Web API Key',
|
||||
group: 'firebase',
|
||||
type: 'text',
|
||||
hint: 'Публичный ключ для клиентского Firebase SDK (веб и PWA)',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_WEB_APP_ID',
|
||||
label: 'Web App ID',
|
||||
group: 'firebase',
|
||||
type: 'text',
|
||||
hint: 'appId веб-приложения из консоли Firebase',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_WEB_MESSAGING_SENDER_ID',
|
||||
label: 'Messaging Sender ID',
|
||||
group: 'firebase',
|
||||
type: 'text',
|
||||
hint: 'senderId для FCM в браузере',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{
|
||||
key: 'FIREBASE_WEB_VAPID_KEY',
|
||||
label: 'Web Push VAPID Key',
|
||||
group: 'firebase',
|
||||
type: 'text',
|
||||
hint: 'Пара ключей Web Push certificates → Key pair из Firebase Cloud Messaging',
|
||||
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||
},
|
||||
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
|
||||
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
|
||||
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
|
||||
@@ -189,6 +253,8 @@ function parseBooleanValue(value: string) {
|
||||
|
||||
export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key);
|
||||
|
||||
export const FIREBASE_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'firebase').map((item) => item.key);
|
||||
|
||||
export const EMAIL_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) =>
|
||||
item.key.startsWith('EMAIL_')
|
||||
).map((item) => item.key);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"firebase": "^12.15.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "^16.0.10",
|
||||
|
||||
72
apps/frontend/public/firebase-messaging-sw.js
Normal file
72
apps/frontend/public/firebase-messaging-sw.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable no-undef */
|
||||
importScripts('https://www.gstatic.com/firebasejs/11.6.0/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/11.6.0/firebase-messaging-compat.js');
|
||||
|
||||
const API_PREFIX = '/idp-api';
|
||||
|
||||
async function loadPublicSettings() {
|
||||
const response = await fetch(`${API_PREFIX}/settings/public`, { credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить публичные настройки Firebase');
|
||||
}
|
||||
const payload = await response.json();
|
||||
const map = Object.fromEntries((payload.settings ?? []).map((item) => [item.key, item.value]));
|
||||
return map;
|
||||
}
|
||||
|
||||
function isEnabled(value) {
|
||||
return ['true', '1', 'yes'].includes(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function initFirebaseMessaging() {
|
||||
const settings = await loadPublicSettings();
|
||||
if (!isEnabled(settings.FIREBASE_PUSH_ENABLED)) return;
|
||||
|
||||
const projectId = settings.FIREBASE_PROJECT_ID?.trim();
|
||||
const apiKey = settings.FIREBASE_WEB_API_KEY?.trim();
|
||||
const appId = settings.FIREBASE_WEB_APP_ID?.trim();
|
||||
const messagingSenderId = settings.FIREBASE_WEB_MESSAGING_SENDER_ID?.trim();
|
||||
|
||||
if (!projectId || !apiKey || !appId || !messagingSenderId) return;
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey,
|
||||
authDomain: `${projectId}.firebaseapp.com`,
|
||||
projectId,
|
||||
messagingSenderId,
|
||||
appId
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
const title = payload.notification?.title || payload.data?.title || 'Уведомление';
|
||||
const options = {
|
||||
body: payload.notification?.body || payload.data?.message || '',
|
||||
data: payload.data ?? {}
|
||||
};
|
||||
self.registration.showNotification(title, options);
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
const existing = clients.find((client) => 'focus' in client);
|
||||
if (existing) {
|
||||
return existing.focus();
|
||||
}
|
||||
return self.clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
initFirebaseMessaging().catch(() => undefined);
|
||||
Reference in New Issue
Block a user