v0.8.0 add menu items + vapid key
This commit is contained in:
157
frontend/src/components/BrandingModal.jsx
Normal file
157
frontend/src/components/BrandingModal.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
export default function BrandingModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState({});
|
||||
const [appName, setAppName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setSettings(settings);
|
||||
setAppName(settings.app_name || 'jama');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!appName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.updateAppName(appName.trim());
|
||||
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
|
||||
toast('App name updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
|
||||
try {
|
||||
const { logoUrl } = await api.uploadLogo(file);
|
||||
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||
toast('Logo updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetSettings();
|
||||
const { settings: fresh } = await api.getSettings();
|
||||
setSettings(fresh);
|
||||
setAppName(fresh.app_name || 'jama');
|
||||
toast('Settings reset to defaults', 'success');
|
||||
notifySidebarRefresh();
|
||||
setShowResetConfirm(false);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 460 }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Branding</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* App Logo */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Logo</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
<img
|
||||
src={settings.logo_url || '/icons/jama.png'}
|
||||
alt="logo"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload Logo
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
|
||||
Square format, max 1MB. Used in sidebar, login page and browser tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Name */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Name</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>
|
||||
{loading ? '...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset + Version */}
|
||||
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
||||
<div className="settings-section-label">Reset</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
{!showResetConfirm ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||
borderRadius: 'var(--radius)', padding: '12px 14px'
|
||||
}}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||
This will reset the app name and logo to their install defaults. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.app_version && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
v{settings.app_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner">
|
||||
<span>⚠️</span>
|
||||
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,153 +4,133 @@ import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
export default function SettingsModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState({});
|
||||
const [appName, setAppName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [vapidPublic, setVapidPublic] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [showRegenWarning, setShowRegenWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setSettings(settings);
|
||||
setAppName(settings.app_name || 'jama');
|
||||
}).catch(() => {});
|
||||
setVapidPublic(settings.vapid_public || '');
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!appName.trim()) return;
|
||||
setLoading(true);
|
||||
const doGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setShowRegenWarning(false);
|
||||
try {
|
||||
await api.updateAppName(appName.trim());
|
||||
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
|
||||
toast('App name updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
const { publicKey } = await api.generateVapidKeys();
|
||||
setVapidPublic(publicKey);
|
||||
toast('VAPID keys generated. Push notifications are now active.', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
toast(e.message || 'Failed to generate keys', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
|
||||
try {
|
||||
const { logoUrl } = await api.uploadLogo(file);
|
||||
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||
toast('Logo updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetSettings();
|
||||
const { settings: fresh } = await api.getSettings();
|
||||
setSettings(fresh);
|
||||
setAppName(fresh.app_name || 'jama');
|
||||
toast('Settings reset to defaults', 'success');
|
||||
notifySidebarRefresh();
|
||||
setShowResetConfirm(false);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
const handleGenerateClick = () => {
|
||||
if (vapidPublic) {
|
||||
setShowRegenWarning(true);
|
||||
} else {
|
||||
doGenerate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 460 }}>
|
||||
<div className="modal" style={{ maxWidth: 480 }}>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>App Settings</h2>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Settings</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* App Logo */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Logo</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
<img
|
||||
src={settings.logo_url || '/icons/jama.png'}
|
||||
alt="logo"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload Logo
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
|
||||
Square format, max 1MB. Used in sidebar, login page and browser tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
|
||||
|
||||
{/* App Name */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Name</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>
|
||||
{loading ? '...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset + Version */}
|
||||
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
||||
<div className="settings-section-label">Reset</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
{!showResetConfirm ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||
borderRadius: 'var(--radius)', padding: '12px 14px'
|
||||
}}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||
This will reset the app name and logo to their install defaults. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
|
||||
{loading ? (
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{vapidPublic ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
background: "var(--surface-variant)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "10px 12px",
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||
Public Key
|
||||
</div>
|
||||
<code style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-primary)",
|
||||
wordBreak: "break-all",
|
||||
lineHeight: 1.5,
|
||||
display: "block",
|
||||
}}>
|
||||
{vapidPublic}
|
||||
</code>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: "var(--success)", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Push notifications active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.app_version && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
v{settings.app_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12 }}>
|
||||
No VAPID keys found. Generate keys to enable Web Push notifications — users can then receive alerts when the app is closed or in the background.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner">
|
||||
<span>⚠️</span>
|
||||
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
|
||||
</div>
|
||||
)}
|
||||
{showRegenWarning && (
|
||||
<div style={{
|
||||
background: "#fce8e6",
|
||||
border: "1px solid #f5c6c2",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "14px 16px",
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--error)", marginBottom: 8 }}>
|
||||
⚠️ Regenerate VAPID keys?
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "#5c2c28", marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: "var(--error)", color: "white" }}
|
||||
onClick={doGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? "Generating…" : "Yes, regenerate keys"}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showRegenWarning && (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleGenerateClick} disabled={generating}>
|
||||
{generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 12, lineHeight: 1.5 }}>
|
||||
Requires HTTPS. After generating, users will be prompted to enable notifications on their next visit. On iOS, the app must be installed to the home screen first.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ function useAppSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
@@ -242,6 +242,10 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
User Manager
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onBranding && onBranding(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 6V2M12 22v-4M6 12H2M22 12h-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
||||
Branding
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onOpenSettings(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
||||
Settings
|
||||
|
||||
@@ -8,6 +8,7 @@ import ChatWindow from '../components/ChatWindow.jsx';
|
||||
import ProfileModal from '../components/ProfileModal.jsx';
|
||||
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||
import SettingsModal from '../components/SettingsModal.jsx';
|
||||
import BrandingModal from '../components/BrandingModal.jsx';
|
||||
import NewChatModal from '../components/NewChatModal.jsx';
|
||||
import GlobalBar from '../components/GlobalBar.jsx';
|
||||
import AboutModal from '../components/AboutModal.jsx';
|
||||
@@ -295,6 +296,7 @@ export default function Chat() {
|
||||
onProfile={() => setModal('profile')}
|
||||
onUsers={() => setModal('users')}
|
||||
onSettings={() => setModal('settings')}
|
||||
onBranding={() => setModal('branding')}
|
||||
onGroupsUpdated={loadGroups}
|
||||
isMobile={isMobile}
|
||||
onAbout={() => setModal('about')}
|
||||
@@ -317,6 +319,7 @@ export default function Chat() {
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
|
||||
@@ -126,4 +126,7 @@ export const api = {
|
||||
getPinnedMessages: (groupId) => req('GET', `/messages/pinned?groupId=${groupId}`),
|
||||
pinMessage: (messageId) => req('POST', `/messages/${messageId}/pin`),
|
||||
unpinMessage: (messageId) => req('DELETE', `/messages/${messageId}/pin`),
|
||||
|
||||
// VAPID key management (admin only)
|
||||
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user