/* =================================================================== KabelFlux v5 — Admin slide-over panels Each panel is a self-contained slide-over (matches BackupsPanel pattern) and is opened from the topbar Admin dropdown. =================================================================== */ /* ---------- Shared slide-over chrome ---------- */ const AdminSlideover = ({ title, subtitle, onClose, children, headerExtra }) => (
e.stopPropagation()}>

{title}

{subtitle &&

{subtitle}

}
{headerExtra}
{children}
); /* =================================================================== AUDIT LOG What happened, who did it, when, and what changed. =================================================================== */ const AUDIT_ENTRIES = [ { id:'a1', ts:'2026-05-23 09:14:22', who:'L. Kohli', role:'designer', kind:'edit', target:'Wire W-014', detail:'Gauge 24 AWG → 22 AWG', ip:'10.4.2.118' }, { id:'a2', ts:'2026-05-23 09:11:08', who:'L. Kohli', role:'designer', kind:'create', target:'Wire W-118', detail:'New wire · J1.5 → J3.2 · red', ip:'10.4.2.118' }, { id:'a3', ts:'2026-05-23 08:57:41', who:'system', role:'system', kind:'backup', target:'Project HA0000167', detail:'Auto-snapshot 218 KB · 0.9s', ip:'—' }, { id:'a4', ts:'2026-05-23 08:42:03', who:'R. Bedi', role:'reviewer', kind:'review', target:'BOM rev B', detail:'Approved · "Looks good"', ip:'10.4.2.27' }, { id:'a5', ts:'2026-05-23 08:31:55', who:'L. Kohli', role:'designer', kind:'delete', target:'Wire W-099', detail:'Removed unused jumper', ip:'10.4.2.118' }, { id:'a6', ts:'2026-05-23 08:14:12', who:'L. Kohli', role:'designer', kind:'edit', target:'Connector J3.2', detail:'Backshell EN3155-008 → -012', ip:'10.4.2.118' }, { id:'a7', ts:'2026-05-23 07:58:00', who:'M. Adler', role:'admin', kind:'auth', target:'Sign-in', detail:'OAuth · Ikran Aerospace SSO', ip:'10.4.0.4' }, { id:'a8', ts:'2026-05-22 18:42:17', who:'R. Bedi', role:'reviewer', kind:'edit', target:'Note N-031', detail:'Added 3 lines · cure cycle ref', ip:'10.4.2.27' }, { id:'a9', ts:'2026-05-22 18:11:09', who:'system', role:'system', kind:'export', target:'Production drawing', detail:'PDF · 14 pages · 2.4 MB', ip:'—' }, { id:'a10', ts:'2026-05-22 17:55:30', who:'L. Kohli', role:'designer', kind:'create', target:'Bundle B-04', detail:'Grouped 18 wires · L=482 mm', ip:'10.4.2.118' }, { id:'a11', ts:'2026-05-22 17:30:21', who:'A. Park', role:'viewer', kind:'auth', target:'Sign-in', detail:'Magic link', ip:'72.14.5.211' }, { id:'a12', ts:'2026-05-22 16:00:00', who:'system', role:'system', kind:'backup', target:'Project HA0000167', detail:'Pre-push snapshot 216 KB', ip:'—' }, ]; const AUDIT_KINDS = [ { k:'all', l:'All', tone:null }, { k:'edit', l:'Edits', tone:'info' }, { k:'create', l:'Creates', tone:'success' }, { k:'delete', l:'Deletes', tone:'danger' }, { k:'review', l:'Reviews', tone:'violet' }, { k:'export', l:'Exports', tone:'warning' }, { k:'backup', l:'Backups', tone:null }, { k:'auth', l:'Auth', tone:null }, ]; const AuditLogPanel = ({ onClose }) => { const [filter, setFilter] = useState('all'); const [q, setQ] = useState(''); const filtered = useMemo(() => { let list = AUDIT_ENTRIES; if (filter !== 'all') list = list.filter(e => e.kind === filter); const needle = q.trim().toLowerCase(); if (needle) list = list.filter(e => (e.who + ' ' + e.target + ' ' + e.detail).toLowerCase().includes(needle)); return list; }, [filter, q]); // Group rows by ISO date. const grouped = useMemo(() => { const out = {}; filtered.forEach(e => { const d = e.ts.slice(0, 10); (out[d] = out[d] || []).push(e); }); return out; }, [filtered]); return ( Export CSV}>
setQ(e.target.value)} placeholder="Search by user, target or detail…" aria-label="Search audit log"/>
{AUDIT_KINDS.map(k => ( ))}
{Object.keys(grouped).length === 0 && ( )} {Object.entries(grouped).map(([day, entries]) => (
{day} {entries.length} entries
{entries.map(e => { const tone = AUDIT_KINDS.find(k => k.k === e.kind)?.tone; return (
{e.ts.slice(11)}
{e.kind}
{e.who.split(' ').map(s => s[0]).join('').slice(0,2)}
{e.who}
{e.role}
{e.target}
{e.detail}
{e.ip}
); })}
))}
); }; /* =================================================================== USERS — wired to /api/users Backend roles: admin · designer · viewer · superadmin (last only if current user is superadmin). Scoped to current company unless the caller is superadmin. =================================================================== */ const ROLE_OPTS = ['admin', 'designer', 'viewer']; const UsersPanel = ({ onClose, me, companies = [] }) => { const toast = useToast(); const [q, setQ] = useState(''); const [roleFilter, setRoleFilter] = useState('all'); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); // user row being edited, or {} for new const [confirm, setConfirm] = useState(null); const isSuper = me && me.role === 'superadmin'; const allRoles = isSuper ? [...ROLE_OPTS, 'superadmin'] : ROLE_OPTS; const refresh = useCallback(async () => { try { const list = await kfxListUsers(); setRows(Array.isArray(list) ? list : []); } catch (e) { toast.err('Could not load users', (e && e.message) || 'Unknown error'); } finally { setLoading(false); } }, [toast]); useEffect(() => { refresh(); }, [refresh]); const filtered = useMemo(() => { let list = rows; const needle = q.trim().toLowerCase(); if (needle) list = list.filter(u => ((u.full_name || '') + ' ' + (u.username || '') + ' ' + (u.email || '')).toLowerCase().includes(needle)); if (roleFilter !== 'all') list = list.filter(u => u.role === roleFilter); return list; }, [rows, q, roleFilter]); const counts = useMemo(() => ({ all: rows.length, active: rows.length, admins: rows.filter(u => u.role === 'admin' || u.role === 'superadmin').length, }), [rows]); return ( setEditing({})}>Add user}>
{counts.all}
Members
{counts.admins}
Admins
setQ(e.target.value)} placeholder="Search by name or email…" aria-label="Search users"/>
{['all', ...allRoles].map(r => ( ))}
{loading &&
Loading users…
} {!loading && ( {isSuper && } {filtered.map(u => ( {isSuper && } ))}
Member RoleCompanyJob title
{((u.full_name || u.username || '??')).split(' ').map(s => s[0]).join('').slice(0,2).toUpperCase()}
{u.full_name || u.username}
{u.email || u.username}
{u.role}{u.company_name || '—'}{u.job_title || '—'} setEditing(u)}/> setConfirm({ title: `Remove ${u.full_name || u.username}?`, body: <>This user will be permanently removed., confirmLabel: 'Remove user', danger: true, onConfirm: async () => { try { await kfxDeleteUser(u.uid || u.id); toast.ok('User removed'); await refresh(); } catch (e) { toast.err('Delete failed', (e && e.message) || 'Unknown error'); } }, })}/>
)} {!loading && filtered.length === 0 && }
{editing && ( setEditing(null)} onSaved={async () => { setEditing(null); await refresh(); }}/> )} {confirm && setConfirm(null)}/>}
); }; const UserEditModal = ({ user, roles, isSuper, companies, onClose, onSaved }) => { const toast = useToast(); const isNew = !user.uid && !user.id; const [username, setUsername] = useState(user.username || ''); const [password, setPassword] = useState(''); const [fullName, setFullName] = useState(user.full_name || ''); const [email, setEmail] = useState(user.email || ''); const [role, setRole] = useState(user.role || 'designer'); const [jobTitle, setJobTitle] = useState(user.job_title || ''); const [companyId, setCompanyId] = useState(user.company_id || (companies[0] && companies[0].id) || ''); const [busy, setBusy] = useState(false); const canSave = username.trim() && (isNew ? password.length >= 6 : true); const submit = async () => { setBusy(true); try { if (isNew) { await kfxCreateUser({ username: username.trim(), password, full_name: fullName, email, role, job_title: jobTitle, company_id: isSuper ? companyId : undefined, }); toast.ok('User created', username); } else { const patch = { full_name: fullName, email, role, job_title: jobTitle }; if (isSuper && companyId) patch.company_id = companyId; await kfxUpdateUser(user.uid || user.id, patch); toast.ok('User updated', username); } await onSaved(); } catch (e) { toast.err(isNew ? 'Create failed' : 'Update failed', (e && e.message) || 'Unknown error'); setBusy(false); } }; return ( {} : onClose} footer={ <> Cancel {isNew ? 'Create user' : 'Save changes'} }>
setUsername(e.target.value)} autoFocus={isNew}/>
{isNew && (
setPassword(e.target.value)} placeholder="min 6 characters"/>
)}
setFullName(e.target.value)}/>
setEmail(e.target.value)}/>
setJobTitle(e.target.value)}/>
{isSuper && companies && companies.length > 0 && (
)}
); }; /* =================================================================== COMPANIES Multi-tenant orgs. Switch active workspace, see usage per org. =================================================================== */ const ORG_ROWS = [ { id:'o7', name:'Skyline Composites', domain:'skyline-c.com', plan:'Team', members: 1, projects: 0, owner:'(pending)', status:'pending', created:'2026-05-23', mrr: 0, admins:[{ name:'David Cho', email:'david@skyline-c.com', role:'Owner Admin', signupIp:'72.14.5.42', verified:false }], requestedAt:'2 hours ago' }, { id:'o8', name:'Arrowhead Robotics', domain:'arrowhead.ai', plan:'Enterprise', members: 1, projects: 0, owner:'(pending)', status:'pending', created:'2026-05-23', mrr: 0, admins:[{ name:'Sara Patel', email:'sara@arrowhead.ai', role:'Owner Admin', signupIp:'34.219.18.7', verified:true }], requestedAt:'5 hours ago' }, { id:'o1', name:'Ikran Aerospace', domain:'ikran.aero', plan:'Enterprise', members: 14, projects: 47, owner:'M. Adler', status:'active', created:'2024-03-12', mrr: 320, admins:[{ name:'Maya Adler', email:'maya@ikran.aero', role:'Owner Admin' }, { name:'Laila Kohli', email:'laila@ikran.aero', role:'Billing Admin' }] }, { id:'o2', name:'Boreal Drones', domain:'borealdrones.com', plan:'Team', members: 6, projects: 12, owner:'L. Kohli', status:'active', created:'2025-01-04', mrr: 199, admins:[{ name:'Laila Kohli', email:'laila@borealdrones.com', role:'Owner Admin' }] }, { id:'o3', name:'Cathay Marine Robotics', domain:'cathay-marine.io', plan:'Team', members: 4, projects: 8, owner:'L. Kohli', status:'active', created:'2025-06-21', mrr: 199, admins:[{ name:'Laila Kohli', email:'laila@cathay-marine.io', role:'Owner Admin' }] }, { id:'o4', name:'Sandbox · personal', domain:'kohli.dev', plan:'Free', members: 1, projects: 3, owner:'L. Kohli', status:'active', created:'2023-11-08', mrr: 0, admins:[{ name:'Laila Kohli', email:'laila@kohli.dev', role:'Owner Admin' }] }, { id:'o5', name:'NovaSat Avionics', domain:'novasat.space', plan:'Enterprise', members: 9, projects: 21, owner:'R. Bedi', status:'suspended', created:'2024-09-30', mrr: 0, admins:[{ name:'Reza Bedi', email:'reza@novasat.space', role:'Owner Admin' }] }, { id:'o6', name:'Helix Composites', domain:'helix-c.com', plan:'Team', members: 3, projects: 5, owner:'D. Halmos', status:'trial', created:'2026-05-18', mrr: 0, admins:[{ name:'Dan Halmos', email:'dan@helix-c.com', role:'Owner Admin' }] }, ]; const PLAN_TONE = { Free:'info', Team:'success', Enterprise:'violet' }; const PLAN_OPTS = ['Free', 'Team', 'Enterprise']; const ORG_STATUS_TONE = { active:'success', suspended:'danger', trial:'warning', pending:'info' }; const CompaniesPanel = ({ onClose }) => { const toast = useToast(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); // company being edited, or null const [confirm, setConfirm] = useState(null); const refresh = useCallback(async () => { try { const list = await kfxListCompanies(); // Normalise: backend rows use { id, name, status, plan, ... }; the // panel reads .name/.status/.id/.plan/.created_at directly. setRows(Array.isArray(list) ? list : []); } catch (e) { toast.err('Could not load companies', (e && e.message) || 'Unknown error'); } finally { setLoading(false); } }, [toast]); useEffect(() => { refresh(); }, [refresh]); const stats = useMemo(() => ({ total: rows.length, pending: rows.filter(r => r.status === 'pending').length, active: rows.filter(r => r.status === 'active').length, suspended: rows.filter(r => r.status === 'suspended').length, trial: rows.filter(r => r.status === 'trial').length, }), [rows]); const approveOrg = async (id, name) => { try { await kfxApproveCompany(id); toast.ok('Workspace approved', `${name} is live (14-day trial)`); await refresh(); } catch (e) { toast.err('Approve failed', (e && e.message) || 'Unknown error'); } }; const setStatus = async (id, name, status) => { try { await kfxSetCompanyStatus(id, status); toast.ok('Status updated', `${name} → ${status}`); await refresh(); } catch (e) { toast.err('Update failed', (e && e.message) || 'Unknown error'); } }; /* Save edited company. Super-admin only — backend PUT /api/companies/{cid} allows superadmin to edit any company, tenant-admin only their own. */ const saveEdit = async (draft) => { try { await kfxUpdateCompany(draft.id, { name: draft.name, email_domain: draft.email_domain, plan: draft.plan, signup_email: draft.signup_email, }); toast.ok('Workspace saved', draft.name); setEditing(null); await refresh(); } catch (e) { toast.err('Save failed', (e && e.message) || 'Unknown error'); } }; const filtered = useMemo(() => { let list = rows; if (statusFilter !== 'all') list = list.filter(r => r.status === statusFilter); const needle = q.trim().toLowerCase(); if (needle) list = list.filter(r => ((r.name || '') + ' ' + (r.email_domain || '') + ' ' + (r.signup_email || '')).toLowerCase().includes(needle)); return list; }, [rows, statusFilter, q]); return ( 0 ? `${stats.pending} awaiting approval` : 'all approved'}`} onClose={onClose} headerExtra={ <> {stats.pending > 0 && {stats.pending} pending} setCreateOpen(true)}>Add company }>
0 ? { color: 'var(--warning)' } : undefined}>{stats.pending}
Awaiting approval
{stats.active}
Active
{stats.trial}
In trial
{stats.suspended}
Suspended
setQ(e.target.value)} placeholder="Search by company name, domain or email…" aria-label="Search companies"/>
{[['all','Any status'],['pending','Pending'],['active','Active'],['trial','Trial'],['suspended','Suspended']].map(([k, l]) => ( ))}
{loading &&
Loading companies…
} {!loading && filtered.length === 0 && } {!loading && filtered.length > 0 && ( {filtered.map(o => ( ))}
Company Domain Status Plan Created
{(o.name || '?').split(' ').map(s => s[0]).join('').slice(0,2).toUpperCase()}
{o.name}
{o.signup_email || ''}
{o.email_domain || '—'} {o.status} {o.status === 'pending' ? ( ) : ( )} {(o.created_at || '').slice(0, 10) || '—'} {/* Edit is super-admin's primary action — rename, change domain, switch plan. PUT /api/companies/{cid}. */} setEditing({ id: o.id, name: o.name || '', email_domain: o.email_domain || '', plan: o.plan || '', signup_email: o.signup_email || '', status: o.status, })}/> {o.status === 'pending' && ( approveOrg(o.id, o.name)}> Approve )} {(o.status === 'active' || o.status === 'trial') && ( setConfirm({ title: `Suspend ${o.name}?`, body: <>Members will lose access until you reactivate., confirmLabel: 'Suspend', danger: true, onConfirm: () => setStatus(o.id, o.name, 'suspended'), })}> Suspend )} {o.status === 'suspended' && ( setStatus(o.id, o.name, 'active')}> Reactivate )}
)}
{createOpen && ( setCreateOpen(false)} onCreated={async () => { setCreateOpen(false); await refresh(); }}/> )} {editing && ( setEditing(null)} onSave={saveEdit}/> )} {confirm && setConfirm(null)}/>}
); }; /* Super-admin edit modal — name, domain, plan, signup_email. Status is changed via the row's status dropdown or Suspend/Reactivate buttons, NOT here, to keep the edit modal scoped to identity fields. */ const EditCompanyModal = ({ draft, onChange, onClose, onSave }) => { const setF = (k, v) => onChange({ ...draft, [k]: v }); return ( Cancel onSave(draft)}>Save changes }>
Status changes (Approve / Suspend / Reactivate) use the row buttons in the table — not this form.
); }; const CreateCompanyModal = ({ onClose, onCreated }) => { const toast = useToast(); const [name, setName] = useState(''); const [domain, setDomain] = useState(''); const [busy, setBusy] = useState(false); const canSubmit = name.trim() && domain.trim(); const submit = async () => { setBusy(true); try { await kfxCreateCompany({ name: name.trim(), email_domain: domain.trim().toLowerCase(), }); toast.ok('Company created', name.trim()); await onCreated(); } catch (e) { toast.err('Create failed', (e && e.message) || 'Unknown error'); setBusy(false); } }; return ( {} : onClose} footer={ <> Cancel Create company }>
setName(e.target.value)} autoFocus/>
setDomain(e.target.value)}/>
Used to scope user accounts to this workspace.
); }; const stringToColor = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return `hsl(${Math.abs(h) % 360} 50% 35%)`; }; /* =================================================================== SHARE LINKS — wired to /api/projects/{pid}/shares and /api/share/{token} Scoped to the currently-open project (realPid from app.jsx). =================================================================== */ const ShareLinksPanel = ({ onClose, realPid, projectName }) => { const toast = useToast(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); const [confirm, setConfirm] = useState(null); const refresh = useCallback(async () => { if (!realPid) { setLoading(false); return; } try { const list = await kfxListShareLinks(realPid); setRows(Array.isArray(list) ? list : []); } catch (e) { toast.err('Could not load share links', (e && e.message) || 'Unknown error'); } finally { setLoading(false); } }, [realPid, toast]); useEffect(() => { refresh(); }, [refresh]); if (!realPid) { return (
); } return ( setCreateOpen(true)}>New share link}>
{loading &&
Loading…
} {!loading && rows.length === 0 && ( )} {!loading && rows.length > 0 && (
{rows.map(l => { const url = `${window.location.origin}/api/share/${l.token}`; const status = l.status || 'active'; const isInactive = status !== 'active'; return (
{l.note || '(no note)'} {status}
{url}
Expires
{l.expires_at ? l.expires_at.slice(0, 10) : 'never'}
{ navigator.clipboard?.writeText(url); toast.ok('Link copied'); }}/> {status === 'active' && ( setConfirm({ title: 'Revoke this share link?', body: <>Anyone using this link will lose access immediately. This cannot be undone., confirmLabel: 'Revoke link', danger: true, onConfirm: async () => { try { await kfxRevokeShareLink(l.token); toast.ok('Share link revoked'); await refresh(); } catch (e) { toast.err('Revoke failed', (e && e.message) || 'Unknown error'); } }, })}/> )}
); })}
)}
{createOpen && ( setCreateOpen(false)} onCreated={async () => { setCreateOpen(false); await refresh(); }}/> )} {confirm && setConfirm(null)}/>}
); }; const NewShareLinkModal = ({ realPid, onClose, onCreated }) => { const toast = useToast(); const [expires, setExpires] = useState(''); // YYYY-MM-DD; empty = never const [note, setNote] = useState(''); const [busy, setBusy] = useState(false); const submit = async () => { setBusy(true); try { const r = await kfxCreateShareLink(realPid, { expires_at: expires || null, note }); toast.ok('Share link created', r.url || r.token || ''); await onCreated(); } catch (e) { toast.err('Create failed', (e && e.message) || 'Unknown error'); setBusy(false); } }; return ( {} : onClose} footer={ <> Cancel Create link }>
setExpires(e.target.value)}/>
Leave blank for no expiry.
setNote(e.target.value)} placeholder="e.g. 'Vendor pin-out · J3.2'"/>
); }; /* =================================================================== SYSTEM HEALTH — wired to /api/health, /api/version, /api/admin/backups Backend doesn't expose service-level uptime / incidents / resource usage, so those sections are dropped (read-only — show what we have). =================================================================== */ const STATUS_TONE = { ok:'success', warn:'warning', err:'danger' }; const STATUS_LABEL = { ok:'Operational', warn:'Degraded', err:'Outage' }; const SystemHealthPanel = ({ onClose }) => { const toast = useToast(); const [health, setHealth] = useState(null); // null = loading, {ok:bool} const [version, setVersion] = useState(null); // {version, name} const [latest, setLatest] = useState(null); // newest backup row const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; (async () => { try { const [h, v, backups] = await Promise.all([ kfxGetHealth().catch(() => ({ ok: false })), kfxGetVersion().catch(() => null), kfxListBackups().catch(() => []), ]); if (cancelled) return; setHealth(h); setVersion(v); setLatest(Array.isArray(backups) && backups.length > 0 ? backups[0] : null); } catch (e) { if (!cancelled) toast.err('Could not load health', (e && e.message) || 'Unknown'); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [toast]); const status = !health ? 'warn' : (health.ok ? 'ok' : 'err'); return ( {STATUS_LABEL[status]}}>
{loading &&
Probing backend…
} {!loading && ( <>
Services
API
{version ? `v${version.version}` : '—'}
Status
{health && health.ok ? 'OK' : 'DOWN'}
App
{version ? version.name : '—'}
Database backups
{latest ? latest.label : '—'}
Last snapshot
{latest ? (latest.timestamp || latest.filename) : 'never'}
Latest size
{latest ? `${(latest.size_kb || 0).toFixed ? latest.size_kb.toFixed(1) : latest.size_kb}` : '—'}KB
)}
); }; /* ---------- Styles ---------- */ const ADMIN_STYLE = ` .kf-slideover-wide { max-width: 1080px; width: 92vw; } .kf-slideover-head-actions { display: flex; align-items: center; gap: 8px; } .kf-admin-toolbar { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--line); flex-wrap: wrap; } .kf-admin-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 220px; height: 32px; padding: 0 12px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-2); transition: border-color .12s var(--ease); } .kf-admin-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .kf-admin-search input { flex:1; background: transparent; border: none; outline: none; color: var(--t-1); font-family: inherit; font-size: 12.5px; } .kf-admin-search input::placeholder { color: var(--t-4); } .kf-admin-chips { display: flex; gap: 4px; flex-wrap: wrap; } .kf-admin-chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; background: transparent; border: 1px solid transparent; color: var(--t-2); font-family: inherit; font-size: 11.5px; font-weight: 500; border-radius: var(--r-2); cursor: pointer; transition: background .12s var(--ease), color .12s var(--ease), border-color .12s var(--ease); } .kf-admin-chip:hover:not(.on) { background: var(--bg-3); color: var(--t-1); } .kf-admin-chip.on { background: var(--bg-1); border-color: var(--line-strong); color: var(--t-1); } .kf-admin-chip-n { font-family: var(--f-mono); font-size: 10px; color: var(--t-3); padding: 1px 6px; background: var(--bg-2); border-radius: 999px; } .kf-admin-body { padding: 16px 20px 28px; overflow-y: auto; flex: 1; } .kf-admin-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 16px 20px 6px; } .kf-admin-stat { padding: 12px 14px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3); } .kf-admin-stat-n { font-family: var(--f-mono); font-size: 22px; font-weight: 600; color: var(--t-1); } .kf-admin-stat-l { font-size: 11px; color: var(--t-3); text-transform: uppercase; letter-spacing: .05em; margin-top: 2px; } .kf-admin-section-label { font-family: var(--f-mono); font-size: 10.5px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 10px; } .kf-admin-section-label:first-child { margin-top: 0; } .kf-admin-muted { color: var(--t-3); font-size: 11.5px; } /* Audit log */ .kf-audit-day { margin-bottom: 18px; } .kf-audit-day-head { display: flex; justify-content: space-between; align-items: baseline; padding: 4px 2px 8px; font-size: 11.5px; color: var(--t-3); font-family: var(--f-mono); border-bottom: 1px dashed var(--line); } .kf-audit-list { display: flex; flex-direction: column; } .kf-audit-row { display: grid; grid-template-columns: 80px 80px 200px 1fr 120px; gap: 12px; align-items: center; padding: 10px 6px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; } .kf-audit-row:hover { background: var(--bg-1); } .kf-audit-time { color: var(--t-3); font-size: 11.5px; } .kf-audit-who { display: flex; align-items: center; gap: 8px; min-width: 0; } .kf-audit-avatar { width: 24px; height: 24px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--bg-3); border: 1px solid var(--line); border-radius: 50%; font-size: 9.5px; font-weight: 700; color: var(--t-2); letter-spacing: .02em; text-transform: uppercase; } .kf-audit-who-name { font-size: 12.5px; font-weight: 600; color: var(--t-1); } .kf-audit-who-role { font-size: 10.5px; color: var(--t-3); text-transform: uppercase; letter-spacing: .04em; } .kf-audit-detail { min-width: 0; } .kf-audit-target { font-size: 11.5px; font-weight: 600; color: var(--t-2); } .kf-audit-msg { font-size: 12px; color: var(--t-2); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .kf-audit-ip { font-size: 11px; color: var(--t-3); text-align: right; } /* Users table */ .kf-admin-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; } .kf-admin-table thead th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--t-3); font-weight: 600; padding: 8px 12px; border-bottom: 1px solid var(--line); } .kf-admin-table tbody td { padding: 10px 12px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; } .kf-admin-table tbody tr:hover { background: var(--bg-1); } .kf-admin-who { display: flex; align-items: center; gap: 10px; min-width: 0; } .kf-input-sm { height: 28px; font-size: 12px; padding: 0 8px; } .kf-admin-actions { display: flex; gap: 4px; justify-content: flex-end; align-items: center; } /* Companies */ .kf-org-current { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 18px 20px; background: var(--bg-1); border-bottom: 1px solid var(--line); } .kf-org-current-label { font-family: var(--f-mono); font-size: 10px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; } .kf-org-current-name { font-family: var(--f-display); font-size: 22px; font-weight: 700; color: var(--t-1); margin: 4px 0 6px; letter-spacing: -.3px; } .kf-org-current-meta { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--t-3); flex-wrap: wrap; } .kf-org-list { display: flex; flex-direction: column; gap: 6px; } .kf-org-row { display: grid; grid-template-columns: 36px 1fr auto auto auto auto; gap: 14px; align-items: center; padding: 12px 14px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3); } .kf-org-avatar { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; font-weight: 700; border-radius: var(--r-2); letter-spacing: .04em; } .kf-org-name { font-size: 13.5px; font-weight: 600; color: var(--t-1); } .kf-org-meta { font-size: 11.5px; color: var(--t-3); margin-top: 2px; } .kf-org-stats { display: flex; gap: 16px; font-size: 12px; } .kf-org-stats .num { font-family: var(--f-mono); font-weight: 600; color: var(--t-1); } /* Share links */ .kf-share-list { display: flex; flex-direction: column; gap: 6px; } .kf-share-row { display: grid; grid-template-columns: 36px 1fr 140px 130px 80px; gap: 14px; align-items: center; padding: 12px 14px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3); } .kf-share-row.expired { opacity: .65; } .kf-share-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--r-2); } .kf-share-name { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--t-1); } .kf-share-url { font-size: 11.5px; color: var(--t-3); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .kf-share-stats > div + div { margin-top: 2px; } .kf-share-stats .num { font-family: var(--f-mono); font-weight: 600; color: var(--t-2); } .kf-share-expiry .num { font-family: var(--f-mono); font-weight: 600; color: var(--t-1); } .kf-share-expired-text { color: var(--danger) !important; } .kf-share-actions { display: flex; gap: 4px; justify-content: flex-end; } /* System health */ .kf-sys-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; } .kf-sys-card { padding: 14px 16px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3); color: var(--success); } .kf-sys-card-warn { color: var(--warning); border-color: rgba(245,183,58,.32); } .kf-sys-card-err { color: var(--danger); border-color: rgba(239,78,78,.32); background: rgba(239,78,78,.04); } .kf-sys-card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .kf-sys-card-name { font-size: 13px; font-weight: 600; color: var(--t-1); flex: 1; } .kf-sys-card-v { font-family: var(--f-mono); font-size: 10.5px; color: var(--t-3); } .kf-sys-card-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-bottom: 8px; } .kf-sys-stat-l { font-size: 10px; color: var(--t-3); text-transform: uppercase; letter-spacing: .05em; } .kf-sys-stat-n { font-family: var(--f-mono); font-size: 15px; font-weight: 600; color: var(--t-1); margin-top: 2px; } .kf-sys-stat-n span { font-size: 10px; font-weight: 500; color: var(--t-3); margin-left: 2px; } .kf-sys-stat-region { font-size: 11px; } .kf-sys-spark { width: 100%; height: 28px; opacity: .8; } .kf-sys-note { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11.5px; color: var(--warning); } .kf-incident-list { display: flex; flex-direction: column; } .kf-incident-row { display: flex; align-items: center; gap: 14px; padding: 10px 8px; border-bottom: 1px solid var(--line-soft); } .kf-incident-text { flex: 1; } .kf-incident-title { font-size: 12.5px; font-weight: 600; color: var(--t-1); } .kf-incident-meta { font-family: var(--f-mono); font-size: 11px; color: var(--t-3); margin-top: 2px; } .kf-sys-usage { display: flex; flex-direction: column; gap: 8px; } .kf-sys-usage-row { display: grid; grid-template-columns: 130px 1fr 60px 1fr; gap: 12px; align-items: center; font-size: 12px; } .kf-sys-usage-l { color: var(--t-2); font-weight: 500; } .kf-sys-usage-bar { height: 6px; background: var(--bg-3); border-radius: 999px; overflow: hidden; } .kf-sys-usage-bar > div { height: 100%; border-radius: 999px; } .kf-sys-usage-pct { font-family: var(--f-mono); font-weight: 600; color: var(--t-1); text-align: right; } /* Workspace settings panel layout */ .kf-ws-body { display: grid; grid-template-columns: 200px 1fr; flex: 1; overflow: hidden; } .kf-ws-nav { padding: 14px 8px; border-right: 1px solid var(--line); background: var(--bg-1); display: flex; flex-direction: column; gap: 2px; overflow-y: auto; } .kf-ws-panel { padding: 18px 20px 28px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; } /* In a flex column, .card (overflow:hidden) was getting squeezed below its content height, cropping bodies and showing only the head row. Block shrinking so each card expands to its natural height. */ .kf-ws-panel > .card, .kf-settings-panel > .card { flex-shrink: 0; } /* Workspace + Project Settings cards use a roomier, more readable header. Layout: h3 on top-left, trailing action on top-right, kf-card-sub on a full-width line beneath both. Explicit row spacing so the action button never crushes against the title. */ .kf-ws-panel .card-head, .kf-settings .card-head { display: flex; flex-wrap: wrap; align-items: center; padding: 18px 20px 14px; gap: 10px 16px; } .kf-ws-panel .card-head > h3, .kf-settings .card-head > h3 { flex: 1 1 auto; font-family: var(--f-display); font-size: 16px; font-weight: 600; text-transform: none; letter-spacing: -.01em; color: var(--t-1); margin: 0; } /* Trailing action (button/chip) on the top row, hard right. */ .kf-ws-panel .card-head > .kf-btn, .kf-ws-panel .card-head > .kf-chip, .kf-ws-panel .card-head > .kf-iconbtn, .kf-settings .card-head > .kf-btn, .kf-settings .card-head > .kf-chip, .kf-settings .card-head > .kf-iconbtn { margin-left: auto; flex-shrink: 0; } /* Subtitle drops to its own row, separated from the title row by the row gap (10px). No negative margin hack. */ .kf-ws-panel .card-head > .kf-card-sub, .kf-settings .card-head > .kf-card-sub { flex: 0 0 100%; margin: 0; font-size: 12.5px; color: var(--t-3); line-height: 1.5; max-width: 620px; font-weight: 400; } .kf-ws-panel .card-body, .kf-settings .card-body { padding: 14px 20px 18px; } .kf-ws-plan-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px dashed var(--line); } .kf-ws-plan-row:last-child { border-bottom: none; } .kf-ws-plan-name { font-size: 15px; font-weight: 600; color: var(--t-1); } .kf-ws-plan-meta { font-size: 12px; color: var(--t-3); margin-top: 4px; line-height: 1.5; max-width: 420px; } .kf-ws-plan-price .num { font-family: var(--f-mono); font-size: 22px; font-weight: 600; color: var(--t-1); } .kf-ws-logo-drop { display: flex; align-items: center; gap: 14px; padding: 14px 16px; background: var(--bg-1); border: 1px dashed var(--line-strong); border-radius: var(--r-3); } .kf-ws-logo-drop > div { flex: 1; font-size: 12px; } /* — Workspace usage cards (Overview section) — */ .kf-ws-usage { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; } .kf-ws-stat-card { padding: 14px 16px 12px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r-3); display: flex; flex-direction: column; gap: 6px; transition: border-color .14s var(--ease), background .14s var(--ease); } .kf-ws-stat-card:hover { background: var(--bg-3); border-color: var(--line-strong); } .kf-ws-stat-top { display: flex; align-items: center; gap: 8px; } .kf-ws-stat-icon { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 6px; flex-shrink: 0; } .kf-ws-stat-icon-info { background: var(--info-soft); color: var(--info); } .kf-ws-stat-icon-success { background: var(--success-soft); color: var(--success); } .kf-ws-stat-icon-warning { background: var(--warning-soft); color: var(--warning); } .kf-ws-stat-icon-violet { background: rgba(167,139,250,.16); color: var(--violet); } .kf-ws-stat-icon svg { stroke: currentColor; } .kf-ws-stat-label { font-size: 11px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .06em; } .kf-ws-stat-delta { margin-left: auto; font-family: var(--f-mono); font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 4px; } .kf-ws-stat-delta-success { background: var(--success-soft); color: var(--success); } .kf-ws-stat-delta-danger { background: rgba(239,78,78,.16); color: var(--danger); } .kf-ws-stat-num { display: flex; align-items: baseline; gap: 3px; font-family: var(--f-mono); font-size: 28px; font-weight: 700; color: var(--t-1); letter-spacing: -.5px; line-height: 1; } .kf-ws-stat-unit { font-size: 14px; font-weight: 600; color: var(--t-3); margin-left: 2px; } .kf-ws-stat-of { font-size: 11.5px; color: var(--t-3); margin-top: 2px; } .kf-ws-stat-bar { height: 4px; border-radius: 999px; background: var(--bg-1); overflow: hidden; margin-top: 4px; } .kf-ws-stat-bar-fill { height: 100%; border-radius: 999px; transition: width .25s var(--ease); } .kf-ws-stat-bar-fill-info { background: var(--info); } .kf-ws-stat-bar-fill-success { background: var(--success); } .kf-ws-stat-bar-fill-warning { background: var(--warning); } .kf-ws-stat-bar-fill-violet { background: var(--violet); } .kf-ws-stat-note { font-size: 10.5px; color: var(--t-3); font-style: italic; margin-top: 2px; } /* Company list (super-admin) */ .kf-co-list { display: flex; flex-direction: column; gap: 8px; } .kf-co-card { background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3); overflow: hidden; transition: border-color .12s var(--ease); } .kf-co-card.on { border-color: var(--line-strong); } .kf-co-row { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; cursor: pointer; transition: background .12s var(--ease); } .kf-co-row:hover { background: var(--bg-2); } .kf-co-row > .kf-org-avatar { flex-shrink: 0; } .kf-co-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } .kf-co-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .kf-co-name { font-size: 14px; font-weight: 600; color: var(--t-1); line-height: 1.2; } .kf-co-meta { font-size: 11.5px; color: var(--t-3); display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } .kf-co-meta .num { font-family: var(--f-mono); } .kf-co-stats { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 11.5px; color: var(--t-3); margin-top: 2px; } .kf-co-stats .num { font-family: var(--f-mono); font-weight: 600; color: var(--t-2); } .kf-co-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .kf-co-plan-select { width: 110px; height: 30px; padding: 0 8px; font-size: 12px; } .kf-co-expand { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid transparent; border-radius: var(--r-2); cursor: pointer; } .kf-co-expand:hover { background: var(--bg-3); border-color: var(--line); } @media (max-width: 560px) { .kf-co-right { flex-direction: column; align-items: flex-end; gap: 4px; } .kf-co-plan-select { width: 100px; } } .kf-co-detail { padding: 14px 16px 16px; background: var(--bg-2); border-top: 1px solid var(--line); } .kf-co-detail-head { display: flex; align-items: center; justify-content: space-between; font-family: var(--f-mono); font-size: 10.5px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px; } .kf-co-empty { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--warning-soft); border: 1px solid rgba(245,183,58,.32); border-radius: var(--r-2); font-size: 12px; color: var(--warning); } .kf-co-admin-list { display: flex; flex-direction: column; gap: 4px; } .kf-co-admin-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--bg-1); border: 1px solid var(--line-soft); border-radius: var(--r-2); } .kf-co-admin-text { flex: 1; min-width: 0; } .kf-co-actions { display: flex; align-items: center; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); } @media (max-width: 720px) { .kf-co-row { gap: 10px; padding: 10px 12px; } } /* — Invoice list (replaces narrow table; flex rows that always show actions) — */ .kf-inv-list { display: flex; flex-direction: column; } .kf-inv-row { display: flex; align-items: center; gap: 14px; padding: 12px 18px; border-bottom: 1px solid var(--line-soft); } .kf-inv-row:last-child { border-bottom: none; } .kf-inv-row:hover { background: var(--bg-1); } .kf-inv-main { flex: 1; min-width: 0; } .kf-inv-id { font-family: var(--f-mono); font-size: 12.5px; font-weight: 600; color: var(--t-1); } .kf-inv-period { font-size: 11px; color: var(--t-3); margin-top: 2px; } .kf-inv-amount { font-family: var(--f-mono); font-size: 13px; font-weight: 700; color: var(--t-1); flex-shrink: 0; } .kf-inv-status { flex-shrink: 0; } .kf-inv-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; margin-left: auto; } /* At narrow widths, wrap status + actions below the main info so they still fit, instead of getting cropped. */ @media (max-width: 560px) { .kf-inv-row { flex-wrap: wrap; row-gap: 8px; } .kf-inv-main { flex: 1 1 100%; } .kf-inv-amount { order: 2; } .kf-inv-status { order: 3; margin-left: auto; } .kf-inv-actions { order: 4; flex-basis: 100%; justify-content: flex-end; } } /* — Billing summary (simple) — */ .kf-bill-card { padding: 18px 20px; display: flex; flex-direction: column; gap: 16px; } .kf-bill-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .kf-bill-eyebrow { font-family: var(--f-mono); font-size: 10.5px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; } .kf-bill-tier-row { display: flex; align-items: center; gap: 10px; } .kf-bill-tier { font-family: var(--f-display); font-size: 22px; font-weight: 700; color: var(--t-1); margin: 0; letter-spacing: -.3px; } .kf-bill-price-block { display: flex; align-items: baseline; gap: 2px; } .kf-bill-price-currency { font-family: var(--f-mono); font-size: 14px; color: var(--t-3); font-weight: 600; } .kf-bill-price-amount { font-family: var(--f-mono); font-size: 30px; font-weight: 700; color: var(--t-1); letter-spacing: -.5px; line-height: 1; } .kf-bill-price-cycle { font-size: 12.5px; color: var(--t-3); margin-left: 4px; } .kf-bill-meta { display: flex; flex-direction: column; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } .kf-bill-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; font-size: 12.5px; border-bottom: 1px dashed var(--line-soft); } .kf-bill-meta-row:last-child { border-bottom: none; } .kf-bill-meta-row > span:first-child { color: var(--t-3); } .kf-bill-meta-row > span:last-child { color: var(--t-1); display: flex; align-items: center; gap: 6px; font-weight: 500; } .kf-bill-meta-row .num { font-family: var(--f-mono); } .kf-bill-meta-row b.num { font-weight: 700; } .kf-bill-card-badge { font-family: var(--f-mono); font-size: 9px; font-weight: 800; color: #fff; padding: 2px 5px; border-radius: 3px; background: linear-gradient(135deg, #1a1f71, #2a40b5); letter-spacing: .04em; } .kf-bill-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .kf-bill-foot-hint { font-size: 12px; color: var(--t-3); max-width: 360px; line-height: 1.45; } .kf-bill-tip { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; background: var(--bg-1); border: 1px dashed var(--line); border-radius: var(--r-2); font-size: 12px; color: var(--t-3); line-height: 1.5; } .kf-bill-tip a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .kf-bill-tip a:hover { color: var(--accent); text-decoration-thickness: 2px; } .kf-bill-hero { background: linear-gradient(135deg, var(--bg-3) 0%, var(--bg-2) 100%); border: 1px solid var(--line-strong); border-radius: var(--r-3); padding: 22px 24px 20px; display: flex; flex-direction: column; gap: 18px; position: relative; overflow: hidden; } .kf-bill-hero::before { /* subtle radial accent so the hero feels distinct from regular cards */ content: ""; position: absolute; right: -120px; top: -120px; width: 320px; height: 320px; background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%); pointer-events: none; } .kf-bill-hero > * { position: relative; } .kf-bill-hero-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; } .kf-bill-eyebrow { font-family: var(--f-mono); font-size: 10.5px; font-weight: 600; color: var(--t-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; } .kf-bill-tier-row { display: flex; align-items: center; gap: 10px; } .kf-bill-tier { font-family: var(--f-display); font-size: 28px; font-weight: 700; color: var(--t-1); margin: 0; letter-spacing: -.4px; } .kf-bill-blurb { font-size: 13px; color: var(--t-2); margin-top: 6px; max-width: 460px; line-height: 1.5; } .kf-bill-cycle-toggle { display: inline-flex; padding: 3px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-3); } .kf-bill-cycle-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; background: transparent; border: none; color: var(--t-2); font-family: inherit; font-size: 12.5px; font-weight: 600; border-radius: var(--r-2); cursor: pointer; transition: background .12s var(--ease), color .12s var(--ease); } .kf-bill-cycle-btn:hover { color: var(--t-1); } .kf-bill-cycle-btn.on { background: var(--bg-3); color: var(--t-1); box-shadow: 0 1px 2px rgba(0,0,0,.18); } .kf-bill-hero-mid { display: flex; align-items: baseline; gap: 14px; padding: 4px 0; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); } .kf-bill-price-block { display: flex; align-items: baseline; gap: 4px; } .kf-bill-price-currency { font-family: var(--f-mono); font-size: 18px; color: var(--t-3); font-weight: 600; } .kf-bill-price-amount { font-family: var(--f-mono); font-size: 42px; font-weight: 700; color: var(--t-1); letter-spacing: -1px; line-height: 1; } .kf-bill-price-cycle { font-size: 14px; color: var(--t-3); margin-left: 6px; } .kf-bill-save { font-size: 12px; color: var(--success); font-weight: 600; } .kf-bill-hero-bottom { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .kf-bill-next-amt { font-size: 14px; font-weight: 600; color: var(--t-1); margin-top: 2px; } .kf-bill-next-amt .num { font-family: var(--f-mono); font-weight: 700; color: var(--t-1); } .kf-bill-next-method { font-size: 11.5px; color: var(--t-3); margin-top: 4px; } .kf-bill-next-method .num { font-family: var(--f-mono); color: var(--t-2); } .kf-bill-actions { display: flex; gap: 8px; flex-wrap: wrap; } .kf-bill-tiers { display: flex; gap: 6px; padding-top: 14px; border-top: 1px dashed var(--line); flex-wrap: wrap; } .kf-bill-tier-pill { flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 10px 14px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--r-2); color: var(--t-2); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; text-align: left; transition: background .12s var(--ease), border-color .12s var(--ease); } .kf-bill-tier-pill .num { font-family: var(--f-mono); font-size: 13px; font-weight: 700; color: var(--t-1); } .kf-bill-tier-pill:hover { background: var(--bg-3); border-color: var(--line-strong); } .kf-bill-tier-pill.on { border-color: var(--accent); background: var(--bg-3); color: var(--t-1); box-shadow: inset 0 0 0 1px var(--accent); } /* — Payment methods — */ .kf-pay-row { display: flex; align-items: center; gap: 14px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); } .kf-pay-row:last-child { border-bottom: none; } .kf-pay-row-default { background: var(--accent-soft); margin: 0 -10px; padding: 12px 10px; border-radius: var(--r-2); border-bottom: 1px solid transparent; } .kf-pay-brand { width: 48px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; font-family: var(--f-mono); font-size: 10px; font-weight: 800; letter-spacing: .04em; color: #fff; flex-shrink: 0; } .kf-pay-brand-visa { background: linear-gradient(135deg, #1a1f71, #2a40b5); } .kf-pay-brand-mc { background: linear-gradient(135deg, #eb001b, #f79e1b); } .kf-pay-brand-bank { background: linear-gradient(135deg, #4a5568, #2d3748); } .kf-pay-text { flex: 1; min-width: 0; } .kf-pay-name { font-size: 13px; font-weight: 600; color: var(--t-1); } .kf-pay-name .num { font-family: var(--f-mono); } .kf-pay-meta { font-size: 11.5px; color: var(--t-3); margin-top: 3px; font-family: var(--f-mono); } /* — Invoice table — */ .kf-invoice-table td { padding: 12px 16px; } .kf-invoice-table .kf-btn-primary { padding: 4px 12px; height: 28px; font-size: 11.5px; } @media (max-width: 720px) { .kf-slideover-wide { width: 100vw; } .kf-admin-stats { grid-template-columns: 1fr 1fr; } .kf-audit-row { grid-template-columns: 70px 70px 1fr; } .kf-audit-row > :nth-child(4), .kf-audit-row > :nth-child(5) { grid-column: 1 / -1; } .kf-org-row { grid-template-columns: 36px 1fr auto; } .kf-org-row > :nth-child(n+4) { grid-column: 2 / -1; } .kf-share-row { grid-template-columns: 36px 1fr; } .kf-share-row > :nth-child(n+3) { grid-column: 1 / -1; } .kf-ws-body { grid-template-columns: 1fr; } .kf-ws-nav { border-right: none; border-bottom: 1px solid var(--line); flex-direction: row; overflow-x: auto; } } `; document.head.insertAdjacentHTML('beforeend', ``); /* =================================================================== WORKSPACE SETTINGS Company / org-level configuration for the ACTIVE workspace — billing, plan, branding, security, defaults. Distinct from "Companies" (the cross-tenant browser, which lives under System). =================================================================== */ const WORKSPACE = { name: 'Ikran Aerospace', legalName: 'Ikran Aerospace Ltd.', domain: 'ikran.aero', vat: 'GB 482 3917 11', address: 'Bay 6, Filton Aerodrome, Bristol BS34 7QW, UK', plan: 'Enterprise', seats: { used: 14, total: 25 }, storage: { used: 4.2, total: 25 }, // GB renews: '2027-01-04', billingEmail:'billing@ikran.aero', sso: 'Okta · auto-provisioned', defaultRole: 'Designer', }; /* ----- Billing summary card ----- Deliberately minimal: a single read-only summary card with the plan, next charge and payment method, plus a primary "Manage billing" button that hands off to the billing portal on the website. Tier comparison, invoice archive and dunning all live there. */ const BillingPlanCard = () => { const toast = useToast(); const tier = 'Enterprise'; const price = 320; const cycle = 'monthly'; const nextDate = 'Jun 1, 2026'; return (
Current plan

{tier}

Active
$ {price} / {cycle === 'monthly' ? 'mo' : 'yr'}
Next charge ${price}.00 · {nextDate}
Payment method VISA ending 4242
Billing email billing@ikran.aero
Plans, invoices and payment methods are managed on the billing portal. toast.ok('Opening billing portal', 'kabelflux.com/billing')}> Manage billing
); }; const WorkspaceSettingsPanel = ({ onClose, me }) => { const toast = useToast(); const [section, setSection] = useState('overview'); const [company, setCompany] = useState(null); // raw row from /api/companies const [edits, setEdits] = useState({}); // pending field edits const [saving, setSaving] = useState(false); // Load the current company on mount. /api/companies returns one row for // admin (current company) or all rows for superadmin. useEffect(() => { (async () => { try { const list = await kfxFetch('/companies'); const myCid = me && me.company_id; const row = Array.isArray(list) ? (list.find(c => c && c.id === myCid) || list[0]) : null; if (row) { setCompany(row); setEdits({ name: row.name || '', email_domain: row.email_domain || '', address: row.address || '', phone: row.phone || '', website: row.website || '', }); } } catch (e) { toast.err('Could not load workspace', (e && e.message) || 'Unknown'); } })(); }, [me, toast]); const saveCompany = async () => { if (!company) return; setSaving(true); try { await kfxFetch('/companies/' + company.id, { method: 'PUT', body: { ...company, ...edits }, }); toast.ok('Workspace saved'); setCompany({ ...company, ...edits }); } catch (e) { toast.err('Save failed', (e && e.message) || 'Unknown'); } finally { setSaving(false); } }; const sections = [ { k:'overview', l:'Overview', icon:'folder' }, { k:'billing', l:'Billing & plan', icon:'bom' }, { k:'branding', l:'Branding', icon:'eye' }, { k:'security', l:'Security & SSO', icon:'check' }, { k:'defaults', l:'Workspace defaults', icon:'settings' }, ]; const NOT_YET = 'Backend endpoint not yet implemented (Behemoth Step 6).'; return (
{section === 'overview' && ( <>

Company details

Save
setEdits(s => ({ ...s, name: e.target.value }))}/>
setEdits(s => ({ ...s, email_domain: e.target.value }))}/>
setEdits(s => ({ ...s, website: e.target.value }))}/>
setEdits(s => ({ ...s, phone: e.target.value }))}/>