v0.8.0 add menu items + vapid key
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# Image version to run (set by build.sh, or use 'latest')
|
||||||
JAMA_VERSION=0.7.9
|
JAMA_VERSION=0.8.0
|
||||||
|
|
||||||
# Default admin credentials (used on FIRST RUN only)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.7.9",
|
"version": "0.8.0",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ router.post('/subscribe', authMiddleware, (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/push/generate-vapid — admin: generate (or regenerate) VAPID keys
|
||||||
|
router.post('/generate-vapid', authMiddleware, (req, res) => {
|
||||||
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
|
||||||
|
const db = getDb();
|
||||||
|
const keys = webpush.generateVAPIDKeys();
|
||||||
|
const ins = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?");
|
||||||
|
ins.run('vapid_public', keys.publicKey, keys.publicKey);
|
||||||
|
ins.run('vapid_private', keys.privateKey, keys.privateKey);
|
||||||
|
// Reinitialise webpush with new keys immediately
|
||||||
|
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
||||||
|
vapidPublicKey = keys.publicKey;
|
||||||
|
console.log('[Push] VAPID keys regenerated by admin');
|
||||||
|
res.json({ publicKey: keys.publicKey });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/push/unsubscribe — remove subscription
|
// POST /api/push/unsubscribe — remove subscription
|
||||||
router.post('/unsubscribe', authMiddleware, (req, res) => {
|
router.post('/unsubscribe', authMiddleware, (req, res) => {
|
||||||
const { endpoint } = req.body;
|
const { endpoint } = req.body;
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.7.9}"
|
VERSION="${1:-0.8.0}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.7.9",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
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 }) {
|
export default function SettingsModal({ onClose }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [settings, setSettings] = useState({});
|
const [vapidPublic, setVapidPublic] = useState('');
|
||||||
const [appName, setAppName] = useState('');
|
const [loading, setLoading] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [showRegenWarning, setShowRegenWarning] = useState(false);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => {
|
||||||
setSettings(settings);
|
setVapidPublic(settings.vapid_public || '');
|
||||||
setAppName(settings.app_name || 'jama');
|
setLoading(false);
|
||||||
}).catch(() => {});
|
}).catch(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
|
const doGenerate = async () => {
|
||||||
|
setGenerating(true);
|
||||||
const handleSaveName = async () => {
|
setShowRegenWarning(false);
|
||||||
if (!appName.trim()) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await api.updateAppName(appName.trim());
|
const { publicKey } = await api.generateVapidKeys();
|
||||||
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
|
setVapidPublic(publicKey);
|
||||||
toast('App name updated', 'success');
|
toast('VAPID keys generated. Push notifications are now active.', 'success');
|
||||||
notifySidebarRefresh();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message || 'Failed to generate keys', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogoUpload = async (e) => {
|
const handleGenerateClick = () => {
|
||||||
const file = e.target.files?.[0];
|
if (vapidPublic) {
|
||||||
if (!file) return;
|
setShowRegenWarning(true);
|
||||||
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
|
} else {
|
||||||
try {
|
doGenerate();
|
||||||
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 (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<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 }}>
|
<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}>
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* App Logo */}
|
<div>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
|
||||||
<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 */}
|
{loading ? (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>Loading…</p>
|
||||||
<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()} />
|
{vapidPublic ? (
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
{loading ? '...' : 'Save'}
|
<div style={{
|
||||||
</button>
|
background: "var(--surface-variant)",
|
||||||
</div>
|
border: "1px solid var(--border)",
|
||||||
</div>
|
borderRadius: "var(--radius)",
|
||||||
|
padding: "10px 12px",
|
||||||
{/* Reset + Version */}
|
marginBottom: 10,
|
||||||
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
}}>
|
||||||
<div className="settings-section-label">Reset</div>
|
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
Public Key
|
||||||
{!showResetConfirm ? (
|
</div>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
<code style={{
|
||||||
Reset All to Defaults
|
fontSize: 11,
|
||||||
</button>
|
color: "var(--text-primary)",
|
||||||
) : (
|
wordBreak: "break-all",
|
||||||
<div style={{
|
lineHeight: 1.5,
|
||||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
display: "block",
|
||||||
borderRadius: 'var(--radius)', padding: '12px 14px'
|
}}>
|
||||||
}}>
|
{vapidPublic}
|
||||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
</code>
|
||||||
This will reset the app name and logo to their install defaults. This cannot be undone.
|
</div>
|
||||||
</p>
|
<span style={{ fontSize: 13, color: "var(--success)", display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<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>
|
||||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
Push notifications active
|
||||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
</span>
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12 }}>
|
||||||
{settings.app_version && (
|
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.
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
</p>
|
||||||
v{settings.app_version}
|
)}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settings.pw_reset_active === 'true' && (
|
{showRegenWarning && (
|
||||||
<div className="warning-banner">
|
<div style={{
|
||||||
<span>⚠️</span>
|
background: "#fce8e6",
|
||||||
<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>
|
border: "1px solid #f5c6c2",
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function useAppSettings() {
|
|||||||
return settings;
|
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 { user, logout } = useAuth();
|
||||||
const { connected } = useSocket();
|
const { connected } = useSocket();
|
||||||
const toast = useToast();
|
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>
|
<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
|
User Manager
|
||||||
</button>
|
</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(); }}>
|
<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>
|
<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
|
Settings
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ChatWindow from '../components/ChatWindow.jsx';
|
|||||||
import ProfileModal from '../components/ProfileModal.jsx';
|
import ProfileModal from '../components/ProfileModal.jsx';
|
||||||
import UserManagerModal from '../components/UserManagerModal.jsx';
|
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||||
import SettingsModal from '../components/SettingsModal.jsx';
|
import SettingsModal from '../components/SettingsModal.jsx';
|
||||||
|
import BrandingModal from '../components/BrandingModal.jsx';
|
||||||
import NewChatModal from '../components/NewChatModal.jsx';
|
import NewChatModal from '../components/NewChatModal.jsx';
|
||||||
import GlobalBar from '../components/GlobalBar.jsx';
|
import GlobalBar from '../components/GlobalBar.jsx';
|
||||||
import AboutModal from '../components/AboutModal.jsx';
|
import AboutModal from '../components/AboutModal.jsx';
|
||||||
@@ -295,6 +296,7 @@ export default function Chat() {
|
|||||||
onProfile={() => setModal('profile')}
|
onProfile={() => setModal('profile')}
|
||||||
onUsers={() => setModal('users')}
|
onUsers={() => setModal('users')}
|
||||||
onSettings={() => setModal('settings')}
|
onSettings={() => setModal('settings')}
|
||||||
|
onBranding={() => setModal('branding')}
|
||||||
onGroupsUpdated={loadGroups}
|
onGroupsUpdated={loadGroups}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onAbout={() => setModal('about')}
|
onAbout={() => setModal('about')}
|
||||||
@@ -317,6 +319,7 @@ export default function Chat() {
|
|||||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'settings' && <SettingsModal 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 === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||||
|
|||||||
@@ -126,4 +126,7 @@ export const api = {
|
|||||||
getPinnedMessages: (groupId) => req('GET', `/messages/pinned?groupId=${groupId}`),
|
getPinnedMessages: (groupId) => req('GET', `/messages/pinned?groupId=${groupId}`),
|
||||||
pinMessage: (messageId) => req('POST', `/messages/${messageId}/pin`),
|
pinMessage: (messageId) => req('POST', `/messages/${messageId}/pin`),
|
||||||
unpinMessage: (messageId) => req('DELETE', `/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