/* ============================================================
shell.jsx — sidebar, topbar, command palette, login
============================================================ */
/* ── Branding bootstrap ─────────────────────────────────────
Fetch /api/branding once, store in window.__branding, apply
document.title and --color-brand CSS var immediately.
─────────────────────────────────────────────────────────── */
window.__branding = window.__branding || {
company_name: 'k9.ms',
product_name: 'attendance',
logo_url: '',
favicon_url: '',
brand_color: '#6366f1',
tagline: '',
login_slogan: 'Sign in to your team.',
email_footer: '',
};
function _applyBranding(b) {
window.__branding = b;
if (b.product_name) document.title = b.product_name;
if (b.brand_color) document.documentElement.style.setProperty('--color-brand', b.brand_color);
if (b.favicon_url) {
let link = document.querySelector("link[rel~='icon']");
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = b.favicon_url;
}
}
(function _loadBranding() {
// Use /api/branding/all (public) and map to the shell's legacy shape.
fetch('/api/branding/all', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(b => {
if (!b) return;
_applyBranding({
product_name: b.brand_name || 'attendance',
company_name: '', // single brand: header shows brand_name once + slogan
logo_url: b.logo_dark_url || b.logo_light_url || '',
favicon_url: b.favicon_url || '',
brand_color: b.primary_color || '#6366f1',
tagline: b.slogan || '',
login_slogan: b.slogan || 'Sign in to your team.',
email_footer: b.support_email || '',
domain: b.domain || '',
});
})
.catch(() => {});
})();
const NAV_GROUPS = [
{ label: 'Activity', items: [
{ id: 'today', label: 'Today', icon: 'today', tip: 'Live attendance & team status' },
{ id: 'employees', label: 'Employees', icon: 'team', tip: 'Employee profiles & compliance' },
{ id: 'pto', label: 'Requests', icon: 'pto', tip: 'PTO & leave request approvals' },
{ id: 'monthly', label: 'Summary', icon: 'chart', tip: 'Monthly attendance summary' },
{ id: 'reports', label: 'Reports', icon: 'report', tip: 'Charts, trends & export' },
{ id: 'tasks', label: 'Tasks', icon: 'audit', tip: 'Track completed, blocked & stalled tasks' },
// Captures nav entry removed — automatic capture cadence runs in the
// background; on-demand capture is surfaced inside Bot Behavior now.
{ id: 'support', label: 'Support', icon: 'alert', tip: 'Tickets & support stats' },
{ id: 'oncall', label: 'On-call', icon: 'alert', tip: 'On-call rota & escalation' },
]},
{ label: 'Insight', items: [
{ id: 'messages', label: 'Messages', icon: 'msg', tip: 'All standup messages & DM history' },
{ id: 'audit', label: 'Audit log', icon: 'audit', tip: 'Admin actions & system audit trail' },
]},
{ label: 'Configure', items: [
{ id: 'teams', label: 'Teams', icon: 'building', tip: 'Manage teams & default settings' },
{ id: 'rooms', label: 'Rooms', icon: 'door', tip: 'Matrix room assignments' },
{ id: 'bots', label: 'Bots', icon: 'ai', tip: 'Bot configuration & personas' },
{ id: 'bot-behavior', label: 'Bot Behavior', icon: 'sliders', tip: 'Thresholds, keywords, templates & AI prompts' },
{ id: 'providers', label: 'Integrations', icon: 'plug', tip: 'Slack, Microsoft Teams, WhatsApp and other third-party integrations' },
{ id: 'server', label: 'Server', icon: 'server', tip: 'Server health & access logs' },
{ id: 'help', label: 'Help / API', icon: 'info', tip: 'API reference & documentation' },
]},
];
function Sidebar({ active, setActive, collapsed, setCollapsed, me, counts, onSignOut }) {
const [profileOpen, setProfileOpen] = useState(false);
// Re-render sidebar avatar when theme changes (auto preference)
const _useTheme = window.useTheme || (() => 'dark');
const _resolveAvatarUrl = window.resolveAvatarUrl || (() => null);
_useTheme(); // subscribe to theme changes
const sidebarAvatarUrl = _resolveAvatarUrl(me);
return (
window.location.reload()} title="Click to reload">
{ e.currentTarget.style.display='none'; e.currentTarget.parentElement.innerHTML = ''; const g = document.createElement('div'); g.innerHTML = '
S '; e.currentTarget.parentElement.appendChild(g); }} />
{!collapsed && (
{(window.__branding && window.__branding.product_name) || 'status'}
{(() => { const b = window.__branding || {}; if (b.tagline) return b.tagline; if (b.company_name && b.product_name && b.company_name !== b.product_name) return b.company_name + " / " + b.product_name; return ""; })()}
)}
setProfileOpen(true)} style={{cursor:'pointer'}} title="Open profile">
{sidebarAvatarUrl
?
:
{me.initials}
}
{!collapsed && (
{me.display_name}
● {me.org_role}
)}
{!collapsed && (
setCollapsed(true)}>
Collapse
)}
{collapsed && (
setCollapsed(false)} title="Expand sidebar">
)}
{profileOpen && ReactDOM.createPortal(
setProfileOpen(false)} />,
document.body
)}
);
}
// Topbar mic enable/disable. Reads/writes localStorage.voice_enabled and
// dispatches 'voice-pref-change' so the Ask-AI widget can show/hide its
// inline mic button. This is the SINGLE entry point for turning the mic on
// site-wide — replaces the old floating "Voice off" pill.
function MicToggle() {
const [on, setOn] = useState(() => localStorage.getItem('voice_enabled') === '1');
useEffect(() => {
const h = () => setOn(localStorage.getItem('voice_enabled') === '1');
window.addEventListener('voice-pref-change', h);
return () => window.removeEventListener('voice-pref-change', h);
}, []);
const toggle = () => {
const next = !on;
if (next) localStorage.setItem('voice_enabled', '1');
else localStorage.removeItem('voice_enabled');
setOn(next);
window.dispatchEvent(new CustomEvent('voice-pref-change', { detail: { enabled: next } }));
};
return (
);
}
function Clock() {
const [t, setT] = useState(new Date());
const [tz, setTz] = useState(null);
useEffect(() => {
const id = setInterval(() => setT(new Date()), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
let alive = true;
fetch('/api/auth/me', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(j => { if (alive && j && j.timezone) setTz(j.timezone); })
.catch(() => {});
return () => { alive = false; };
}, []);
const tOpts = { hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:true, ...(tz ? { timeZone: tz } : {}) };
const dOpts = { weekday:'short', month:'short', day:'numeric', ...(tz ? { timeZone: tz } : {}) };
let offset = '';
if (tz) {
try {
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts(t);
const o = parts.find(p => p.type === 'timeZoneName');
offset = (o && o.value) ? o.value : tz;
} catch (_) { offset = tz; }
}
return (
{t.toLocaleTimeString([], tOpts)}
{t.toLocaleDateString([], dOpts)}{offset ? ' · ' + offset : ''}
);
}
function Topbar({ pageTitle, pageCrumb, liveCount, totalCount, onCmdOpen, theme, setTheme, aesthetic, setAesthetic, density, setDensity, isAdmin, isManager }) {
// Phase 1 — admin/manager Edit Mode toggle. Wraps the global useEditMode
// hook so this top-bar button stays in sync with any other component that
// flips the flag (e.g. keyboard shortcut in a future phase).
const editMode = (typeof useEditMode === 'function') ? useEditMode() : false;
const canSeeEditToggle = !!(isAdmin || isManager);
const [qrOpen, setQrOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [brandingOpen, setBrandingOpen] = useState(false);
const [sseFlash, setSseFlash] = useState(false);
const [notifEnabled, setNotifEnabled] = useState(() => {
try { return localStorage.getItem('notif_enabled') === 'true'; } catch { return false; }
});
const [notifCount, setNotifCount] = useState(0);
// PWA install prompt — capture beforeinstallprompt at topbar level (survives modal lifecycle)
const [installPrompt, setInstallPrompt] = useState(null);
const [isInstalled, setIsInstalled] = useState(() =>
window.matchMedia && window.matchMedia('(display-mode: standalone)').matches
);
useEffect(() => {
if (isInstalled) return;
const handler = (e) => { e.preventDefault(); setInstallPrompt(e); };
const _appInstalledHandler = () => { setInstallPrompt(null); setIsInstalled(true); };
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', _appInstalledHandler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', _appInstalledHandler);
};
}, [isInstalled]);
const doTopbarInstall = async () => {
if (!installPrompt) return;
installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') { setInstallPrompt(null); setIsInstalled(true); }
};
// Visit count toast — show on 3rd visit, once only
const toast = useToast();
const visitToastFiredRef = useRef(false);
useEffect(() => {
if (visitToastFiredRef.current) return;
if (localStorage.getItem('status_install_toast_dismissed') === 'true') return;
visitToastFiredRef.current = true;
let count = 0;
try { count = parseInt(localStorage.getItem('status_visit_count') || '0', 10) || 0; } catch {}
count += 1;
try { localStorage.setItem('status_visit_count', String(count)); } catch {}
if (count === 3) {
setTimeout(() => {
toast.push({
msg: 'Install Status as an app (beta) — press Cmd+Shift+A or use Settings > Install / QR code',
kind: 'info',
icon: '↓',
duration: 10000,
});
}, 2000);
}
}, []);
// Keyboard shortcut Cmd+Shift+A → open QR
useEffect(() => {
const handler = (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'A') {
e.preventDefault();
setQrOpen(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Theme color overrides — load on mount, apply to CSS vars
const [themeColors, setThemeColors] = useState({});
const [themeColorsLoading, setThemeColorsLoading] = useState(false);
const themeColorKeys = ['on_time', 'late', 'absent', 'pto', 'ai'];
const themeColorCssVar = { on_time: '--c-on-time', late: '--c-late', absent: '--c-absent', pto: '--c-pto', ai: '--c-ai' };
const themeColorLabel = { on_time: 'On time', late: 'Late', absent: 'Absent', pto: 'PTO', ai: 'AI accent' };
const themeColorDefault = { on_time: '#22c55e', late: '#f59e0b', absent: '#ef4444', pto: '#6366f1', ai: '#a855f7' };
useEffect(() => {
fetch('/api/branding/theme', { credentials: 'include' })
.then(r => r.ok ? r.json() : {})
.then(data => {
setThemeColors(data || {});
Object.entries(data || {}).forEach(([k, v]) => {
if (themeColorCssVar[k]) document.documentElement.style.setProperty(themeColorCssVar[k], v);
});
})
.catch(() => {});
}, []);
const saveThemeColor = async (key, value) => {
setThemeColors(prev => ({ ...prev, [key]: value }));
if (themeColorCssVar[key]) document.documentElement.style.setProperty(themeColorCssVar[key], value);
setThemeColorsLoading(true);
try {
await fetch('/api/branding/theme', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ [key]: value }),
});
} catch {}
setThemeColorsLoading(false);
};
const clearThemeColor = async (key) => {
setThemeColors(prev => { const n = {...prev}; delete n[key]; return n; });
if (themeColorCssVar[key]) document.documentElement.style.removeProperty(themeColorCssVar[key]);
try {
await fetch('/api/branding/theme', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ [key]: null }),
});
} catch {}
};
const toggleNotif = async () => {
if (!('Notification' in window)) {
alert('This browser does not support desktop notifications.');
return;
}
if (!notifEnabled) {
const perm = await Notification.requestPermission();
if (perm === 'granted') {
setNotifEnabled(true);
try { localStorage.setItem('notif_enabled', 'true'); } catch {}
setNotifCount(0);
(typeof Notification !== 'undefined' && Notification.permission === 'granted') && new Notification('Attendance Bot', { body: 'Attendance alerts enabled.', icon: '/static/v2/logo.png' });
}
} else {
setNotifEnabled(false);
try { localStorage.setItem('notif_enabled', 'false'); } catch {}
}
};
// SSE flash — briefly highlight the live chip when any SSE event arrives
useEffect(() => {
const handler = () => {
setSseFlash(true);
setTimeout(() => setSseFlash(false), 200);
};
window.addEventListener('standup-bot-event', handler);
// Also flash on attendance/audit SSE events dispatched by app.jsx
window.addEventListener('attendance-updated', handler);
return () => {
window.removeEventListener('standup-bot-event', handler);
window.removeEventListener('attendance-updated', handler);
};
}, []);
// Close settings popover on outside click
useEffect(() => {
if (!settingsOpen) return;
const handler = (e) => {
if (!e.target.closest('.settings-popover-wrap')) setSettingsOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [settingsOpen]);
// Listen for SSE attendance events and fire browser notifications when enabled
useEffect(() => {
const handler = (e) => {
if (!notifEnabled) return;
try {
const detail = e.detail || {};
const name = detail.who || detail.display_name || 'Someone';
const kind = detail.kind || 'signed in';
setNotifCount(n => n + 1);
(typeof Notification !== 'undefined' && Notification.permission === 'granted') && new Notification(`${name} ${kind}`, { body: new Date().toLocaleTimeString(), icon: '/static/v2/logo.png', silent: true });
} catch {}
};
window.addEventListener('standup-bot-event', handler);
return () => window.removeEventListener('standup-bot-event', handler);
}, [notifEnabled]);
return (
<>
{qrOpen && setQrOpen(false)} />}
>
);
}
/* ── Branding Modal (admin) ── */
function BrandingModal({ onClose, onSaved }) {
const defaults = window.__branding || {};
const [form, setForm] = useState({
company_name: defaults.company_name || '',
product_name: defaults.product_name || '',
logo_url: defaults.logo_url || '',
favicon_url: defaults.favicon_url || '',
brand_color: defaults.brand_color || '#6366f1',
tagline: defaults.tagline || '',
login_slogan: defaults.login_slogan || '',
email_footer: defaults.email_footer || '',
});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [logoUploading, setLogoUploading] = useState(false);
const [faviconUploading, setFaviconUploading] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function save() {
setBusy(true); setMsg('');
try {
const r = await fetch('/api/branding', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form),
});
if (!r.ok) { setMsg('Save failed: ' + (await r.text())); setBusy(false); return; }
const updated = await r.json();
if (onSaved) onSaved(updated);
setMsg('Saved!');
setTimeout(onClose, 800);
} catch (e) { setMsg('Error: ' + e.message); }
setBusy(false);
}
async function uploadFile(field, url, file) {
const setter = field === 'logo_url' ? setLogoUploading : setFaviconUploading;
setter(true);
try {
const fd = new FormData();
fd.append('file', file);
const endpoint = field === 'logo_url'
? '/api/admin/branding/logo?mode=light'
: '/api/admin/branding/favicon';
const r = await fetch(endpoint, { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) { setMsg('Upload failed'); setter(false); return; }
const json = await r.json();
set(field, json.url || url);
setMsg('Uploaded!');
// #37: browsers cache favicon + logo aggressively, so the new
// file isn't visible until the user hard-refreshes. Bust the
// cached and any
// by re-pointing them to the same URL with a timestamp query.
try {
const bust = '?t=' + Date.now();
document.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]').forEach(el => {
const base = (el.getAttribute('href') || '').split('?')[0];
if (base.startsWith('/api/branding/')) el.href = base + bust;
});
document.querySelectorAll('img').forEach(el => {
const base = (el.getAttribute('src') || '').split('?')[0];
if (base.startsWith('/api/branding/')) el.src = base + bust;
});
// Tell other components on the page (Topbar, page-misc preview)
// that branding changed, so they can re-fetch.
window.dispatchEvent(new CustomEvent('branding-updated', { detail: { ts: Date.now() } }));
} catch (_) { /* non-fatal */ }
} catch (e) { setMsg('Upload error: ' + e.message); }
setter(false);
}
const inp = (label, key, opts = {}) => (
{label}
set(key, e.target.value)}
style={{ fontSize: 13, ...(opts.style || {}) }}
/>
);
return (
e.target === e.currentTarget && onClose()}>
{inp('Company name', 'company_name', { placeholder: 'k9.ms' })}
{inp('Product name', 'product_name', { placeholder: 'attendance' })}
{inp('Tagline', 'tagline', { placeholder: 'e.g. Team standup made simple' })}
{inp('Login slogan', 'login_slogan', { placeholder: 'Sign in to your team.' })}
{inp('Email footer', 'email_footer', { placeholder: 'Powered by k9.ms Attendance' })}
{msg &&
{msg}
}
Cancel
{busy ? 'Saving…' : 'Save branding'}
);
}
/* ── QR Install Modal (D3/D6) ── */
function QrInstallModal({ onClose }) {
const url = window.location.origin + '/';
const qrRef = useRef(null);
// Render QR code using local QRCode.js lib (no external API call needed)
useEffect(() => {
if (!qrRef.current) return;
if (typeof QRCode === 'undefined') return;
qrRef.current.innerHTML = '';
new QRCode(qrRef.current, {
text: url,
width: 180,
height: 180,
colorDark: '#111318',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M,
});
}, [url]);
// Listen for browser beforeinstallprompt
const [installPrompt, setInstallPrompt] = useState(null);
useEffect(() => {
const handler = (e) => { e.preventDefault(); setInstallPrompt(e); };
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const doInstall = async () => {
if (!installPrompt) return;
installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') { setInstallPrompt(null); onClose(); }
};
// Close on Escape
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
e.stopPropagation()}
style={{
background: 'var(--bg-elev-1)',
border: '1px solid var(--line-hi)',
borderRadius: 'var(--r-lg)',
width: 320, padding: 24,
position: 'fixed',
top: '50%', left: '50%',
transform: 'translate(-50%,-50%)',
boxShadow: '0 24px 80px -20px #000000c0',
textAlign: 'center',
}}
>
Install / Open on mobile BETA
Scan with your phone to open the dashboard. Add to home screen for a native-app experience.
{url}
{installPrompt && (
Install as app
)}
{!installPrompt && (
To install: open in Chrome/Edge on Android, or Safari on iOS, then "Add to Home Screen."
)}
);
}
/* ============================================================
COMMAND PALETTE
============================================================ */
function CommandPalette({ open, onClose, setActive, employees, setTheme, theme, setAesthetic }) {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const [helpResults, setHelpResults] = useState([]);
const helpDebRef = useRef(null);
// Detect "Help: " prefix
const helpPrefix = 'help:';
const isHelpMode = q.trim().toLowerCase().startsWith(helpPrefix);
const helpQ = isHelpMode ? q.trim().slice(helpPrefix.length).trim() : '';
// Fetch help results when in help mode
useEffect(() => {
if (!isHelpMode || !helpQ) { setHelpResults([]); return; }
clearTimeout(helpDebRef.current);
helpDebRef.current = setTimeout(async () => {
try {
const r = await fetch(`/api/help/search?q=${encodeURIComponent(helpQ)}`, { credentials: 'include' });
if (!r.ok) return;
const data = await r.json();
setHelpResults(Array.isArray(data) ? data.slice(0, 8) : []);
} catch { setHelpResults([]); }
}, 200);
return () => clearTimeout(helpDebRef.current);
}, [helpQ, isHelpMode]);
const items = useMemo(() => {
const pages = NAV_GROUPS.flatMap(g => g.items.map(i => ({ ...i, kind:'page', section: g.label })));
const people = employees.slice(0, 30).map(e => ({ id: `emp-${e.id}`, kind:'person', label: e.display_name, hint: e.matrix_id, employee: e, icon: 'user' }));
const actions = [
{ id:'theme-dark', kind:'action', label:'Switch to dark theme', icon:'moon', do: () => setTheme('dark') },
{ id:'theme-light', kind:'action', label:'Switch to light theme', icon:'sun', do: () => setTheme('light') },
// Aesthetic picker removed — Editorial Calm is the only direction.
// Theme toggle (light/dark) stays accessible via the entries above.
];
return [...pages, ...people, ...actions];
}, [employees]);
const filtered = useMemo(() => {
if (isHelpMode) return [];
if (!q.trim()) return items;
const lc = q.toLowerCase();
return items.filter(i => (i.label || '').toLowerCase().includes(lc) || (i.hint || '').toLowerCase().includes(lc));
}, [q, items, isHelpMode]);
const allVisible = isHelpMode
? helpResults.map(r => ({ id: `help-${r.slug}`, kind:'help', label: r.title, hint: r.category, slug: r.slug }))
: filtered;
useEffect(() => { setIdx(0); }, [q]);
useEffect(() => {
if (!open) return;
const onKey = (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(i + 1, allVisible.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(i - 1, 0)); }
if (e.key === 'Enter') {
const sel = allVisible[idx];
if (!sel) return;
if (sel.kind === 'page') setActive(sel.id);
else if (sel.kind === 'action') sel.do();
else if (sel.kind === 'person') setActive('employees');
else if (sel.kind === 'help') { setActive('help'); }
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, allVisible, idx, onClose, setActive]);
if (!open) return null;
// group non-help items
const grouped = filtered.reduce((acc, item) => {
const k = item.kind === 'page' ? 'Pages' : item.kind === 'person' ? 'People' : 'Actions';
(acc[k] ||= []).push(item);
return acc;
}, {});
let runningIdx = -1;
const renderItem = (item) => {
runningIdx++;
const isOn = runningIdx === idx;
return (
{
if (item.kind === 'page') setActive(item.id);
else if (item.kind === 'action') item.do();
else if (item.kind === 'person') setActive('employees');
else if (item.kind === 'help') setActive('help');
onClose();
}}>
{item.label}
{item.hint && {item.hint} }
);
};
return (
e.stopPropagation()}>
setQ(e.target.value)}
placeholder={isHelpMode ? 'Search help articles…' : 'Search pages, people, or run a command…'}
/>
esc
{isHelpMode ? (
<>
Help articles
{helpResults.length === 0 && helpQ && (
No articles found
Try a different query.
)}
{helpResults.length === 0 && !helpQ && (
Type to search help
e.g. Help: sign in
)}
{helpResults.map(r => ({id:`help-${r.slug}`, kind:'help', label:r.title, hint:r.category, slug:r.slug})).map(renderItem)}
>
) : (
<>
{Object.entries(grouped).map(([section, list]) => (
{section}
{list.map(renderItem)}
))}
{filtered.length === 0 && (
No results
Try "Help: your question" to search articles.
)}
{/* Hint about Help: mode */}
{q.trim() && (
Tip: type Help: {q.trim()} to search help articles
)}
>
)}
);
}
/* ============================================================
LOGIN
============================================================ */
const LOGIN_LAST_ID_KEY = 'status_last_matrix_id';
function normalizeLoginMatrixId(value) {
let s = (value || '').trim().toLowerCase();
if (!s || /\s/.test(s)) return '';
if (s.startsWith('@')) s = s.slice(1);
if (!s.includes(':')) s = `${s}:k9.ms`;
return `@${s}`;
}
function loginDeviceKey(matrixId) {
const canonical = normalizeLoginMatrixId(matrixId);
return canonical ? `status_device_${canonical}` : '';
}
function apiErrorText(detail, fallback = 'Request failed') {
const norm = (v) => {
if (v == null) return '';
if (typeof v === 'string') return v;
if (Array.isArray(v)) return v.map(norm).filter(Boolean).join('; ');
if (typeof v === 'object') {
const nested = v.msg || v.message || v.error || v.detail;
if (nested && nested !== v) {
const n = norm(nested);
if (n) return n;
}
try { return JSON.stringify(v); } catch(_) { return String(v); }
}
return String(v);
};
if (!detail) return fallback;
const out = norm(detail);
return out || fallback;
}
function LoginPage({ onLogin, theme, setTheme }) {
const [step, setStep] = useState('id');
const [chatId, setChatId] = useState(() => localStorage.getItem(LOGIN_LAST_ID_KEY) || '');
const [code, setCode] = useState('');
const [pw, setPw] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [loginStats, setLoginStats] = useState(null);
// Mobile + auto-trust detection. On Capacitor / true touch phones, we hide
// the "Sign in with password" button and silently attempt the trusted-
// device login on mount so returning users skip the OTP screen entirely.
const isMobileSurface = (typeof window.M_isMobileSurface === 'function')
? !!window.M_isMobileSurface()
: false;
// Auth headers: the native Capacitor app announces itself so the backend
// exempts it from the employee web-login block (employees use the app, not
// the web dashboard). No header on mobile/desktop web → web stays gated.
const authHeaders = () => {
const h = { 'Content-Type': 'application/json' };
try {
const cap = window.Capacitor;
const native = cap && (
(typeof cap.isNativePlatform === 'function' && cap.isNativePlatform()) ||
(typeof cap.getPlatform === 'function' && cap.getPlatform() !== 'web')
);
if (native) h['X-K9-Client'] = 'ios-app';
} catch (_) {}
return h;
};
const [autoTrying, setAutoTrying] = useState(() => {
const last = localStorage.getItem(LOGIN_LAST_ID_KEY) || '';
return !!last; // attempt iff we know who you are
});
useEffect(() => {
let alive = true;
fetch('/api/login/stats')
.then(r => r.ok ? r.json() : null)
.then(data => { if (alive) setLoginStats(data); })
.catch(() => {});
return () => { alive = false; };
}, []);
const rememberDevice = (matrixId, token) => {
const id = normalizeLoginMatrixId(matrixId);
const key = loginDeviceKey(id);
if (!id || !key || !token) return;
const expiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
localStorage.setItem(key, token);
localStorage.setItem(`${key}_expiry`, expiry);
localStorage.setItem(LOGIN_LAST_ID_KEY, id);
};
const rememberedDevice = (matrixId) => {
const id = normalizeLoginMatrixId(matrixId);
const canonicalKey = loginDeviceKey(id);
if (!id || !canonicalKey) return null;
const keys = [canonicalKey];
const legacyKey = `standup_device_${id}`;
if (legacyKey !== canonicalKey) keys.push(legacyKey);
for (const key of keys) {
const token = localStorage.getItem(key);
const expiry = localStorage.getItem(`${key}_expiry`);
if (!token || !expiry) continue;
if (new Date(expiry) <= new Date()) {
localStorage.removeItem(key);
localStorage.removeItem(`${key}_expiry`);
continue;
}
if (key !== canonicalKey) {
localStorage.setItem(canonicalKey, token);
localStorage.setItem(`${canonicalKey}_expiry`, expiry);
localStorage.removeItem(key);
localStorage.removeItem(`${key}_expiry`);
}
return { id, token, key: canonicalKey };
}
return null;
};
const loginWithDevice = async (matrixId) => {
const remembered = rememberedDevice(matrixId);
if (!remembered) return false;
let r;
try {
r = await fetch('/api/auth/device-login', {
method:'POST', credentials:'include',
headers: authHeaders(),
body: JSON.stringify({ matrix_id: remembered.id, device_token: remembered.token }),
});
} catch (_) {
// Network blip — keep the trust token so the next attempt can still
// skip OTP. Never punish a transient failure with a re-login.
return false;
}
if (!r.ok) {
// Only forget the device when the server explicitly rejects the token
// (401/403 = revoked/expired). For 5xx or anything else, keep trust so
// the user isn't forced back to OTP by a temporary backend hiccup.
if (r.status === 401 || r.status === 403) {
localStorage.removeItem(remembered.key);
localStorage.removeItem(`${remembered.key}_expiry`);
}
return false;
}
const data = await r.json();
// Refresh the stored 90-day expiry in lockstep with the server's sliding
// window so an actively-used device never lets its token lapse → no re-OTP.
if (data.device_token) rememberDevice(data.matrix_id || remembered.id, data.device_token);
localStorage.setItem(LOGIN_LAST_ID_KEY, data.matrix_id || remembered.id);
onLogin(data);
return true;
};
// Auto-try the trusted-device login on every cold start when we remember
// who this device belongs to. Returning users never see the OTP screen —
// they just land in the app. Only falls through to UI if the stored token
// has expired or was server-side revoked.
useEffect(() => {
let cancelled = false;
const tryAuto = async () => {
const last = localStorage.getItem(LOGIN_LAST_ID_KEY) || '';
if (!last) { setAutoTrying(false); return; }
try {
const ok = await loginWithDevice(last);
if (!cancelled && !ok) setAutoTrying(false);
} catch (_) { if (!cancelled) setAutoTrying(false); }
};
if (autoTrying) tryAuto();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const send = async () => {
const id = normalizeLoginMatrixId(chatId);
if (!id || busy) return;
setBusy(true); setErr('');
try {
if (await loginWithDevice(id)) return;
const r = await fetch('/api/auth/request-otp', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ matrix_id: id }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(apiErrorText(j.detail ?? j.message, `Could not send code (${r.status})`));
}
localStorage.setItem(LOGIN_LAST_ID_KEY, id);
setStep('code');
} catch (e) { setErr(apiErrorText(e.message, 'Failed')); } finally { setBusy(false); }
};
const verify = async () => {
if (code.length < 6 || busy) return;
setBusy(true); setErr('');
try {
const r = await fetch('/api/auth/verify-otp', {
method:'POST', credentials:'include',
headers: authHeaders(),
body: JSON.stringify({ matrix_id: normalizeLoginMatrixId(chatId), otp: code }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(apiErrorText(j.detail ?? j.message, 'Invalid code'));
}
const data = await r.json();
if (data.device_token) rememberDevice(data.matrix_id || chatId, data.device_token);
onLogin(data);
} catch (e) { setErr(apiErrorText(e.message, 'Failed')); } finally { setBusy(false); }
};
const loginPassword = async () => {
const id = normalizeLoginMatrixId(chatId);
if (!id || !pw || busy) return;
setBusy(true); setErr('');
try {
const r = await fetch('/api/auth/login-password', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ matrix_id: id, password: pw }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(apiErrorText(j.detail ?? j.message, 'Invalid credentials'));
}
const data = await r.json();
localStorage.setItem(LOGIN_LAST_ID_KEY, data.matrix_id || id);
onLogin(data);
} catch (e) { setErr(apiErrorText(e.message, 'Failed')); } finally { setBusy(false); }
};
// demo mosaic seed
const tiles = useMemo(() => {
const out = [];
for (let i = 0; i < 64; i++) {
const r = Math.random();
out.push(r > 0.85 ? 'err' : r > 0.7 ? 'warn' : r > 0.25 ? 'ok' : '');
}
return out;
}, []);
const signedIn = loginStats?.signed_in_today ?? '—';
const totalUsers = loginStats?.total_users ?? '—';
const lateToday = loginStats?.late_today ?? '—';
const absentToday = loginStats?.absent_today ?? '—';
const blockersToday = loginStats?.active_blockers ?? '—';
// Quiet "welcome back" frame while the trusted-device login is in flight.
// No form, no fields — just the brand. Either we get logged in silently
// or we drop through to the normal OTP screen.
if (autoTrying) {
return (
{(window.appT||(s=>s))('welcome back…')}
{(window.appT||(s=>s))('signing you in')}
);
}
return (
{e.currentTarget.replaceWith(Object.assign(document.createElement('div'),{innerHTML:'
S '}));}} />
{(window.__branding && window.__branding.product_name) || 'status'}
{(() => { const b = window.__branding || {}; if (b.tagline) return b.tagline; if (b.company_name && b.product_name && b.company_name !== b.product_name) return b.company_name + " / " + b.product_name; return b.product_name || ""; })()}
{(window.__branding && window.__branding.login_slogan) || (window.appT||(s=>s))('Sign in to your team.')}
{(window.appT||(s=>s))("We'll DM a 6-digit code to your K9 Chat ID. No passwords, no setup.")}
{err && (
{err}
)}
{step === 'id' ? (
{(window.appT||(s=>s))('K9 Chat ID')}
setChatId(e.target.value)}
onKeyDown={e => e.key === 'Enter' && send()}
style={{ fontSize: 14, padding: '10px 12px' }} />
{busy ? (window.appT||(s=>s))('Sending code…') : (window.appT||(s=>s))('Send code')}
{(window.appT||(s=>s))('Trusted devices stay signed in for 90 days')}
{/* Password login is a desktop-only fallback (App Review demo
account). On Capacitor / mobile phones we hide it — OTP +
trusted-device is the only auth path. */}
{!isMobileSurface && (
{ setErr(''); setStep('pw'); }}
style={{ width:'100%', justifyContent:'center', fontSize: 12.5 }}>
{(window.appT||(s=>s))('Sign in with password')}
)}
) : step === 'pw' ? (
) : (
{(window.appT||(s=>s))('Code sent to')} {chatId} — {(window.appT||(s=>s))('expires in 5 min')}
{(window.appT||(s=>s))('Verification code')}
setCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={e => e.key === 'Enter' && verify()}
placeholder="······" autoFocus />
{busy ? (window.appT||(s=>s))('Verifying…') : (window.appT||(s=>s))('Sign in')}
{ setStep('id'); setCode(''); setErr(''); }}
style={{ width:'100%', justifyContent:'center' }}>
{(window.appT||(s=>s))('Back')}
{(window.appT||(s=>s))('Tip: enter')} 123456 {(window.appT||(s=>s))('to demo')}
)}
v1.4.2 · {new Date().toISOString().slice(0,10)}
setTheme(theme === 'dark' ? 'light' : 'dark')}>
{(window.appLang && window.appLang()==='ar' ? 'مباشر · اليوم، ' : 'live · today, ')}{new Date().toLocaleDateString([],{ month:'short', day:'numeric' })}
{(window.appLang && window.appLang()==='ar')
? <>تم تسجيل دخول {signedIn} من {totalUsers}.{lateToday} متأخر · {absentToday} غائب · {blockersToday} عوائق. >
: <>{signedIn} of {totalUsers} signed in.{lateToday} late · {absentToday} absent · {blockersToday} blockers. >}
{(window.appT||(s=>s))("One K9 room. One dashboard. Adaptive late-detection, AI roll-ups, payroll-ready exports — and a bot that's nicer than your old time-clock.")}
);
}
/* AvatarVariantRow — upload / remove one variant (dark or light) */
function AvatarVariantRow({ label, variant, currentUrl, onUploaded, onRemoved }) {
const [preview, setPreview] = useState(currentUrl);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const fileRef = React.useRef();
const onPick = (file) => {
if (!file) return;
setErr('');
const reader = new FileReader();
reader.onload = async (ev) => {
setPreview(ev.target.result);
setBusy(true);
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(`/api/users/me/avatar?variant=${variant}`, {
method: 'POST', credentials: 'include', body: fd,
});
if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); }
const j = await r.json();
setPreview(j.url + '?t=' + Date.now());
onUploaded(variant, j.url);
} catch(e) { setErr(e.message || String(e)); setPreview(currentUrl); }
finally { setBusy(false); }
};
reader.readAsDataURL(file);
};
const onRemove = async () => {
setBusy(true); setErr('');
try {
const r = await fetch(`/api/users/me/avatar?variant=${variant}`, { method: 'DELETE', credentials: 'include' });
if (!r.ok) throw new Error(`${r.status}`);
setPreview(null);
onRemoved(variant);
} catch(e) { setErr(e.message || String(e)); }
finally { setBusy(false); }
};
return (
{preview
?
setPreview(null)}/>
:
none
}
{label}
onPick(e.target.files[0])} />
fileRef.current?.click()} disabled={busy}>
{busy ? 'Uploading…' : preview ? 'Replace' : 'Upload'}
{preview && Remove }
{err &&
{err}
}
);
}
/* ─── Linked accounts section (CP6) ──────────────────────────────── */
function LinkedAccountsSection({ me, onMsg }) {
const [accounts, setAccounts] = React.useState(null); // null = loading
const [busy, setBusy] = React.useState(false);
const toast = useToast ? useToast() : { push: () => {} };
const load = async () => {
try {
const r = await fetch('/api/users/me/external-accounts', { credentials: 'same-origin' });
if (r.ok) setAccounts(await r.json());
else setAccounts([]);
} catch { setAccounts([]); }
};
React.useEffect(() => { load(); }, []);
const unlink = async (protocol) => {
if (!confirm(`Unlink ${protocol} account?`)) return;
setBusy(true);
try {
const r = await fetch(`/api/users/me/external/${protocol}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (!r.ok) throw new Error(`${r.status}`);
onMsg && onMsg(`${protocol} account unlinked`);
load();
} catch(e) {
onMsg && onMsg(`Unlink failed: ${e.message}`);
} finally { setBusy(false); }
};
const PROTO_LABELS = { matrix: 'K9 Chat', slack: 'Slack', teams: 'Microsoft Teams' };
return (
Linked accounts
Accounts linked to your profile for bot messaging. K9 Chat is always linked.
{accounts === null &&
Loading...
}
{accounts && accounts.length === 0 && (
No external accounts linked yet.
)}
{accounts && accounts.map(acct => (
{PROTO_LABELS[acct.protocol] || acct.protocol}
{acct.display_name || acct.external_id}
{acct.last_seen_at && (
Last seen {new Date(acct.last_seen_at).toLocaleDateString()}
)}
{!acct.verified && (
Email-matched (not OAuth-verified)
)}
{acct.protocol !== 'matrix' && (
unlink(acct.protocol)}
>
Unlink
)}
))}
{/* If no Slack account, show Link Slack button */}
{accounts && !accounts.find(a => a.protocol === 'slack') && (
window.open('/slack/install', 'slack_install', 'width=600,height=700')}
>
Link Slack workspace
)}
);
}
function ProfileModal({ me, onClose }) {
const [name, setName] = useState(me.display_name || '');
const [busy, setBusy] = useState(false);
const [pwOld, setPwOld] = useState('');
const [pwNew, setPwNew] = useState('');
const [pwBusy, setPwBusy] = useState(false);
const [msg, setMsg] = useState('');
// Avatar variant state — seeded from me (which now includes avatar_dark_url etc.)
const [darkUrl, setDarkUrl] = useState(me.avatar_dark_url || null);
const [lightUrl, setLightUrl] = useState(me.avatar_light_url || null);
const [pref, setPref] = useState(me.avatar_preference || 'auto');
const [prefBusy, setPrefBusy] = useState(false);
// Team selection
const [team, setTeam] = useState(me.team || '');
const [teams, setTeams] = useState([]);
const [teamBusy, setTeamBusy] = useState(false);
// Work location
const [workLocation, setWorkLocation] = useState(me.work_location || 'office');
const [remoteDays, setRemoteDays] = useState((me.remote_days || '').split(',').filter(Boolean));
const [locBusy, setLocBusy] = useState(false);
// Contact fields
const [cellPhone, setCellPhone] = useState(me.cell_phone || '');
const [altPhone, setAltPhone] = useState(me.alt_phone || '');
const [privateEmail, setPrivateEmail] = useState(me.private_email || '');
const [contactBusy, setContactBusy] = useState(false);
// CP5 smart-routing: preferred DM channel
const [dmProto, setDmProto] = useState(me.preferred_protocol || 'auto');
const [dmProtoBusy, setDmProtoBusy] = useState(false);
useEffect(() => {
fetch('/api/teams', { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then(data => setTeams(data))
.catch(() => {});
}, []);
const saveTeam = async (newTeam) => {
setTeamBusy(true); setMsg('');
try {
const userId = me.user_id || me.id;
const r = await fetch(`/api/employees/${userId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team: newTeam || null }),
});
if (!r.ok) throw new Error(await r.text());
setTeam(newTeam);
setMsg('✓ Team saved');
if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.team = newTeam || null;
} catch(e) { setMsg(`✗ ${e.message || e}`); }
finally { setTeamBusy(false); }
};
const saveWorkLocation = async () => {
setLocBusy(true); setMsg('');
try {
const userId = me.user_id || me.id;
const r = await fetch(`/api/users/${userId}/work-location`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
location: workLocation,
remote_days: workLocation === 'hybrid' ? remoteDays.join(',') : null,
}),
});
if (!r.ok) throw new Error(await r.text());
setMsg('Work location saved');
if (window.STATUS_DATA?.ME) {
window.STATUS_DATA.ME.work_location = workLocation;
window.STATUS_DATA.ME.remote_days = workLocation === 'hybrid' ? remoteDays.join(',') : null;
}
} catch(e) { setMsg(`Error: ${e.message || e}`); }
finally { setLocBusy(false); }
};
const saveName = async () => {
setBusy(true); setMsg('');
try {
const r = await fetch('/api/auth/me/profile', {
method:'PUT', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({display_name: name}),
});
if (!r.ok) throw new Error(await r.text());
setMsg('✓ Name saved');
if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.display_name = name;
} catch(e){ setMsg(`✗ ${e.message || e}`); }
finally { setBusy(false); }
};
const savePref = async (newPref) => {
setPref(newPref);
setPrefBusy(true);
try {
await fetch('/api/users/me/avatar-preference', {
method:'PUT', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({preference: newPref}),
});
if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_preference = newPref;
} catch(_) {}
finally { setPrefBusy(false); }
};
const onUploaded = (variant, url) => {
if (variant === 'dark') { setDarkUrl(url); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_dark_url = url; }
if (variant === 'light') { setLightUrl(url); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_light_url = url; }
setMsg('✓ Avatar uploaded');
};
const onRemoved = (variant) => {
if (variant === 'dark') { setDarkUrl(null); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_dark_url = null; }
if (variant === 'light') { setLightUrl(null); if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.avatar_light_url = null; }
setMsg('✓ Avatar removed');
};
const saveContact = async () => {
setContactBusy(true); setMsg('');
try {
const userId = me.user_id || me.id;
const r = await fetch(`/api/employees/${userId}/profile`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cell_phone: cellPhone || null,
alt_phone: altPhone || null,
private_email: privateEmail || null,
}),
});
if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); }
setMsg('Contact saved');
if (window.STATUS_DATA?.ME) {
window.STATUS_DATA.ME.cell_phone = cellPhone || null;
window.STATUS_DATA.ME.alt_phone = altPhone || null;
window.STATUS_DATA.ME.private_email = privateEmail || null;
}
} catch(e) { setMsg(`Error: ${e.message || e}`); }
finally { setContactBusy(false); }
};
const saveDmProto = async (newProto) => {
setDmProtoBusy(true); setMsg('');
try {
const userId = me.user_id || me.id;
const r = await fetch(`/api/employees/${userId}/profile`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferred_protocol: newProto === 'auto' ? null : newProto }),
});
if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); }
setDmProto(newProto);
setMsg('DM channel preference saved');
if (window.STATUS_DATA?.ME) window.STATUS_DATA.ME.preferred_protocol = newProto === 'auto' ? null : newProto;
} catch(e) { setMsg(`Error: ${e.message || e}`); }
finally { setDmProtoBusy(false); }
};
const changePw = async () => {
if (!pwNew || pwNew.length < 6) { setMsg('✗ New password must be at least 6 chars'); return; }
setPwBusy(true); setMsg('');
try {
const r = await fetch('/api/auth/change-password', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({old_password: pwOld, new_password: pwNew}),
});
if (!r.ok) { const j = await r.json().catch(()=>({})); throw new Error(j.detail || `${r.status}`); }
setMsg('✓ Password changed'); setPwOld(''); setPwNew('');
} catch(e){ setMsg(`✗ ${e.message || e}`); }
finally { setPwBusy(false); }
};
// Esc to close + body scroll lock
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', onKey);
};
}, []);
return (
e.stopPropagation()} style={{background:'var(--bg-elev-1)', border:'1px solid var(--line-hi)', borderRadius: 12, width:'100%', maxWidth: 520, maxHeight: '90vh', overflowY:'auto', padding: 24, boxShadow:'0 20px 60px -10px #000a'}}>
{/* Avatar variants */}
Profile picture
Show variant
{['auto','dark','light'].map(v => (
savePref(v)}
disabled={prefBusy}
>{v === 'auto' ? 'Auto (follows theme)' : v === 'dark' ? 'Always dark' : 'Always light'}
))}
Auto: uses dark variant in dark theme, light variant in light theme. Max 2 MB, PNG/JPG/WEBP.
Leave blank to use your K9 Chat profile picture automatically.
{/* Display name */}
{/* Team */}
Team
setTeam(e.target.value)} style={{flex:1}} disabled={teamBusy}>
(no team)
{teams.map(t => (
{t.team_name}
))}
saveTeam(team)} disabled={teamBusy || team === (me.team || '')}>Save
{/* Work location */}
Work location
setWorkLocation(e.target.value)} style={{flex:1}} disabled={locBusy}>
Office
Remote
Hybrid
Save
{workLocation === 'hybrid' && (
)}
{/* Contact */}
{/* CP5 — Preferred DM channel */}
Preferred DM channel
Where the bot sends you direct messages. Auto uses your most recently active account.
saveDmProto(e.target.value)}
>
Auto (most recent activity)
K9 Chat
Slack
Microsoft Teams
{dmProtoBusy && Saving... }
{/* Linked accounts */}
{/* Change password */}
{msg &&
{msg}
}
);
}
/* ============================================================
BOTTOM TAB BAR — mobile only (<768px), fixed to viewport bottom
Shows 5 key tabs: Today / Employees / Requests / Messages / Server
============================================================ */
const BOTTOM_TABS = [
{ id: 'today', label: 'Today', icon: 'today' },
{ id: 'employees', label: 'Employees', icon: 'team' },
{ id: 'pto', label: 'Requests', icon: 'pto' },
{ id: 'oncall', label: 'On-call', icon: 'clock' },
{ id: 'support', label: 'Support', icon: 'info' },
];
function BottomTabBar({ active, setActive }) {
return (
<>
Mobile · BETA
{BOTTOM_TABS.map(tab => (
setActive(tab.id)}
>
{tab.label}
))}
>
);
}
// B2: collapsible nav sections, per-group state persisted to localStorage.
function NavSections({ collapsed, active, setActive, counts }) {
const [openMap, setOpenMap] = useState(() => {
try {
const raw = localStorage.getItem('nav_section_open');
if (raw) return JSON.parse(raw);
} catch (_) {}
return Object.fromEntries(NAV_GROUPS.map(g => [g.label, true]));
});
const persist = (next) => {
setOpenMap(next);
try { localStorage.setItem('nav_section_open', JSON.stringify(next)); } catch (_) {}
};
const toggle = (label) => persist({ ...openMap, [label]: !openMap[label] });
// Gate adminOnly entries by current user's role. Admin tier includes
// moderator + superadmin so promoted users see admin-only sidebar
// items (Server, Bots, Bot Behavior, etc.).
const meRole = String(((window.STATUS_DATA || {}).ME || {}).org_role || '').toLowerCase();
const isAdmin = meRole === 'admin' || meRole === 'owner'
|| meRole === 'moderator' || meRole === 'superadmin';
return (
{NAV_GROUPS.map(group => {
const visibleItems = group.items.filter(i => !i.adminOnly || isAdmin);
if (visibleItems.length === 0) return null;
const isOpen = collapsed ? true : (openMap[group.label] !== false);
return (
{!collapsed && (
{
// #39: clicking a section header now NAVIGATES to its first
// page (and expands the section), instead of only collapse-
// toggling — clicking "Insights"/"Audit" used to feel like it
// did nothing or hid the menu. The chevron still toggles.
if (openMap[group.label] === false) persist({ ...openMap, [group.label]: true });
if (visibleItems[0]) setActive(visibleItems[0].id);
}}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { if (visibleItems[0]) setActive(visibleItems[0].id); } }}
style={{ cursor: 'pointer', display:'flex', alignItems:'center', justifyContent:'space-between', userSelect:'none' }}
title={`Open ${group.label}`}
>
{(window.appT || (s=>s))(group.label)}
· {visibleItems.length}
{ e.stopPropagation(); toggle(group.label); }}
title={isOpen ? `Collapse ${group.label}` : `Expand ${group.label}`}
style={{ opacity: 0.5, fontSize: 11, padding: '0 4px', cursor: 'pointer', transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }}>▶
)}
{isOpen && visibleItems.map(item => (
setActive(item.id)}
title={collapsed ? (window.appT || (s=>s))(item.label) : ''}
>
{!collapsed && {(window.appT || (s=>s))(item.label)} }
{!collapsed && counts?.[item.id] != null && {counts[item.id]} }
{!collapsed && item.pill && {item.pill} }
{item.tip && {item.tip} }
))}
);
})}
);
}
window.BottomTabBar = BottomTabBar;
window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.CommandPalette = CommandPalette;
window.LoginPage = LoginPage;
window.NAV_GROUPS = NAV_GROUPS;