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
+
+
+ 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()} /> + +
+
+ + {/* Reset + Version */} +
+
Reset
+
+ {!showResetConfirm ? ( + + ) : ( +
+

+ This will reset the app name and logo to their install defaults. This cannot be undone. +

+
+ + +
+
+ )} + {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
-
-
- 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()} /> - -
-
- - {/* Reset + Version */} -
-
Reset
-
- {!showResetConfirm ? ( - - ) : ( -
-

- This will reset the app name and logo to their install defaults. This cannot be undone. -

-
- - + {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. +

+
+ + +
+
+ )} + + {!showRegenWarning && ( + + )} + +

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