Unicis Platform is an open-core, enterprise-ready trust management and compliance platform designed for startups and SMEs. It provides tools for managing compliance frameworks, security controls, privacy assessments, and risk management across multiple compliance standards (GDPR, ISO 27001, NIST, SOC 2, PCI DSS, etc.).
Current Version: 0.2.3 Build Tool: Next.js 16 Database: PostgreSQL ORM: Prisma Authentication: NextAuth.js License: Apache 2.0
This section documents the Direction B design language. All new and refactored components must follow these tokens, patterns, and responsiveness rules. Non-compliance is a blocking review issue.
Direction B is a calm, professional visual language built for compliance and security professionals (CISO, DPO, Engineers, DevSecOps, HR, C-level). The core principles are:
- Information density over decoration — data is primary, chrome is secondary
- Consistent spatial rhythm — uniform padding, border radius, and type scale across every module
- Dark mode as first-class — every token has an explicit
dark:counterpart - Legibility at small scale — table headers at
text-[11px]uppercase; body text attext-[12px]–text-[13px] - Mobile-first, always — every new component must work on a 375 px viewport before it is considered complete
- Accessibility as a requirement — WCAG 2.1 AA compliance is mandatory, not optional
All components must meet WCAG 2.1 AA. Accessibility failures are treated as bugs, not enhancements.
pages/_document.tsx sets <Html lang={locale ?? 'en'}> using the locale from getServerSideProps. This satisfies WCAG 3.1.1 Language of Page.
Every <Button size="icon"> must have an aria-label. The icon SVG must have aria-hidden="true". Use translated strings:
<Button variant="outline" size="icon" aria-label={t('edit-task')}>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Button>
<Button variant="destructive" size="icon" aria-label={t('delete')}>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>For buttons that operate on a named item (courses, webhooks), include the item name for context:
aria-label={`${t('edit')} ${item.name}`}Every <Label> must be paired with a matching htmlFor/id:
<Label htmlFor="field-id">{t('label')}</Label>
<Input id="field-id" ... />For grouped checkboxes (e.g. event type selectors), use <fieldset> / <legend> — never a plain <div> / <Label>:
<fieldset>
<legend className="text-sm font-medium ...">
{t('events-to-send')}
</legend>
<EventTypes ... />
</fieldset>When Formik (or any form library) renders an error, link it to the input with aria-invalid and aria-describedby:
<Input
id="name"
aria-invalid={!!formik.errors.name}
aria-describedby={formik.errors.name ? 'name-error' : undefined}
/>;
{
formik.errors.name && (
<p id="name-error" role="alert" className="text-xs text-destructive mt-1">
{formik.errors.name}
</p>
);
}Dashboard and module tab bars must use full ARIA tab pattern:
<div role="tablist" aria-label={t('tab-group-label')}>
<button
role="tab"
aria-selected={activeTab === 0}
aria-controls="tabpanel-0"
id="tab-0"
>
Tab Label
</button>
</div>
<div
id="tabpanel-0"
role="tabpanel"
aria-labelledby="tab-0"
hidden={activeTab !== 0}
>
...
</div>Every chart must be wrapped in a container with role="img" and a meaningful aria-label. The label must describe the chart content, not just its type. Always include className="w-full h-full" on the wrapper to preserve responsive sizing:
<div
role="img"
aria-label={t('chart.risk-matrix-aria-label', {
defaultValue: 'Risk matrix showing distribution of risks by likelihood and impact level'
})}
className="w-full h-full"
>
<Bubble ... />
</div>Applied to: DashboardMatrixChart, DashboardPieChart, PieChart (CSC), RadarChart (CSC), TasksPieChart, RiskMatrixDashboardChart (PIA).
Critical: Do not add a wrapper
<div>to charts withoutclassName="w-full h-full". Chart.js uses responsive sizing from the parent container — a zero-height wrapper collapses the chart.
The mobile drawer (components/shared/shell/Drawer.tsx) implements a full accessible dialog pattern:
// Backdrop — clicks dismiss the drawer
<div
className="fixed inset-0 bg-gray-600/80"
aria-hidden="true"
onClick={() => setSidebarOpen(false)}
/>
// Panel — ARIA dialog
<div
ref={drawerPanelRef}
role="dialog"
aria-modal="true"
aria-label={t('navigation')}
onKeyDown={handleTabKey} // focus trap
>useEffect moves focus to the close button on open. useEffect listens for Escape to close. onKeyDown cycles Tab/Shift+Tab within the panel.
External links (navigation items with target="_blank") must announce this to screen reader users via a visually hidden span:
{
newTab && (
<>
<ArrowTopRightOnSquareIcon aria-hidden="true" />
<span className="sr-only">(opens in new tab)</span>
</>
);
}Clickable card components (<button>) that switch context must carry an explicit aria-label describing the action, plus aria-pressed to communicate current state:
<button
aria-label={t('domain-health.switch-tab-cybersecurity', { defaultValue: 'Switch to Cybersecurity tab' })}
aria-pressed={active}
onClick={onClick}
>Use text-slate-500 dark:text-slate-400 for all content text (labels, sub-lines, data values). text-slate-400 alone (#94a3b8) fails the 4.5:1 minimum on white backgrounds and must not be used for text that carries meaning.
| Token | Ratio on white | Use |
|---|---|---|
text-slate-900 |
~19:1 | Primary body text |
text-slate-700 |
~8:1 | Secondary body text |
text-slate-500 |
~4.6:1 | Labels, sub-text, meta ✅ |
text-slate-400 |
~2.85:1 | Decorative/placeholder only ❌ for content |
Do not use text-[10px] for text that conveys information. Minimum is text-[11px]. The 10px size is reserved for badge numerals inside fixed-size pills only.
NotificationBell uses a dynamic aria-label that includes the unread count:
aria-label={unreadCount > 0
? t('notifications.title-unread', { count: unreadCount })
: t('notifications.title')}The numeric badge span carries aria-hidden="true" since the count is already in the button label.
Responsiveness is not optional. Every component, page, and modal must be tested at 375 px (mobile), 768 px (tablet), and 1280 px (desktop) before being merged. Layouts that break, overflow, or wrap uncontrollably at any of these widths are bugs.
Tailwind CSS breakpoints used across the platform:
| Breakpoint | Token | Min-width | Typical device |
|---|---|---|---|
| Mobile | (default) | 0 px | iPhone SE, Android compact |
| Small | sm: |
640 px | Landscape phone, small tablet |
| Medium | md: |
768 px | iPad portrait |
| Large | lg: |
1024 px | iPad landscape, small laptop |
| Extra Large | xl: |
1280 px | Desktop |
Design at mobile first, add breakpoints upward. Never design desktop-first and bolt on mobile later.
- Sidebar is always hidden on mobile / tablet and revealed as a drawer via
lg:hiddenhamburger - Main content area has
lg:pl-64offset (sidebar width) and falls to full width belowlg: - Content padding:
px-4 sm:px-6 lg:px-6
The header is a fixed-height h-12 sticky bar. It is split into two zones:
<div className="sticky top-0 z-40 flex h-12 shrink-0 items-center ... gap-x-3">
{/* LEFT ZONE — flex-1 min-w-0: fills available space, title truncates */}
<div className="flex items-center gap-x-2.5 flex-1 min-w-0">
<button className="... lg:hidden flex-shrink-0"> {/* hamburger */}
<span className="truncate text-[14px] font-medium ..."> {/* module title */}
</div>
{/* RIGHT ZONE — flex-shrink-0: always fully visible */}
<div className="flex items-center gap-x-3 flex-shrink-0">
<GlobalSearch />
<NotificationBell />
<AccountDropdown /> {/* mobile: icon only; sm+: name + chevron */}
</div>
</div>Rules:
- The title MUST use
truncate— never allow it to wrap to a second line - The right zone MUST use
flex-shrink-0so controls are always reachable AccountDropdownshows only aUserCircleIconon mobile (< sm), full name onsm+- Module title strings are mapped per-route in
useModuleTitle(). Long names (e.g. "Cybersecurity Management System") truncate gracefully
Top-to-bottom, each module item is conditionally rendered behind its own canAccess(...) check (so the sidebar naturally shrinks for teams without a given module/plan):
- Dashboard
- All Tasks (overdue badge)
- Asset Management (Ultimate plan +
asset_dashboardpermission — see Asset Management module notes; note: this sidebar item only checks plan/permission, not live Fleet connection — the connection prompt lives on the Asset Management page itself) - RPA → TIA → PIA → CSC (open-controls badge) → IAP → Risk Management (open-risks badge)
- divider
- REST API Docs (
/api-docs, internal route, opens in a new tab) → Documentation → Knowledge Base → Feedback → Support (all external links) - Settings (pinned to the sidebar footer, outside this list)
Keep new module nav items inside the numbered groups above (modules before the divider, links after) rather than appending to the end — placement communicates whether something is a first-party module or an external resource.
<div className="flex flex-col lg:flex-row gap-3">
<PrimaryPanel /> {/* flex-1 */}
<SidePanel /> {/* lg:w-[280px] flex-shrink-0 */}
</div><div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2"><div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"><div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4">Rule: Never use a fixed column count without responsive fallback. Always start with grid-cols-1 and add larger breakpoints.
Every module toolbar (heading + filter controls + Create button) must use:
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div className="flex items-center gap-2">
<h1 className="text-[15px] font-semibold ...">{t('module-name')}</h1>
<span className="... rounded-full">{count}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* filters, create button */}
</div>
</div>Rule: flex-wrap is mandatory on both the outer container and the controls group. Without it, buttons overflow off-screen on narrow viewports.
Applied to: RPA, TIA, PIA, RM, IAP, All Tasks, IAP Admin.
All module tables require a horizontal scroll wrapper. Narrow screens must scroll the table, not reflow it:
{
/* Card wrapper */
}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
{/* Scroll container */}
<div className="overflow-x-auto">
<table className="w-full min-w-full ...">...</table>
</div>
</div>;Rule: overflow-x-auto is mandatory on every table wrapper. Tables must never force the page to scroll horizontally.
Applied to: All Tasks (TaskListView), RPA (RpaTable), TIA (TiaTable), PIA (RisksTable), RM (RisksTable), CSC (StatusesTable), dashboard Task Matrix.
// Base class (all dialogs inherit this)
className =
'... w-full max-w-[calc(100vw-2rem)] sm:max-w-lg ... p-4 sm:p-6 rounded-lg overflow-x-hidden';Key properties:
max-w-[calc(100vw-2rem)]— leaves 1 rem margin on each side on mobile; no edge-to-edge dialogsp-4 sm:p-6— compact padding on mobile, full padding on desktoprounded-lg— always visible corners (nosm:rounded-lg)overflow-x-hidden— internal content (e.g. stepper) cannot break dialog bounds
Multi-step dialogs (RPA, TIA, PIA, RM) must use a flex sticky-footer layout:
{
/* DialogContent — flex column, content scrolls, footer always pinned */
}
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden p-4 sm:p-6">
<DialogHeader>...</DialogHeader>
{/* Scrollable body — expands to fill space, overflows internally */}
<div className="w-full flex-1 min-h-0 overflow-y-auto">
{/* step content */}
</div>
{/* Footer — always pinned at the bottom */}
<DialogFooter className="flex flex-wrap justify-end gap-2">
<DialogClose asChild>
<Button variant="outline">{t('close')}</Button>
</DialogClose>
{currentStep > 0 && (
<Button variant="outline" onClick={back}>
{t('back')}
</Button>
)}
<Button onClick={next}>
{currentStep < maxStep ? t('next') : t('save')}
</Button>
</DialogFooter>
</DialogContent>;Critical: Do NOT put
overflow-y-autodirectly on theDialogContentgrid/flex container. Doing so places theDialogFooterinside the scroll area — it scrolls out of view as soon as the content grows. The correct pattern isoverflow-hiddenon the container andflex-1 min-h-0 overflow-y-autoon the inner content div only.
Key properties:
flex flex-col— overrides the shadcn basegridwith a vertical flex columnoverflow-hidden— the container clips; children manage their own overflowflex-1 min-h-0on the body div — expands to fill remaining space;min-h-0is required to allow a flex child to shrink below its natural content heightoverflow-y-autoon the body div — only the step content scrolls, never the footer
<DialogFooter className="flex flex-wrap justify-end gap-2">Shadcn's base DialogFooter stacks buttons vertically on mobile (flex-col-reverse) and lays them out horizontally on sm+. The gap-2 replaces the legacy space-x-2.
Rules:
- Never use fixed-width dialogs without
max-w-[95vw]or the base class - Never pass
p-6without thesm:prefix — usep-4 sm:p-6 - Never use
space-x-2in aDialogFooter— usegap-2 - Never put
overflow-y-autoonDialogContentdirectly — use the sticky-footer flex pattern above
The Stepper component (components/shadcn/ui/stepper.tsx) renders a two-tier responsive layout:
┌─────────────────────────────────────────┐
│ Data processing 2 / 5 │
│ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────────────────┘
- Current step name (left) + "N / M" counter (right)
- Blue progress bar fills proportionally
- Fits in any dialog width, even 300 px
● ──── ○ ──── ○ ──── ○ ──── ○
Data Conf. Avail. Trans. Result
proc. & Int.
- Dots + connector lines + labels below each dot
- Labels use
break-words whitespace-normal [overflow-wrap:anywhere]
Rule: Never add a fixed-width stepper that forces a multi-column horizontal layout without the hidden sm:flex guard. The mobile tier is mandatory.
SectionRail (components/interfaces/csc/SectionRail.tsx) is a 210 px sidebar showing per-section compliance progress within the CSC controls panel.
Rule: It MUST be hidden on mobile/tablet and visible only on lg+:
<aside className="hidden lg:flex lg:w-[210px] flex-shrink-0 border-l ... flex-col overflow-hidden">On mobile the user accesses section filtering via the SectionFilter dropdown in the CSC toolbar instead.
The CSC filter toolbar contains 4 controls: SectionFilter, StatusFilter, PerPageSelector, SoA export button.
Rule: Must use flex-wrap so controls drop to a second row on narrow screens:
<div className="flex flex-wrap justify-end gap-1">
<SectionFilter ... />
<StatusFilter ... />
<PerPageSelector ... />
<button>SoA Export</button>
</div>Any row of filter chips (CSC framework tabs on the dashboard, CscTabs) must use flex-wrap:
<div className="flex items-center gap-2 mb-3 flex-wrap">
{frameworks.map((iso) => (
<button key={iso}>...</button>
))}
</div>Rule: flex-wrap is mandatory on any chip/badge row that can contain a variable number of items.
// Module tab containers
className =
'flex gap-0.5 bg-slate-100 dark:bg-slate-800 rounded-lg p-[3px] mb-4 flex-wrap';
// Tab buttons
className =
'px-3 py-[6px] text-[12px] font-medium rounded-md ... whitespace-nowrap';Rule: The container uses flex-wrap so tabs fall to a second row on mobile. Tab text uses whitespace-nowrap so individual tab labels don't break mid-word.
For settings navigation tabs (many items), use overflow-x-auto no-scrollbar scroll pattern instead of wrapping.
Any row containing a status banner alongside a utility button (e.g. dashboard top bar):
<div className="flex flex-wrap items-center gap-3 mb-3">
<ActionRequiredBanner ... />
<Button className="flex-shrink-0 ml-auto" ...>Export CSV</Button>
</div>Rule: Use flex-wrap so the button falls below the banner on narrow screens rather than getting pushed off-screen.
Before submitting any new component or page, verify:
- 375 px mobile — no horizontal overflow, no text wrapping in fixed-height containers
- 768 px tablet — two-column layouts stack correctly, no truncated buttons
- 1280 px desktop — full layout renders as designed
- Tables have
overflow-x-autowrapper - Toolbars have
flex-wrap - Dialogs use
max-w-[95vw]or the base dialog class (never fixed width without mobile fallback) - Multi-step dialogs use
flex flex-col overflow-hiddenonDialogContent+flex-1 min-h-0 overflow-y-autoon body div —DialogFootermust always be visible without scrolling - Task selection in dialogs uses
<TaskPicker>(Combobox), not<Select> - Tab rows use
flex-wraporoverflow-x-auto - No
h-*(fixed height) on text containers that could receive translated strings of variable length - Header title uses
truncate(enforced globally — do not override) -
SectionRailor any always-on sidebar useshidden lg:flexpattern - All icon-only buttons have
aria-label - Chart wrappers have
role="img"+aria-label+className="w-full h-full" - Content text uses
text-slate-500 dark:text-slate-400minimum (nevertext-slate-400alone for meaningful text)
- Every
useTranslationhook includes all namespaces the component'st()calls reference - Every page's
serverSideTranslationsincludes namespaces for all dialogs it can open (including dialogs from other modules) - No
t('namespace:key')call in a component that only hasuseTranslation('common') - All new i18n keys are added to all 7 locale files (
en,fr,de,es,it,ja,pt), not justen - New keys that use
defaultValueare still added to locale files (defaultValue is a fallback for development, not a substitute for proper translation)
All module surfaces (tables, charts, stat panels) use the same card shell:
className =
'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden';For tables that also need overflow-x-auto, compose them:
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table ...>className =
'px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900';
// Label inside:
className =
'text-[12px] font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wide';All module table header cells use a unified Direction B style:
className =
'px-1.5 py-1.5 text-left text-[11px] font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide';For tables with wider padding (e.g. All Tasks):
className =
'px-4 py-2 text-left text-[11px] font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide';// tbody divider
className = 'divide-y divide-slate-100 dark:divide-slate-700';
// hover state
className = 'hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors';// Container — flex-wrap mandatory
className =
'flex gap-0.5 bg-slate-100 dark:bg-slate-800 rounded-lg p-[3px] flex-wrap';
// Active tab
className =
'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border border-slate-200 dark:border-slate-700 shadow-xs';
// Inactive tab
className =
'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 bg-transparent border border-transparent';Self-contained, no DaisyUI dependency. Used in the All Tasks table and the dashboard Task Overview matrix.
| Module | Background | Text | Key |
|---|---|---|---|
| RPA | bg-red-600 |
text-red-100 |
rpa_procedure |
| TIA | bg-blue-600 |
text-blue-100 |
tia_procedure |
| PIA | bg-yellow-500 |
text-yellow-950 |
pia_risk |
| RM | bg-green-600 |
text-green-100 |
rm_risk |
| CSC | bg-slate-500 |
text-slate-100 |
csc_controls |
Base badge class:
className =
'inline-block whitespace-nowrap rounded px-2 py-[2px] text-[11px] font-medium leading-tight align-middle';Component: components/shared/ModuleBadge.tsx
The module title and its record-count badge live in one place only: the sticky shell Header (components/shared/shell/Header.tsx), immediately left of the search button. Module dashboards do not render their own <h1> — this used to duplicate the title and cost a row of vertical space on every page; that heading bar was removed from RPA/TIA/PIA/RM/IAP/CSC so each module's content starts directly with its toolbar.
Header's useModuleTitle() hook maps the current route to a { title, count } pair and fetches whatever data each module needs to size its count (team tasks, IAP courses, enabled CSC frameworks):
{
title && (
<span className="flex items-center gap-2 min-w-0">
<span className="truncate text-[14px] font-medium text-slate-900 dark:text-slate-100 tracking-tight">
{title}
</span>
{count > 0 && (
<span className="flex-shrink-0 text-[11px] text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded-full">
{count}
</span>
)}
</span>
);
}Counts by route:
| Route | Count meaning |
|---|---|
/tasks |
Total tasks in the team |
/rpa |
Tasks with an rpa_procedure |
/tia |
Tasks with a tia_procedure |
/pia |
Tasks with a pia_risk |
/risk-management |
Tasks with an rm_risk |
/iap |
Total courses (useIap) |
/csc |
Controls in the currently selected framework tab only (useCscIso + lib/csc/frameworks), hidden on the cross-framework Mapping Matrix tab |
| Dashboard, Settings | No count |
Sidebar nav badges (NavigationItems.tsx) use a third neutral variant (grey, bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400) for this same "total items" meaning on RPA/TIA/PIA/IAP nav items, distinct from the existing red/amber "needs attention" badges (overdue tasks, open risks, unmapped CSC controls) on Tasks/RM/CSC.
The active CSC framework tab is local UI state owned by Dashboards.tsx, which the Header can't otherwise see, so it's synced to the URL as ?framework=<iso> (or MATRIX_QUERY_VALUE for the Mapping Matrix tab) via router.replace(..., { shallow: true }) — the same pattern CscPanel.tsx already uses for its section/status/perPage filters. This also makes a specific framework tab deep-linkable/bookmarkable, and the Header's count updates live as the user switches tabs.
All table action columns use icon-only buttons. Text buttons ("Edit", "Delete") are not used — they waste column space and are inconsistent.
// Standard pair: edit + delete
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => editHandler(task)}
aria-label={t('edit-task')}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="destructive"
size="icon"
onClick={() => deleteHandler(task)}
aria-label={t('delete')}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>Applied to: TaskListView, RpaTable, TiaTable, RisksTable (PIA + RM), Members, PendingInvitations, CoursesTable, Webhooks.
File: pages/teams/[slug]/dashboard.tsx
- Top bar —
ActionRequiredBanner+ Export CSV button (flex flex-wrap items-center gap-3) - KPI strip —
KpiRow— 6-card grid (grid-cols-2 sm:grid-cols-3 lg:grid-cols-6) - Task matrix + Needs Attention —
flex flex-col lg:flex-row gap-3 - Domain Health Row —
DomainHealthRow— 3 clickable cards above the tab switcher (+ a conditional 4th Asset Management card, see below) - Tab switcher — segmented pill control (
role="tablist", full ARIA) - Tab panels — Data Protection / Cybersecurity / Risk / Asset Management (
role="tabpanel",aria-labelledby) — the 4th tab only exists in the DOM at all whenuseAssetModuleAccess(slug).isReadyis true
Six cards: Compliance score hero (col-span-2) + CSC gaps + Open tasks + Open risks + IAP completion.
IAP card behaviour:
- Shows
nonCompliantCount("X yet to complete") as sub-text - Variant:
amberif 1–49% complete with non-compliant members,redif below 50%,greenif 100% - Clicking navigates to
/teams/${slug}/iap/admin
CSC cards are rendered by CscKpiCards → CscKpiPair sub-components that call useISO + useCscStatuses internally (safe — hooks only called when ISO is known).
Location: components/interfaces/TeamDashboard/DomainHealthRow.tsx
Three equal-width side-by-side cards. Each is a <button> that switches the active dashboard tab. Each card shows:
- Status dot (red / amber / green / slate)
- Domain name (uppercase label)
- Primary metric (e.g. "67%" / "Transfers OK" / "3 high risks")
- Sub-line with detail (e.g. "2 gaps · ISO 27001")
ChevronRightarrow on hover
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ ● DATA PROTECTION│ │ ● CYBERSECURITY │ │ ● RISK MANAGEMENT│
│ Transfers OK │ │ 67% │ │ 2 high risks │
│ 🔒 4 TIA · 12 RPA│ │ 🛡 2 gaps·ISO 27k│ │ ⚠ 1 Extreme · 1 M│
└──────────────────┘ └──────────────────┘ └──────────────────┘
Status thresholds:
| Domain | Healthy | Warning | Critical |
|---|---|---|---|
| Data Protection | All TIA transfers authorized | — | Any TransferMechanism !== 'yes' |
| Cybersecurity | Score ≥ 75% | 50–74% | < 50% |
| Risk Management | No extreme/major risks | Any major (60–79) | Any extreme (≥80) |
ARIA: Each card has aria-label="Switch to [Domain] tab" and aria-pressed={active}.
Asset Management card (conditional 4th card). Unlike the three cards above, this card has no empty-state fallback — it does not render at all unless every one of the following resolves true:
All three checks live in one shared hook — hooks/fleets/useAssetModuleAccess.ts — used by both this card and the dashboard's 4th tab (see Tab panels below), so they can't drift out of sync:
canAccess('asset_dashboard', ['read'])— permission gateuseHasPlan().hasPlan(slug) === true— Ultimate plan only (NEXT_PUBLIC_ASSET_REQUIRED_PLAN)useVerifyFleetAsses()→access.is_active && !access.is_expired— an active, non-expired Fleet connection
If any gate fails (including "still loading"), the component returns null. There is deliberately no skeleton, no "Connect Fleet" CTA, and no upsell on the dashboard — teams that aren't eligible should never see this domain referenced. This is an intentional exception to the "always show with an empty state" convention used by the other three domain cards; don't "fix" it to match them.
Once rendered, it behaves exactly like its siblings: clicking it switches the dashboard to tab 3 (Asset Management), via the same onTabChange callback. Metric is the total enrolled asset count (useNodes(team.id, 'all')); status/sub-line is driven by the inactive asset count, not the raw total — an endpoint that stopped reporting is a visibility gap, which is the actual compliance signal for this audience (ISO 27001 / NIS2 asset inventory completeness), not the headline number.
| Status | Condition |
|---|---|
empty (slate) |
0 assets enrolled |
critical (red) |
≥1 asset not reporting (is_active === false) |
healthy (green) |
All enrolled assets active |
Container: DomainHealthRow's wrapper is flex flex-wrap (not a fixed 3-up row) specifically to accommodate this optional 4th card without squeezing the other three when present.
Shows up to 5 overdue tasks. Each row is a <button> with aria-label="{title}, overdue {dueDate}" that deep-links directly to /teams/${slug}/tasks/${task.taskNumber} (not the task list).
// tablist
<div role="tablist" aria-label={t('dashboard.tab.aria-label')}>
<button
id="dashboard-tab-0"
role="tab"
aria-selected={activeTab === 0}
aria-controls="dashboard-tabpanel-0"
>
// tabpanel
<div
id="dashboard-tabpanel-0"
role="tabpanel"
aria-labelledby="dashboard-tab-0"
hidden={activeTab !== 0}
>Tab 3 (Asset Management) is conditional end-to-end, not just hidden: both the tab button and its visibleTabs array entry are omitted unless useAssetModuleAccess(slug).isReady is true — the same hook used by the Domain Health Row's Asset card. Content is AssetManagementAnalysis (components/interfaces/TeamDashboard/AssetManagementAnalysis.tsx): a Total Assets card + one card per platform (Windows/Linux/macOS), reusing AssetCard and the shared lib/fleet/platformCounts.ts helpers also used by the full Asset Management dashboard page, so the two surfaces can't disagree on counts.
useTranslation('common') only resolves keys from common.json. Keys from module-specific namespaces (e.g. tia:fields.DataExporter) require that namespace to be registered in the hook. When a component uses the namespace:key prefix syntax but the hook only loads 'common', next-i18next silently strips the prefix and returns the bare key path as fallback text.
// ❌ Wrong — hook only knows 'common'; prefix is stripped, raw key rendered
const { t } = useTranslation('common');
t('tia:fields.DataExporter'); // renders "fields.DataExporter"
// ✅ Correct
const { t } = useTranslation(['common', 'tia']);
t('tia:fields.DataExporter'); // renders "a) Data exporter"Rule: Any component that calls t('namespace:key') must include that namespace in its useTranslation call.
| Component group | Required hook |
|---|---|
| TIA dialog + all 5 step files | useTranslation(['common', 'tia']) |
| PIA dialog + all 6 step files | useTranslation(['common', 'pia']) |
| RM dialog + step files | useTranslation(['common', 'rm']) |
| RPA dialog + step files | useTranslation(['common', 'rpa']) |
When a page can open dialogs from a different module (e.g. the RPA creation flow triggers TIA and PIA dialogs), all required namespaces must be listed in serverSideTranslations — even if the page's own components don't use those namespaces directly.
// pages/teams/[slug]/rpa.tsx — also loads tia + pia because RPA flow can launch both dialogs
await serverSideTranslations(locale, ['common', 'rpa', 'tia', 'pia']);| Page | Required namespaces |
|---|---|
rpa.tsx |
common, rpa, tia, pia |
tia.tsx |
common, tia |
pia.tsx |
common, pia |
risk-management.tsx |
common, rm |
dashboard.tsx |
common |
tasks.tsx |
common |
Rule: If a page renders a component tree that contains t('namespace:key') calls (even inside lazy-opened dialogs), that namespace must appear in serverSideTranslations. Missing it produces the same raw-key fallback as a missing hook namespace.
Country fields stored in the database use lowercase keys (e.g. "united states", "germany"). Always pass through the translation function before displaying:
// In tables and panels:
{
value ? t(`country.${value}`) : '—';
}
// Examples in TiaTable.tsx and TiaPanel.tsx:
<span>
{step.LawImporterCountry ? t(`country.${step.LawImporterCountry}`) : '—'}
</span>;Applies to: CountryDataExporter, CountryDataImporter, LawImporterCountry in TIA procedures.
Never display raw stored keys. They are lowercase strings like "united states" — they will render uncapitalized without the translation layer.
Files: lib/svix.ts, pages/api/teams/[slug]/webhooks/index.ts, components/webhook/Webhooks.tsx
The webhook table includes a "Last delivery" column showing the most recent Svix delivery attempt.
Architecture:
getLastWebhookAttempt(appId, endpointId)inlib/svix.tscallssvix.messageAttempt.listByEndpoint(..., { limit: 1 })handleGETin the webhooks API enriches each endpoint in parallel viaPromise.allWebhooks.tsxrendersLastDeliveryCellwithCheckCircle2(success) /Clock(pending) /XCircle(failed + HTTP status code)
Status codes (Svix MessageStatus): 0 = Success, 1 = Pending, 2 = Fail, 3 = Sending
TeamAssessmentAnalysis (TeamAssessment.tsx) shows a risk badge in the card header when transfers are at risk:
{
notPermitAuthorization > 0 ? (
<span className="inline-flex items-center gap-1 text-[11px] font-semibold bg-red-100 dark:bg-red-950/40 text-red-700 dark:text-red-400 border border-red-200 dark:border-red-800/50 px-2 py-0.5 rounded-full">
<AlertCircle size={10} aria-hidden />
{notPermitAuthorization} {t('tia-at-risk', { defaultValue: 'at risk' })}
</span>
) : totalNumberOfAssessment > 0 ? (
<span className="text-[11px] font-medium text-green-600 dark:text-green-400">
✓ {t('tia-all-permitted', { defaultValue: 'All permitted' })}
</span>
) : null;
}The canonical "donut/pie chart + total card + per-category mini-stat grid" layout (first built for the Tasks status chart, now also used by Asset Management). When adding a new chart-backed summary, reuse these — don't rebuild the hero card or stat tiles inline in the module:
HeroStatCard(components/shared/HeroStatCard.tsx) — blue gradient "total" card: uppercase label, large bold number, icon badge on the right.StatCard(components/shared/StatCard.tsx) — small card: colored dot + label, value, and value as a % of the total you pass in.- Pair with a chart card styled like
TasksPieChart.tsx/AssetPieChart.tsx: fixedxl:w-[420px], inner chart wrapperh-[220px], plain uppercase label above the chart (not a Chart.js title plugin), legendposition: 'right'withboxWidth/boxHeight: 12andfont.size: 11. - Reference implementations:
TeamTasksAnalysis.tsx,AssetAnalysis.tsx,AssetManagementAnalysis.tsx.
Location: components/shared/ModuleEmptyState.tsx
Displayed when a module has no records. Uses Shadcn Button (not DaisyUI):
<ModuleEmptyState
icon={ShieldIcon}
title={t('empty-state.rpa.title')}
description={t('empty-state.rpa.description')}
regulatoryContext="GDPR Art. 30"
ctaLabel={t('create')}
onCta={() => setIsCreateOpen(true)}
docsHref="https://www.unicis.tech/docs/..."
/>Location: components/shared/ModuleBadge.tsx
Renders a colored badge for a given module property key. No external dependencies:
<ModuleBadge propName="rpa_procedure" /> // → red "RPA"
<ModuleBadge propName="csc_controls" /> // → slate "CSC"Location: components/interfaces/Task/AuditTimeline.tsx
Reusable audit log timeline used across all modules (RPA, TIA, PIA, CSC, RM). Accepts a generic AuditLog[] array.
Location: components/shared/shadcn/TaskPicker.tsx
Searchable task selector used as step 0 in all multi-module create dialogs (RPA, TIA, PIA, RM). Built on the Combobox component (not <Select>).
<TaskPicker
control={taskForm.control}
name="task"
tasks={availableTasks} // pre-filtered by the caller (e.g. excludes tasks that already have an RPA)
/>Behaviour:
- Options rendered as
#taskNumber taskTitleso users can search by number or by name - Selected label uses
truncate— long task names never overflow the dialog width PopoverContentis a Radix portal overlay; it floats above the dialog and does not push theDialogFooterdown
Rules:
- Never replace
TaskPickerwith a plain<Select>— task titles can be very long and lists can be hundreds of items deep; a non-searchable select is unusable - The caller must filter out tasks that already have the relevant module data (e.g.
tasks.filter(t => !t.properties?.tia_procedure)) before passing them in
Location: components/shared/atlaskit/StageTracker.tsx → components/shadcn/ui/stepper.tsx
Used in all multi-step dialogs. Responsive: compact bar on mobile, full dot stepper on sm+. See Stepper section above.
Location: components/interfaces/TeamDashboard/DomainHealthRow.tsx
Three clickable domain health cards above the dashboard tab switcher. See Domain Health Row above.
Location: components/interfaces/TeamDashboard/KpiRow.tsx
Six KPI cards. Includes internal CscKpiCards and IapKpiCard sub-components that manage their own data fetching. See KPI Row above.
File: components/interfaces/Task/TaskListView.tsx
- Card wrapper: Direction B standard with
overflow-hidden+ inneroverflow-x-auto - Header typography:
text-[11px] font-medium uppercase tracking-wide - Module badges (
ModuleBadge) inline with task title in aflex flex-wrapcontainer - Toolbar:
flex-col lg:flex-rowouter +flex flex-wrapcontrols group - Hover state on rows
- Action column: icon-only Edit + Delete buttons (
Pencil,Trash2) witharia-label
File: components/interfaces/rpa/
- Card wrapper with
rounded-xl <th>uniform Direction B typography- Empty state via
ModuleEmptyState - Create button: Shadcn
<Button variant="default"> - Dialog:
RpaProcedureDialog— 6-step form, responsive Stepper, sticky-footer flex layout (flex flex-col overflow-hidden/flex-1 min-h-0 overflow-y-auto) - Task selection:
<TaskPicker>(Combobox with search) at step 0 - Table:
overflow-x-autowrapper - Action column: icon-only Edit + Delete (
Pencil,Trash2) witharia-label
File: components/interfaces/tia/
- Same card + table pattern as RPA
- Data note: Legal Analysis column shows
procedure[0].LawImporterCountrywrapped int('country.${value}')— raw stored keys are lowercase and must always go through translation - Transfer permitted/not-permitted badge: still uses
DaisyBadge(planned migration) - Dialog:
TiaProcedureDialog— 5-step, responsive Stepper, sticky-footer flex layout - Task selection:
<TaskPicker>at step 0 - Namespace: All step components use
useTranslation(['common', 'tia'])— keys live intia.json, notcommon.json - Action column: icon-only Edit + Delete with
aria-label
File: components/interfaces/pia/
- Three risk axes: confidentiality & integrity, availability, transparency & data minimization
- Data note: First column header uses
t('title')(nott('rpa')) - Dialog:
RiskAssessmentDialog— up to 6 steps (incl. optional Corrective Measures), responsive Stepper, sticky-footer flex layout - Task selection:
<TaskPicker>at step 0 - Namespace: All step components use
useTranslation(['common', 'pia'])— keys live inpia.json - Action column: icon-only Edit + Delete with
aria-label
File: components/interfaces/csc/
CscPanelrenders:CscChartsLayout→ filter toolbar →[StatusesTable + SectionRail]- Card wrapper in
CscPanelwraps bothStatusesTableandSectionRailas a unified card SectionRail:hidden lg:flex— only shown onlg+; hidden on mobile/tablet- Filter toolbar:
flex flex-wrap justify-end gap-1— wraps on narrow screens CscTabs:flex-wrapon the tab container- Framework chip row on dashboard:
flex flex-wrap - Dark mode tab fix: inactive tabs use
dark:hover:text-slate-200(notdark:text-slate-200) - Charts:
PieChartandRadarCharteach wrapped in<div role="img" aria-label="..." className="w-full h-full">— thew-full h-fullis mandatory to preserve responsive sizing - Table header cells:
px-3 py-2with Direction B typography StatusesTablecolumn:flex-1 min-w-0 [&_th]:whitespace-normal! [&_td]:whitespace-normal!- Dialog:
StatusPromptDialog(single-step, no stepper) — protected by base dialog class
File: components/interfaces/risk-management/
- Full table rewrite: 14 columns in two-row grouped header
- Column groups: Task info | Raw Risk | Treatment | Target Risk | Current Risk
- Risk color cells use a static lookup table (not dynamic Tailwind class names which break JIT)
- Asset Owner resolved via
membersById.get(userId)— Map lookup, not bracket access[userId] - Dialog:
RmRiskDialog— 2-step, responsive Stepper, sticky-footer flex layout - Task selection:
<TaskPicker>at step 0 - Action column: icon-only Edit + Delete with
aria-label
File: components/interfaces/iap/
- Completion summary banner:
flex flex-wrapouter,flex-1 min-w-[160px]progress bar - Stats row:
flex items-center gap-5 flex-shrink-0(fits on 375 px with wrap fallback from outer) - Course grid:
grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 - Admin:
CoursesTable— 4 icon-only buttons per row (edit, delete, completion-results, status-results) each witharia-labelincluding course name for context
Files: components/interfaces/AssetManagement/, pages/teams/[slug]/asset.tsx, pages/teams/[slug]/asset-management/
Inventory of enrolled endpoints (laptops/servers/workstations), backed by a separate Fleet osquery server per team. This module is Ultimate plan only and additionally requires the team to have completed its own Fleet account/connection setup — it has its own gating story distinct from every other module in this app, since most modules only check plan/permission, not a live external connection.
Three-gate access pattern (reused verbatim across Assets.tsx, the dashboard's AssetDomainCard, and the sidebar nav item):
canAccess('asset_dashboard', ['read'|'create'|'update'|'delete'])— RBAC permissionuseHasPlan().hasPlan(slug)— Ultimate plan check (NEXT_PUBLIC_ASSET_REQUIRED_PLAN, defaultULTIMATE)- Fleet connection —
useVerifyFleetAsses()(readsfleet_access_tokencookie, verifies against/api/fleet/access/verify) →access.is_active && !access.is_expired
If gate 1 or 2 fails, the feature is hidden entirely (sidebar item, dashboard card, dashboard tab). If gates 1–2 pass but gate 3 fails, the Asset Management page shows a FleetConnectRequired modal (password bootstrap/login flow) — but the dashboard card/tab stay hidden either way (see Domain Health Row above); only the full Asset Management page prompts for connection.
The gate logic lives in one place — hooks/fleets/useAssetModuleAccess.ts — consumed by the dashboard's Domain Health card and its 4th tab. Assets.tsx (the full page) and the sidebar nav item still inline the same three checks separately, since they predate the shared hook; keep all of them in sync if the gate logic ever changes.
Page heading: the Asset Management dashboard page (asset.tsx) and the Asset Details page (assets/[nodeId]) have no in-page <h1> — the module title comes entirely from the sticky header (useModuleTitle() in Header.tsx), which maps /assets/{id} → "Asset Details" and every other /asset* route → "Asset Management". Don't re-add a page-level heading; it would just duplicate the header.
Sub-pages (tabs via AssetTab.tsx, labels translated through the fleet namespace):
| Tab | Route | Purpose |
|---|---|---|
| Asset | /teams/{slug}/asset-management |
Fleet node (device) list — Nodes.tsx |
| Tags | /teams/{slug}/asset-management/tags |
Tag-based asset grouping |
| Queries | /teams/{slug}/asset-management/queries |
Saved osquery queries |
| Packs | /teams/{slug}/asset-management/packs |
Query packs |
| Distributors | /teams/{slug}/asset-management/distributors |
Distributed query tasks |
Asset list (Nodes.tsx) — Direction B conventions:
- Status badge:
AssetStatusBadge(components/shared/AssetStatusBadge.tsx) —ub-green/ub-reddot + pill,role="status", mirrorsCscStatusBadge. Not raw emoji — emoji status indicators don't carry a reliable accessible name and don't adapt to dark mode. - Table header:
text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-400, wrapped in arounded-xl bordercontainer — same as every other module table. - Mobile (
< md): stacked cards, not a horizontally-scrolled 6-column table — a wide table is unreadable at the mandatory 375 px baseline. - Pagination: client-side, 20 rows/page (
PAGE_SIZEconst) — the Fleet API has no native paging for this list endpoint yet. - Owner is shown as full first + last name (not truncated to a last initial) — compliance/audit users need unambiguous ownership.
Own REST API surface vs. external Fleet API. The platform owns /api/fleet/* (account bootstrap/login, secret management, enrollment, team-membership sync) — these 13 endpoints are documented in openapi/openapi.yaml under the Fleet (Asset Management) tag and shown at /api-docs (linked from the sidebar as REST API Docs, between the Risk Management section and the external links group). The actual node/query/pack/tag inventory CRUD is not part of this REST API — the browser calls the external Fleet server directly (lib/fleet/fleetFetcher.ts / lib/fleet/apiBase.ts) using the team's Fleet credentials, since that's Fleet's own API surface, not ours.
pages/api/fleet/debug-enrollments.ts has no auth guard at all (returns enrollment emails/status for any team to any caller) — flagged as a standalone security fix, intentionally excluded from the OpenAPI docs and from this list of "real" endpoints. Do not document or rely on it.
Asset host summary — reuses the Tasks analysis pattern verbatim, not a bespoke layout. AssetAnalysis.tsx and the dashboard's AssetManagementAnalysis.tsx (tab 3) are both structured exactly like TeamTasksAnalysis.tsx: a fixed-width xl:w-[420px], h-[220px] chart card with a plain uppercase label above it (no in-chart title, legend on the right with boxWidth/boxHeight: 12, font.size: 11 — see AssetPieChart.tsx vs TasksPieChart.tsx), next to a right column with one HeroStatCard ("Total Assets", blue gradient, big number, icon) followed by a StatCard grid (color dot + label + value + % of total) — one card per platform (Windows/Linux/macOS, always shown even at 0). HeroStatCard and StatCard live in components/shared/ specifically so this "chart + hero total + dot-stat grid" pattern is shared, not reinvented per module — TeamTasksAnalysis.tsx was refactored to consume the same two components. Platform counts come from lib/fleet/platformCounts.ts (computePlatformCounts, buildPlatformsData, platformDotClasses), used identically by both Asset Management surfaces so the numbers can't drift between them. There is no more standalone AssetCard.tsx — it was deleted once both call sites moved to StatCard/HeroStatCard.
Dashboard summary card + tab: see "Asset Management card" under Domain Health Row, above, and "Tab 3" under Tab panels.
File: pages/teams/[slug]/dashboard.tsx
- Top bar (banner + export):
flex flex-wrap items-center gap-3— wraps on mobile - KPI row:
grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 - Task matrix + Needs Attention:
flex flex-col lg:flex-row - Task Status Matrix:
overflow-x-autotable wrapper - Domain Health Row: 3 clickable cards (
DomainHealthRow) inserted above the tab switcher — summarises each domain with status colour + key metric - Tab bar:
flex gap-0.5 ... flex-wrap(3 tabs, always fits) — full ARIA wiring (tablist,tab,tabpanel) - Needs Attention: each item deep-links to
/teams/${slug}/tasks/${task.taskNumber}
Location: components/interfaces/Task/comments/
- Layout: Single-column, avatar + header in one row, content below
- Avatar:
h-7 w-7 rounded-full border border-slate-200 dark:border-slate-700 - Header:
text-[13px] font-semibold text-slate-800 dark:text-slate-100·text-[11px] text-slate-400date - Edit/Delete actions: Hover-only (
opacity-0 group-hover:opacity-100) inline with header - Compose area:
rounded-lg border focus-within:border-slate-400with QuillEditor - Mobile: single-column layout works naturally at all breakpoints; hover actions are tap-to-reveal on touch
All Create/Add/primary action buttons use Shadcn <Button variant="default">. DaisyUI color="primary" and btn btn-primary classes are removed from:
rpa/Dashboard.tsxpages/teams/[slug]/tia.tsxpia/Dashboard.tsxrisk-management/Dashboard.tsxiap/admin/AdminPage.tsxshared/ModuleEmptyState.tsx
DaisyBadge is still used in tia/TiaTable.tsx for the transfer permitted/not-permitted badge (success / error colors). This is a planned migration to a Shadcn Badge variant.
- Next.js 16.2.6 — React framework with SSR/SSG
- React 18.3.1 — UI library
- TypeScript 5.4.5 — Type safety
- Tailwind CSS 4 — Utility-first CSS framework (
darkMode: 'class') - daisyUI 5 — Legacy component library (being phased out in favour of Shadcn)
- Shadcn/UI — Headless component library (primary going forward)
- Radix UI — Primitive component library (checkboxes, dialogs, dropdowns, etc.)
- Formik 2.4.6 & React Hook Form 7.56.4 — Form handling
- SWR 2.3.6 — Data fetching with caching
- Lucide React — Icon library
- React Markdown & Quill — Rich text editing
- Next.js API Routes — Serverless API endpoints
- Prisma 7.4.2 — Database ORM with PostgreSQL adapter
- graphile-worker 0.16.6 — Background job queue for async tasks
- NextAuth.js 4.24.13 — Authentication & authorization
- SAML Jackson 1.52.2 — SAML SSO & Directory Sync (SCIM)
- Svix 1.76.1 — Webhook orchestration
- Retraced — Audit logging service
- Resend 6.2.0 — Email delivery service
- OpenAI SDK 5.23.1 — AI/Chatbot integration
- web-push 3.6.7 — Web push notifications
- bcryptjs 2.4.3 — Password hashing
- i18next 23.16.8 — Translation management
- next-i18next 15.3.0 — i18next integration for Next.js
- Supported Languages: English, French, Spanish, German, Italian, Japanese, Portuguese
- ExcelJS 4.4.0 — Excel file generation
- XLSX 0.18.5 — Spreadsheet manipulation
- jsPDF 4.2.0 — PDF generation
- React PDF Renderer 4.0.0 — PDF generation from React
- jsdom 23.2.0 — DOM in Node.js
- Playwright 1.56.1 — E2E testing
- Jest 29.7.0 — Unit testing
- ESLint 9.0.0 — Code linting
- Prettier 3.3.0 — Code formatting
- TypeScript Compiler — Type checking
- Bearer — SAST (Static Application Security Testing)
- google-recaptcha — Bot protection
- dotenv — Environment variable management
- DOMPurify — XSS prevention
- Matomo Analytics — Privacy-respecting analytics; page views plus server/client event tracking (
lib/matomo/server.ts,lib/matomo/client.ts,lib/matomo/events.ts) — e.g. pricing page content tracking, activation funnel events (first framework selection) - GlitchTip (via
@sentry/nextjs) — error tracking across client, server, and edge runtimes (sentry.client.config.ts,sentry.server.config.ts,sentry.edge.config.ts,instrumentation.ts); events tagged withNEXT_PUBLIC_APP_ENV(local/dev/prod) instead ofNODE_ENVsince both deployed environments run in production mode
unicis-platform/
├── pages/ # Next.js pages & API routes
│ ├── api/ # REST API endpoints
│ │ ├── auth/ # Authentication endpoints
│ │ ├── teams/ # Team management APIs
│ │ ├── notifications/ # Notification APIs
│ │ ├── invitations/ # Invitation handling
│ │ ├── oauth/ # SAML, OIDC, OAuth flows
│ │ ├── webhooks/ # Webhook handlers (SCIM, svix)
│ │ └── [...] # Other API routes
│ ├── auth/ # Authentication pages (login, signup, etc.)
│ ├── teams/ # Team workspace pages
│ │ └── [slug]/ # Per-team pages
│ │ ├── dashboard.tsx
│ │ ├── tasks.tsx
│ │ ├── rpa.tsx
│ │ ├── tia.tsx
│ │ ├── pia.tsx
│ │ ├── csc.tsx
│ │ ├── risk-management.tsx
│ │ └── iap/
│ ├── notifications/ # Notification pages
│ ├── settings/ # Settings pages
│ └── [...] # Other pages
├── components/ # Reusable React components
│ ├── interfaces/ # Module-specific interface components
│ │ ├── Task/ # Task management (list, kanban, comments)
│ │ ├── rpa/ # Record of Processing Activities
│ │ ├── tia/ # Transfer Impact Assessment
│ │ ├── pia/ # Privacy Impact Assessment
│ │ ├── csc/ # Cybersecurity Controls
│ │ ├── risk-management/ # Risk Management
│ │ ├── iap/ # Interactive Awareness Program
│ │ └── TeamDashboard/ # Dashboard widgets
│ ├── account/ # Account-related components
│ ├── auth/ # Auth components (forms, dialogs)
│ ├── team/ # Team workspace components
│ ├── shared/ # Shared/common components
│ │ ├── ModuleBadge.tsx # Colored module identifier badge
│ │ ├── ModuleEmptyState.tsx # Empty state with CTA
│ │ ├── StatusBadge.tsx
│ │ ├── MemberName.tsx
│ │ ├── shell/
│ │ │ ├── AppShell.tsx # Main layout shell (sidebar + main)
│ │ │ ├── Header.tsx # Responsive sticky header
│ │ │ ├── Drawer.tsx # Mobile sidebar (accessible dialog pattern)
│ │ │ ├── AccountDropdown.tsx
│ │ │ └── GlobalSearch.tsx
│ │ └── [...]
│ ├── notifications/ # Notification UI components
│ └── shadcn/ # Shadcn UI components
│ └── ui/
│ ├── dialog.tsx # Base dialog — responsive defaults
│ ├── stepper.tsx # Responsive 2-tier stepper
│ └── [...]
├── lib/ # Utility functions & helpers
│ ├── csc/ # CSC module logic
│ ├── rpa/ # RPA helpers
│ ├── pia/ # PIA helpers
│ ├── tia/ # TIA helpers
│ ├── rm/ # Risk Management helpers
│ ├── svix.ts # Svix SDK wrapper (webhooks + last delivery attempt)
│ └── [...]
├── hooks/ # React custom hooks
│ ├── useTeamMembersMap.ts # Map<userId, name> for member lookups
│ ├── useISO.ts # Active CSC framework for a team
│ ├── useCscStatuses.ts # Control statuses for a framework
│ ├── useCanAccess.ts
│ ├── usePagination.ts
│ └── [...]
├── prisma/ # Database schema & migrations
├── workers/ # Background workers
├── types/ # TypeScript type definitions
│ ├── rm.ts # RMProcedureInterface (two-tuple)
│ ├── iap.ts # TeamCourseWithProgress
│ └── [...]
├── locales/ # i18n translation files (en, fr, de, es, it, ja, pt)
├── styles/ # Global CSS (quill-view-mode, etc.)
├── public/ # Static assets
├── src/
│ └── mcp-server/ # Unicis MCP Server — standalone Node.js package (own package.json/tsconfig.json)
│ └── src/
│ ├── index.ts # stdio + HTTP transport entry point
│ ├── services/api.ts # typed REST client against the platform's public API
│ └── tools/ # MCP tool registrations (tasks, compliance, privacy, risk)
└── [config files]
- Email-based identity (unique email)
- Password (optional, for OAuth users)
- First name, last name, profile image
- Sessions for multi-device support
- Workspace/organization container
- Unique slug for URL routing
- Optional domain for SAML SSO
- Default role for new members
- Task indexing for sequential task numbering
- Custom properties (JSON)
- User's role within a team (ADMIN, OWNER, MEMBER, AUDITOR)
- Association between User and Team
- Course progress tracking
- Task: Individual to-do items with status, priority, due dates
- TaskRecurrence: Scheduled recurring tasks (daily, weekly, monthly, yearly)
- Kanban board support via
kanbanOrder - Comments and attachments
- Priority levels: low, medium, high
- Properties (JSON) for module-specific data:
rpa_procedure— RPA multi-step form datatia_procedure— TIA procedure (4-step tuple)pia_risk— PIA risk categoriesrm_risk— RM two-step tuple[RawRisk, Treatment]csc_controls— array of control IDs
RM Risk (RMProcedureInterface)
type RMProcedureInterface = [
{
Risk: string;
AssetOwner: string;
Impact: string;
RawProbability: number;
RawImpact: number;
},
{
RiskTreatment: string;
TreatmentCost: string;
TreatmentStatus: number;
TreatedProbability: number;
TreatedImpact: number;
},
];TIA Procedure
procedure[0] = TransferScenario (DataExporter, DataImporter, StartDateAssessment, LawImporterCountry, AssessmentYears)
procedure[1] = ProblematicLawfulAccess
procedure[2] = Risk
procedure[3] = Probability/Conclusion (used by isTranferPermitted())- Notification: User messages (in-app, email, web push)
- NotificationPreference: Per-user channel preferences
- PushSubscription: Web push subscription endpoints
- Course: Training/awareness program content
- TeamCourse: Many-to-many association with progress tracking
- CourseProgress:
{ teamCourseId, teamMemberId, progress: Int (0–100), answers: Json } - Category: Course categorization
- ApiKey: Bearer token authentication for API access
- Team-scoped keys with optional expiry
- Subscription: Team plan (COMMUNITY, PREMIUM, ULTIMATE)
- Payment: Payment records
- Multi-method: Email/Password, Magic link, SAML SSO, Google OAuth, GitHub OAuth, SCIM
- RBAC: ADMIN, OWNER, MEMBER, AUDITOR
useCanAccess(slug)hook for client-side permission checks
- Kanban board + List view (Direction B table)
- Priority, status, due date, recurrence
- Comments (Direction B redesign: avatar + hover-reveal edit/delete)
- File attachments
- Webhooks via Svix
- Module badges inline in the list view
- KPI row: compliance score (hero), CSC gaps, open tasks (overdue indicator), open risks, IAP completion (non-compliant count + admin link)
- Domain Health Row: 3 clickable cards (Data Protection / Cybersecurity / Risk) that switch the active tab
- Task Overview by Module matrix (clickable rows → module pages, colored
ModuleBadge) - Needs Attention panel (overdue tasks — deep-linked to individual task)
- Compliance charts (CSC, PIA, RM tabs)
- Global search across tasks and module fields
- Export CSV of all tasks
- GDPR Art. 30 documentation
- Multi-step creation form (6 steps)
- Export: XLSX, ODS, CSV, HTML, PDF
- Cross-border data transfer analysis
- Legal Analysis column: shows
LawImporterCountryfromprocedure[0]— always displayed viat('country.${value}')translation - Transfer permitted/not-permitted badge via
isTranferPermitted()
- Three risk axes: confidentiality & integrity, availability, transparency & data minimisation
- Optional Corrective Measures step (triggered when any risk score > 10)
- Risk matrix filter
- Multi-framework support (ISO 27001:2013/2022, NIST CSF 2.0, CIS, NIS2, GDPR, SOC 2, PCI DSS, OWASP ASVS v5, C5:2020, ISO 42001)
- Framework mapping matrix
- Statement of Applicability (SoA) export (XLSX, ODS, PDF, HTML)
- Section rail for per-section compliance navigation (desktop only,
hidden lg:flex) - Bulk control selection + status assignment
- URL-encoded filter state (section + status + perPage)
- 14-column table with grouped section headers (Raw Risk / Treatment / Target Risk / Current Risk)
- Risk rating calculated from
RawProbability × RawImpact - Asset Owner resolved via
useTeamMembersMap→membersById.get(userId)(Map API, not bracket access)
- Course catalogue with thumbnail, category, estimated time
- Progress tracking per team member (
CourseProgress.progress0–100) - Completion summary banner (total / completed / in-progress / %)
- Admin: create categories and courses; 4-icon action column per course row
- Ultimate plan only, additionally gated on a live connection to the team's Fleet osquery server (see "Asset Management (Fleet)" under Module-by-Module Design Notes for the full 3-gate pattern)
- Sub-modules: Asset (node) list, Tags, Queries, Packs, Distributors
- Asset status shown via token-based
AssetStatusBadge(not emoji), mobile stacked-card fallback, client-side pagination - Dashboard surfaces a 4th Domain Health card + a 4th tab (host totals per platform) only when fully gated — no empty/upsell state for ineligible teams
- Svix-powered webhook endpoints
- Last delivery status column: success / pending / failed (HTTP code) with timestamp
- Delivery data fetched server-side via
getLastWebhookAttempt()usingmessageAttempt.listByEndpoint(appId, endpointId, { limit: 1 })
- OpenAPI 3.0 — Swagger UI at
/api-docs, linked from the sidebar as REST API Docs (positioned right after Risk Management, before the external links group — see Shell & Navigation) - Bearer token authentication via API keys
- Endpoints: Tasks, CSC, RM, PIA, RPA, TIA, API Keys, AI Chatbot, Notifications, Fleet (Asset Management)
- Fleet tag covers only the platform's own
/api/fleet/*endpoints (account bootstrap, secret, enrollment, revoke, sync-member, etc.) — Fleet's own node/query/pack inventory API is called directly by the browser and is intentionally not duplicated here
- Located at
src/mcp-server— a standalone Node.js package (ownpackage.json/tsconfig.json, not part of the Next.js build) that exposes the platform's REST API as MCP tools - Connects AI assistants (Claude, Cursor, VS Code Copilot, any MCP-compatible client) to manage Tasks, RoPA, TIA, PIA, CSC statuses, Risk records, attachments, comments, and API keys through natural language
- Supports stdio transport (Claude Desktop, local) and HTTP transport (remote/
claude.aiweb, behind a reverse proxy) - See src/mcp-server/README.md for the full tool reference and setup instructions
- Per-module
AuditTimelinecomponent - Integrated with Retraced
- In-app bell, email, web push
- Per-user preferences
- Types: task due, created, updated, deleted, commented, file uploaded, comment reacted
- Credential Providers: Email/Password, Magic Link, OAuth (Google, GitHub), SAML SSO
- Session Management: JWT, HTTP-only cookies
- Authorization:
lib/permissions.ts,useCanAccesshook
/api/auth/* - Authentication
/api/teams/* - Team management
/api/teams/[slug]/* - Team-specific resources
/api/notifications/* - Notifications
/api/invitations/* - Invitations
/api/oauth/* - SAML/OIDC
/api/webhooks/* - Webhook receivers
/api/chatbot - AI chatbot
/api/users - User management
/api/health - Health check
/api-docs - Swagger UI
- Graphile-worker for:
send-notification,send-email,send-push-notification,process-recurrence - Run:
npm run worker
- XSS: DOMPurify, sanitized Quill output
- CSRF: NextAuth CSRF tokens
- Password: bcryptjs hashing
- API Keys: Hashed storage, team-scoped
- Rate limiting:
lib/rate-limit.ts - Headers:
X-Frame-Options: DENY,HSTS,X-Content-Type-Options: nosniff - SAST: Bearer CI/CD scan
- SBOM: Generated and published
DATABASE_URL— PostgreSQLNEXTAUTH_SECRET,NEXTAUTH_URLRESEND_API_KEY— Email- Google/GitHub OAuth credentials
OPENAI_API_KEY— AI chatbot- SAML Jackson config
docker-compose up -d # Postgres + Redis
npx prisma db push
npm run dev # port 4002
npm run workernpm run build # Prisma client → migrations → OpenAPI → Next.js- E2E: Playwright —
npm run test:e2e(Chromium, Firefox) - Unit: Jest —
npm run test - Type check:
npm run check-types - Lint:
npm run check-lint - All:
npm run test-all
| File | What it covers |
|---|---|
lib/tasks/status-keys.spec.ts |
Regression guard — task status values have no hyphens; statusLabels, isTaskPriority, hasTaskModule, getTaskModules |
lib/csc/helpers.spec.ts |
getCscStatusesProp, getCscControlsProp, CSC status values and numeric ordering |
lib/dashboard/task-status-matrix.spec.ts |
Status count logic — inprogress/inreview keys (not hyphenated variants) produce correct counts |
pages/api/teams/tasks/index.spec.ts |
GET list, POST create (valid/invalid), 405 for unsupported methods |
pages/api/teams/tasks/taskNumber.spec.ts |
GET found/404/400; PUT update/status-key/bad-priority/bad-duedate/404; DELETE success/404 |
pages/api/teams/csc/update-status.spec.ts |
PUT passes correct args, returns updated map; 405 for GET/DELETE/POST |
pages/api/teams/csc/get-statuses.spec.ts |
GET returns statuses, empty map; 405 for PUT/DELETE |
Jest config notes:
- Use bare paths in
jest.mock()factories —'lib/svix'not'@/lib/svix'. The@/alias resolves via Next.js/tsconfig but not in Jest mock factories even withmoduleDirectories. sanitizeRichTextmust be mocked in API handler tests — it importsjsdom/whatwg-urlwhich requiresTextEncodernot available injest-environment-jsdom.
| File | What it covers |
|---|---|
dashboard/task-matrix.spec.ts |
Dashboard loads without errors; matrix headers show translated labels (no raw keys); domain health tab switching |
tasks/task-management.spec.ts |
Task list loads; status cards show numeric values; filter labels use friendly names (not inprogress/inreview); kanban activation; task detail navigation |
csc/bulk-actions.spec.ts |
CSC page loads; control status cells show values not raw keys; bulk selection shows action bar; bulk status change updates all selected controls without page refresh; task assignment reflects immediately |
E2E prerequisites: Set TEST_TEAM_SLUG env var (defaults to demo). Auth state must be pre-configured in playwright.config.ts via storageState.
Every PR that touches module UI must verify:
- Status key correctness — run
lib/tasks/status-keys.spec.ts; no hyphens in status values - Dashboard counting — run
lib/dashboard/task-status-matrix.spec.ts; In Progress and In Review must have non-zero counts when tasks exist - API contracts — run the relevant
pages/api/teams/...spec for each touched endpoint - Translation completeness — manually verify in a non-English locale that no raw keys appear; add missing keys to all 7 locale files
- Dialog footer visibility — open every multi-step dialog at step 0 and confirm the Next/Save button is visible without scrolling, at both 375 px and 1280 px viewports
- SWR caching for all data fetching
- Pagination on all module tables (
usePaginationhook) - DB indexes on
duedate,teamId/status/kanbanOrder,recipientId/isRead - Dynamic imports for large chart components
- Next.js Image for thumbnail optimisation
- Server:
lib/errors.ts+ consistent JSON response envelope - Client:
react-hot-toastfor feedback, SWR retry - Loading / Error shared components throughout module pages
- Right to Erasure Request Form
- Processor Questionnaire Checklist
- Asset Inventory Management
- Vendor Assessment Checklist
- Benchmark Report / Trust Center
- Incident Management
- Document Management / Policy Lifecycle
- Custom Framework Support (JSON/YAML DSL)
- Jira Integration (bidirectional)
- AI Questionnaire Automation
- Continuous Control Monitoring
- Executive Dashboards & Reporting
- EU Cyber Resilience Act (CRA)
- EU Digital Operational Resilience Act (DORA)
- Cloud Controls Matrix (CCM) v4
- TISAX
- EU AI Act
- HIPAA
- Custom frameworks
- Sparklines on KPI cards (requires backend
kpi_snapshotstable with daily compliance/risk values — frontendKpiStripcomponent already has SVG sparkline support viasparkline?: number[]prop) - Migrate remaining DaisyUI components to Shadcn (
DaisyBadgein TIA table) - Per-module URL filter persistence for CSC section / status filters
- Responsive breakpoint testing in CI (Playwright viewport matrix)
- GitHub: UnicisTech/unicis-platform-ce
- Documentation: https://www.unicis.tech/docs/platform
- Discord: https://discord.com/invite/8TwyeD97HD
- LinkedIn: https://www.linkedin.com/company/unicis-tech-oü/
Apache License 2.0 — See LICENSE file for details.
See CONTRIBUTING.md for guidelines on bug reporting, feature requests, code contributions, and pull request process.
Accessibility checklist and Responsive design checklist (above) must be included in every pull request description that touches UI components.
| Term | Meaning |
|---|---|
| RPA | Record of Processing Activities (GDPR Art. 30) |
| TIA | Transfer Impact Assessment (cross-border data transfers) |
| PIA | Privacy Impact Assessment |
| CSC | Cybersecurity Controls (multi-framework control mapping) |
| IAP | Interactive Awareness Program (training courses) |
| RM | Risk Management |
| SoA | Statement of Applicability |
| SAML | Security Assertion Markup Language (SSO) |
| SCIM | System for Cross-domain Identity Management |
| JWT | JSON Web Token |
| SAST | Static Application Security Testing |
| SBOM | Software Bill of Materials |
| RBAC | Role-Based Access Control |
| DPO | Data Protection Officer |
| CISO | Chief Information Security Officer |
| WCAG | Web Content Accessibility Guidelines |