diff --git a/.env.example b/.env.example
index 57e874a..b0311a9 100644
--- a/.env.example
+++ b/.env.example
@@ -7,7 +7,7 @@ TZ=UTC
# Copy this file to .env and customize
# 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)
ADMIN_NAME=Admin User
diff --git a/backend/package.json b/backend/package.json
index 4a9511e..6217ae6 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-backend",
- "version": "0.7.9",
+ "version": "0.8.0",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js
index 8d235b3..9835ddd 100644
--- a/backend/src/routes/push.js
+++ b/backend/src/routes/push.js
@@ -78,6 +78,21 @@ router.post('/subscribe', authMiddleware, (req, res) => {
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
router.post('/unsubscribe', authMiddleware, (req, res) => {
const { endpoint } = req.body;
diff --git a/build.sh b/build.sh
index 7d1e33f..a45d280 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.7.9}"
+VERSION="${1:-0.8.0}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"
diff --git a/frontend/package.json b/frontend/package.json
index de8567c..36f702c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
- "version": "0.7.9",
+ "version": "0.8.0",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/BrandingModal.jsx b/frontend/src/components/BrandingModal.jsx
new file mode 100644
index 0000000..63436d5
--- /dev/null
+++ b/frontend/src/components/BrandingModal.jsx
@@ -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 (
+
e.target === e.currentTarget && onClose()}>
+
+
+
Branding
+
+
+
+
+
+ {/* App Logo */}
+
+
App Logo
+
+
+
+
+
+
+ Upload Logo
+
+
+
+ Square format, max 1MB. Used in sidebar, login page and browser tab.
+
+
+
+
+
+ {/* App Name */}
+
+
App Name
+
+ setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
+
+ {loading ? '...' : 'Save'}
+
+
+
+
+ {/* Reset + Version */}
+
+
Reset
+
+ {!showResetConfirm ? (
+
setShowResetConfirm(true)}>
+ Reset All to Defaults
+
+ ) : (
+
+
+ This will reset the app name and logo to their install defaults. This cannot be undone.
+
+
+
+ {resetting ? 'Resetting...' : 'Yes, Reset Everything'}
+
+ setShowResetConfirm(false)}>Cancel
+
+
+ )}
+ {settings.app_version && (
+
+ v{settings.app_version}
+
+ )}
+
+
+
+ {settings.pw_reset_active === 'true' && (
+
+ ⚠️
+ ADMPW_RESET is active. The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx
index 6aa60c4..f564600 100644
--- a/frontend/src/components/SettingsModal.jsx
+++ b/frontend/src/components/SettingsModal.jsx
@@ -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 (
e.target === e.currentTarget && onClose()}>
-
+
+
-
App Settings
+ Settings
- {/* App Logo */}
-
-
App Logo
-
-
-
-
-
-
- Upload Logo
-
-
-
- Square format, max 1MB. Used in sidebar, login page and browser tab.
-
-
-
-
+
+
Web Push Notifications (VAPID)
- {/* App Name */}
-
-
App Name
-
- setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
-
- {loading ? '...' : 'Save'}
-
-
-
-
- {/* Reset + Version */}
-
-
Reset
-
- {!showResetConfirm ? (
-
setShowResetConfirm(true)}>
- Reset All to Defaults
-
- ) : (
-
-
- This will reset the app name and logo to their install defaults. This cannot be undone.
-
-
-
- {resetting ? 'Resetting...' : 'Yes, Reset Everything'}
-
-
setShowResetConfirm(false)}>Cancel
+ {loading ? (
+
Loading…
+ ) : (
+ <>
+ {vapidPublic ? (
+
+
+
+ Public Key
+
+
+ {vapidPublic}
+
+
+
+
+ Push notifications active
+
-
- )}
- {settings.app_version && (
-
- v{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.
+
+ )}
- {settings.pw_reset_active === 'true' && (
-
- ⚠️
- ADMPW_RESET is active. The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.
-
- )}
+ {showRegenWarning && (
+
+
+ ⚠️ Regenerate VAPID keys?
+
+
+ Generating new keys will invalidate all existing push subscriptions . Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone.
+
+
+
+ {generating ? "Generating…" : "Yes, regenerate keys"}
+
+ setShowRegenWarning(false)}>
+ Cancel
+
+
+
+ )}
+
+ {!showRegenWarning && (
+
+ {generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"}
+
+ )}
+
+
+ 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.
+
+ >
+ )}
+
);
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 375101e..aa2f596 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -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
User Manager
+
{ setShowMenu(false); onBranding && onBranding(); }}>
+
+ Branding
+
{ setShowMenu(false); onOpenSettings(); }}>
Settings
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
index abae85c..15d6343 100644
--- a/frontend/src/pages/Chat.jsx
+++ b/frontend/src/pages/Chat.jsx
@@ -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' && setModal(null)} />}
{modal === 'users' && setModal(null)} />}
{modal === 'settings' && setModal(null)} />}
+ {modal === 'branding' && setModal(null)} />}
{modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && setModal(null)} />}
{modal === 'help' && setModal(null)} dismissed={helpDismissed} />}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index a0f78a7..d98bb02 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -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'),
};