Files
rosterchirp/frontend/src/components/SettingsModal.jsx

318 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
// ── Helpers ───────────────────────────────────────────────────────────────────
const APP_TYPES = {
'JAMA-Chat': { label: 'JAMA-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' },
'JAMA-Brand': { label: 'JAMA-Brand', desc: 'Chat and Branding.' },
'JAMA-Team': { label: 'JAMA-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
};
// ── Team Management Tab ───────────────────────────────────────────────────────
function TeamManagementTab() {
const toast = useToast();
const [userGroups, setUserGroups] = useState([]);
const [toolManagers, setToolManagers] = useState([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.getUserGroups().then(({ groups }) => setUserGroups(groups || [])).catch(() => {});
api.getSettings().then(({ settings }) => {
// Read from unified key, fall back to legacy key
setToolManagers(JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'));
}).catch(() => {});
}, []);
const toggle = (id) => {
setToolManagers(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const handleSave = async () => {
setSaving(true);
try {
await api.updateTeamSettings({ toolManagers });
toast('Team settings saved', 'success');
window.dispatchEvent(new Event('jama:settings-changed'));
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
<div>
<div className="settings-section-label">Tool Managers</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 12 }}>
Members of selected groups can access Group Manager, Schedule Manager, and User Manager. Admin users always have access to all three tools.
</p>
{userGroups.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-tertiary)', marginBottom: 16 }}>No user groups created yet. Create groups in the Group Manager first.</p>
) : (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
{userGroups.map(g => (
<label key={g.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
<input type="checkbox" checked={toolManagers.includes(g.id)} onChange={() => toggle(g.id)}
style={{ accentColor: 'var(--primary)', width: 15, height: 15 }} />
<div style={{ width: 24, height: 24, borderRadius: 5, background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>UG</div>
<span style={{ flex: 1, fontSize: 14 }}>{g.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{g.member_count} member{g.member_count !== 1 ? 's' : ''}</span>
</label>
))}
</div>
)}
{toolManagers.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 16 }}>No groups selected tools are admin-only.</p>
)}
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
);
}
// ── Registration Tab ──────────────────────────────────────────────────────────
function RegistrationTab({ onFeaturesChanged }) {
const toast = useToast();
const [settings, setSettings] = useState({});
const [regCode, setRegCode] = useState('');
const [regLoading, setRegLoading] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
}, []);
const appType = settings.app_type || 'JAMA-Chat';
const activeCode = settings.registration_code || '';
const adminEmail = settings.admin_email || '—';
// Placeholder serial number derived from hostname
const serialNumber = btoa(window.location.hostname).replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16).padEnd(16, '0');
const handleCopySerial = async () => {
await navigator.clipboard.writeText(serialNumber).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleRegister = async () => {
if (!regCode.trim()) return toast('Enter a registration code', 'error');
setRegLoading(true);
try {
const { features: f } = await api.registerCode(regCode.trim());
setRegCode('');
const fresh = await api.getSettings();
setSettings(fresh.settings);
toast('Registration applied successfully.', 'success');
window.dispatchEvent(new Event('jama:settings-changed'));
onFeaturesChanged && onFeaturesChanged(f);
} catch (e) { toast(e.message || 'Invalid registration code', 'error'); }
finally { setRegLoading(false); }
};
const handleClear = async () => {
try {
const { features: f } = await api.registerCode('');
const fresh = await api.getSettings();
setSettings(fresh.settings);
toast('Registration cleared.', 'success');
window.dispatchEvent(new Event('jama:settings-changed'));
onFeaturesChanged && onFeaturesChanged(f);
} catch (e) { toast(e.message, 'error'); }
};
const typeInfo = APP_TYPES[appType] || APP_TYPES['JAMA-Chat'];
const siteUrl = window.location.origin;
return (
<div>
{/* Info box */}
<div style={{ background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '14px 16px', marginBottom: 24 }}>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>
Registration {activeCode ? 'is' : 'required:'}
</p>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
JAMA {activeCode ? 'is' : 'will be'} registered to:<br />
<strong>Type:</strong> {typeInfo.label}<br />
<strong>URL:</strong> {siteUrl}
</p>
</div>
{/* Type */}
<div style={{ marginBottom: 16 }}>
<div className="settings-section-label">Application Type</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 6 }}>
<div style={{ padding: '7px 14px', borderRadius: 'var(--radius)', border: '1px solid var(--border)', background: 'var(--surface-variant)', fontSize: 14, fontWeight: 600, color: 'var(--primary)' }}>
{typeInfo.label}
</div>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{typeInfo.desc}</span>
</div>
</div>
{/* Serial Number */}
<div style={{ marginBottom: 16 }}>
<div className="settings-section-label">Serial Number</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
<input className="input flex-1" value={serialNumber} readOnly style={{ fontFamily: 'monospace', letterSpacing: 1 }} autoComplete="new-password" />
<button className="btn btn-secondary btn-sm" onClick={handleCopySerial} style={{ flexShrink: 0 }}>
{copied ? '✓ Copied' : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
)}
</button>
</div>
</div>
{/* Registration Code */}
<div style={{ marginBottom: 20 }}>
<div className="settings-section-label">Registration Code</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<input className="input flex-1" placeholder="Enter registration code" value={regCode}
onChange={e => setRegCode(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRegister()} autoComplete="new-password" />
<button className="btn btn-primary btn-sm" onClick={handleRegister} disabled={regLoading}>
{regLoading ? '…' : 'Register'}
</button>
</div>
</div>
{activeCode && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<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>
Registered {typeInfo.label}
</span>
<button className="btn btn-secondary btn-sm" onClick={handleClear}>Clear</button>
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
Registration codes unlock application features. Contact your JAMA provider for a code.<br />
<strong>JAMA-Brand</strong> unlocks Branding.&nbsp;
<strong>JAMA-Team</strong> unlocks Branding, Group Manager and Schedule Manager.
</p>
</div>
);
}
// ── Web Push Tab ──────────────────────────────────────────────────────────────
function WebPushTab() {
const toast = useToast();
const [vapidPublic, setVapidPublic] = useState('');
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [showRegenWarning, setShowRegenWarning] = useState(false);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setVapidPublic(settings.vapid_public || '');
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const doGenerate = async () => {
setGenerating(true);
setShowRegenWarning(false);
try {
const { publicKey } = await api.generateVapidKeys();
setVapidPublic(publicKey);
toast('VAPID keys generated. Push notifications are now active.', 'success');
} catch (e) {
toast(e.message || 'Failed to generate keys', 'error');
} finally { setGenerating(false); }
};
if (loading) return <p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Loading</p>;
return (
<div>
<div className="settings-section-label" style={{ marginBottom: 12 }}>Web Push Notifications (VAPID)</div>
{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>
) : (
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
No VAPID keys found. Generate keys to enable Web Push notifications.
</p>
)}
{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>. Users will need to re-enable notifications.
</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={() => vapidPublic ? setShowRegenWarning(true) : doGenerate()} 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. On iOS, the app must be installed to the home screen first.
</p>
</div>
);
}
// ── Main modal ────────────────────────────────────────────────────────────────
export default function SettingsModal({ onClose, onFeaturesChanged }) {
const [tab, setTab] = useState('registration');
const [appType, setAppType] = useState('JAMA-Chat');
useEffect(() => {
api.getSettings().then(({ settings }) => {
setAppType(settings.app_type || 'JAMA-Chat');
}).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'JAMA-Chat')).catch(() => {});
window.addEventListener('jama:settings-changed', handler);
return () => window.removeEventListener('jama:settings-changed', handler);
}, []);
const isTeam = appType === 'JAMA-Team';
const tabs = [
isTeam && { id: 'team', label: 'Team Management' },
{ id: 'registration', label: 'Registration' },
{ id: 'webpush', label: 'Web Push' },
].filter(Boolean);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 520 }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<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>
{/* Tab buttons */}
<div className="flex gap-2" style={{ marginBottom: 24 }}>
{tabs.map(t => (
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}>
{t.label}
</button>
))}
</div>
{tab === 'team' && <TeamManagementTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
{tab === 'webpush' && <WebPushTab />}
</div>
</div>
);
}