v0.8.0 add menu items + vapid key

This commit is contained in:
2026-03-12 13:03:04 -04:00
parent 5697e3a59c
commit 25a1343838
10 changed files with 289 additions and 127 deletions

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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} />}

View File

@@ -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'),
};