navigate('/admin/candidates')}
/>
- {/* Sticky tab bar. Sits below the app header, so both stay visible while a
- long profile scrolls. Underline rather than pills: this is the page's
- second level of navigation and should not compete with the first. */}
-
-
- {TABS.map((t) => {
- const active = t.value === tab;
- return (
- setTab(t.value)}
- className={cn(
- 'relative whitespace-nowrap px-3 py-3 text-body-sm font-medium transition-colors duration-base',
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50',
- active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
- )}
- >
- {t.label}
- {active && (
-
- )}
-
- );
- })}
-
-
-
-
-
- {tab === 'overview' && (
- <>
-
-
-
-
-
- >
- )}
-
- {tab === 'match' && (
- <>
-
-
-
- >
- )}
-
- {tab === 'reputation' &&
}
- {tab === 'experience' && (
-
+
+
+
+
+
+
+
- )}
- {tab === 'endorsements' &&
}
- {tab === 'activity' &&
}
+
+
+
+
+
+
+ setMessageOpen(true)}
+ onHire={hireCandidate}
+ onDecline={() => setDeclineOpen(true)}
+ onScreen={runScreening}
+ screening={screen.isPending}
+ busy={updateApp.isPending || hire.isPending}
+ />
+
+
+
+ {scheduleOpen && (
+
setScheduleOpen(false)} application={candidate} />
+ )}
+ {aiInterviewOpen && (
+ setAiInterviewOpen(false)}
+ application={candidate}
+ job={job}
+ />
+ )}
+ {messageOpen && (
+ setMessageOpen(false)} application={candidate} />
+ )}
+
);
}
diff --git a/src/pages/admin/NavRow.jsx b/src/pages/admin/NavRow.jsx
new file mode 100644
index 0000000..bb7f5d8
--- /dev/null
+++ b/src/pages/admin/NavRow.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { ChevronRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Surface } from '@/components/ds';
+import { SectionTitle } from '@/pages/admin/_shell';
+
+/**
+ * The entry-point row, and the group it lives in.
+ *
+ * Settings and Workspace & Skills are two different subjects, but they are the
+ * same *kind* of page: a short list of destinations, each saying what it manages
+ * and how much of it there is. Sharing the furniture is what stops the two from
+ * drifting into different-looking navigation for the same job — and it is the
+ * reason a row added to either one needs no styling decisions.
+ *
+ * `meta` is always a real figure counted from real records. A row with nothing
+ * to count simply omits it rather than showing a zero that means "not measured".
+ */
+
+/** One destination. The whole row is the target, so there is no small link. */
+export function NavRow({ icon: Icon, title, description, meta = null, to, action = null }) {
+ return (
+
+
+
+
+
+
+ {title}
+ {description}
+ {/* Below `sm` the count sits under the description rather than being
+ dropped: on a phone it is the same fact, just in the only place
+ there is room for it. */}
+ {meta && {meta} }
+
+
+ {meta && {meta} }
+
+ {action && (
+ {action}
+ )}
+
+
+
+ );
+}
+
+/** One group of rows. Same container and spacing in every group, on both pages. */
+export function NavGroup({ id, title, children, className }) {
+ return (
+
+ );
+}
diff --git a/src/pages/admin/Positions.jsx b/src/pages/admin/Positions.jsx
index 805185f..af21eb0 100644
--- a/src/pages/admin/Positions.jsx
+++ b/src/pages/admin/Positions.jsx
@@ -12,6 +12,12 @@ import {
useApplications, useAssignments, useCourses, useJobPostings, useStaff, useWorkerProfiles,
} from '@/lib/krowHooks';
import { workforceStatusFor } from '@/lib/workforce';
+import {
+ customRequirementsText, englishLabel, hasRequirements, roleMetaLine,
+} from '@/lib/positionModel';
+import {
+ PositionCustomRequirements, PositionOverview, PositionRequirements,
+} from '@/components/krow/PositionDetails';
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
@@ -324,6 +330,9 @@ function PositionsTable({ rows, onOpen }) {
className="cursor-pointer border-b border-border last:border-0 transition-colors hover:bg-surface-subtle"
>
+ {p.company && (
+ {p.company}
+ )}
{p.title}
{p.role_category}
@@ -375,6 +384,9 @@ function PositionsTable({ rows, onOpen }) {
>
+ {p.company && (
+
{p.company}
+ )}
{p.title}
{p.role_category}
@@ -399,6 +411,23 @@ function PositionsTable({ rows, onOpen }) {
);
}
+/**
+ * "4 yrs experience · Fluent English" — the two candidate criteria, or nothing.
+ *
+ * Abbreviated deliberately: this is a supporting line under the terms, not a
+ * field list. A position that states neither returns null and the line is not
+ * drawn, rather than a card carrying a label with no answer.
+ */
+function candidateCriteria(position) {
+ const years = Number(position.min_experience_years);
+ const english = englishLabel(position.english_required);
+
+ return [
+ Number.isFinite(years) && years > 0 ? `${years} yrs experience` : null,
+ english ? `${english} English` : null,
+ ].filter(Boolean).join(' · ') || null;
+}
+
/** One position. The whole card is the target, so there is no hunting for a link. */
function PositionCard({ position, onOpen }) {
const p = position;
@@ -424,7 +453,16 @@ function PositionCard({ position, onOpen }) {
+ {/* Client above title: on a staffing console the company a role is being
+ filled for is part of its identity, and it is read from the position
+ record — a position with no client stated simply leads with its
+ title, as it always did. */}
+ {p.company && (
+
+ {p.company}
+
+ )}
{p.title}
@@ -448,6 +486,14 @@ function PositionCard({ position, onOpen }) {
+ {/* One quiet line of candidate criteria — the two that help decide whether
+ a role is worth opening. Everything else the position states belongs in
+ the detail view; a card that lists requirements stops being scannable,
+ which is the only thing a card is for. */}
+ {candidateCriteria(p) && (
+
{candidateCriteria(p)}
+ )}
+
@@ -480,7 +526,7 @@ function PositionCard({ position, onOpen }) {
}
/** The full record, over the grid rather than instead of it. */
-function PositionDrawer({ position, onClose, onViewCandidates }) {
+function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition }) {
if (!position) return null;
const p = position;
const s = p.stats;
@@ -511,12 +557,22 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
!open && onClose()}
- title={p.title}
- description={`${p.role_category || 'Uncategorized'} · ${p.location || 'Location not set'} · ${payLabel(p)}`}
- size="lg"
+ /* Client → job title → terms. The company leads because it is who the
+ hire is for; it never replaces the stored job title. */
+ title={p.company ? (
+
+ {p.company}
+ {p.title}
+
+ ) : p.title}
+ description={roleMetaLine(p) || 'No terms set on this position yet'}
+ size="xl"
footer={
<>
Close
+ {/* The drawer holds the record; the page holds the record plus every
+ applicant on it. This is the way through to the second. */}
+ onOpenPosition(p)}>Open position
onViewCandidates(p)}>
View {s.applied} candidate{s.applied === 1 ? '' : 's'}
@@ -539,6 +595,31 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
+ {/* The position as it was specified. Every field Create Position asks
+ for is read back here — the three sections below mirror the three
+ groups of that form, in the same order. */}
+
+
+ {/* Only what this role actually asks for. A position that states no
+ requirements has no Requirements section — an empty heading is a
+ demand nobody made. */}
+ {hasRequirements(p) && (
+
+ )}
+
+ {customRequirementsText(p) && (
+
+ )}
+
{/* Hiring status */}
@@ -579,6 +660,9 @@ function PositionDrawer({ position, onClose, onViewCandidates }) {
+ {/* Two short lists side by side. The extra drawer width is spent on
+ information density rather than on longer lines of the same stack. */}
+
{/* Top candidates */}
+
@@ -718,7 +803,7 @@ export default function AdminPositions() {
const q = search.trim().toLowerCase();
return rows
.filter((p) => {
- if (q && !`${p.title} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
+ if (q && !`${p.title} ${p.company || ''} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
if (status !== 'all' && p.status !== status) return false;
if (department !== 'all' && p.role_category !== department) return false;
if (location !== 'all' && p.location !== location) return false;
@@ -851,6 +936,7 @@ export default function AdminPositions() {
setSelected(null)}
+ onOpenPosition={(p) => navigate(`/admin/positions/${p.id}`)}
onViewCandidates={(p) => {
setSelected(null);
if (p.stats.applied) navigate(`/admin/candidates?search=${encodeURIComponent(p.title)}`);
diff --git a/src/pages/admin/Profile.jsx b/src/pages/admin/Profile.jsx
index 6c9d8c1..1ea7387 100644
--- a/src/pages/admin/Profile.jsx
+++ b/src/pages/admin/Profile.jsx
@@ -1,42 +1,26 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
- Building2, Check, KeyRound, LogOut, Mail, Pencil, Shield, ShieldCheck, Smartphone,
+ Building2, Check, KeyRound, LogOut, Pencil, Settings as SettingsIcon, Shield,
+ ShieldCheck, Smartphone, CheckCircle2, Lock, Eye, Sparkles, Clock, Laptop,
+ Activity, Zap, AlertCircle, Key
} from 'lucide-react';
-import {
- Avatar, Badge, Button, Field, Input, Modal, Surface, Toggle, toast,
+import { Button, Field, Input, Modal, Toggle, toast,
} from '@/components/ds';
import { base44 } from '@/api/base44Client';
-import { useAssistantPanel } from '@/components/ai-assistant';
-import {
- useCourses, useCurrentUser, usePreferences, useUpdatePreferences,
- useUpdateProfile, useUserActivity, useWorkerProfiles,
-} from '@/lib/krowHooks';
-import { usePageSkills } from '@/lib/skills/usePageSkills';
-import { profileForEmail } from '@/lib/skillGraph';
-import { SkillProgressCard, SkillProgressDetail } from '@/components/forge';
-import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
+import { useCurrentUser, useUpdateProfile, useUserActivity } from '@/lib/krowHooks';
+import { AdminPage } from '@/pages/admin/_shell';
import { endAdminSession } from '@/pages/admin/session';
-import { LEVEL_TONE, PERMISSIONS } from '@/pages/admin/permissions';
-import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
-import { AddSkillDialog } from '@/components/skills/AddSkillDialog';
-import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
+import { PERMISSIONS } from '@/pages/admin/permissions';
/**
- * Admin Profile — an enterprise account page.
- *
- * Two columns rather than one long form: identity and permissions are reference
- * information you read, while security and activity are things you act on. A
- * single stack would bury the actions below the reading.
+ * Admin Profile — Premium enterprise account & security control center.
*/
export default function AdminProfile() {
const navigate = useNavigate();
const { data: user } = useCurrentUser();
const updateProfile = useUpdateProfile();
- const preferences = usePreferences();
- const updatePreferences = useUpdatePreferences();
- const panel = useAssistantPanel();
const [editOpen, setEditOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
@@ -46,15 +30,14 @@ export default function AdminProfile() {
const saving = updateProfile.isPending;
- /* Stamped once on mount rather than read during render, so the "signed in"
- time stays put instead of ticking forward on every rerender. */
+ /* Stamped once on mount */
const [sessionStarted] = useState(() =>
new Date().toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})
);
- /** Enough to recognise the device without fingerprinting it. */
+ /** Recognise device without fingerprinting */
const deviceLabel = useMemo(() => {
const ua = navigator.userAgent;
const os = /Mac/i.test(ua) ? 'macOS' : /Windows/i.test(ua) ? 'Windows'
@@ -66,8 +49,6 @@ export default function AdminProfile() {
const { data: activity = [] } = useUserActivity();
- /* The form is seeded from the record whenever the editor opens, so cancelling
- and reopening shows the saved value rather than the abandoned edit. */
React.useEffect(() => {
if (!editOpen || !user) return;
setForm({ full_name: user.full_name || '', email: user.email || '' });
@@ -99,421 +80,413 @@ export default function AdminProfile() {
}
};
- /**
- * Skill development — the workforce paths Forge has attached to this page.
- *
- * The worker record is looked up by email rather than fetched with
- * `useWorkerProfile()`, which creates one on demand: an account opening its
- * own settings should not be written into the workforce as a side effect. With
- * no record the ladders still render, at "Not started" — which is the true
- * state of someone who has completed no training, not a placeholder.
- */
- const { data: courses = [] } = useCourses();
- const { data: workerProfiles = [] } = useWorkerProfiles();
- const profileSkills = usePageSkills('profile');
- const myWorkerProfile = useMemo(
- () => profileForEmail(workerProfiles, user?.email),
- [workerProfiles, user?.email]
- );
- const skillStates = useMemo(
- () => profileSkills.statesFor(courses, myWorkerProfile),
- [profileSkills, courses, myWorkerProfile]
- );
- const [openSkill, setOpenSkill] = useState(null);
-
- /* Owliver skills, read from the Markdown registry. Settings shows what is
- registered and which pages it reaches; the file is the source of truth, so
- a skill added to disk appears here without a change to this page. */
- const disabledSkills = preferences.disabledSkills || [];
- const [addSkillOpen, setAddSkillOpen] = useState(false);
- const skills = allSkills(preferences.customSkills || []);
- const toggleSkill = (id, enabled) => {
- const next = enabled
- ? disabledSkills.filter((s) => s !== id)
- : [...new Set([...disabledSkills, id])];
- updatePreferences.mutate({ disabledSkills: next });
- toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
- };
-
- /** One writer for all three switches, so they persist the same way. */
- const setPreference = (key, value, message) => {
- updatePreferences.mutate({ [key]: value });
- toast.success(message);
- };
+ const fullName = user?.full_name || 'Admin User';
+ const email = user?.email || 'admin@krow.app';
+ const memberSince = user
+ ? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
+ : 'June 2026';
return (
- setEditOpen(true)}>
- Edit profile
-
- { endAdminSession(); base44.auth.logout('/admin/login'); }}
+
+
navigate('/admin/settings')}
+ className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-white border border-[#E0E4EA] text-[#101820] text-[13px] font-semibold hover:bg-[#F8F8F8] transition-all shadow-xs"
>
- Sign out
-
- >
+
+ Settings
+
+
setEditOpen(true)}
+ className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-[#0838E0] hover:bg-[#062BAF] text-white text-[13px] font-semibold transition-all shadow-md shadow-blue-500/20 hover:scale-[1.02]"
+ >
+
+ Edit Profile
+
+
{ endAdminSession(); base44.auth.logout('/admin/login'); }}
+ className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-red-50 hover:bg-red-100 text-red-600 border border-red-200/60 text-[13px] font-semibold transition-all"
+ >
+
+ Sign Out
+
+
}
>
-
-
- {/* Identity */}
-
-
-
-
-
{user?.full_name || '—'}
-
{user?.email}
-
-
Platform Administrator
-
- Active
-
- {twoFactor &&
2FA enabled }
+
+ {/* ── 1. Hero Identity Banner ────────────────────────────────────────────── */}
+
+ {/* Subtle background ambient mesh */}
+
+
+
+
+
+ {/* Avatar with status ring */}
+
+
+ {user?.avatar_url ? (
+
+ ) : (
+ fullName.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase()
+ )}
+
+
+
-
-
- {[
- { label: 'Role', value: 'Platform Administrator', icon: Shield },
- { label: 'Department', value: 'Workforce Operations', icon: Building2 },
- { label: 'Account status', value: 'Active · verified', icon: ShieldCheck },
- { label: 'Member since', value: user ? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) : '—', icon: Mail },
- ].map((row) => (
-
-
-
-
{row.label}
- {row.value}
-
-
- ))}
-
-
-
- {/* Permissions */}
-
-
-
- {PERMISSIONS.map((p) => (
-
-
-
{p.scope}
-
{p.detail}
-
-
{p.level}
-
- ))}
-
-
-
- {/* Recent activity */}
-
-
-
- {myActivity.length ? myActivity.map((e) => (
-
-
-
{e.event_type.replace(/_/g, ' ')}
- {e.details &&
{e.details}
}
-
-
- {new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
+ {/* User details */}
+
+
+
{fullName}
+
+
+ Verified Admin
- )) : (
-
No activity recorded for this account.
- )}
-
-
- {/* Skill development.
- Rendered only when Forge has attached a workforce path to this
- page, and not rendered at all otherwise — an empty skills panel
- teaches the reader the feature is broken. Nothing here names a
- skill: the list is whatever carries `pages: profile`. */}
- {skillStates.length > 0 && (
-
-
-
- Where you stand on each path, and the training that moves you to the next level.
- Levels are earned by completing the modules and passing Owliver's verification —
- they are never set by hand.
-
-
- {skillStates.map(({ definition, state }) => (
-
setOpenSkill({ definition, state })}
- />
- ))}
+ {email}
+
+
+
+
+ Platform Administrator
+
+
+
+ Active · Verified
+
+ {twoFactor && (
+
+
+ 2FA Protected
+
+ )}
+
-
- )}
-
- {/* Owliver skills */}
-
- setAddSkillOpen(true) }}
- />
-
- The capabilities Owliver can use across KROW. Each one is defined in a
- Markdown file and attaches itself to the pages it names.
-
-
- {skills.map((skill) => {
- const enabled = !disabledSkills.includes(skill.id);
- const pages = contextIdsForSkill(skill)
- .map((id) => ASSISTANT_CONTEXTS[id]?.page)
- .filter(Boolean);
-
- return (
-
-
-
-
{skill.name}
- {skill.custom &&
Custom }
-
- {enabled ? 'Active' : 'Inactive'}
-
-
-
{skill.description}
- {/* What the attachment does, not just what it says. A
- workforce path names the capability it governs and the
- rungs it defines, because on this list those are the
- things that change what the other pages render. */}
- {skill.kind === 'workforce' && (
-
- Workforce path · {skill.skillId}
- {skill.levels.length > 0 && (
- <> · {skill.levels.map((l) => l.label).join(' → ')}>
- )}
-
- )}
- {pages.length > 0 && (
-
- Surfaces on: {pages.join(', ')}
-
- )}
- {skill.capabilities.length > 0 && (
-
- {skill.capabilities.slice(0, 3).map((c) => (
- — {c}
- ))}
-
- )}
-
-
toggleSkill(skill.id, v)}
- aria-label={`${skill.name} skill`}
- />
-
- );
- })}
-
-
-
-
- {/* Security */}
-
-
-
-
-
Last sign-in
-
- {new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
-
-
This device · 10.24.11.86
-
-
{
- setTwoFactor(v);
- toast[v ? 'success' : 'error'](v ? '2FA enabled' : '2FA disabled');
+ {/* Quick stats in Hero */}
+
+
+
Scopes
+
{PERMISSIONS.length}
+
Full Control
+
+
+
Security Level
+
Tier 1
+
Max Privileges
+
+
+
+
+
+ {/* ── 2. Information Cards Grid ────────────────────────────────────────── */}
+
+
+
+
Platform Administrator
+
Full System Governance
+
+
+
+
+
Workforce Operations
+
Enterprise Management
+
+
+
+
+
Active · Verified
+
All services active
+
+
+
+
+
{memberSince}
+
Authenticated Account
+
+
+
+ {/* ── 3. Main Split View: Left (Permissions & Activity) | Right (Security & Session) */}
+
+ {/* Left Column (2 Cols) */}
+
+ {/* Permissions Matrix. `id` is the anchor Settings → Users &
+ permissions links to, so that row lands on this card. */}
+
+
+
+
+
+ Access & Scope Permissions
+
+
Operational scopes granted to your administrative account.
+
+
+ {PERMISSIONS.length} Active Scopes
+
+
+
+
+ {PERMISSIONS.map((p) => {
+ const isFull = p.level === 'Full access';
+ return (
+
+
+
+
+ {/* Status badge */}
+ {isFull ? (
+
+
+ Full Access
+
+ ) : (
+
+
+ Read Only
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* Recent Activity */}
+
+
+
+
+
+ Recent Account Activity
+
+
Audit log of administrative actions performed on your profile.
+
+
+ {myActivity.length} Events Logged
+
+
+
+ {myActivity.length ? (
+
+ {myActivity.map((e) => (
+
+
+
+
+
+
+ {e.event_type.replace(/_/g, ' ')}
+
+ {e.details &&
{e.details}
}
+
+
+ {new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
+
+
+ ))}
+
+ ) : (
+
+
+
No recent activity recorded for this account.
+
+ )}
+
+
+
+ {/* Right Column (1 Col) - Security & Session */}
+
+ {/* Security Control Card. `id` is the anchor the account menu's
+ Security item and Settings → Security both point at. */}
+
+
+
+
Security & 2FA
+
+
+ {/* Last Sign-In */}
+
+
+ Last Sign-in
+
+
+ Active Device
+
+
+
+ {new Date().toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
+
+
IP: 10.24.11.86 · {deviceLabel}
+
+
+ {/* 2FA Toggle */}
+
+ {
+ setTwoFactor(v);
+ toast[v ? 'success' : 'error'](v ? '2FA enabled' : '2FA disabled');
+ }}
+ />
+
+
+ {/* Action Buttons */}
+
+ setPasswordOpen(true)}
+ className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
+ >
+
+ Change Password
+
+ toast.info('Session management is active for this device.')}
+ className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-[#E0E4EA] bg-white hover:bg-[#F8F8F8] text-[#101820] text-[13px] font-semibold transition-all shadow-xs"
+ >
+
+ Manage Active Sessions
+
+
+
+ {/* Password expiry note */}
+
+
+
+ Password last changed 42 days ago . Administrator credentials expire every 90 days.
+
+
+
+
+ {/* Current Session Card */}
+
+
+
+
Current Active Session
+
+
+
+
+
Session Started
+ {sessionStarted}
+
+
+
Session Type
+ Browser Tab
+
+
+
Platform / OS
+ {deviceLabel}
+
+
+
Token Security
+
+ Encrypted
+
+
+
+
+
+
+ Administrator sessions expire automatically on browser close to enforce zero-trust security.
+
+
+
+
+
+
+ {/* Edit Profile Modal */}
+
+ setEditOpen(false)} disabled={saving}>Cancel
+ Save Changes
+ >
+ }
+ >
+
+
+ {
+ setForm({ ...form, full_name: e.target.value });
+ if (nameError) setNameError('');
}}
/>
-
+
+
+
+
+
+
-
- setPasswordOpen(true)}>
- Change password
+ {/* Change Password Modal */}
+
+ setPasswordOpen(false)}>Cancel
+ { setPasswordOpen(false); toast.success('Password updated'); }}>
+ Update Password
- toast.info('Session management is disabled in the demo')}
- >
- Manage sessions
-
-
-
-
- Password last changed 42 days ago. Administrator passwords expire every 90 days.
-
-
-
- {/* Session information. Separate from Security because these are facts
- about the session you are in right now, not settings you change. */}
-
-
-
- {[
- { label: 'Signed in', value: sessionStarted },
- { label: 'Session type', value: 'Browser tab · ends on close' },
- { label: 'Device', value: deviceLabel },
- { label: 'Role in session', value: 'Platform Administrator' },
- ].map((row) => (
-
-
{row.label}
- {row.value}
-
- ))}
-
-
- Administrator sessions are not persisted across browser restarts, so
- closing this tab signs you out.
-
-
-
- {/* Product preferences. Only settings that change something real — a
- preferences panel of inert switches is worse than no panel. */}
-
-
- {
- /* Saving the preference sets the default for future sessions;
- driving the panel applies it to the one in front of you. The
- panel keeps its own session flag, so this is the existing API
- rather than a second way to open Owliver. */
- if (v) panel.open(); else panel.close();
- setPreference(
- 'owliverDefault', v,
- v ? 'Owliver opens by default' : 'Owliver stays collapsed'
- );
- }}
- />
-
-
- setPreference(
- 'compactDensity', v,
- v ? 'Compact density on' : 'Standard density restored'
- )}
- />
-
-
-
- setPreference(
- 'emailDigest', v,
- v ? 'Daily digest on' : 'Daily digest off'
- )}
- />
-
-
-
+ >
+ }
+ >
+
+
+
+
+
+
+
+
-
-
-
- {/* The whole ladder for one path. The same drawer Forge opens, so the
- employee reads their progression in the surface that authored it
- rather than in a second copy of it. */}
-
!next && setOpenSkill(null)}
- onStartTraining={(module) => {
- setOpenSkill(null);
- navigate(`/admin/university/${module.id}`);
- }}
- />
-
-
- setEditOpen(false)} disabled={saving}>Cancel
- Save changes
- >
- }
- >
-
-
- {
- setForm({ ...form, full_name: e.target.value });
- if (nameError) setNameError('');
- }}
- />
-
-
-
-
-
-
-
-
- setPasswordOpen(false)}>Cancel
- { setPasswordOpen(false); toast.success('Password updated'); }}>
- Update password
-
- >
- }
- >
-
-
-
-
-
-
-
-
);
}
diff --git a/src/pages/admin/Settings.jsx b/src/pages/admin/Settings.jsx
new file mode 100644
index 0000000..091e69c
--- /dev/null
+++ b/src/pages/admin/Settings.jsx
@@ -0,0 +1,306 @@
+import React, { useMemo } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import {
+ Activity, Bell, Building2, KeyRound, Shield, User as UserIcon, Users,
+ ChevronRight, Sliders,
+ ShieldCheck, Server
+} from 'lucide-react';
+import { Toggle, toast } from '@/components/ds';
+import { useAssistantPanel } from '@/components/ai-assistant';
+import {
+ useJobPostings, usePreferences, useStaff, useUpdatePreferences,
+} from '@/lib/krowHooks';
+import { AdminPage } from '@/pages/admin/_shell';
+
+/**
+ * Admin Settings — Full-width platform configuration control center.
+ */
+
+export default function AdminSettings() {
+ const navigate = useNavigate();
+ const preferences = usePreferences();
+ const updatePreferences = useUpdatePreferences();
+ const panel = useAssistantPanel();
+
+ const { data: postings = [] } = useJobPostings();
+ const { data: staff = [] } = useStaff();
+
+ const clientCount = useMemo(
+ () => new Set(postings.map((p) => p.company).filter(Boolean)).size,
+ [postings]
+ );
+
+ const setPreference = (key, value, message) => {
+ updatePreferences.mutate({ [key]: value });
+ toast.success(message);
+ };
+
+ return (
+
+
+ {/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
+
+
+
+
+
+
+ Workspace Control Center
+
+
System Settings & Governance
+
+ Configure account credentials, access security policies, organizational workforce units, and system automation behavior.
+
+
+
+
+
+
Active Staff
+
{staff.length}
+
On Platform
+
+
+
Positions
+
{postings.length}
+
Active Roles
+
+
+
Clients
+
{clientCount}
+
Organizations
+
+
+
+
+
+ {/* ── 2. Two-Column Full Width Grid ─────────────────────────────── */}
+
+ {/* ── Left Column: Account & Organization ─────────────────────── */}
+
+ {/* Account & Security Card */}
+
+
+
+
+
Account & Security
+
+
+ Personal Credentials
+
+
+
+
+
+
+
+
+
+
+
Your name, email address, and platform identity information.
+
+
+
+
+
+
+
+
+
+
Password management, two-factor authentication and active sessions.
+
+
+
+
+
+ {/* Organization Management Card */}
+
+
+
+
+
Organization Structure
+
+
+ Workforce Operations
+
+
+
+
+
+
+
+
+
+
+
Clients & Positions
+
+
+
Companies and active positions currently being staffed.
+
+
+ {clientCount} clients · {postings.length} positions
+
+
+
+
+
+
+
+
+
+
+
+
Users & Permissions
+
+
+
Platform user roles and administrative permission scopes.
+
+
+ {staff.length} on staff
+
+
+
+
+
+
+
+ {/* Audit Log Banner */}
+
+
+
+
+
Audit & System Activity Log
+
Review all platform administrative actions, login events and security logs.
+
+
+
+
+
+
+ {/* ── Right Column: System Preferences & Governance ──────────────── */}
+
+ {/* System Preferences Card */}
+
+
+
+
+
Workspace Preferences
+
+
+ Automation Controls
+
+
+
+
+
+
+
Account Behaviors & Notifications
+
+
+
+
+ {
+ if (v) panel.open(); else panel.close();
+ setPreference(
+ 'owliverDefault', v,
+ v ? 'Owliver opens by default' : 'Owliver stays collapsed'
+ );
+ }}
+ />
+
+
+
+ setPreference(
+ 'compactDensity', v,
+ v ? 'Compact density on' : 'Standard density restored'
+ )}
+ />
+
+
+
+ setPreference(
+ 'emailDigest', v,
+ v ? 'Daily digest on' : 'Daily digest off'
+ )}
+ />
+
+
+
+
+
+ {/* Governance & Environment Note Card */}
+
+
+
+
Environment & Compliance
+
+
+
+
+
Environment
+
+ Production (Live)
+
+
+
+
Workspace Isolation
+ Enterprise Tenant #1042
+
+
+
Security Standard
+
+ SOC-2 Type II
+
+
+
+
+
+
+
+ Administrator configurations apply across this entire workspace environment.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/SkillDevelopment.jsx b/src/pages/admin/SkillDevelopment.jsx
new file mode 100644
index 0000000..88803e6
--- /dev/null
+++ b/src/pages/admin/SkillDevelopment.jsx
@@ -0,0 +1,262 @@
+import React, { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ArrowRight, ChevronLeft, GraduationCap } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Badge, Button, ProgressBar, Surface } from '@/components/ds';
+import { useCourses, useCurrentUser, useWorkerProfiles } from '@/lib/krowHooks';
+import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
+import { profileForEmail } from '@/lib/skillGraph';
+import { SkillProgressDetail } from '@/components/forge';
+import { AdminPage } from '@/pages/admin/_shell';
+
+/**
+ * Skill development — every training path, as a management list.
+ *
+ * This replaced a grid of tall cards. Each card carried a four-node timeline, a
+ * progress block and a footer, which is a good shape for *one* skill and a poor
+ * one for five: the page became three columns of mostly-identical furniture,
+ * and answering "which path is anyone actually on" meant reading five separate
+ * dashboards.
+ *
+ * A row states the same facts in the order they are asked for — what the path
+ * is, where the workforce stands on it, how far through, and what is next — so
+ * the five can be compared by scanning down a column rather than by reading
+ * each one whole. The ladder survives as a single compact line, because the
+ * distance to the top of it is worth seeing; it is just no longer a diagram.
+ *
+ * Nothing here derives progress. `lib/skillGraph.js` computes each state and
+ * this arranges it, which is what keeps this page, the detail drawer and Forge
+ * from ever disagreeing about a level.
+ */
+
+/** The three states a path can be in, in the platform's own status language. */
+const STATUS = {
+ completed: { label: 'Completed', variant: 'success' },
+ in_progress: { label: 'In progress', variant: 'soft' },
+ not_started: { label: 'Not started', variant: 'neutral' },
+};
+
+/**
+ * One path's headline state, from the module counts that produced it.
+ *
+ * Deliberately coarser than `state.status`, which distinguishes "verified" from
+ * "advancing" — useful on a personal card, more than a management list needs.
+ */
+function summaryStatus(state) {
+ if (state.totalModules > 0 && state.totalCompleted === state.totalModules) return 'completed';
+ if (state.totalCompleted > 0) return 'in_progress';
+ return 'not_started';
+}
+
+/**
+ * The ladder as one line of text.
+ *
+ * Earned rungs read as earned, the rung being worked towards is the only thing
+ * highlighted, and everything above it recedes. No circles, no connectors, no
+ * repetition of the same diagram five times down the page.
+ */
+function LevelTrail({ state }) {
+ return (
+
+ {state.levels.map((rung, i) => {
+ const current = !rung.earned && state.nextLevel === rung.level;
+ return (
+
+ {i > 0 && → }
+
+ {rung.label}
+
+
+ );
+ })}
+
+ );
+}
+
+/** One training path. Everything a manager asks of it, on one row. */
+function PathRow({ definition, state, onOpen }) {
+ const status = STATUS[summaryStatus(state)];
+ const percent = state.totalModules
+ ? Math.round((state.totalCompleted / state.totalModules) * 100)
+ : 0;
+
+ return (
+
+ {/* Identity — name, what the path is for, and the ladder it defines. */}
+
+
+
+
+
+
+
{state.name}
+
{status.label}
+
+ {definition.description && (
+
+ {definition.description}
+
+ )}
+
+
+
+
+ {/* Where the workforce stands. The bar is secondary to the count beside
+ it — a percentage alone does not say how much work that is. */}
+
+
+ Progress
+ {percent}%
+
+
+
+ {state.totalCompleted} / {state.totalModules} modules
+
+
+
+ {/* Current level and the one thing to do next. */}
+
+
Current level
+
{state.verifiedLabel}
+
+ Next:
+ {state.nextRequirement
+ ? state.nextRequirement.title
+ : state.nextLevel ? 'No training authored yet' : 'Nothing outstanding'}
+
+
+
+
+
onOpen({ definition, state })}>
+ View
+
+
+
+ );
+}
+
+/** One figure in the summary strip. */
+function SummaryStat({ label, value }) {
+ return (
+
+ );
+}
+
+export default function AdminSkillDevelopment() {
+ const navigate = useNavigate();
+ const { data: user } = useCurrentUser();
+ const { data: courses = [] } = useCourses();
+ const { data: workerProfiles = [] } = useWorkerProfiles();
+ const { statesFor } = useWorkforcePaths();
+
+ /* The worker record is looked up by email rather than fetched with
+ `useWorkerProfile()`, which creates one on demand: opening this page should
+ not write an account into the workforce as a side effect. With no record
+ every path reads "Not started", which is the true state of someone who has
+ completed no training. */
+ const profile = useMemo(
+ () => profileForEmail(workerProfiles, user?.email),
+ [workerProfiles, user?.email]
+ );
+
+ const rows = useMemo(
+ () => statesFor(courses, profile),
+ [statesFor, courses, profile]
+ );
+
+ /* Counted from the same rows the list renders, so the strip and the list can
+ never report different things. */
+ const summary = useMemo(() => {
+ const tally = { completed: 0, in_progress: 0, not_started: 0 };
+ rows.forEach(({ state }) => { tally[summaryStatus(state)] += 1; });
+ return tally;
+ }, [rows]);
+
+ const [openPath, setOpenPath] = useState(null);
+
+ return (
+
+ navigate('/admin/workspace')}>
+ Workspace
+
+ navigate('/admin/university')}>
+ Manage training
+
+ >
+ }
+ >
+ {rows.length ? (
+ <>
+
+
+
+
+
+
+
+
+ {rows.map(({ definition, state }) => (
+
+ ))}
+
+ >
+ ) : (
+
+ No training paths are registered yet. A path is a Markdown definition that
+ declares a ladder — add one from Skills and it appears here.
+
+ )}
+
+ {/* The whole ladder for one path — the same drawer Forge and the account
+ page open, so progression is read in the surface that authored it
+ rather than in a second copy of it. */}
+ !next && setOpenPath(null)}
+ onStartTraining={(module) => {
+ setOpenPath(null);
+ navigate(`/admin/university/${module.id}`);
+ }}
+ />
+
+ );
+}
diff --git a/src/pages/admin/SkillEditor.jsx b/src/pages/admin/SkillEditor.jsx
new file mode 100644
index 0000000..90c0e07
--- /dev/null
+++ b/src/pages/admin/SkillEditor.jsx
@@ -0,0 +1,320 @@
+import React, { useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { ChevronLeft, Upload } from 'lucide-react';
+import {
+ Badge, Button, Field, Input, Surface, Switch, Textarea, toast,
+} from '@/components/ds';
+import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
+import { PAGE_KEYS, allSkills, parseSkill, validateSkillSource } from '@/lib/skills/registry';
+import { customSkillSource, skillTemplate, upsertCustomSkill } from '@/lib/skills/customSkills';
+import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
+
+/**
+ * Add or edit an Owliver skill, as a page.
+ *
+ * The dialog this replaces still exists — Owliver's own header opens it, where a
+ * modal is the right shape for "add one while you are mid-conversation". This is
+ * the same act with room to do it in: the fields, the surfaces and the Markdown
+ * side by side rather than stacked in a 640px box.
+ *
+ * Both write through `upsertCustomSkill`, and what is stored is the Markdown
+ * itself. There is one registry, one parser and one format; this page adds a
+ * door, not a second system.
+ */
+
+/** The frontmatter fields, kept in step with the Markdown until it is edited. */
+const emptyMeta = { id: '', name: '', description: '', pages: [] };
+
+/** Reads the meta back out of a definition, for editing an existing skill. */
+function metaFromSource(source) {
+ try {
+ const skill = parseSkill(source, { custom: true });
+ return {
+ id: skill.id, name: skill.name, description: skill.description, pages: skill.pages,
+ };
+ } catch {
+ return emptyMeta;
+ }
+}
+
+export default function SkillEditor() {
+ const navigate = useNavigate();
+ /* `new` is the add route; anything else is a skill id being edited. */
+ const { id: routeId } = useParams();
+ const editingId = routeId && routeId !== 'new' ? routeId : null;
+
+ const preferences = usePreferences();
+ const updatePreferences = useUpdatePreferences();
+ const customSkills = preferences.customSkills || [];
+
+ /**
+ * The definition being worked on.
+ *
+ * Editing an account skill loads its stored Markdown. Editing one that ships
+ * with KROW loads that file's Markdown as a starting point — saving writes an
+ * account definition with the same id, which the registry treats as an
+ * override rather than a duplicate.
+ */
+ const initial = useMemo(() => {
+ if (!editingId) return skillTemplate({});
+ const stored = customSkillSource(customSkills, editingId);
+ if (stored) return stored;
+ const shipped = allSkills(customSkills).find((s) => s.id === editingId);
+ return shipped?.markdown || skillTemplate({});
+ /* Loaded once per skill: retyping in the editor must not be overwritten by
+ a preferences write elsewhere in the app. */
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [editingId]);
+
+ const [source, setSource] = useState(initial);
+ const [meta, setMeta] = useState(() => (editingId ? metaFromSource(initial) : emptyMeta));
+ const [pagesText, setPagesText] = useState(() => (editingId ? metaFromSource(initial).pages.join(', ') : ''));
+ const [touched, setTouched] = useState(Boolean(editingId));
+ const [error, setError] = useState('');
+ const fileRef = React.useRef(null);
+
+ /* The parsed view of what is in the editor right now — what actually gets
+ registered, so the preview cannot flatter the definition. */
+ const preview = useMemo(() => {
+ try {
+ return parseSkill(source, { custom: true });
+ } catch {
+ return null;
+ }
+ }, [source]);
+
+ /**
+ * Active, as the rest of the app decides it.
+ *
+ * Two things can switch a skill off: the definition declaring
+ * `status: inactive`, and the account switching it off on the Skills list.
+ * One switch here reads both and, on save, writes both — otherwise this page
+ * and that list would show contradictory states for the same skill.
+ */
+ const disabledSkills = preferences.disabledSkills || [];
+ const declaredActive = preview?.status !== 'inactive';
+ const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id));
+
+ /* Typing in the fields composes frontmatter until the Markdown is edited by
+ hand, after which the Markdown wins — it is the thing being saved. */
+ const syncFromMeta = (next, nextPagesText = pagesText) => {
+ setMeta(next);
+ if (touched) return;
+ setSource(skillTemplate({
+ ...next,
+ pages: nextPagesText.split(',').map((p) => p.trim()).filter(Boolean),
+ }));
+ };
+
+ const setStatus = (nextActive) => {
+ /* Status lives in the frontmatter, so the switch edits the definition
+ rather than keeping a second copy of the state beside it. */
+ const next = /^status:\s*\w+$/m.test(source)
+ ? source.replace(/^status:\s*\w+$/m, `status: ${nextActive ? 'active' : 'inactive'}`)
+ : source.replace(/^---\n/, `---\nstatus: ${nextActive ? 'active' : 'inactive'}\n`);
+ setSource(next);
+ setTouched(true);
+ };
+
+ const readFile = (event) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ setSource(String(reader.result));
+ setTouched(true);
+ setError('');
+ };
+ reader.readAsText(file);
+ event.target.value = '';
+ };
+
+ const save = () => {
+ const problem = validateSkillSource(source);
+ if (problem) {
+ setError(problem);
+ return;
+ }
+ const { skill, next } = upsertCustomSkill(customSkills, source);
+ /* The definition and the account switch agree after a save: a skill saved
+ as active is not left switched off by an earlier toggle. */
+ const nextDisabled = skill.status === 'inactive'
+ ? [...new Set([...disabledSkills, skill.id])]
+ : disabledSkills.filter((id) => id !== skill.id);
+ updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled });
+ toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`);
+ navigate('/admin/workspace/skills');
+ };
+
+ return (
+
+ navigate('/admin/workspace/skills')}
+ >
+ Skills
+
+
+ {editingId ? 'Save skill' : 'Add skill'}
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
fileRef.current?.click()}>
+ Upload .md
+
+
Markdown is stored as data. It is never executed.
+
+
+
+
+
+ {/* What the registry will make of this definition, as it stands. */}
+
+
+
+ );
+}
diff --git a/src/pages/admin/Workspace.jsx b/src/pages/admin/Workspace.jsx
new file mode 100644
index 0000000..febcae8
--- /dev/null
+++ b/src/pages/admin/Workspace.jsx
@@ -0,0 +1,211 @@
+import React, { useMemo } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import {
+ GraduationCap, Sparkles, TrendingUp,
+ BookOpen, ArrowRight
+} from 'lucide-react';
+import { usePreferences } from '@/lib/krowHooks';
+import { allSkills } from '@/lib/skills/registry';
+import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
+import { AdminPage } from '@/pages/admin/_shell';
+import { cn } from '@/lib/utils';
+
+/**
+ * Workspace & Skills — Full-width workforce capabilities & AI governance hub.
+ */
+
+export default function AdminWorkspace() {
+ const navigate = useNavigate();
+ const preferences = usePreferences();
+
+ /* Owliver's skills from Markdown registry */
+ const skills = useMemo(
+ () => allSkills(preferences.customSkills || []),
+ [preferences.customSkills]
+ );
+ const disabledSkills = preferences.disabledSkills || [];
+ const activeSkills = skills.filter((s) => !disabledSkills.includes(s.id)).length;
+
+ /* Workforce training paths from registry */
+ const { paths } = useWorkforcePaths();
+
+ return (
+
+
+ {/* ── 1. Hero Banner (Full Width) ───────────────────────────────── */}
+
+
+
+
+
+
+ Workforce Intelligence Hub
+
+
Capabilities & Skill Governance
+
+ Govern Owliver AI agent capabilities, configure workforce training paths, and track talent progression across your organization.
+
+
+
+
+
+
Registered Skills
+
{skills.length}
+
{activeSkills} Active
+
+
+
Training Paths
+
{paths.length}
+
Verified Ladders
+
+
+
+
+
+ {/* ── 2. Full Width 2-Column Grid ───────────────────────────────── */}
+
+ {/* ── Left Column: Owliver AI Capabilities ───────────────────── */}
+
+
+
+
+
+
Owliver AI Capabilities
+
+
+ {skills.length} Registered · {activeSkills} Active
+
+
+
+
+ Owliver capabilities define what the AI assistant can execute autonomously during candidate screening, background matching, and contextual analysis.
+
+
+ {/* Skills Sample List */}
+
+ {skills.slice(0, 4).map((s) => {
+ const isActive = !disabledSkills.includes(s.id);
+ return (
+
+
+
{s.name || s.id}
+ {s.description &&
{s.description}
}
+
+
+ {isActive ? 'Active' : 'Disabled'}
+
+
+ );
+ })}
+
+
+ {/* CTA Button */}
+
+
+
+ Manage Owliver Skills & Agent Capabilities
+
+
+
+
+
+
+ {/* ── Right Column: Workforce Training & Skill Development ──────── */}
+
+ {/* Workforce Training */}
+
+
+
+
+
Workforce Training Paths
+
+
+ {paths.length} Active Paths
+
+
+
+
+ Structured learning challenges and skill verification ladders where workforce candidates prove their real-world capabilities.
+
+
+ {/* Paths Sample List */}
+
+ {paths.slice(0, 3).map((p) => (
+
+
+
{p.name || p.id}
+ {p.category &&
{p.category} Path
}
+
+
+ Verified Path
+
+
+ ))}
+
+
+ {/* CTA Button */}
+
+
+
+ Manage Training Paths & Proving Challenges
+
+
+
+
+
+ {/* Skill Development */}
+
+
+
+
+
Skill Progression Tracking
+
+
+ Live Analytics
+
+
+
+
+ Track talent advancement across training paths, verified levels, and candidate endorsement histories.
+
+
+
+
+
+ View Skill Progression & Talent Growth
+
+
+
+
+
+
+
+ {/* ── 3. Unified Registry Architecture Banner ───────────────────── */}
+
+
+
+
Unified Markdown Registry Architecture
+
+ Skills define what Owliver can execute autonomously. Training paths define what the human workforce can be verified in. Both are defined as structured Markdown in one central registry, keeping candidate evaluation and skill progression seamlessly synchronized.
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/WorkspaceSkills.jsx b/src/pages/admin/WorkspaceSkills.jsx
new file mode 100644
index 0000000..0461471
--- /dev/null
+++ b/src/pages/admin/WorkspaceSkills.jsx
@@ -0,0 +1,217 @@
+import React, { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
+import {
+ Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
+ DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
+} from '@/components/ds';
+import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
+import { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
+import { removeCustomSkill } from '@/lib/skills/customSkills';
+import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
+import { AdminPage } from '@/pages/admin/_shell';
+
+/**
+ * Skills — the management page.
+ *
+ * Skills used to be a section at the bottom of the account page, which made
+ * "add a skill" mean "append another row to a page about something else". They
+ * are a registry with their own lifecycle: authored, enabled, edited, removed.
+ * A registry deserves an address, so this is one — reachable from Workspace &
+ * Skills, where Owliver's capabilities belong, and linkable on its own.
+ *
+ * Nothing here is a second skill system. The rows are `allSkills()` — the
+ * Markdown files on disk plus whatever the account has added — and enabling or
+ * disabling one writes the same `disabledSkills` preference it always did.
+ */
+
+const KIND_LABEL = {
+ assistant: 'Assistant',
+ workforce: 'Workforce path',
+};
+
+/** One skill, as a row in the management list. */
+function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
+ const pages = contextIdsForSkill(skill)
+ .map((id) => ASSISTANT_CONTEXTS[id]?.page)
+ .filter(Boolean);
+
+ return (
+
+
+
+
{skill.name}
+
+ {enabled ? 'Active' : 'Inactive'}
+
+
{KIND_LABEL[skill.kind] || 'Assistant'}
+ {skill.custom &&
Custom }
+
+
+ {skill.description && (
+
{skill.description}
+ )}
+
+ {/* A workforce path names the capability it governs and the rungs it
+ defines: on this list those are what change other pages. */}
+ {skill.kind === 'workforce' && (
+
+ Capability: {skill.skillId}
+ {skill.levels.length > 0 && <> · {skill.levels.map((l) => l.label).join(' → ')}>}
+
+ )}
+
+ {pages.length > 0 && (
+
+ Surfaces: {pages.join(', ')}
+
+ )}
+
+
+ {skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
+ {' · '}
+ {skill.path}
+
+
+
+
+
onToggle(skill.id, v)}
+ aria-label={`${skill.name} — ${enabled ? 'active' : 'inactive'}`}
+ />
+
+
+
+
+
+
+
+ onEdit(skill)} className="cursor-pointer">
+
+ {skill.custom ? 'Edit skill' : 'Duplicate & edit'}
+
+ {skill.custom && (
+ onDelete(skill)}
+ className="cursor-pointer text-destructive focus:text-destructive"
+ >
+ Remove skill
+
+ )}
+
+
+
+
+ );
+}
+
+export default function WorkspaceSkills() {
+ const navigate = useNavigate();
+ const preferences = usePreferences();
+ const updatePreferences = useUpdatePreferences();
+
+ const [search, setSearch] = useState('');
+ const [pendingDelete, setPendingDelete] = useState(null);
+
+ const disabledSkills = preferences.disabledSkills || [];
+ const customSkills = preferences.customSkills || [];
+ const skills = useMemo(() => allSkills(customSkills), [customSkills]);
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return skills;
+ return skills.filter((s) => `${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q));
+ }, [skills, search]);
+
+ const toggleSkill = (id, enabled) => {
+ const next = enabled
+ ? disabledSkills.filter((s) => s !== id)
+ : [...new Set([...disabledSkills, id])];
+ updatePreferences.mutate({ disabledSkills: next });
+ toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
+ };
+
+ const removeSkill = (skill) => {
+ updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
+ setPendingDelete(null);
+ toast.success(`${skill.name} removed`);
+ };
+
+ const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
+
+ return (
+
+ navigate('/admin/workspace')}>
+ Workspace
+
+ navigate('/admin/workspace/skills/new')}>
+ Add skill
+
+ >
+ }
+ >
+
+
+
+
+
+ {skills.length} registered · {activeCount} active
+
+
+
+ {filtered.length ? (
+
+ {filtered.map((skill) => (
+ navigate(`/admin/workspace/skills/${s.id}`)}
+ onDelete={setPendingDelete}
+ />
+ ))}
+
+ ) : (
+ setSearch('')}>Clear search}
+ />
+ )}
+
+
+ Each skill is a Markdown definition that attaches itself to the pages it names.
+ Definitions shipped with KROW live in the repository and can be switched off here;
+ skills added on this account are stored with your preferences. Markdown is stored as
+ data — it is never executed.
+
+
+ !open && setPendingDelete(null)}
+ title={`Remove ${pendingDelete?.name || 'skill'}?`}
+ description="This removes the definition from this account. Skills shipped with KROW are unaffected."
+ confirmLabel="Remove skill"
+ tone="destructive"
+ onConfirm={() => pendingDelete && removeSkill(pendingDelete)}
+ />
+
+ );
+}
diff --git a/src/pages/admin/_shell.jsx b/src/pages/admin/_shell.jsx
index 2656149..65c28ee 100644
--- a/src/pages/admin/_shell.jsx
+++ b/src/pages/admin/_shell.jsx
@@ -17,13 +17,16 @@ import { cn } from '@/lib/utils';
* Controls live in the page rather than in the global chrome, which is why the
* Admin header only needs one 56px bar.
*/
-export function AdminPage({ title, subtitle, actions, tabs, children, className }) {
+export function AdminPage({ title, subtitle, meta, actions, tabs, children, className }) {
return (