/* ============================================================
page-misc.jsx — Audit, Messages, Providers, Server
============================================================ */
/* Re-bind window exports from other Babel scripts */
const StatusPill = window.StatusPill || (({ children }) => React.createElement('span', null, children));
const parseDetail = window.parseDetail || ((body, status) => body?.message || `HTTP ${status}`);
/* Issue #32: human-readable audit action labels.
Primary source of truth is the API field `action_label` (sent by every
/api/audit row). This local table mirrors api.py:_AUDIT_ACTION_LABELS so
we still render cleanly if the FE caches a row from an older API build,
if action_label is missing, or if an action_type isn't in the table yet
(last-resort fallback: capitalize underscores → spaces). */
const _AUDIT_ACTION_LABELS = {
signin_recorded: "Sign-in recorded",
signoff_recorded: "Sign-off recorded",
format_strike: "Format strike",
late_strike: "Late strike",
missing_signoff: "Missing sign-off",
late_reason_waived: "Late reason accepted",
late_reason_rejected: "Late reason rejected",
dm_sent: "DM sent",
dm_failed: "DM failed",
public_reply: "Public reply (room)",
flag: "Flagged",
hr_doc_uploaded: "HR document uploaded",
hr_doc_viewed: "HR document viewed",
hr_doc_deleted: "HR document deleted",
oncall_csv_import: "On-call CSV imported",
pto_approved: "PTO approved",
pto_denied: "PTO denied",
strike_warned: "Strike warning sent",
strike_escalated: "Strike escalated to admin",
availability_ping_sent: "Availability ping sent",
availability_ping_responded: "Availability ping responded",
availability_miss_escalated: "Availability miss escalated",
user_edited: "User edited",
team_created: "Team created",
team_edited: "Team edited",
policy_created: "Policy created",
policy_edited: "Policy edited",
ai_query: "AI query",
ai_provider_fallback: "AI fallback (provider switch)",
otp_sent: "OTP sent",
otp_verified: "OTP verified",
otp_failed: "OTP failed",
trusted_device_added: "Trusted device added",
session_revoked: "Session revoked",
admin_manual_violation: "Admin manual violation",
format_violation: "Format violation",
admin_override_applied: "Admin override applied",
container_start: "Container started",
db_migration: "Database migration",
health_check_fail: "Health check failed",
admin_manual_strike: "Admin manual strike",
thread_reply: "Bot thread reply",
ip_red_flag: "IP red-flag",
no_progress: "No progress detected",
stuck_detected: "Stuck task detected",
stalled_task: "Stalled task",
repeated_accomplishment: "Repeated accomplishment",
force_strike: "Force strike (admin)",
force_clear_strikes: "Strikes cleared (admin)",
force_decrement_strike: "Strike decremented (admin)",
};
function humanizeActionType(raw) {
if (!raw) return '';
if (_AUDIT_ACTION_LABELS[raw]) return _AUDIT_ACTION_LABELS[raw];
const s = String(raw).replace(/_/g, ' ').trim();
return s.charAt(0).toUpperCase() + s.slice(1);
}
// Expose so other Babel scripts (page-employees violations history,
// hud.jsx audit widget) can use the same table.
try { window.humanizeActionType = humanizeActionType; } catch (_) {}
/* ── Helpers ── */
function relTime(iso) {
const d = new Date(iso);
const raw = Math.floor((Date.now() - d) / 1000);
// clock-skew safe — abs() the magnitude and prefix "in" when iso is in the future
const sec = Math.abs(raw);
const future = raw < 0;
if (sec < 30) return "just now";
const fmt = (n, unit) => future ? `in ${n}${unit}` : `${n}${unit} ago`;
if (sec < 60) return fmt(sec, "s");
if (sec < 3600) return fmt(Math.floor(sec/60), "m");
if (sec < 86400) return fmt(Math.floor(sec/3600), "h");
return fmt(Math.floor(sec/86400), "d");
}
function absTime(iso) {
return new Date(iso).toLocaleString([], { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' });
}
const KIND_COLOR = {
'sign-in': 'ok',
'sign_in': 'ok',
'sign-off': 'info',
'sign_off': 'info',
'sick': 'warn',
'pto': 'warn',
'vacation': 'warn',
'half_day': 'warn',
'late': 'warn',
'invalid': 'err',
'format-violation': 'err',
'unknown': 'mute',
};
function kindColor(k) { return KIND_COLOR[k] || 'mute'; }
const PAGE_SIZE = 50;
/* ============================================================
MessagesPage
============================================================ */
/* ── Broadcast DM Section ── */
const BROADCAST_GROUPS = [
{ value: 'all', label: 'Everyone' },
{ value: 'admins', label: 'Admins only' },
{ value: 'absent_today', label: 'Absent today' },
{ value: 'late_today', label: 'Late today' },
{ value: 'test_only', label: 'Test accounts only' },
];
// NOTE: label values above are English lookup keys; wrapped with appT at the render site.
function BroadcastDmSection({ employees }) {
const toast = useToast();
const [mode, setMode] = useState('group'); // 'team'|'group'|'manual'
const [teams, setTeams] = useState([]);
const [selTeam, setSelTeam] = useState('');
const [selGroup, setSelGroup] = useState('all');
const [selManual, setSelManual] = useState([]); // matrix_ids
const [msgBody, setMsgBody] = useState('');
const [preview, setPreview] = useState([]); // resolved names preview
const [sending, setSending] = useState(false);
const [channel, setChannel] = useState('dm'); // 'dm' (direct msgs) | 'rooms' (post in team rooms)
/* load teams once */
useEffect(() => {
fetch('/api/teams', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : [])
.then(rows => {
setTeams(rows.filter(t => t.name || t.team_name));
if (rows.length) setSelTeam(rows[0].name || rows[0].team_name || '');
})
.catch(() => {});
}, []);
/* resolve preview whenever mode/values/employees change */
useEffect(() => {
if (mode === 'manual') {
const m = employees.filter(e => selManual.includes(e.matrix_id));
setPreview(m.map(e => e.display_name));
} else if (mode === 'team') {
const m = employees.filter(e => e.team === selTeam);
setPreview(m.map(e => e.display_name));
} else {
/* group — we can't perfectly resolve absent/late without a live query,
show a label instead */
const grp = BROADCAST_GROUPS.find(g => g.value === selGroup);
setPreview(grp ? [(window.appLang&&window.appLang()==='ar') ? `(${(window.appT||(s=>s))(grp.label)} — يُحدَّد عند الإرسال)` : `(${grp.label} — resolved on send)`] : []);
}
}, [mode, selTeam, selGroup, selManual, employees]);
const toggleManual = (mxid) => {
setSelManual(prev => prev.includes(mxid) ? prev.filter(x => x !== mxid) : [...prev, mxid]);
};
const send = async () => {
if (!msgBody.trim()) { toast.push({ msg: 'Message body is required', kind: 'err', icon: '!' }); return; }
setSending(true);
try {
const filter_value = mode === 'manual' ? selManual : mode === 'team' ? selTeam : selGroup;
const payload = channel === 'rooms'
? { channel: 'rooms', body: msgBody }
: { filter_type: mode, filter_value, body: msgBody };
const r = await fetch('/api/messages/broadcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(payload),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
if (channel === 'rooms') {
toast.push({ msg: `Posted to ${data.posted||0} room${(data.posted||0) !== 1 ? 's' : ''}`, kind: 'ok', icon: '✓' });
} else if (data.failed > 0) {
toast.push({ msg: `Sent to ${data.sent}, failed for ${data.failed} (see audit log)`, kind: 'warn', icon: '!' });
} else {
toast.push({ msg: `Sent to ${data.sent} user${data.sent !== 1 ? 's' : ''}`, kind: 'ok', icon: '✓' });
}
setMsgBody('');
if (mode === 'manual') setSelManual([]);
} catch (e) {
toast.push({ msg: `Broadcast failed: ${e.message}`, kind: 'err', icon: '!' });
} finally {
setSending(false);
}
};
const previewFirst5 = preview.slice(0, 5);
const previewRest = preview.length - 5;
return (
s))("Send DM")}
tip={(window.appT||(s=>s))("Compose a manual DM to one user as the bot. Optional template dropdown for prebuilt messages.")} sub={(window.appT||(s=>s))("Broadcast a direct message to one or more users")} />
{/* Delivery channel: DM each person, or post one announcement in the team rooms */}
{[['dm','Direct messages'],['rooms','Post in team rooms']].map(([v,l]) => (
setChannel(v)}
>{(window.appT||(s=>s))(l)}
))}
{channel === 'rooms' && (
{(window.appT||(s=>s))("Posts one announcement into every team attendance + general room (everyone sees it in-channel).")}
)}
{channel === 'dm' && (<>
{/* Mode tabs */}
{[['group','By group'],['team','By team'],['manual','Pick manually']].map(([v,l]) => (
setMode(v)}
>{(window.appT||(s=>s))(l)}
))}
{/* Mode-specific selector */}
{mode === 'group' && (
setSelGroup(e.target.value)} style={{ height: 32, marginBottom: 10 }}>
{BROADCAST_GROUPS.map(g => {(window.appT||(s=>s))(g.label)} )}
)}
{mode === 'team' && (
setSelTeam(e.target.value)} style={{ height: 32, marginBottom: 10 }}>
{teams.length === 0 && {(window.appT||(s=>s))("No teams configured")} }
{teams.map(t => {
const name = t.name || t.team_name || '';
return {name} ;
})}
)}
{mode === 'manual' && (
{employees.map(e => {
const sel = selManual.includes(e.matrix_id);
return (
toggleManual(e.matrix_id)}
>{e.display_name}
);
})}
)}
{/* Recipient chip preview */}
{preview.length > 0 && (
{preview.length > 5 ? ((window.appLang&&window.appLang()==='ar') ? `${preview.length} مستلمين:` : `${preview.length} recipients:`) : (window.appT||(s=>s))('To:')}
{previewFirst5.map((n, i) => (
{n}
))}
{previewRest > 0 && (
{(window.appLang&&window.appLang()==='ar') ? `و${previewRest} آخرين` : `and ${previewRest} more`}
)}
)}
{preview.length === 0 && mode !== 'group' && (
{(window.appT||(s=>s))("No recipients selected")}
)}
>)}
{/* Message body */}
);
}
function MessagesPage() {
const toast = useToast();
/* filter state */
const [qText, setQText] = useState('');
const [filterKind, setFK] = useState('');
const [filterUser, setFU] = useState('');
const [expanded, setExpand] = useState(null); // row id
/* server state */
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoad] = useState(false);
const [stats, setStats] = useState(null);
const [employees, setEmps] = useState([]);
/* debounce helpers */
const debRef = useRef(null);
const buildParams = useCallback((pg) => {
const p = new URLSearchParams();
p.set('limit', String(PAGE_SIZE));
p.set('offset', String((pg - 1) * PAGE_SIZE));
if (filterKind) p.set('kind', filterKind);
if (filterUser) p.set('user_id', filterUser);
if (qText.trim()) p.set('q_text', qText.trim());
return p.toString();
}, [filterKind, filterUser, qText]);
const load = useCallback(async (pg, notify) => {
setLoad(true);
try {
const r = await fetch(`/api/messages?${buildParams(pg)}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
setItems(data.items || []);
setTotal(data.total || 0);
setPage(pg);
if (notify) toast.push({ msg: 'Refreshed', kind: 'ok', icon: '✓' });
} catch (e) {
toast.push({ msg: `Messages error: ${e.message}`, kind: 'err', icon: '!' });
} finally {
setLoad(false);
}
}, [buildParams, toast]);
/* load stats + employees once */
useEffect(() => {
fetch('/api/messages/stats', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => d && setStats(d))
.catch(() => {});
fetch('/api/employees', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : [])
.then(setEmps)
.catch(() => {});
}, []);
/* initial load */
useEffect(() => { load(1, false); }, []);
/* debounced re-fetch when filters change */
useEffect(() => {
clearTimeout(debRef.current);
debRef.current = setTimeout(() => {
load(1, false);
if (filterKind || filterUser || qText.trim()) {
toast.push({ msg: 'Filter applied', kind: 'ok', icon: '✓' });
}
}, 380);
return () => clearTimeout(debRef.current);
}, [filterKind, filterUser, qText]);
/* by_kind is a dict {kind: count} in the real API, not an array */
const byKind = useMemo(() => {
if (!stats) return [];
const raw = stats.by_kind || {};
return Object.entries(raw)
.map(([kind, count]) => ({ kind, count }))
.sort((a, b) => b.count - a.count);
}, [stats]);
const maxKindCount = useMemo(() => Math.max(...byKind.map(x => x.count), 1), [byKind]);
const invalidCount = byKind.find(k => k.kind === 'invalid')?.count ?? 0;
/* group items by kind for display */
const grouped = useMemo(() => {
const groups = {};
for (const m of items) {
const k = m.parsed_kind || 'unknown';
if (!groups[k]) groups[k] = [];
groups[k].push(m);
}
return Object.entries(groups);
}, [items]);
return (
for older bundles where saveDownload is missing.
if (typeof window.saveDownload === 'function') {
await window.saveDownload(blob, filename);
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
} catch (e) {
toast.push({ msg: `Export failed: ${e.message}`, kind: 'err', icon: '!' });
}
};
const actionTypes = useMemo(() => {
const seen = new Set(allRows.map(r => r.action_type).filter(Boolean));
return Array.from(seen).sort();
}, [allRows]);
return (
s))("Audit log")}
meta={(window.appT||(s=>s))("Every action the bot took — 90-day retention")}
right={
load(true)} loading={loading} />
handleExport('csv')}
title={(window.appT||(s=>s))("Full row-by-row dump (no row cap) for compliance / Excel")}>
{(window.appT||(s=>s))("Export CSV")}
handleExport('pdf')}
title={(window.appT||(s=>s))("Human-readable summary (first 500 rows)")}>
{(window.appT||(s=>s))("Export PDF")}
}
/>
{/* Left sidebar — per-user counts */}
s))("By user")}
tip={(window.appT||(s=>s))("Per-user message counts in the 30-day window. Click a row to filter the message list.")} sub={(window.appT||(s=>s))("Click to filter")} />
{userCounts.length === 0 && s))("No data")} />}
{userCounts.map(u => (
{
const newId = String(u.user_id);
setFU(v => v === newId ? '' : newId);
}}
style={{
display:'flex', justifyContent:'space-between', alignItems:'center',
padding: '6px 4px', cursor:'pointer', borderRadius:'var(--r-xs)',
background: String(filterUser) === String(u.user_id) ? 'var(--bg-elev-2)' : 'transparent',
fontSize: 12.5,
}}
>
{u.name}
{u.count}
))}
{/* Main audit table */}
);
}
function AuditRow({ a, onPickActor }) {
const [open, setOpen] = useState(false);
const [actBusy, setActBusy] = useState('');
const [actDone, setActDone] = useState('');
const toast = useToast();
const trigger = a.trigger || '';
const isIpFlag = a.action_type === 'ip_red_flag';
// Permission: any manager/admin viewing this row can act on it (the API
// re-checks _can_edit_user server-side, so manager-not-on-team yields 403).
const canAct = !!(window.STATUS_DATA && window.STATUS_DATA.user &&
['admin', 'manager'].includes((window.STATUS_DATA.user.org_role || '').toLowerCase()));
const doAck = async (e) => {
e.stopPropagation();
if (!a.id || actBusy) return;
setActBusy('ack');
try {
const r = await fetch(`/api/ip-flags/${a.id}/ack`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (!r.ok) throw new Error(await r.text());
setActDone('acked');
toast.push({ kind: 'ok', icon: '✓', msg: 'Flag acknowledged' });
} catch (err) {
toast.push({ kind: 'err', icon: '!', msg: 'Ack failed: ' + (err.message || err) });
} finally { setActBusy(''); }
};
const doSnooze = async (e) => {
e.stopPropagation();
if (!a.id || actBusy) return;
setActBusy('snooze');
try {
const r = await fetch(`/api/ip-flags/${a.id}/snooze`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: 7 }),
});
if (!r.ok) throw new Error(await r.text());
setActDone('snoozed');
toast.push({ kind: 'ok', icon: '💤', msg: 'Snoozed for 7 days' });
} catch (err) {
toast.push({ kind: 'err', icon: '!', msg: 'Snooze failed: ' + (err.message || err) });
} finally { setActBusy(''); }
};
const triggerColor = trigger === 'strike' ? 'err' : trigger === 'admin' ? 'info' : trigger === 'sign-in' || trigger === 'sign_in' ? 'ok' : 'mute';
// Result column: HTTP status code if we matched an http_log row, else "—".
const status = a.status;
let resultBadge;
if (status == null) {
resultBadge = — ;
} else {
const kind = status >= 500 ? 'err' : status >= 400 ? 'warn' : status >= 300 ? 'info' : 'ok';
resultBadge = {status} ;
}
// Truncate long paths in the cell; full value lives in the expanded panel.
const shortPath = (p) => {
if (!p) return '—';
return p.length > 40 ? `${p.slice(0, 38)}…` : p;
};
return (
<>
setOpen(x => !x)} style={{ cursor:'pointer' }}>
{relTime(a.occurred_at)}
{a.actor ? (
{ ev.stopPropagation(); onPickActor && onPickActor(a.actor); }}
title={(window.appLang&&window.appLang()==='ar') ? `تصفية سجل التدقيق حسب الفاعل: ${a.actor}` : `Filter audit log to actor: ${a.actor}`}
style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer', color: 'var(--text)', fontSize: 12, textAlign: 'left' }}
>
{a.actor}
) : (a.user_display_name || '—')}
{a.actor && a.user_display_name && a.actor !== a.user_display_name && (
→ {a.user_display_name}
)}
{a.ip || '—'}
{a.method ? {a.method} : '—'}
{shortPath(a.path)}
{/* Issue #32: show human label (sent by API as action_label).
Fall back to local humanizer if an older API didn't include it. */}
{(window.appT||(s=>s))(a.action_label || humanizeActionType(a.action_type) || '—')}
{trigger || '—'}
{resultBadge}
{open && (
{(window.appT||(s=>s))("When:")} {absTime(a.occurred_at)}
{a.target && <> · {(window.appT||(s=>s))("Target:")} {a.target}>}
{a.path && <> · {(window.appT||(s=>s))("Path:")} {a.path} >}
{a.user_agent && <> · {(window.appT||(s=>s))("UA:")} {a.user_agent} >}
{a.body && (
{a.body}
)}
{isIpFlag && canAct && (
s))("Mark this IP red-flag as reviewed")}
>
{actDone === 'acked' ? '✓ ' + (window.appT||(s=>s))('Acknowledged') : (actBusy === 'ack' ? '…' : '✓ ' + (window.appT||(s=>s))('Acknowledge'))}
s))("Suppress further red-flag DMs for this user for 7 days")}
>
{actDone === 'snoozed' ? '💤 ' + (window.appT||(s=>s))('Snoozed 7d') : (actBusy === 'snooze' ? '…' : '💤 ' + (window.appT||(s=>s))('Snooze 7d'))}
{(window.appT||(s=>s))("Snooze suppresses repeat red-flag DMs for the affected user.")}
)}
)}
>
);
}
/* ── Configure Provider Modal ── */
function ProviderConfigModal({ provider, onClose, onSaved }) {
const [key, setKey] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const toast = useToast();
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const save = async () => {
if (!key.trim()) { setErr((window.appT||(s=>s))('API key is required')); return; }
setBusy(true); setErr('');
try {
const r = await fetch('/api/providers/set-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ provider: provider.id, key: key.trim() }),
});
if (!r.ok) {
const body = await r.json().catch(() => null);
throw new Error(parseDetail(body, r.status));
}
toast.push({ msg: `${provider.name} API key saved`, kind: 'ok' });
onSaved && onSaved();
onClose();
} catch(e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
return (
e.stopPropagation()}
style={{
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
background: 'var(--bg-elev-1)', border: '1px solid var(--line-hi)',
borderRadius: 'var(--r-lg)', padding: 24, width: 380, maxWidth: '90vw',
boxShadow: '0 24px 64px -16px #00000080', zIndex: 301,
}}
>
{(window.appLang&&window.appLang()==='ar') ? `إعداد ${provider.name}` : `Configure ${provider.name}`}
{err && (
{err}
)}
{(window.appT||(s=>s))("API Key")}
setKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && save()}
placeholder="sk-…"
style={{ marginBottom: 16 }}
autoFocus
/>
{(window.appT||(s=>s))("Cancel")}
{busy ? (window.appT||(s=>s))('Saving…') : <> {(window.appT||(s=>s))('Save key')}>}
);
}
/* ─── Slack OAuth app credentials card ──────────────────────── */
function SlackOAuthCredentialsCard() {
const toast = useToast();
const [cfg, setCfg] = React.useState(null);
const [form, setForm] = React.useState({ client_id: '', client_secret: '', signing_secret: '' });
const [busy, setBusy] = React.useState(false);
const load = () => {
fetch('/api/admin/slack/credentials', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setCfg(d); })
.catch(() => {});
};
React.useEffect(() => { load(); }, []);
const save = async () => {
const body = {};
if (form.client_id.trim()) body.client_id = form.client_id.trim();
if (form.client_secret.trim()) body.client_secret = form.client_secret.trim();
if (form.signing_secret.trim()) body.signing_secret = form.signing_secret.trim();
if (Object.keys(body).length === 0) {
toast.push({ msg: 'Nothing to save — paste at least one value', kind: 'warn' });
return;
}
setBusy(true);
try {
const r = await fetch('/api/admin/slack/credentials', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
// Defend against HTML error pages (nginx 405/502, etc.)
const text = await r.text().catch(() => '');
const isJson = text.trim().startsWith('{');
let msg = `Server error ${r.status}`;
if (isJson) {
try { msg = JSON.parse(text).detail || msg; } catch (_) {}
}
throw new Error(msg);
}
toast.push({ msg: 'Slack OAuth app credentials saved', kind: 'ok' });
setForm({ client_id: '', client_secret: '', signing_secret: '' });
load();
} catch(e) {
toast.push({ msg: 'Save failed: ' + e.message, kind: 'err' });
} finally {
setBusy(false);
}
};
const allSet = cfg && cfg.client_id_set && cfg.client_secret_set && cfg.signing_secret_set;
return (
);
}
/* ─── Slack provider card ──────────────────────────────────── */
function SlackProviderSection() {
const toast = useToast();
const [workspaces, setWorkspaces] = React.useState(null); // null = loading
const [busy, setBusy] = React.useState(false);
const load = async () => {
try {
const r = await fetch('/api/admin/slack/workspaces', { credentials: 'same-origin' });
if (r.ok) setWorkspaces(await r.json());
else setWorkspaces([]);
} catch { setWorkspaces([]); }
};
React.useEffect(() => { load(); }, []);
// Listen for postMessage from the OAuth popup
React.useEffect(() => {
const handler = (e) => {
if (e.data && e.data.type === 'slack_installed') {
load();
toast.push({ msg: `Slack workspace "${e.data.team_name}" connected`, kind: 'ok' });
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
const openInstall = async () => {
// Pre-check: verify credentials are configured before opening popup
try {
const r = await fetch('/api/admin/slack/credentials', { credentials: 'same-origin' });
if (r.ok) {
const d = await r.json();
if (!d.client_id_set || !d.client_secret_set) {
toast.push({ msg: 'Set Slack OAuth app Client ID and Client Secret first (see card below)', kind: 'warn' });
return;
}
}
} catch (_) {}
window.open('/slack/install', 'slack_install', 'width=600,height=700');
};
const uninstall = async (ws) => {
if (!confirm((window.appLang&&window.appLang()==='ar') ? `فصل مساحة عمل Slack "${ws.team_name || ws.team_id}"؟` : `Disconnect Slack workspace "${ws.team_name || ws.team_id}"?`)) return;
setBusy(true);
try {
const r = await fetch('/api/admin/slack/uninstall', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ team_id: ws.team_id }),
});
if (!r.ok) throw new Error(`${r.status}`);
toast.push({ msg: `Workspace "${ws.team_name || ws.team_id}" disconnected`, kind: 'ok' });
load();
} catch(e) {
toast.push({ msg: `Uninstall failed: ${e.message}`, kind: 'err' });
} finally { setBusy(false); }
};
const installed = workspaces && workspaces.length > 0;
return (
{(window.appT||(s=>s))("Messaging integrations")}
Slack · Microsoft Teams · WhatsApp
{/* Full-width section per provider — was a single 460px card with
Teams nested under Slack, which collapsed everything into the
left rail. Now each integration gets its own panel with the
provider's branding/header so the surface area is balanced. */}
{/* Slack section */}
Slack
{installed ? ((window.appLang&&window.appLang()==='ar') ? `${workspaces.length} مساحة عمل متصلة` : `${workspaces.length} workspace${workspaces.length>1?'s':''} connected`) : (window.appT||(s=>s))('Not connected')}
{(window.appT||(s=>s))("messaging")}
{installed && {(window.appT||(s=>s))("active")} }
{(window.appT||(s=>s))("Employees can post standups directly from Slack. Sign-ins, sign-offs, and status declarations route through the same pipeline as K9 Chat.")}
{workspaces === null &&
{(window.appT||(s=>s))("Loading…")}
}
{workspaces && workspaces.map(ws => (
{ws.team_name || ws.team_id}
{ws.team_id} · bot: {ws.bot_user_id || 'unknown'}
{ws.installed_at && (
{(window.appLang&&window.appLang()==='ar') ? `تم التثبيت ${new Date(ws.installed_at).toLocaleDateString()}` : `Installed ${new Date(ws.installed_at).toLocaleDateString()}`}
)}
uninstall(ws)}
>
{(window.appT||(s=>s))("Disconnect")}
))}
{(window.appT||(s=>s))("Connect Slack workspace")}
{installed && (
{(window.appT||(s=>s))("Refresh")}
)}
{/* Direct-config (single-workspace MVP) now lives inside the
Slack column instead of as a separate full-width row below.
Keeps the layout balanced and stops the empty right rail. */}
{/* Microsoft Teams section — no longer nested inside Slack */}
{/* Jira (commercial v2 — per-tenant OAuth 2.0). */}
{/* WhatsApp section — moved out of the Server tab so all messaging
providers live together. Component is exposed via window so we
don't have to inline it. */}
{typeof window.WhatsAppIntegrationCard === 'function'
? React.createElement(window.WhatsAppIntegrationCard)
: (
<>
WhatsApp
{(window.appT||(s=>s))("Twilio escalation channel")}
{(window.appT||(s=>s))("messaging")}
{(window.appT||(s=>s))("WhatsApp settings now live in this tab. If you don't see the form yet, the Server tab's WhatsApp panel needs to be moved here in the next deploy — bump the page-misc cache-bust and re-check.")}
>
)
}
);
}
/* ─── Teams integration card + install modal (CP2) ──────── */
function SlackDirectConfigSection() {
const [cfg, setCfg] = React.useState({ bot_token: '', signing_secret: '', channel_id: '', bot_token_set: false, signing_secret_set: false, channel_id_set: false });
const [token, setToken] = React.useState('');
const [secret, setSecret] = React.useState('');
const [chan, setChan] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [tested, setTested] = React.useState(null);
const toast = useToast();
const reload = () => {
fetch('/api/admin/slack/settings', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) { setCfg(d); setChan(d.channel_id || ''); } })
.catch(() => {});
};
React.useEffect(() => { reload(); }, []);
const save = async () => {
setBusy(true);
try {
const body = {};
if (token.trim()) body.bot_token = token.trim();
if (secret.trim()) body.signing_secret = secret.trim();
if (chan.trim()) body.channel_id = chan.trim();
if (Object.keys(body).length === 0) { toast.push({ msg: 'Nothing to save', kind: 'warn' }); return; }
const r = await fetch('/api/admin/slack/settings', {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const text = await r.text().catch(() => '');
const isJson = text.trim().startsWith('{');
let msg = `Server error ${r.status}`;
if (isJson) { try { msg = JSON.parse(text).detail || msg; } catch (_) {} }
throw new Error(msg);
}
toast.push({ msg: 'Slack config saved', kind: 'ok' });
setToken(''); setSecret('');
reload();
} catch(e) {
toast.push({ msg: 'Save failed: ' + e.message, kind: 'err' });
} finally {
setBusy(false);
}
};
const test = async () => {
setBusy(true); setTested(null);
try {
// Hit Slack auth.test through the bot — we proxy by re-saving (no-op)
// and reading state. For simplicity, we just re-fetch settings.
// For now use auth.test through our backend: there's no dedicated endpoint, so
// just verify by hitting GET /api/admin/slack/settings and ensuring bot_token_set is true.
const r = await fetch('/api/admin/slack/settings', { credentials: 'same-origin' });
const d = await r.json();
const ok = d.bot_token_set && d.signing_secret_set && d.channel_id_set;
setTested({ ok, ...d });
toast.push({ msg: ok ? '✅ All 3 fields configured' : '⚠️ Missing one or more fields', kind: ok ? 'ok' : 'warn' });
} catch(e) {
toast.push({ msg: 'Check failed: ' + e.message, kind: 'err' });
} finally {
setBusy(false);
}
};
return (
);
}
function TeamsInstallModal({ onClose, onSaved }) {
const [form, setForm] = React.useState({ aad_tenant_id: '', app_id: '', app_password: '', service_url: '' });
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState('');
const toast = useToast();
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }));
const save = async () => {
if (!form.aad_tenant_id.trim() || !form.app_id.trim() || !form.app_password.trim()) {
setErr((window.appT||(s=>s))('AAD Tenant ID, App ID, and App Password are required'));
return;
}
setBusy(true); setErr('');
try {
const r = await fetch('/api/admin/teams/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(form),
});
if (!r.ok) {
const body = await r.json().catch(() => null);
throw new Error(parseDetail(body, r.status));
}
toast.push({ msg: 'Teams installation saved', kind: 'ok' });
onSaved && onSaved();
onClose();
} catch(e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
const Field = ({ label, k, type = 'text', placeholder = '' }) => (
{label}
);
return (
e.stopPropagation()}
style={{
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
background: 'var(--bg-elev-1)', border: '1px solid var(--line-hi)',
borderRadius: 'var(--r-lg)', padding: 24, width: 420, maxWidth: '92vw',
boxShadow: '0 24px 64px -16px #00000080', zIndex: 301,
}}
>
{(window.appT||(s=>s))("Configure Teams integration")}
{(window.appT||(s=>s))("Create an Azure Bot at portal.azure.com, copy the credentials below, then side-load the manifest zip into your Teams tenant.")}
{err && (
{err}
)}
s))("Azure AD Tenant ID")} k="aad_tenant_id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
s))("App ID (Application Client ID)")} k="app_id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
s))("App Password (Client Secret)")} k="app_password" type="password" placeholder={(window.appT||(s=>s))("secret value from Azure portal")} />
s))("Service URL (optional)")} k="service_url" placeholder="https://smba.trafficmanager.net/amer/" />
{(window.appT||(s=>s))("Cancel")}
{busy ? (window.appT||(s=>s))('Saving...') : <> {(window.appT||(s=>s))('Save')}>}
);
}
/* ─── Jira v2 (commercial — Atlassian OAuth 2.0 per-tenant) ──────── */
function JiraV2IntegrationCard() {
const [installs, setInstalls] = React.useState(null);
const [creds, setCreds] = React.useState({ client_id: '', client_secret_set: false });
const [credForm, setCredForm] = React.useState({ client_id: '', client_secret: '' });
const [projects, setProjects] = React.useState([]);
const [editingCreds, setEditingCreds] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const toast = useToast();
const probe = async () => {
try {
const [s, c] = await Promise.all([
fetch('/api/admin/jira/status', { credentials: 'same-origin' }),
fetch('/api/admin/jira/credentials', { credentials: 'same-origin' }),
]);
if (s.ok) setInstalls((await s.json()).installs || []);
if (c.ok) {
const cj = await c.json();
setCreds(cj);
setCredForm({ client_id: cj.client_id, client_secret: '' });
}
} catch (e) { /* ignore */ }
};
React.useEffect(() => {
probe();
const onMsg = (e) => { if (e.data && e.data.type === 'jira_installed') probe(); };
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, []);
const loadProjects = async () => {
try {
const r = await fetch('/api/admin/jira/projects', { credentials: 'same-origin' });
if (r.ok) setProjects((await r.json()).projects || []);
} catch { setProjects([]); }
};
const saveCreds = async () => {
if (!credForm.client_id || !credForm.client_secret) {
toast.push({ msg: 'Client ID + secret required', kind: 'warn' }); return;
}
setBusy(true);
try {
const r = await fetch('/api/admin/jira/credentials', {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credForm),
});
if (!r.ok) throw new Error('save failed');
toast.push({ msg: 'Atlassian OAuth credentials saved', kind: 'ok' });
setEditingCreds(false);
await probe();
} catch (e) {
toast.push({ msg: e.message, kind: 'err' });
} finally { setBusy(false); }
};
const startInstall = () => {
window.open('/jira/install', 'jira_install', 'width=700,height=800');
};
const setProject = async (p) => {
setBusy(true);
try {
const r = await fetch('/api/admin/jira/project', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_key: p.key, project_name: p.name }),
});
if (!r.ok) throw new Error('save failed');
toast.push({ msg: `Project ${p.key} set`, kind: 'ok' });
await probe();
} catch (e) { toast.push({ msg: e.message, kind: 'err' }); }
finally { setBusy(false); }
};
const uninstall = async (id) => {
if (!confirm((window.appT||(s=>s))('Disconnect this Jira installation?'))) return;
setBusy(true);
try {
await fetch(`/api/admin/jira/install/${id}`, {
method: 'DELETE', credentials: 'same-origin',
});
toast.push({ msg: 'Disconnected', kind: 'ok' });
await probe();
} finally { setBusy(false); }
};
return (
s))("Jira (Atlassian OAuth)")}
tip={(window.appT||(s=>s))("Per-tenant Atlassian Cloud integration via OAuth 2.0 (3LO). Each tenant connects their own Atlassian site and picks a target project + status mapping.")}
sub={installs === null ? (window.appT||(s=>s))('Loading…') :
installs.length > 0 ? ((window.appLang&&window.appLang()==='ar') ? `${installs.length} موقع متصل` : `${installs.length} site(s) connected`) : (window.appT||(s=>s))('Not connected')}
/>
{/* Step 1: ISV OAuth app credentials */}
{(window.appT||(s=>s))("Atlassian app credentials")}
{creds.client_id ? (window.appT||(s=>s))('configured') : (window.appT||(s=>s))('missing')}
setEditingCreds(!editingCreds)}
style={{ marginLeft: 'auto', padding: '2px 8px', fontSize: 12 }}>
{editingCreds ? (window.appT||(s=>s))('Cancel') : (window.appT||(s=>s))('Edit')}
{editingCreds && (
)}
{/* Step 2: Install into a tenant */}
{creds.client_id && (
{installs && installs.length > 0 ? (window.appT||(s=>s))('Connect another site') : (window.appT||(s=>s))('Connect Jira site')}
)}
{/* Step 3: per-install controls */}
{installs && installs.map(i => (
{i.site_url}
{i.cloud_id}
uninstall(i.id)}>{(window.appT||(s=>s))("Disconnect")}
{(window.appT||(s=>s))("Project:")}
{i.project_key || (window.appT||(s=>s))('not set')}
{i.project_name ? {i.project_name} : null}
{projects.length ? (window.appT||(s=>s))('Refresh') : (window.appT||(s=>s))('Pick')}
{projects.length > 0 && (
{projects.slice(0, 8).map(p => (
setProject(p)}
style={{ textAlign: 'left', padding: '4px 8px', fontSize: 12 }}>
{p.key} {p.name}
))}
)}
))}
);
}
function TeamsIntegrationCard() {
const [installations, setInstallations] = React.useState(null);
const [showModal, setShowModal] = React.useState(false);
const toast = useToast();
const load = async () => {
try {
const r = await fetch('/api/admin/teams/installations', { credentials: 'same-origin' });
if (r.ok) setInstallations(await r.json());
else setInstallations([]);
} catch(_) { setInstallations([]); }
};
React.useEffect(() => { load(); }, []);
const remove = async (aad_tenant_id) => {
if (!confirm((window.appLang&&window.appLang()==='ar') ? `إزالة تثبيت Teams للمستأجر ${aad_tenant_id}؟` : `Remove Teams installation for tenant ${aad_tenant_id}?`)) return;
try {
const r = await fetch(`/api/admin/teams/install/${encodeURIComponent(aad_tenant_id)}`, {
method: 'DELETE', credentials: 'same-origin',
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
toast.push({ msg: 'Installation removed', kind: 'ok' });
load();
} catch(e) { toast.push({ msg: `Failed: ${e.message}`, kind: 'err' }); }
};
const isConfigured = installations && installations.length > 0;
return (
Microsoft Teams
Bot Framework / Azure AD
{(window.appT||(s=>s))("messaging")}
{isConfigured && {(window.appT||(s=>s))("connected")} }
{(window.appT||(s=>s))("Let employees post standups directly in Microsoft Teams. Free-text Y/T/B and Adaptive Card form both supported. Multi-tenant; credentials stored encrypted.")}
{installations === null && (
{(window.appT||(s=>s))("Loading...")}
)}
{isConfigured && (
{installations.map(inst => (
{inst.aad_tenant_id}
app: {inst.app_id.slice(0,8)}...
remove(inst.aad_tenant_id)}
>
{(window.appT||(s=>s))("Remove")}
))}
)}
setShowModal(true)}>
{isConfigured ? (window.appT||(s=>s))('Add tenant') : (window.appT||(s=>s))('Configure credentials')}
{
// If a tenant is configured the GET has a real GUID server-side.
// If not, prompt for the Azure AD App ID inline so the user
// can still build a working manifest without round-tripping
// through the credentials modal. Empty input → server uses a
// placeholder GUID with a clear "[TEMPLATE]" note in the
// manifest description so the admin sees what to swap.
let appId = '';
if (!isConfigured) {
appId = (window.prompt(
(window.appT||(s=>s))("Azure AD App (Bot) ID — paste here to bake into the manifest, or leave blank to download a template you can edit in Teams' Developer Portal."),
''
) || '').trim();
}
const url = appId
? `/teams/manifest.zip?app_id=${encodeURIComponent(appId)}`
: '/teams/manifest.zip';
try {
const r = await fetch(url, { credentials: 'same-origin' });
if (!r.ok) {
let detail = '';
try { detail = (await r.json())?.detail || ''; } catch (_) {}
throw new Error(`${r.status} ${detail || r.statusText}`);
}
const blob = await r.blob();
const source = r.headers.get('X-App-Id-Source') || 'configured';
const filename = 'standup-bot-teams.zip';
if (typeof window.saveDownload === 'function') {
await window.saveDownload(blob, filename);
} else {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
toast.push({
msg: source === 'template'
? 'Manifest downloaded (TEMPLATE — edit App ID in Teams Dev Portal before publishing)'
: 'Manifest downloaded',
kind: 'ok',
});
} catch (e) {
toast.push({ msg: `Manifest download failed: ${e.message}`, kind: 'err' });
}
}}
>
{(window.appT||(s=>s))("Download manifest")}
{showModal && (
setShowModal(false)}
onSaved={load}
/>
)}
);
}
/* ─── Failover order ─────────────────────────────────────
Lets an admin set the order the bot falls back through when the active AI
provider errors/rate-limits. Add providers from the dropdown (configured
ones selectable, keyless ones disabled); chain order = priority. */
function FailoverOrderSection() {
const toast = useToast();
const [data, setData] = React.useState(null); // {order, providers, active_provider}
const [chain, setChain] = React.useState([]); // ordered provider names
const [busy, setBusy] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const load = React.useCallback(async () => {
try {
const r = await fetch('/api/providers/fallback-order', { credentials: 'same-origin' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json();
setData(j);
setChain(j.order || []);
setDirty(false);
} catch (e) {
toast.push({ msg: `Failed to load failover order: ${e.message}`, kind: 'err' });
}
}, []);
React.useEffect(() => { load(); }, [load]);
if (!data) return null;
const byName = Object.fromEntries((data.providers || []).map(p => [p.name, p]));
const notInChain = (data.providers || []).filter(p => !chain.includes(p.name));
const statusLabel = (p) => p.active ? ' (' + (window.appT||(s=>s))('active') + ')' : p.configured ? ' (' + (window.appT||(s=>s))('configured') + ')' : ' (' + (window.appT||(s=>s))('no key') + ')';
const addProvider = (name) => {
if (!name || chain.includes(name)) return;
setChain([...chain, name]); setDirty(true);
};
const removeProvider = (name) => { setChain(chain.filter(n => n !== name)); setDirty(true); };
const move = (idx, dir) => {
const j = idx + dir;
if (j < 0 || j >= chain.length) return;
const next = [...chain];
[next[idx], next[j]] = [next[j], next[idx]];
setChain(next); setDirty(true);
};
const save = async () => {
setBusy(true);
try {
const r = await fetch('/api/providers/fallback-order', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ order: chain }),
});
if (!r.ok) { const b = await r.json().catch(() => null); throw new Error(parseDetail(b, r.status)); }
toast.push({ msg: 'Failover order saved', kind: 'ok' });
setDirty(false);
} catch (e) {
toast.push({ msg: `Save failed: ${e.message}`, kind: 'err' });
} finally { setBusy(false); }
};
return (
{(window.appT||(s=>s))("Failover order")}
{dirty &&
{(window.appT||(s=>s))("unsaved")} }
{(window.appT||(s=>s))("When the active provider errors or hits a rate limit, the bot retries down this list in order. Keyless providers can't be added until you set their API key.")}
{chain.length === 0 ? (
{(window.appT||(s=>s))("No custom chain — using the built-in default order.")}
) : (
{chain.map((name, i) => {
const p = byName[name] || { name, label: name, configured: false, active: false };
return (
{i + 1}
{p.label}{statusLabel(p)}
move(i, -1)} title={(window.appT||(s=>s))("Move up")}>↑
move(i, 1)} title={(window.appT||(s=>s))("Move down")}>↓
removeProvider(name)} title={(window.appT||(s=>s))("Remove")}>✕
);
})}
)}
{ addProvider(e.target.value); e.target.value = ''; }}
style={{ minWidth: 220 }}
>
{(window.appT||(s=>s))("Add provider to failover chain…")}
{notInChain.map(p => (
{p.label}{statusLabel(p)}
))}
{busy ? (window.appT||(s=>s))('Saving…') : (window.appT||(s=>s))('Save order')}
{dirty && (
{(window.appT||(s=>s))("Reset")}
)}
);
}
function ProvidersPage() {
const D = window.STATUS_DATA;
const [providers, setProviders] = useState(D.PROVIDERS || []);
const [configModal, setConfigModal] = useState(null); // provider being configured
const toast = useToast();
const makeActive = async (p) => {
try {
const r = await fetch('/api/providers/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ provider: p.id }),
});
if (!r.ok) {
const body = await r.json().catch(() => null);
throw new Error(parseDetail(body, r.status));
}
// Refresh providers list — normalize raw API shape
const rawFresh = await fetch('/api/providers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : null);
if (rawFresh) {
const avail = Array.isArray(rawFresh) ? rawFresh : (rawFresh.available || []);
const activeName = rawFresh.active?.name || rawFresh.active?.active_provider || p.id;
setProviders(avail.map(x => ({
id: x.name || x.id,
name: x.display_name || x.name,
default_model: x.default_model || x.summary_model || '',
tagline: x.notes || x.tagline || '',
tier: x.tier || 'unknown',
configured: !!x.configured || !!x.api_key_configured,
is_active: x.active || (x.name === activeName) || false,
})));
}
toast.push({ msg: `${p.name} is now active`, kind: 'ok' });
} catch(e) {
toast.push({ msg: `Failed: ${e.message}`, kind: 'err' });
}
};
return (
s))("AI providers")}
meta={(window.appT||(s=>s))("Choose the model that powers Ask Pulse and the morning roll-up")}
/>
{providers.map(p => {
const isActive = p.is_active || p.active;
return (
{p.name}
{p.default_model}
{(window.appT||(s=>s))(p.tier)}
{isActive && {(window.appT||(s=>s))("active")} }
{p.tagline}
{p.configured ? (
<>
{!isActive && (
makeActive(p)}>{(window.appT||(s=>s))("Make active")}
)}
setConfigModal(p)}>
{(window.appT||(s=>s))("Configure")}
>
) : (
setConfigModal(p)}>
{(window.appT||(s=>s))("Add API key")}
)}
);
})}
{configModal && (
setConfigModal(null)}
onSaved={async () => {
const rawFresh = await fetch('/api/providers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : null);
if (rawFresh) {
const avail = Array.isArray(rawFresh) ? rawFresh : (rawFresh.available || []);
const activeName = rawFresh.active?.name || rawFresh.active?.active_provider || '';
setProviders(avail.map(x => ({
id: x.name || x.id,
name: x.display_name || x.name,
default_model: x.default_model || x.summary_model || '',
tagline: x.notes || x.tagline || '',
tier: x.tier || 'unknown',
configured: !!x.configured || !!x.api_key_configured,
is_active: x.active || (x.name === activeName) || false,
})));
}
}}
/>
)}
{/* Messaging Integrations (Slack / Teams / WhatsApp) hidden from the
Integrations tab per request. The section + all backend wiring stay
intact and configured — re-enable by un-commenting for commercial /
licensing deployments. */}
{/* */}
);
}
/* ─── helpers ─────────────────────────────────────────── */
function fmtBytes(n) {
if (n == null) return '—';
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GB';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB';
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB';
return n + ' B';
}
/* ─── Run now modal ─────────────────────────────────────── */
function RunNowModal({ onClose }) {
const toast = useToast();
const [busy, setBusy] = React.useState(false);
const download = async (fmt) => {
setBusy(true);
try {
const r = await fetch(`/api/scheduler/run-now/daily-recompute?format=${fmt}`, {
method: 'POST', credentials: 'include',
});
if (!r.ok) throw new Error(`${r.status}`);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `daily_recompute.${fmt}`;
a.click();
URL.revokeObjectURL(url);
toast.push({ msg: `${fmt.toUpperCase()} downloaded`, kind: 'ok', icon: '✓' });
} catch (e) {
toast.push({ msg: `Failed: ${e.message}`, kind: 'err', icon: '!' });
} finally {
setBusy(false);
}
};
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
e.stopPropagation()}
style={{
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
background: 'var(--bg-elev-1)', border: '1px solid var(--line-hi)',
borderRadius: 'var(--r-lg)', padding: 24, width: 380, maxWidth: '90vw',
boxShadow: '0 24px 64px -16px #00000080', zIndex: 301,
}}
>
{(window.appT||(s=>s))("Run")} daily_recompute
{(window.appT||(s=>s))("Runs the daily attendance recompute for today and returns a fresh executive report. Choose your download format.")}
download('pdf')} disabled={busy}>
{(window.appT||(s=>s))("Download PDF")}
download('xlsx')} disabled={busy}>
{(window.appT||(s=>s))("Download XLSX")}
{busy &&
{(window.appT||(s=>s))("Computing report…")}
}
);
}
/* ─── Branding section ─────────────────────────────────── */
/* ─── White-label section (#14) ─── */
function WhitelabelSection() {
const toast = useToast();
const [b, setB] = React.useState(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
fetch('/api/branding/all', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(setB)
.catch(() => setB({}));
}, []);
const save = async () => {
setBusy(true);
try {
const r = await fetch('/api/branding/all', {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brand_name: b.brand_name || '',
slogan: b.slogan || '',
primary_color: b.primary_color || '#3b66f5',
support_email: b.support_email || '',
domain: b.domain || '',
}),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
setB(j);
toast.push({ kind: 'ok', icon: '✓', msg: 'White-label saved.' });
// Re-apply brand color + name immediately
try {
if (j.primary_color) document.documentElement.style.setProperty('--color-brand', j.primary_color);
if (j.brand_name) document.title = j.brand_name;
} catch(_) {}
} catch (e) {
toast.push({ kind: 'err', icon: '!', msg: 'Save failed: ' + (e.message || e) });
} finally { setBusy(false); }
};
if (!b) return
;
return (
{(window.appT||(s=>s))("Make this dashboard your own. Brand name, slogan and primary color are applied immediately to the header and login screen. Domain is for your records — your ops team still owns the actual nginx + DNS configuration.")}
{(window.appT||(s=>s))("Slogan / tagline")}
{busy ? (window.appT||(s=>s))('Saving…') : <> {(window.appT||(s=>s))('Save white-label')}>}
);
}
/* ─── Email Alerts section (#12) ─── */
function EmailAlertsSection() {
const toast = useToast();
const [cfg, setCfg] = React.useState(null);
const [keyVal, setKeyVal] = React.useState('');
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
fetch('/api/alerts/config', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(setCfg)
.catch(() => setCfg({}));
}, []);
const save = async () => {
setBusy(true);
try {
const body = {
enabled: !!cfg.enabled,
from_email: cfg.from_email || '',
recipients: cfg.recipients || '',
};
if (keyVal && keyVal.trim()) body.sendgrid_key = keyVal.trim();
const r = await fetch('/api/alerts/config', {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
setCfg(await r.json());
setKeyVal('');
toast.push({ kind: 'ok', icon: '✓', msg: 'Alert config saved.' });
} catch (e) {
toast.push({ kind: 'err', icon: '!', msg: 'Save failed: ' + (e.message || e) });
} finally { setBusy(false); }
};
const test = async () => {
setBusy(true);
try {
const r = await fetch('/api/alerts/test', { method: 'POST', credentials: 'include' });
const j = await r.json();
toast.push({
kind: j.ok ? 'ok' : 'warn',
icon: j.ok ? '✓' : '!',
msg: j.ok ? 'Test alert sent — check your inbox.' : 'Test failed; check API key + recipients.',
});
} catch (e) {
toast.push({ kind: 'err', icon: '!', msg: 'Test failed: ' + (e.message || e) });
} finally { setBusy(false); }
};
if (!cfg) return
;
return (
{(window.appT||(s=>s))("Bot health alerts are sent via")}
SendGrid . {(window.appT||(s=>s))("Get a free API key at")}
{' '}
app.sendgrid.com/settings/api_keys .
{(window.appT||(s=>s))("Verify your sender address first, then paste the key below. Alerts fire when the scheduler detects a recent")}
BotErrorLog {(window.appT||(s=>s))("entry (debounced to 1/hour per kind).")}
{(window.appT||(s=>s))("Recipients (comma-separated)")}
setCfg(prev => ({ ...prev, recipients: e.target.value }))} />
setCfg(prev => ({ ...prev, enabled: e.target.checked }))} />
{(window.appT||(s=>s))("Enable alerts (when off, the scheduler still logs but does not send)")}
{(window.appT||(s=>s))("Send test email")}
{busy ? (window.appT||(s=>s))('Saving…') : <> {(window.appT||(s=>s))('Save alert config')}>}
);
}
function BrandingSection() {
const toast = useToast();
const [branding, setBranding] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const fileRef = React.useRef();
React.useEffect(() => {
fetch('/api/admin/branding', { credentials: 'include' })
.then(r => r.ok ? r.json() : {})
.then(setBranding)
.catch(() => setBranding({}));
}, []);
const save = async () => {
setBusy(true);
try {
const r = await fetch('/api/admin/branding', {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
app_name: branding.app_name || '',
accent_color: branding.accent_color || '',
login_tagline: branding.login_tagline || '',
}),
});
if (!r.ok) throw new Error(`${r.status}`);
toast.push({ msg: 'Branding saved', kind: 'ok', icon: '✓' });
} catch (e) {
toast.push({ msg: `Save failed: ${e.message}`, kind: 'err', icon: '!' });
} finally {
setBusy(false);
}
};
const uploadFavicon = async (file) => {
if (!file) return;
setBusy(true);
try {
const fd = new FormData(); fd.append('file', file);
const r = await fetch('/api/admin/branding/favicon', { method: 'POST', credentials: 'include', body: fd });
if (!r.ok) throw new Error(await r.text());
toast.push({ kind: 'ok', icon: '✓', msg: 'Favicon updated.' });
// #37: cache-bust every and any
// so the new icon shows without a hard refresh.
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;
});
window.dispatchEvent(new CustomEvent('branding-updated', { detail: { ts: Date.now() } }));
window.dispatchEvent(new CustomEvent('status-data-refresh-requested'));
} catch(_) {}
} catch (e) {
toast.push({ kind: 'err', icon: '!', msg: `Upload failed: ${e.message}` });
} finally { setBusy(false); }
};
if (!branding) return
;
const accent = branding.accent_color || '#6366f1';
return (
{/* Live preview topbar */}
{ e.currentTarget.style.display = 'none'; }} />
{branding.app_name || (window.appT||(s=>s))('Attendance Bot')}
— {(window.appT||(s=>s))("preview")}
{(window.appT||(s=>s))("Login tagline")}
{ e.currentTarget.style.display='none'; }} />
{(window.appT||(s=>s))("Favicon / logo (PNG, JPG, ICO — max 1 MB)")}
{(window.appT||(s=>s))("Square images work best. Shown in browser tab and sidebar.")}
uploadFavicon(e.target.files[0])} />
fileRef.current?.click()} disabled={busy}>{(window.appT||(s=>s))("Upload")}
{busy ? (window.appT||(s=>s))('Saving…') : <> {(window.appT||(s=>s))('Save branding')}>}
);
}
/* ─── Logs section ─────────────────────────────────────── */
function LogsSection() {
const toast = useToast();
const [rows, setRows] = React.useState([]);
const [loading, setLoading] = React.useState(false);
const [page, setPage] = React.useState(1);
const [total, setTotal] = React.useState(0);
const [filters, setFilters] = React.useState({
q: '', from: '', to: '',
});
const LOG_SIZE = 50;
/* Parse user-agent into a short readable label */
const parseUA = (ua) => {
if (!ua) return '—';
const m = ua.match(/(Chrome|Firefox|Safari|Edge|OPR|Opera)[\/ ]([\d.]+)/i);
const os = ua.match(/\((Windows|Mac OS X|Linux|Android|iOS)[^)]*\)/i);
const browser = m ? `${m[1]} ${m[2].split('.')[0]}` : 'Unknown';
const platform = os ? (os[1] === 'Mac OS X' ? 'macOS' : os[1]) : null;
return platform ? `${browser} / ${platform}` : browser;
};
const load = React.useCallback(async (pg, notify) => {
setLoading(true);
try {
const p = new URLSearchParams();
p.set('limit', String(LOG_SIZE));
p.set('offset', String((pg - 1) * LOG_SIZE));
if (filters.from) p.set('from', filters.from);
if (filters.to) p.set('to', filters.to);
if (filters.q) p.set('q', filters.q);
const r = await fetch(`/api/server/logs?${p}`, { credentials: 'include' });
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
const arr = Array.isArray(data.logs) ? data.logs : (Array.isArray(data) ? data : []);
setRows(arr);
setTotal(typeof data.total === 'number' ? data.total : arr.length);
setPage(pg);
if (notify) toast.push({ msg: 'Refreshed', kind: 'ok', icon: '✓' });
} catch (e) {
toast.push({ msg: `Logs error: ${e.message}`, kind: 'err', icon: '!' });
} finally {
setLoading(false);
}
}, [filters, toast]);
React.useEffect(() => { load(1, false); }, []);
const statusKind = (s) => {
if (s == null) return 'mute';
const str = String(s);
if (str.startsWith('2')) return 'ok';
if (str.startsWith('4')) return 'warn';
if (str.startsWith('5')) return 'err';
return 'mute';
};
return (
{/* Filter toolbar */}
setFilters(f => ({ ...f, q: v }))}
placeholder={(window.appT||(s=>s))("Search IP, path, user agent...")}
style={{ flex: '1 1 200px' }}
/>
setFilters(f => ({ ...f, from: e.target.value }))}
style={{ flex: '0 0 130px', height: 32 }} title={(window.appT||(s=>s))("From date")} />
setFilters(f => ({ ...f, to: e.target.value }))}
style={{ flex: '0 0 130px', height: 32 }} title={(window.appT||(s=>s))("To date")} />
load(1, false)} disabled={loading}>{(window.appT||(s=>s))("Apply")}
load(1, true)} loading={loading} />
{loading && rows.length === 0 && (
)}
{!loading && rows.length === 0 && (
s))("No log entries")} subtitle={(window.appT||(s=>s))("Try widening filters or refreshing.")} />
)}
{rows.length > 0 && (
<>
{(window.appT||(s=>s))("When")} {(window.appT||(s=>s))("Who")} {(window.appT||(s=>s))("IP")} {(window.appT||(s=>s))("Browser")} {(window.appT||(s=>s))("Method")} {(window.appT||(s=>s))("Path")} {(window.appT||(s=>s))("Status")} {(window.appT||(s=>s))("ms")}
{rows.map((row, i) => (
{row.ts ? new Date(row.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
{row.user_display || '—'}
{row.ip || '—'}
{parseUA(row.user_agent)}
{row.method || '—'}
{row.path || '—'}
{row.status != null ? String(row.status) : '—'}
{row.duration_ms != null ? row.duration_ms : '—'}
))}
load(pg, false)} />
>
)}
);
}
/* ─── Main ServerPage ──────────────────────────────────── */
// ─── Bot logic (admin-editable thresholds + keyword lists) ──────────────────
//
// Reorganised for readability:
// 1. "How it works" intro panel up top
// 2. Numeric thresholds split into 4 grouped cards (enforcement / timing /
// wellbeing / activity) instead of one big mash
// 3. Plain-English labels + full-sentence descriptions (no more "min hours/day")
// 4. Bigger, less-muted hint text (fontSize 12, color text-2)
// 5. Each setting shows its current value vs. default with a one-click reset
//
// The set of underlying keys is unchanged so /api/bot-config keeps working.
const _BOT_LOGIC_GROUPS = [
{
id: 'enforcement',
icon: '⚖️',
title: 'Strike enforcement',
blurb: 'How strict the bot is when someone misses a standup or files the wrong format.',
fields: [
{
key: 'strike_cap',
label: 'Strikes before admin alert',
unit: 'strikes',
help: 'After this many strikes in a row, the bot posts an admin report to the standup room and the user gets a "last warning" DM.',
},
{
key: 'absent_minutes_warn',
label: 'First ping if no sign-in (minutes)',
unit: 'min after deadline',
help: 'How long after the daily sign-in deadline before the bot DMs the user "didn\'t see your sign-in yet — everything OK?".',
},
{
key: 'absent_minutes_strike',
label: 'Auto-strike if still no sign-in (minutes)',
unit: 'min after deadline',
help: 'How long after the deadline before the bot records an absence-strike automatically (no manual admin click needed).',
},
],
},
{
id: 'timing',
icon: '⏰',
title: 'Timing & spacing',
blurb: 'Grace windows and cooldowns so the bot doesn\'t feel naggy.',
fields: [
{
key: 'late_buffer_minutes',
label: 'Grace period after start time (minutes)',
unit: 'min',
help: 'A sign-in within this window of the user\'s expected start time is still counted as on-time (✅). Beyond it, the ack switches to the late-emoji ⏰ and a "what happened?" DM goes out.',
},
{
key: 'dm_cooldown_seconds',
label: 'Minimum gap between bot DMs (seconds)',
unit: 's',
help: 'Stops the bot from spamming a user with multiple DMs back-to-back. After any DM, no follow-up DM fires for at least this long.',
},
{
key: 'auto_relisten_seconds',
label: 'Voice "still listening" window (seconds)',
unit: 's',
help: 'After the user finishes one voice question, the mic stays open this long for a follow-up before the bot stops listening.',
},
],
},
{
id: 'wellbeing',
icon: '💪',
title: 'Workload & wellbeing',
blurb: 'When to flag a short day, a long day, or genuine burnout.',
fields: [
{
key: 'min_hours_per_day',
label: 'Minimum expected hours per day',
unit: 'hours',
help: 'A workday shorter than this fraction of the team\'s "normal" hours triggers a soft "short day" tag in the report.',
},
{
key: 'short_day_pct',
label: 'Short-day threshold (% of expected)',
unit: '%',
help: 'A day below this % of the team\'s normal hours is marked as "short". Example: team norm is 8h, threshold 60% → anything under 4h 48m is flagged.',
},
{
key: 'burnout_hours_per_day',
label: 'Burnout-warning cap (hours)',
unit: 'hours',
help: 'A day longer than this triggers a private DM to the user ("seems like a long day — make sure to rest").',
},
],
},
{
id: 'activity',
icon: '🛌',
title: 'Inactivity tracking',
blurb: 'How long a user can be silent before the bot checks in.',
fields: [
{
key: 'inactivity_hours',
label: 'Idle hours before "stuck?" DM',
unit: 'hours',
help: 'If the user signed in but no progress message lands within this many hours, the bot DMs "any blockers? need help?".',
},
],
},
];
const _BOT_LOGIC_KEYWORDS = [
{
key: 'critical_keywords',
label: 'Emergency keywords',
help: 'Words/phrases that will page the on-call team the moment they appear in a standup or DM. Comma-separated.',
placeholder: 'urgent, p0, outage, fire',
},
{
key: 'sick_keywords',
label: 'Sick-day keywords',
help: 'Fuzzy phrases that the bot recognises as "I\'m off sick today" — auto-records a sick PTO block.',
placeholder: 'sick, flu, fever, doctor',
},
{
key: 'pto_keywords',
label: 'Vacation / time-off keywords',
help: 'Fuzzy phrases the bot treats as PTO requests. Triggers the PTO confirmation flow.',
placeholder: 'vacation, ooo, day off, holiday',
},
{
key: 'signin_shortcut_keywords',
label: 'Sign-in shortcuts',
help: 'Short forms accepted as a valid sign-in even without the full template. Use sparingly — too many shortcuts and people stop writing real updates.',
placeholder: 'in, here, signing in, online',
},
{
key: 'wake_phrases',
label: 'AI bot wake phrases',
help: 'Phrases that route a DM to the AI assistant instead of the standup parser.',
placeholder: 'hey bot, ai, ask',
},
];
function _BotLogicGroup({ group, settings, defaults, onChange }) {
return (
{group.icon}
{(window.appT||(s=>s))(group.title)}
{(window.appT||(s=>s))(group.blurb)}
{group.fields.map(f => {
const cur = settings[f.key];
const def = defaults[f.key];
const isCustom = cur !== def && cur != null && cur !== '';
return (
{(window.appT||(s=>s))(f.label)}
onChange(f.key, e.target.value === '' ? '' : Number(e.target.value))}
style={{ flex:1, padding:'6px 10px', borderRadius:6, border:'1px solid var(--line)', fontSize:13 }}
/>
{(window.appT||(s=>s))(f.unit)}
{(window.appT||(s=>s))(f.help)}
{(window.appT||(s=>s))("Default")} {String(def ?? '—')}
{isCustom && (
onChange(f.key, def)}
className="btn-link"
style={{ marginLeft:8, fontSize:11, padding:0, background:'none', border:'none', color:'var(--brand)', cursor:'pointer' }}
>{(window.appT||(s=>s))("reset")}
)}
);
})}
);
}
function BotLogicSection() {
const toast = useToast();
const [data, setData] = React.useState(null);
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
fetch('/api/bot-config', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(setData)
.catch(() => setData({ settings: {}, defaults: {} }));
}, []);
if (!data) return {(window.appT||(s=>s))("Loading bot logic…")}
;
const s = data.settings || {};
const d = data.defaults || {};
const setVal = (k, v) => setData({ ...data, settings: { ...s, [k]: v } });
const save = async () => {
setBusy(true);
try {
const updates = { ...s };
const r = await fetch('/api/bot-config', {
method:'PUT', credentials:'include',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ updates }),
});
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
setData({ settings: j.settings, defaults: d });
toast.push({ kind:'ok', icon:'✓', msg:'Bot logic saved.' });
} catch (e) {
toast.push({ kind:'err', icon:'!', msg:'Save failed: ' + (e.message || e) });
} finally { setBusy(false); }
};
const reset = (k) => setVal(k, d[k]);
const listToText = (v) => Array.isArray(v) ? v.join(', ') : (v || '');
// Count pending diffs across all sections.
const allKeys = [
..._BOT_LOGIC_GROUPS.flatMap(g => g.fields.map(f => f.key)),
..._BOT_LOGIC_KEYWORDS.map(f => f.key),
];
const pending = allKeys.filter(k => {
if (Array.isArray(s[k]) || Array.isArray(d[k])) {
return JSON.stringify(s[k] || []) !== JSON.stringify(d[k] || []);
}
return s[k] !== d[k] && s[k] != null && s[k] !== '';
}).length;
return (
{/* ── How it works intro ─────────────────────────────────────── */}
{(window.appT||(s=>s))("How the bot decides things")}
{(window.appT||(s=>s))("Every Matrix message is parsed by the bot and routed through these rules. Numbers below control")}
{(window.appT||(s=>s))("when")} {(window.appT||(s=>s))("the bot pings, warns, or strikes; keyword lists control")}
{(window.appT||(s=>s))("what")} {(window.appT||(s=>s))("it recognises as sick-day, PTO, or emergency language. Saved changes go live within ~60s — no rebuild required.")}
{/* ── Numeric threshold groups ───────────────────────────────── */}
{_BOT_LOGIC_GROUPS.map(g => (
<_BotLogicGroup key={g.id} group={g} settings={s} defaults={d} onChange={setVal} />
))}
{/* ── Keyword lists ──────────────────────────────────────────── */}
🔤
{(window.appT||(s=>s))("Keyword lists")}
{(window.appT||(s=>s))("Phrases the bot treats as triggers. Comma-separated. Fuzzy-matched (close spellings + word variants count too).")}
{_BOT_LOGIC_KEYWORDS.map(f => {
const cur = s[f.key];
const def = d[f.key];
const isCustom = Array.isArray(cur) && Array.isArray(def) &&
cur.join('|') !== def.join('|');
return (
{(window.appT||(s=>s))(f.label)}
);
})}
{/* ── Save bar ───────────────────────────────────────────────── */}
{busy ? (window.appT||(s=>s))('Saving…') : pending > 0 ? ((window.appLang&&window.appLang()==='ar') ? `حفظ ${pending} تغيير` : `Save ${pending} change${pending === 1 ? '' : 's'}`) : (window.appT||(s=>s))('No changes')}
{(window.appT||(s=>s))("Changes apply within ~60s (config cache TTL). Restart the bot container for instant pickup.")}
);
}
/* IpLearningSection — Phase 3 inline knobs for the per-team IP red-flag
detector. Lists each team and exposes the four threshold columns plus
the master enable flag. Each save goes through PATCH /api/team-settings/
{team_name} which already emits a 90s undo toast. */
function IpLearningSection() {
const EditableCell = window.EditableCell;
const editMode = window.useEditMode ? window.useEditMode() : false;
const [teams, setTeams] = React.useState(null);
const [tick, setTick] = React.useState(0);
const toast = useToast();
React.useEffect(() => {
let alive = true;
fetch('/api/teams', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : [])
.then(rows => { if (alive) setTeams(rows); })
.catch(() => { if (alive) setTeams([]); });
return () => { alive = false; };
}, [tick]);
const reload = () => setTick(t => t + 1);
const patch = async (team_name, field, value) => {
const r = await fetch(`/api/team-settings/${encodeURIComponent(team_name || '')}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ [field]: value }),
});
if (!r.ok) {
const body = await r.json().catch(() => null);
const msg = (body && (body.detail || body.error)) || `HTTP ${r.status}`;
toast.push({ msg: `Save failed: ${msg}`, kind: 'err' });
throw new Error(msg);
}
const data = await r.json().catch(() => ({}));
if (data && data.undo_token) {
const token = data.undo_token;
toast.push({
msg: `Saved ${field} for ${team_name || '(default)'}. Undo?`,
kind: 'ok',
actions: [{
label: 'Undo',
onClick: async () => {
const ur = await fetch(`/api/admin/undo/v2/${token}`, { method: 'POST', credentials:'same-origin' });
if (ur.ok) toast.push({ msg: 'Reverted', kind:'ok' });
else toast.push({ msg: 'Undo failed', kind:'err' });
reload();
},
}],
});
} else {
toast.push({ msg: `Saved ${field}`, kind:'ok' });
}
reload();
};
if (teams == null) return {(window.appT||(s=>s))("Loading…")}
;
if (teams.length === 0) return {(window.appT||(s=>s))("No teams found.")}
;
if (!editMode) {
return (
{(window.appT||(s=>s))("Enable Edit mode (top-right) to inline-edit per-team thresholds. Read-only summary:")}
{(window.appT||(s=>s))("Team")} {(window.appT||(s=>s))("Enabled")} {(window.appT||(s=>s))("Min samples")} {(window.appT||(s=>s))("Match %")} {(window.appT||(s=>s))("Throttle (h)")}
{teams.map(t => (
{t.team_name || (window.appT||(s=>s))('(default)')}
{t.ip_redflag_enabled === false ? '—' : '✓'}
{t.ip_redflag_min_samples ?? 5}
{t.ip_redflag_min_match_pct ?? 60}
{t.ip_redflag_throttle_hours ?? 24}
))}
);
}
return (
{alive ? T('Healthy') : T('Down?')}
{d.pause && {T('PAUSED')} }
{T('Uptime 24h')}: {d.uptime_24h != null ? d.uptime_24h + '%' : '—'}
{T('Open incidents')}: {d.open_incidents != null ? d.open_incidents : '—'}
{T('Logic gate')}: {d.baseline_count != null ? d.baseline_count + ' tests' : '—'}
{['P1','P2','P3','P4','P5','P6','P7'].map(pk => (
))}
run('probe', T('Health probes'))}>
{busy === 'probe' ? T('Queuing…') : T('Run probes now')}
run('logic-check', T('Logic check'))}>
{busy === 'logic-check' ? T('Queuing…') : T('Run logic check')}
run('test-now', T('E2E test'))}>
{busy === 'test-now' ? T('Queuing…') : T('Run E2E test')}
{T('Recent runs')}
{runs.length === 0 &&
{T('No runs yet.')}
}
{runs.map(r => (
{r.status}
{r.command}
{r.result || (r.status === 'running' ? T('running…') : '')}
{(r.completed_at || r.requested_at || '').replace('T', ' ').slice(5, 19)}
))}
);
}
function ServerPage() {
const D = window.STATUS_DATA;
const [health, setHealth] = React.useState({});
const [resources, setResources] = React.useState(null);
const [connectivity, setConn] = React.useState(null);
const [latency, setLatency] = React.useState(null); // from /api/health/detailed connectivity sub-dict
const [runModal, setRunModal] = React.useState(false);
// Promoted to "admin or above" so moderators (Miro) get the live
// Server polling / scheduler view. Superadmins (Hany) also pass.
const isAdmin = (window.isAdminOrAbove ? window.isAdminOrAbove(D.ME) : D.ME?.org_role === 'admin');
const toast = useToast();
/* Poll /api/server-health every 5s for uptime + scheduler jobs */
React.useEffect(() => {
let canc = false;
const load = () => fetch('/api/server-health', { credentials: 'include' })
.then(r => r.ok ? r.json() : {})
.then(j => { if (!canc) setHealth(j); })
.catch(() => {});
load();
const id = setInterval(load, 5000);
return () => { canc = true; clearInterval(id); };
}, []);
/* Fetch host-resources + connectivity + latency every 3s for the
"live" feel — the donuts smoothly animate between samples via the
stroke-dashoffset transition, so the dial looks continuous even
though we sample discretely. (3s is the floor we can hit without
making the host probe rate-limit itself.) */
React.useEffect(() => {
if (!isAdmin) return;
let canc = false;
const load = async () => {
try {
const [resR, conR, detR] = await Promise.all([
fetch('/api/server/host-resources', { credentials: 'include' }),
fetch('/api/server/connectivity', { credentials: 'include' }),
fetch('/api/health/detailed', { credentials: 'include' }),
]);
if (!canc) {
if (resR.ok) setResources(await resR.json());
if (conR.ok) setConn(await conR.json());
if (detR.ok) {
const det = await detR.json();
setLatency(det.connectivity || null);
}
}
} catch (_) {}
};
load();
const id = setInterval(load, 3000);
return () => { canc = true; clearInterval(id); };
}, [isAdmin]);
const us = health.uptime_seconds || health.uptime_s || 0;
const uptime = `${Math.floor(us / 86400)}d ${Math.floor((us % 86400) / 3600)}h ${Math.floor((us % 3600) / 60)}m`;
return (
);
}
// Re-expose so the Providers tab (above) can mount WhatsApp alongside
// Slack + Microsoft Teams without the Server tab having to render it.
window.WhatsAppIntegrationCard = WhatsAppSection;
/* ── MonitoringSection ── */
function MonitoringSection() {
const [probe, setProbe] = React.useState(null);
const [alerts, setAlerts] = React.useState([]);
const [cfg, setCfg] = React.useState({ recipients: [], enabled: true });
const [recipInput, setRecipInput] = React.useState('');
const [saving, setSaving] = React.useState(false);
const toast = useToast();
const load = React.useCallback(async () => {
try {
const [pr, ar, cr] = await Promise.all([
fetch('/api/server/probe/latest', { credentials: 'include' }),
fetch('/api/server/alerts/recent?limit=20', { credentials: 'include' }),
fetch('/api/settings/alerts', { credentials: 'include' }),
]);
if (pr.ok) setProbe(await pr.json());
if (ar.ok) setAlerts(await ar.json());
if (cr.ok) {
const c = await cr.json();
setCfg(c);
setRecipInput((c.recipients || []).join(', '));
}
} catch (_) {}
}, []);
React.useEffect(() => {
load();
const id = setInterval(load, 30000);
return () => clearInterval(id);
}, [load]);
const handleSave = async () => {
setSaving(true);
const recipients = recipInput.split(',').map(s => s.trim()).filter(Boolean);
try {
const r = await fetch('/api/settings/alerts', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients, enabled: cfg.enabled }),
});
if (r.ok) {
toast('Alert settings saved', 'ok');
load();
} else {
toast('Save failed', 'err');
}
} catch (_) { toast('Save failed', 'err'); }
setSaving(false);
};
const probeCols = probe ? [
{ label: (window.appT||(s=>s))('CPU'), val: probe.cpu_pct != null ? `${probe.cpu_pct}%` : '—', warn: probe.cpu_pct > 85 },
{ label: (window.appT||(s=>s))('Memory'), val: probe.mem_pct != null ? `${probe.mem_pct}%` : '—', warn: probe.mem_pct > 85 },
{ label: (window.appT||(s=>s))('Disk'), val: probe.disk_pct != null ? `${probe.disk_pct}%` : '—', warn: probe.disk_pct > 85 },
{ label: (window.appT||(s=>s))('K9 sync'), val: probe.matrix_sync_status || '—', warn: probe.matrix_sync_status !== 'synced' },
{ label: (window.appT||(s=>s))('DB'), val: probe.db_status || '—', warn: probe.db_status !== 'ok' },
{ label: (window.appT||(s=>s))('AI'), val: probe.ai_provider_status || '—', warn: false },
] : [];
return (