diff --git a/.claude/skills/frontend-dev-guidelines/resources/forms-and-modals.md b/.claude/skills/frontend-dev-guidelines/resources/forms-and-modals.md
index c442834f4c..0ed2704d52 100644
--- a/.claude/skills/frontend-dev-guidelines/resources/forms-and-modals.md
+++ b/.claude/skills/frontend-dev-guidelines/resources/forms-and-modals.md
@@ -93,13 +93,13 @@ import { getFieldErrors, handleSubmit } from '@app-builder/utils/form';
### Form Components
-| Component | Props | Purpose |
-|-----------|-------|---------|
-| `FormInput` | `type`, `name`, `defaultValue`, `onChange`, `onBlur`, `valid` | Text/number input with error border |
-| `FormLabel` | `name`, `valid` | Label linked to input via `htmlFor` |
-| `FormErrorOrDescription` | `errors?`, `description?` | Shows errors or help text |
-| `FormTextArea` | Same as FormInput | Multi-line input |
-| `FormError` | `field`, `asString?`, `translations?` | Translates Zod error codes to i18n |
+| Component | Props | Purpose |
+| ------------------------ | ------------------------------------------------------------- | ----------------------------------- |
+| `FormInput` | `type`, `name`, `defaultValue`, `onChange`, `onBlur`, `valid` | Text/number input with error border |
+| `FormLabel` | `name`, `valid` | Label linked to input via `htmlFor` |
+| `FormErrorOrDescription` | `errors?`, `description?` | Shows errors or help text |
+| `FormTextArea` | Same as FormInput | Multi-line input |
+| `FormError` | `field`, `asString?`, `translations?` | Translates Zod error codes to i18n |
### Number Fields
@@ -161,14 +161,14 @@ export function DeleteConfirmModal({ onDelete }: { onDelete: () => void }) {
### Modal Components
-| Component | Purpose |
-|-----------|---------|
-| `Modal.Root` | Root state manager. Props: `open`, `onOpenChange` |
-| `Modal.Trigger` | Opens the modal. Use `asChild` to wrap custom trigger |
-| `Modal.Content` | Dialog container. Props: `size` (`small`/`medium`/`large`/`xlarge`) |
-| `Modal.Title` | Dialog title (required for accessibility) |
-| `Modal.Close` | Closes on click. Use `asChild` to wrap custom button |
-| `Modal.Footer` | Standard buttons in the footer, asClosed for the buttons that close the modal, with optional icon and isLoading state |
+| Component | Purpose |
+| --------------- | --------------------------------------------------------------------------------------------------------------------- |
+| `Modal.Root` | Root state manager. Props: `open`, `onOpenChange` |
+| `Modal.Trigger` | Opens the modal. Use `asChild` to wrap custom trigger |
+| `Modal.Content` | Dialog container. Props: `size` (`small`/`medium`/`large`/`xlarge`) |
+| `Modal.Title` | Dialog title (required for accessibility) |
+| `Modal.Close` | Closes on click. Use `asChild` to wrap custom button |
+| `Modal.Footer` | Standard buttons in the footer, asClosed for the buttons that close the modal, with optional icon and isLoading state |
### Modal with Form
@@ -196,7 +196,7 @@ Wrap `
+ );
+}
diff --git a/packages/backoffice/src/components/organisms/CreateOrganizationPanel/ImportFlow.tsx b/packages/backoffice/src/components/organisms/CreateOrganizationPanel/ImportFlow.tsx
new file mode 100644
index 0000000000..a4b1f6a033
--- /dev/null
+++ b/packages/backoffice/src/components/organisms/CreateOrganizationPanel/ImportFlow.tsx
@@ -0,0 +1,446 @@
+import { importOrganization } from '@bo/data/organization';
+import { OrgImportSpec } from '@bo/schemas/org-import';
+import { useForm } from '@tanstack/react-form';
+import { useMutation } from '@tanstack/react-query';
+import { type ReactNode, useState } from 'react';
+import { Button, Checkbox, Input, Panel, PanelSharpFactory, Tabs, Typo, tabClassName } from 'ui-design-system';
+import { Icon } from 'ui-icons';
+import { z } from 'zod/v4';
+
+const FORM_ID = 'org-import-form';
+
+// Both sanctions fields are `*int` on the backend, so reject floats here rather than
+// letting the spec schema fail later with no field to point at.
+const numericString = z
+ .string()
+ .optional()
+ .refine((value) => !value || Number.isInteger(Number(value)), 'Enter a whole number.');
+
+const importEditSchema = z.object({
+ org: z.object({
+ name: z.string().min(1, 'A name is required.'),
+ default_scenario_timezone: z.string().optional(),
+ sanctions_threshold: numericString,
+ sanctions_limit: numericString,
+ }),
+ admins: z
+ .array(
+ z.object({
+ // Only the email is required — the backend stores both names as-is and never
+ // validates them (createAdmins in org_import_usecase.go).
+ email: z.email('Enter a valid email address.'),
+ first_name: z.string(),
+ last_name: z.string(),
+ }),
+ )
+ // The export never carries admins, and the backend indexes `admins[0]` without a guard
+ // (org_import_usecase.go), so submitting an empty list crashes the import.
+ .min(1, 'Add at least one admin.'),
+});
+
+type ImportEditValues = z.infer;
+
+export const ImportFlow = ({ data }: { data: OrgImportSpec }) => {
+ const panelSharp = PanelSharpFactory.useSharp();
+ const importOrgMutation = useMutation(importOrganization());
+
+ const form = useForm({
+ defaultValues: {
+ org: {
+ name: data.org.name,
+ default_scenario_timezone: data.org.default_scenario_timezone ?? '',
+ sanctions_threshold: data.org.sanctions_threshold?.toString() ?? '',
+ sanctions_limit: data.org.sanctions_limit?.toString() ?? '',
+ },
+ admins: (data.admins ?? []).map((admin) => ({
+ email: admin.email,
+ first_name: admin.first_name ?? '',
+ last_name: admin.last_name ?? '',
+ })),
+ } as ImportEditValues,
+ validators: {
+ onMount: importEditSchema,
+ onChange: importEditSchema,
+ onSubmit: importEditSchema,
+ },
+ onSubmit: async ({ value, formApi }) => {
+ if (!formApi.state.isValid) return;
+ const editedSpec: OrgImportSpec = {
+ ...data,
+ org: {
+ // Spread first: the form only covers four fields, and rebuilding `org` from
+ // scratch would drop everything else the spec carries (screening_providers,
+ // environment, and any key a newer backend added).
+ ...data.org,
+ name: value.org.name,
+ default_scenario_timezone: value.org.default_scenario_timezone || undefined,
+ sanctions_threshold: value.org.sanctions_threshold ? Number(value.org.sanctions_threshold) : undefined,
+ sanctions_limit: value.org.sanctions_limit ? Number(value.org.sanctions_limit) : undefined,
+ },
+ admins: value.admins,
+ };
+ await importOrgMutation.mutateAsync(editedSpec);
+
+ panelSharp.actions.close();
+ },
+ });
+
+ return (
+ <>
+
+ {(adminsField) => (
+ adminsField.pushValue({ email: '', first_name: '', last_name: '' })}
+ >
+
+ Add admin
+
+ }
+ >
+ {adminsField.state.value.length === 0 ? (
+
+ No admins yet — add at least one to administer this organization.
+
+ ) : (
+
+ {adminsField.state.value.map((_, index) => (
+
+
+
+ {(field) => (
+
+ field.handleChange(event.target.value)}
+ onBlur={field.handleBlur}
+ placeholder="admin@company.com"
+ />
+ {field.state.meta.isTouched ? (
+ {firstError(field.state.meta.errors)}
+ ) : null}
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {/* -------- Read-only recap (imported as-is) -------- */}
+
+
+
+ {importOrgMutation.isError ? (
+
+
+ Import failed. Check the settings above and try again.
+
+ ) : null}
+
+
+
+
+ [state.canSubmit] as const}>
+ {([canSubmit]) => (
+
+ )}
+
+
+ >
+ );
+};
+
+/* -------------------------------- Helpers ---------------------------------- */
+
+function Section({
+ title,
+ hint,
+ action,
+ children,
+}: {
+ title: string;
+ hint?: string;
+ action?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+ {hint ? {hint} : null}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+function Field({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+function ErrorText({ children }: { children: ReactNode }) {
+ if (!children) return null;
+ return {children};
+}
+
+function firstError(errors: unknown[]): string | null {
+ for (const error of errors) {
+ if (typeof error === 'string') return error;
+ if (error && typeof error === 'object' && 'message' in error) {
+ return String((error as { message: unknown }).message);
+ }
+ }
+ return null;
+}
+
+/* --------------------------- Read-only recap ------------------------------- */
+
+/**
+ * `seeds` is a struct on the backend, so an export always carries it — with both maps
+ * `null`. Presence therefore says nothing; only content does.
+ */
+function seedLines(seeds: OrgImportSpec['seeds']) {
+ if (!seeds) return [];
+ return [
+ ...Object.entries(seeds.ingestion ?? {}).map(([table, { count }]) => `${count} ${table}`),
+ ...Object.entries(seeds.decisions ?? {}).map(([triggerType, count]) => `${count} ${triggerType} decisions`),
+ ];
+}
+
+const SeedingSection = ({ lines }: { lines: string[] }) => {
+ if (lines.length === 0) return null;
+
+ return (
+
+
+ Seeding
+
+
+
+
Activating the seeding will create:
+
+ {lines.map((line) => (
+
{line}
+ ))}
+
+
+
+ );
+};
+
+const DataModelRecap = ({ data }: { data: OrgImportSpec }) => {
+ const tables = data.data_model.tables ?? [];
+ const [activeTab, setActiveTab] = useState(tables[0]?.name);
+ const currentTable = tables.find((table) => table.name === activeTab);
+
+ return (
+
+
+ Data model
+ ({tables.length} tables)
+
+
+ {tables.map((table) => (
+
+ ))}
+
+ {currentTable ? (
+
+
+
Name
+
Description
+
Type
+
+ {Object.entries(currentTable.fields ?? {}).map(([key, field]) => (
+
+
{key}
+
{field.description}
+
{field.data_type}
+
+ ))}
+
+
+ ) : null}
+
+ );
+};
+
+type TableLinksProps = {
+ data: OrgImportSpec;
+ currentTable: NonNullable[number];
+};
+
+const TableLinks = ({ data, currentTable }: TableLinksProps) => {
+ const linksForTable = (data.data_model.links ?? []).filter(
+ (link) => link.parent_table_name === currentTable.name || link.child_table_name === currentTable.name,
+ );
+
+ return (
+ <>
+
+ {linksForTable.map((link) => (
+
+
+ {link.parent_table_name} → {link.child_table_name}
+
+
+ ))}
+ >
+ );
+};
diff --git a/packages/backoffice/src/components/organisms/CreateOrganizationPanel/index.tsx b/packages/backoffice/src/components/organisms/CreateOrganizationPanel/index.tsx
new file mode 100644
index 0000000000..3824a163a4
--- /dev/null
+++ b/packages/backoffice/src/components/organisms/CreateOrganizationPanel/index.tsx
@@ -0,0 +1,42 @@
+import { useState } from 'react';
+import { match } from 'ts-pattern';
+import { Panel } from 'ui-design-system';
+import { ChoiceStep } from './ChoiceStep';
+import { ImportFlow } from './ImportFlow';
+import { OrganizationCreationFlow } from './types';
+
+export const CreateOrganizationPanel = ({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) => {
+ const [flow, setFlow] = useState(null);
+ const handleChooseFlow = (choice: OrganizationCreationFlow) => {
+ setFlow(choice);
+ };
+
+ const handleOnOpenChange = (open: boolean) => {
+ if (!open) {
+ setFlow(null);
+ }
+ onOpenChange(open);
+ };
+
+ return (
+
+
+
+ Create a new organization
+
+ {match(flow)
+ .with(null, () => )
+ .with({ type: 'import' }, ({ data }) => )
+ .exhaustive()}
+
+
+
+
+ );
+};
diff --git a/packages/backoffice/src/components/organisms/CreateOrganizationPanel/types.ts b/packages/backoffice/src/components/organisms/CreateOrganizationPanel/types.ts
new file mode 100644
index 0000000000..2a4daebfd6
--- /dev/null
+++ b/packages/backoffice/src/components/organisms/CreateOrganizationPanel/types.ts
@@ -0,0 +1,3 @@
+import { OrgImportSpec } from '@bo/schemas/org-import';
+
+export type OrganizationCreationFlow = { type: 'import'; data: OrgImportSpec };
diff --git a/packages/backoffice/src/components/organisms/FeatureAccessPanel/index.tsx b/packages/backoffice/src/components/organisms/FeatureAccessPanel/index.tsx
new file mode 100644
index 0000000000..ee7479870e
--- /dev/null
+++ b/packages/backoffice/src/components/organisms/FeatureAccessPanel/index.tsx
@@ -0,0 +1,194 @@
+import { makeQueryErrorComponent } from '@bo/components/common/ErrorComponent';
+import { SuspenseQuery } from '@bo/components/core/SuspenseQuery';
+import { listOrganizationFeatures, patchOrganizationFeatures } from '@bo/data/organization';
+import {
+ type FeatureValue,
+ OVERRIDABLE_FEATURES,
+ type PatchOrganizationFeaturesPayload,
+ patchOrganizationFeaturesPayloadSchema,
+} from '@bo/schemas/features';
+import { useForm } from '@tanstack/react-form';
+import { useMutation } from '@tanstack/react-query';
+import { FeatureAccessDto } from 'marble-api/generated/backoffice-api';
+import { cn, Panel } from 'ui-design-system';
+
+const FormError = makeQueryErrorComponent(
+ Could not load feature access.,
+);
+
+type FeatureAccessPanelProps = {
+ orgId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function FeatureAccessPanel({ orgId, open, onOpenChange }: FeatureAccessPanelProps) {
+ return (
+
+
+
+
+
+
Feature access
+
+ Set which capabilities this organization can use.
+
+
+
+ }
+ errorComponent={FormError}
+ >
+ {(featureAccess) => (
+ onOpenChange(false)} />
+ )}
+
+
+
+
+ );
+}
+
+const FEATURE_LABELS: Record<(typeof OVERRIDABLE_FEATURES)[number], string> = {
+ test_run: 'Test run',
+ sanctions: 'Sanctions',
+ case_auto_assign: 'Case auto-assign',
+ case_ai_assist: 'Case AI assist',
+ continuous_screening: 'Continuous screening',
+ ai_rule_building: 'AI Rule building',
+ lexisnexis: 'Lexis Nexis',
+};
+
+const ACCESS_LEVELS: { value: FeatureValue; label: string; dot: string }[] = [
+ { value: 'restricted', label: 'Restricted', dot: 'bg-grey-disabled' },
+ { value: 'test', label: 'Test', dot: 'bg-yellow-primary' },
+ { value: 'allowed', label: 'Allowed', dot: 'bg-green-primary' },
+];
+
+const FORM_ID = 'feature-access-form';
+
+function FeaturesForm({
+ orgId,
+ featureAccess,
+ onSaved,
+}: {
+ orgId: string;
+ featureAccess: FeatureAccessDto;
+ onSaved: () => void;
+}) {
+ const patchMutation = useMutation(patchOrganizationFeatures());
+
+ const form = useForm({
+ defaultValues: Object.fromEntries(
+ OVERRIDABLE_FEATURES.map((feature) => [feature, featureAccess[feature]]),
+ ) as PatchOrganizationFeaturesPayload,
+ validators: {
+ onMount: patchOrganizationFeaturesPayloadSchema,
+ onChange: patchOrganizationFeaturesPayloadSchema,
+ onSubmit: patchOrganizationFeaturesPayloadSchema,
+ },
+ onSubmit: async ({ value, formApi }) => {
+ if (!formApi.state.isValid) return;
+ await patchMutation.mutateAsync({ orgId, features: value });
+ onSaved();
+ },
+ });
+
+ return (
+ <>
+
+ {(field) => (
+
+
{FEATURE_LABELS[feature]}
+
field.handleChange(value)}
+ />
+
+ )}
+
+ ))}
+
+
+
+
+ [state.canSubmit, state.isDirty]}>
+ {([canSubmit, isDirty]) => (
+
+ )}
+
+
+ >
+ );
+}
+
+function AccessSegmentedControl({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: FeatureValue | undefined;
+ onChange: (value: FeatureValue) => void;
+}) {
+ return (
+
+ {ACCESS_LEVELS.map((level) => {
+ const active = value === level.value;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function FeaturesFormSkeleton() {
+ return (
+
+ {Array.from({ length: OVERRIDABLE_FEATURES.length }).map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/backoffice/src/components/pages/dashboard.tsx b/packages/backoffice/src/components/pages/dashboard.tsx
new file mode 100644
index 0000000000..d4a1e3f736
--- /dev/null
+++ b/packages/backoffice/src/components/pages/dashboard.tsx
@@ -0,0 +1,211 @@
+import { makeQueryErrorComponent } from '@bo/components/common/ErrorComponent';
+import { SuspenseQuery } from '@bo/components/core/SuspenseQuery';
+import { CreateOrganizationPanel } from '@bo/components/organisms/CreateOrganizationPanel';
+import { listOrganizationsQueryOptions } from '@bo/data/organization';
+import { useRouter } from '@tanstack/react-router';
+import { type ReactNode, useState } from 'react';
+import { Button, Command, CommandEmpty, CommandInput, CommandItem, CommandList, Kbd, Typo } from 'ui-design-system';
+import { Icon } from 'ui-icons';
+
+const ErrorComponent = makeQueryErrorComponent(Something went wrong while fetching organizations);
+
+export function DashboardPage() {
+ const [isCreatingOrg, setIsCreatingOrg] = useState(false);
+ const openCreate = () => setIsCreatingOrg(true);
+
+ return (
+ <>
+
+
+
+
+ }
+ errorComponent={ErrorComponent}
+ >
+ {(organizations) =>
+ organizations.length === 0 ? (
+
+
+
+ ) : (
+
+
+
+
+ )
+ }
+
+
+
+ >
+ );
+}
+
+function LaunchpadColumn({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function LaunchpadHeader({ onCreate }: { onCreate: () => void }) {
+ return (
+
+ );
+}
+
+type Organization = { id: string; name: string };
+
+function OrganizationLauncher({ organizations, onCreate }: { organizations: Organization[]; onCreate: () => void }) {
+ const router = useRouter();
+ const [query, setQuery] = useState('');
+
+ const openOrganization = (orgId: string) => {
+ router.navigate({ to: '/organizations/$orgId', params: { orgId } });
+ };
+
+ const sorted = [...organizations].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+ const hasQuery = query.trim().length > 0;
+
+ return (
+
+
+
+
+ {query.length > 0 ? (
+
+ ) : null}
+
+
+
+ {hasQuery ? (
+
+
+ No organization matches your search.
+
+
+
+ ) : null}
+
+ {sorted.map((organization) => (
+ openOrganization(organization.id)}
+ className="group gap-md rounded-lg px-md py-sm data-[selected=true]:bg-purple-background-light"
+ >
+
+ {organization.name.charAt(0).toUpperCase()}
+
+
+ {organization.name}
+
+ {organization.id}
+
+
+
+
+ ))}
+
+
+
+
+
+ ↑
+ ↓
+ to navigate
+
+
+ ↵
+ to open
+
+
+
+ {organizations.length} {organizations.length === 1 ? 'organization' : 'organizations'}
+
+
+
+ );
+}
+
+function EmptyState({ onCreate }: { onCreate: () => void }) {
+ return (
+
+
+
+
+
+ No organizations yet
+ Provision your first customer organization to get started.
+
+
+
+ );
+}
+
+function LaunchpadSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/backoffice/src/components/pages/licenses.tsx b/packages/backoffice/src/components/pages/licenses.tsx
new file mode 100644
index 0000000000..2aaa265301
--- /dev/null
+++ b/packages/backoffice/src/components/pages/licenses.tsx
@@ -0,0 +1,649 @@
+import { makeQueryErrorComponent } from '@bo/components/common/ErrorComponent';
+import { SuspenseQuery } from '@bo/components/core/SuspenseQuery';
+import { createLicense, listLicensesQueryOptions, updateLicense } from '@bo/data/licenses';
+import { licensePayloadSchema } from '@bo/server-fns/licenses';
+import { useForm } from '@tanstack/react-form';
+import { useMutation } from '@tanstack/react-query';
+import type { LicenseDto, LicenseEntitlementsDto } from 'marble-api/generated/backoffice-api';
+import { type ReactNode, useMemo, useState } from 'react';
+import { Button, cn, Input, MenuCommand, Panel, Switch, Tag, TextArea, Typo } from 'ui-design-system';
+import { Icon } from 'ui-icons';
+import { z } from 'zod/v4';
+
+/* ---------------------------------- Domain --------------------------------- */
+
+const EXPIRING_SOON_DAYS = 30;
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+type EntitlementKey = keyof LicenseEntitlementsDto;
+
+const ENTITLEMENT_GROUPS: { title: string; items: { key: EntitlementKey; label: string }[] }[] = [
+ {
+ title: 'Platform',
+ items: [
+ { key: 'sso', label: 'SSO' },
+ { key: 'user_roles', label: 'User roles' },
+ { key: 'webhooks', label: 'Webhooks' },
+ { key: 'analytics', label: 'Analytics' },
+ { key: 'workflows', label: 'Workflows' },
+ { key: 'rule_snoozes', label: 'Rule snoozes' },
+ { key: 'test_run', label: 'Test run' },
+ { key: 'data_enrichment', label: 'Data enrichment' },
+ { key: 'user_scoring', label: 'User scoring' },
+ ],
+ },
+ {
+ title: 'Screening',
+ items: [
+ { key: 'sanctions', label: 'Sanctions' },
+ { key: 'continuous_screening', label: 'Continuous screening' },
+ { key: 'lexisnexis', label: 'LexisNexis' },
+ ],
+ },
+ {
+ title: 'Cases',
+ items: [
+ { key: 'auto_assignment', label: 'Auto-assignment' },
+ { key: 'case_ai_assist', label: 'Case AI assist' },
+ ],
+ },
+];
+
+const ENTITLEMENT_KEYS = ENTITLEMENT_GROUPS.flatMap((group) => group.items.map((item) => item.key));
+const ENTITLEMENT_TOTAL = ENTITLEMENT_KEYS.length;
+
+const emptyEntitlements = (): LicenseEntitlementsDto =>
+ Object.fromEntries(ENTITLEMENT_KEYS.map((key) => [key, false])) as LicenseEntitlementsDto;
+
+function enabledCount(entitlements: LicenseEntitlementsDto) {
+ return ENTITLEMENT_KEYS.reduce((count, key) => count + (entitlements[key] ? 1 : 0), 0);
+}
+
+type LicenseStatus = 'active' | 'expiring' | 'expired' | 'suspended';
+
+function getStatus(license: LicenseDto, now: number): LicenseStatus {
+ if (license.suspended_at) return 'suspended';
+ const expiresAt = new Date(license.expiration_date).getTime();
+ if (expiresAt < now) return 'expired';
+ if (expiresAt < now + EXPIRING_SOON_DAYS * DAY_MS) return 'expiring';
+ return 'active';
+}
+
+const STATUS_TAG: Record = {
+ active: { label: 'Active', color: 'green' },
+ expiring: { label: 'Expiring soon', color: 'yellow' },
+ expired: { label: 'Expired', color: 'red' },
+ suspended: { label: 'Suspended', color: 'grey' },
+};
+
+/* -------------------------------- Formatting ------------------------------- */
+
+const dateFormatter = new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short', year: 'numeric' });
+const relativeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
+
+function formatDate(iso: string) {
+ return dateFormatter.format(new Date(iso));
+}
+
+function formatRelative(iso: string, now: number) {
+ const days = Math.round((new Date(iso).getTime() - now) / DAY_MS);
+ if (Math.abs(days) >= 60) return relativeFormatter.format(Math.round(days / 30), 'month');
+ if (Math.abs(days) >= 1) return relativeFormatter.format(days, 'day');
+ return 'today';
+}
+
+/* ---------------------------------- Page ----------------------------------- */
+
+const LicensesError = makeQueryErrorComponent(
+ Could not load licences.,
+);
+
+type PanelState = { mode: 'create' } | { mode: 'edit'; license: LicenseDto } | null;
+
+export function LicensesPage() {
+ const [panel, setPanel] = useState(null);
+
+ return (
+
+
+
+
Licences
+
+ Issue, inspect and revoke the license keys that unlock Marble for each customer.
+
+
+
+
+
+
} errorComponent={LicensesError}>
+ {(licenses) =>
setPanel({ mode: 'edit', license })} />}
+
+
+ (open ? null : setPanel(null))} />
+
+ );
+}
+
+/* ---------------------------------- List ----------------------------------- */
+
+function LicensesList({ licenses, onEdit }: { licenses: LicenseDto[]; onEdit: (license: LicenseDto) => void }) {
+ const now = Date.now();
+ const [search, setSearch] = useState('');
+
+ const sections = useMemo(() => {
+ const term = search.trim().toLowerCase();
+ const matched = term
+ ? licenses.filter(
+ (license) =>
+ license.organization_name.toLowerCase().includes(term) || license.description.toLowerCase().includes(term),
+ )
+ : licenses;
+
+ const attention: LicenseDto[] = [];
+ const active: LicenseDto[] = [];
+ const suspended: LicenseDto[] = [];
+ for (const license of matched) {
+ const status = getStatus(license, now);
+ if (status === 'suspended') suspended.push(license);
+ else if (status === 'expired' || status === 'expiring') attention.push(license);
+ else active.push(license);
+ }
+
+ const byExpiry = (a: LicenseDto, b: LicenseDto) =>
+ new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime();
+ const byName = (a: LicenseDto, b: LicenseDto) => a.organization_name.localeCompare(b.organization_name);
+
+ return {
+ matched,
+ attention: attention.sort(byExpiry),
+ active: active.sort(byName),
+ suspended: suspended.sort(byName),
+ };
+ }, [licenses, search, now]);
+
+ if (licenses.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ setSearch('')}
+ placeholder="Search by organisation or description"
+ value={search}
+ onChange={(event) => setSearch(event.currentTarget.value)}
+ aria-label="Search licences"
+ />
+
+
+ {sections.matched.length === 0 ? (
+
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+}
+
+function LicenseSection({
+ title,
+ icon,
+ tone,
+ licenses,
+ now,
+ onEdit,
+}: {
+ title: string;
+ icon?: 'warning';
+ tone?: 'attention';
+ licenses: LicenseDto[];
+ now: number;
+ onEdit: (license: LicenseDto) => void;
+}) {
+ if (licenses.length === 0) return null;
+
+ return (
+
+
+ {icon ? (
+
+ ) : null}
+ {title}
+ {licenses.length}
+
+
+ {licenses.map((license) => (
+ onEdit(license)} />
+ ))}
+
+
+ );
+}
+
+function LicenseRow({ license, now, onEdit }: { license: LicenseDto; now: number; onEdit: () => void }) {
+ const status = getStatus(license, now);
+ const tag = STATUS_TAG[status];
+ const count = enabledCount(license.license_entitlements);
+ const updateMutation = useMutation(updateLicense());
+
+ const toggleSuspend = (suspend: boolean) =>
+ updateMutation.mutate({
+ licenseId: license.id,
+ payload: {
+ expiration_date: license.expiration_date,
+ organization_name: license.organization_name,
+ description: license.description,
+ license_entitlements: license.license_entitlements,
+ suspend,
+ },
+ });
+
+ return (
+
+
+ {license.organization_name}
+ {license.description ? (
+ {license.description}
+ ) : null}
+
+
+
+ {tag.label}
+
+
+
+ {count}/{ENTITLEMENT_TOTAL} enabled
+
+
+
+ {formatDate(license.expiration_date)}
+
+ {status === 'expired' ? 'Expired ' : 'Expires '}
+ {formatRelative(license.expiration_date, now)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {license.suspended_at ? (
+ toggleSuspend(false)}>
+ Reactivate
+
+ ) : (
+ toggleSuspend(true)}>
+ Suspend
+
+ )}
+
+
+
+
+
+ );
+}
+
+/* ------------------------------- Secret value ------------------------------ */
+
+function SecretValue({
+ value,
+ defaultRevealed = false,
+ className,
+}: {
+ value: string;
+ defaultRevealed?: boolean;
+ className?: string;
+}) {
+ const [revealed, setRevealed] = useState(defaultRevealed);
+ const [copied, setCopied] = useState(false);
+
+ const copy = () => {
+ navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ return (
+
+
+ {revealed ? value : '••••••••'}
+
+
+
+
+ );
+}
+
+/* ------------------------------ Empty / skeleton --------------------------- */
+
+function EmptyState({ title, body }: { title: string; body: string }) {
+ return (
+
+ );
+}
+
+function LicensesSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+ -
+
+
+
+ ))}
+
+
+ );
+}
+
+/* ------------------------------- Create / edit ----------------------------- */
+
+const licenseFormSchema = licensePayloadSchema.extend({
+ expiration_date: z.string().min(1, 'An expiration date is required.'),
+});
+
+type LicenseFormValues = z.infer;
+
+const FORM_ID = 'license-form';
+
+function toDateInputValue(iso: string) {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+ return date.toISOString().slice(0, 10);
+}
+
+function LicensePanel({ state, onOpenChange }: { state: PanelState; onOpenChange: (open: boolean) => void }) {
+ return (
+
+
+
+ {state ? onOpenChange(false)} /> : null}
+
+
+
+ );
+}
+
+function LicensePanelBody({ state, onClose }: { state: NonNullable; onClose: () => void }) {
+ const isEdit = state.mode === 'edit';
+ const existing = isEdit ? state.license : null;
+
+ const createMutation = useMutation(createLicense());
+ const updateMutation = useMutation(updateLicense());
+ const [suspend, setSuspend] = useState(Boolean(existing?.suspended_at));
+ const [createdLicense, setCreatedLicense] = useState(null);
+
+ const form = useForm({
+ defaultValues: {
+ organization_name: existing?.organization_name ?? '',
+ description: existing?.description ?? '',
+ expiration_date: existing ? toDateInputValue(existing.expiration_date) : '',
+ license_entitlements: existing?.license_entitlements ?? emptyEntitlements(),
+ } as LicenseFormValues,
+ validators: {
+ onMount: licenseFormSchema,
+ onChange: licenseFormSchema,
+ onSubmit: licenseFormSchema,
+ },
+ onSubmit: async ({ value, formApi }) => {
+ if (!formApi.state.isValid) return;
+ const payload = {
+ ...value,
+ expiration_date: new Date(value.expiration_date).toISOString(),
+ };
+ if (existing) {
+ await updateMutation.mutateAsync({ licenseId: existing.id, payload: { ...payload, suspend } });
+ onClose();
+ } else {
+ const created = await createMutation.mutateAsync(payload);
+ setCreatedLicense(created);
+ }
+ },
+ });
+
+ if (createdLicense) {
+ return ;
+ }
+
+ const setAllEntitlements = (enabled: boolean) =>
+ form.setFieldValue(
+ 'license_entitlements',
+ Object.fromEntries(ENTITLEMENT_KEYS.map((key) => [key, enabled])) as LicenseEntitlementsDto,
+ );
+
+ return (
+ <>
+
+
+
{isEdit ? 'Edit licence' : 'Create licence'}
+
+ {isEdit
+ ? 'Update entitlements, expiry, and access for this customer.'
+ : 'Grant a customer access to Marble by issuing a new licence key.'}
+
+
+
+
+
+ {(field) => (
+
+ field.handleChange(event.currentTarget.value)}
+ onBlur={field.handleBlur}
+ placeholder="Acme Inc."
+ />
+
+ )}
+
+
+
+ {(field) => (
+
+
+ )}
+
+
+
+ {(field) => (
+
+ field.handleChange(event.currentTarget.value)}
+ onBlur={field.handleBlur}
+ />
+
+ )}
+
+
+
+
+
Entitlements
+
+
+ ·
+
+
+
+
+
+ {(field) => (
+
+ {ENTITLEMENT_GROUPS.map((group) => (
+
+
+ {group.title}
+
+
+ {group.items.map((item) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ {isEdit ? (
+
+ ) : null}
+
+
+
+
+ [formState.canSubmit, formState.isDirty] as const}>
+ {([canSubmit, isDirty]) => (
+
+ )}
+
+
+ >
+ );
+}
+
+function CreatedLicenseStep({ license, onDone }: { license: LicenseDto; onDone: () => void }) {
+ return (
+ <>
+
+
+
Licence created
+
+ Copy the key now and share it securely — it unlocks Marble for {license.organization_name}.
+
+
+
+
+
+
+
+ Licence for {license.organization_name} is ready.
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function Field({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/packages/backoffice/src/components/pages/organization._layout.tsx b/packages/backoffice/src/components/pages/organization._layout.tsx
new file mode 100644
index 0000000000..6948c65b40
--- /dev/null
+++ b/packages/backoffice/src/components/pages/organization._layout.tsx
@@ -0,0 +1,34 @@
+import { Link } from '@tanstack/react-router';
+import { OrganizationDto } from 'marble-api';
+import { ReactNode } from 'react';
+import { Tabs, Typo, tabClassName } from 'ui-design-system';
+
+const ORGANIZATION_TABS = ['overview', 'users' /* , 'settings' */] as const;
+
+type OrganizationLayoutProps = {
+ organization: OrganizationDto;
+ children: ReactNode;
+};
+
+export function OrganizationLayout({ organization, children }: OrganizationLayoutProps) {
+ return (
+
+
+ {organization.name}
+
+ {ORGANIZATION_TABS.map((tab) => (
+
+ {tab}
+
+ ))}
+
+
+
{children}
+
+ );
+}
diff --git a/packages/backoffice/src/components/pages/organization.overview.tsx b/packages/backoffice/src/components/pages/organization.overview.tsx
new file mode 100644
index 0000000000..1a14633262
--- /dev/null
+++ b/packages/backoffice/src/components/pages/organization.overview.tsx
@@ -0,0 +1,450 @@
+import { makeQueryErrorComponent } from '@bo/components/common/ErrorComponent';
+import { SuspenseQuery } from '@bo/components/core/SuspenseQuery';
+import { FeatureAccessPanel } from '@bo/components/organisms/FeatureAccessPanel';
+import {
+ getOrganizationQueryOptions,
+ listOrganizationFeatures,
+ listOrganizationUsersQueryOptions,
+} from '@bo/data/organization';
+import { OVERRIDABLE_FEATURES } from '@bo/schemas/features';
+import { Link } from '@tanstack/react-router';
+import { OrganizationDto } from 'marble-api';
+import { FeatureAccessDto } from 'marble-api/generated/backoffice-api';
+import { type ReactNode, useState } from 'react';
+import { Button, cn, Tag, Typo } from 'ui-design-system';
+import { Icon, type IconName } from 'ui-icons';
+
+export function OrganizationOverviewPage({ orgId }: { orgId: string }) {
+ const [editingFeatures, setEditingFeatures] = useState(false);
+
+ return (
+
+
+
+ }
+ errorComponent={ConfigurationError}
+ >
+ {(organization) => }
+
+
+
+
+
+ Manage users
+
+ }
+ >
+ }
+ errorComponent={PeopleError}
+ >
+ {(users) => }
+
+
+
+
setEditingFeatures(true)}>
+
+ Edit
+
+ }
+ >
+ }
+ errorComponent={FeaturesError}
+ >
+ {(featureAccess) => }
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* ------------------------------- Layout shells ------------------------------ */
+
+function Region({
+ title,
+ action,
+ className,
+ children,
+}: {
+ title: string;
+ action?: ReactNode;
+ className?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {action}
+
+ {children}
+
+ );
+}
+
+function JumpLink({ orgId, to, children }: { orgId: string; to: '/organizations/$orgId/users'; children: ReactNode }) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function FieldLabel({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+function NotSet() {
+ return Not set;
+}
+
+/* ------------------------------ Configuration ------------------------------ */
+
+const PROVIDER_LABELS: Record = {
+ opensanctions: 'OpenSanctions',
+ lexisnexis: 'LexisNexis',
+};
+
+const SCREENING_CHANNELS = [
+ { key: 'transaction_monitoring', label: 'Transaction monitoring' },
+ { key: 'continuous_monitoring', label: 'Continuous monitoring' },
+ { key: 'manual_search', label: 'Manual search' },
+] as const;
+
+function ConfigurationFields({ organization }: { organization: OrganizationDto }) {
+ const providers = organization.screening_providers;
+
+ return (
+
+
+ Organization ID
+
-
+
+
+
+
+
+ Default scenario timezone
+
- {organization.default_scenario_timezone ?? }
+
+
+
+ Auto-assign queue limit
+
- {organization.auto_assign_queue_limit ?? }
+
+
+
+ Sanctions threshold
+
- {organization.sanctions_threshold ?? }
+
+
+
+ Sanctions limit
+
- {organization.sanctions_limit ?? }
+
+
+
+
Screening providers
+
-
+ {SCREENING_CHANNELS.map(({ key, label }) => {
+ const value = providers?.[key];
+ return (
+
+ {label}
+ {value ? (
+
+ {PROVIDER_LABELS[value] ?? value}
+
+ ) : (
+ Not set
+ )}
+
+ );
+ })}
+
+
+
+
+ Allowed networks
+
-
+ {organization.allowed_networks.length > 0 ? (
+ organization.allowed_networks.map((network) => (
+
+ {network}
+
+ ))
+ ) : (
+ None
+ )}
+
+
+
+ );
+}
+
+function CopyableValue({ value }: { value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ };
+
+ return (
+
+ );
+}
+
+/* --------------------------------- People ---------------------------------- */
+
+type OrgUser = {
+ user_id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ role: string;
+};
+
+const ROSTER_PREVIEW = 5;
+
+function humanizeRole(role: string) {
+ return role.charAt(0) + role.slice(1).toLowerCase();
+}
+
+function People({ users, orgId }: { users: OrgUser[]; orgId: string }) {
+ if (users.length === 0) {
+ return (
+
+ No users have access to this organization yet.
+
+ Add the first user
+
+
+ );
+ }
+
+ const roleCounts = users.reduce>((acc, user) => {
+ acc[user.role] = (acc[user.role] ?? 0) + 1;
+ return acc;
+ }, {});
+
+ const preview = users.slice(0, ROSTER_PREVIEW);
+ const remaining = users.length - preview.length;
+
+ return (
+
+
+
{users.length}
+
{users.length === 1 ? 'user' : 'users'}
+
+ {Object.entries(roleCounts).map(([role, count]) => (
+
+ {humanizeRole(role)} · {count}
+
+ ))}
+
+
+
+
+ {preview.map((user) => {
+ const fullName = `${user.first_name} ${user.last_name}`.trim();
+ const monogram = (user.first_name || user.email).charAt(0).toUpperCase();
+ return (
+ -
+
+ {monogram}
+
+
+ {fullName || user.email}
+ {user.email}
+
+ {humanizeRole(user.role)}
+
+ );
+ })}
+
+
+ {remaining > 0 ? (
+
+ View all {users.length} users
+
+ ) : null}
+
+ );
+}
+
+/* ----------------------------- Feature access ------------------------------ */
+
+const FEATURE_STATE: Record = {
+ allowed: { label: 'Allowed', color: 'green' },
+ test: { label: 'Test', color: 'yellow' },
+ restricted: { label: 'Restricted', color: 'grey' },
+ missing_configuration: { label: 'Not configured', color: 'red' },
+};
+
+function humanizeFeatureName(featureName: string) {
+ return featureName
+ .split('_')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+}
+
+function FeatureAccess({ featureAccess }: { featureAccess: FeatureAccessDto }) {
+ return (
+
+ {OVERRIDABLE_FEATURES.map((feature) => {
+ const value = featureAccess[feature];
+ const state = FEATURE_STATE[value] ?? { label: value, color: 'grey' as const };
+ return (
+ -
+ {humanizeFeatureName(feature)}
+
+ {state.label}
+
+
+ );
+ })}
+
+ );
+}
+
+/* --------------------------- Operational data ------------------------------ */
+
+const OPERATIONAL_SLOTS: { icon: IconName; label: string }[] = [
+ { icon: 'version', label: 'Live scenario versions' },
+ { icon: 'decision', label: 'Decisions' },
+ { icon: 'lock', label: 'API keys' },
+];
+
+function OperationalData() {
+ return (
+
+
+ Operational data
+
+ Not yet available per organization — coming with org-scoped reporting.
+
+
+
+ {OPERATIONAL_SLOTS.map(({ icon, label }) => (
+
+
+
+
+
+ {label}
+ Coming soon
+
+
+ ))}
+
+
+ );
+}
+
+/* -------------------------------- Skeletons -------------------------------- */
+
+function SkeletonBar({ className }: { className?: string }) {
+ return ;
+}
+
+function ConfigurationSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+function PeopleSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
+
+function FeaturesSkeleton() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+/* ------------------------------ Error states ------------------------------- */
+
+const ConfigurationError = makeQueryErrorComponent(
+ Could not load configuration.,
+);
+const PeopleError = makeQueryErrorComponent(Could not load users.);
+const FeaturesError = makeQueryErrorComponent(
+ Could not load feature access.,
+);
diff --git a/packages/backoffice/src/components/pages/organization.users.tsx b/packages/backoffice/src/components/pages/organization.users.tsx
new file mode 100644
index 0000000000..7844cbc2dc
--- /dev/null
+++ b/packages/backoffice/src/components/pages/organization.users.tsx
@@ -0,0 +1,262 @@
+import { makeQueryErrorComponent } from '@bo/components/common/ErrorComponent';
+import { GridContentLoader } from '@bo/components/common/GridContentLoader';
+import { SuspenseQuery } from '@bo/components/core/SuspenseQuery';
+import {
+ listOrganizationFeatures,
+ listOrganizationUsersQueryOptions,
+ useCreateOrganizationUserMutationOptions,
+} from '@bo/data/organization';
+import { CreateUserPayload, createUserPayloadSchema, DUPLICATE_EMAIL_ERROR, USER_ROLES } from '@bo/schemas/user';
+import { AnyFieldApi, useForm } from '@tanstack/react-form';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { FeatureAccessDto } from 'marble-api/generated/backoffice-api';
+import { ChangeEvent, useRef, useState } from 'react';
+import toast from 'react-hot-toast';
+import { Button, Input, Modal, SelectV2, Typo } from 'ui-design-system';
+import { Icon } from 'ui-icons';
+
+const ErrorComponent = makeQueryErrorComponent(Something went wrong while fetching organization users);
+
+// TODO: Move this helper out
+const getAvailableUserRoles = (featureValue: FeatureAccessDto['roles'] | undefined) => {
+ if (!featureValue) return [];
+
+ return featureValue === 'restricted' ? (['ADMIN'] as const) : USER_ROLES;
+};
+
+export const OrganizationUsersPage = ({ orgId }: { orgId: string }) => {
+ const [isCreatingUser, setIsCreatingUser] = useState(false);
+ const handleCreateUser = () => {
+ setIsCreatingUser(true);
+ };
+
+ return (
+
+
+ Users
+
+
+
+
+
Name
+
ID
+
Email
+
Role
+
Actions
+
+
}
+ errorComponent={ErrorComponent}
+ >
+ {(users) => (
+ <>
+ {users.map((user) => (
+
+
+ {user.first_name} {user.last_name}
+
+
{user.user_id}
+
{user.email}
+
{user.role}
+
+
+ ))}
+ >
+ )}
+
+
+
+
+ );
+};
+
+// TODO: Move this to shared
+const handleChange = (field: AnyFieldApi) => {
+ return (e: ChangeEvent) => {
+ field.handleChange(e.target.value);
+ };
+};
+
+const FORM_ID = 'create-user-form';
+
+type SubmitIntent = 'save' | 'saveAndNew';
+
+const getCreateUserErrorMessage = (error: unknown) =>
+ error instanceof Error && error.message === DUPLICATE_EMAIL_ERROR
+ ? 'A user with this email already exists'
+ : 'Something went wrong while creating the user';
+
+const UserCreationModal = ({
+ orgId,
+ open,
+ onOpenChange,
+}: {
+ orgId: string;
+ open: boolean;
+ onOpenChange: (state: boolean) => void;
+}) => {
+ return (
+
+
+ Create new user
+ {/* Radix unmounts the content while closed, so keeping the form state in a child
+ component guarantees a clean form on every open. */}
+ onOpenChange(false)} />
+
+
+ );
+};
+
+const UserCreationForm = ({ orgId, onClose }: { orgId: string; onClose: () => void }) => {
+ const firstNameRef = useRef(null);
+ const [pendingIntent, setPendingIntent] = useState(null);
+
+ const createOrganizationUserMutationOptions = useCreateOrganizationUserMutationOptions();
+ const createOrganizationUserMutation = useMutation({
+ ...createOrganizationUserMutationOptions,
+ onSuccess: (_user, variables) => {
+ toast.success(`User ${variables.userPayload.email} created`);
+ },
+ onError: (error) => {
+ toast.error(getCreateUserErrorMessage(error));
+ },
+ });
+
+ const form = useForm({
+ defaultValues: {
+ first_name: '',
+ last_name: '',
+ email: '',
+ role: '' as unknown as CreateUserPayload['role'],
+ } as CreateUserPayload,
+ validators: {
+ onSubmit: createUserPayloadSchema,
+ onChange: createUserPayloadSchema,
+ onMount: createUserPayloadSchema,
+ },
+ onSubmitMeta: { keepOpen: false },
+ onSubmit: async ({ value, formApi, meta }) => {
+ if (!formApi.state.isValid) return;
+
+ setPendingIntent(meta.keepOpen ? 'saveAndNew' : 'save');
+ try {
+ await createOrganizationUserMutation.mutateAsync({ orgId, userPayload: value });
+ } catch {
+ // The error toast is raised by the mutation's onError. Keep the form as-is so the
+ // user can fix the input and retry.
+ return;
+ } finally {
+ setPendingIntent(null);
+ }
+
+ if (!meta.keepOpen) {
+ onClose();
+ return;
+ }
+
+ formApi.reset();
+ // reset() clears the whole errorMap, which would leave canSubmit true on an empty
+ // form; re-running the onMount validator restores the disabled state.
+ formApi.validateSync('mount');
+ firstNameRef.current?.focus();
+ },
+ });
+ const orgFeaturesAccessQuery = useQuery(listOrganizationFeatures(orgId));
+
+ return (
+ <>
+
+ {(field) => (
+
+
+
+
+ )}
+
+
+ {(field) => (
+
+
+
+
+ )}
+
+
+ {(field) => (
+
+
+
+
+ )}
+
+
+ {(field) => (
+
+
+
+ disabled={!orgFeaturesAccessQuery.isSuccess}
+ placeholder="Role"
+ value={field.state.value}
+ onChange={field.handleChange}
+ options={getAvailableUserRoles(orgFeaturesAccessQuery.data?.roles).map((r) => ({
+ value: r,
+ label: r,
+ }))}
+ />
+
+ )}
+
+
+
+
+ s.canSubmit}>
+ {(canSubmit) => (
+ <>
+ {/* "Save" keeps type="submit" so it stays the form's default button: with three
+ text inputs, Enter-key implicit submission only works if one exists. */}
+
+ void form.handleSubmit({ keepOpen: true })}
+ />
+ >
+ )}
+
+
+ >
+ );
+};
diff --git a/packages/backoffice/src/components/ui/.gitkeep b/packages/backoffice/src/components/ui/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/backoffice/src/contexts/AppConfig.ts b/packages/backoffice/src/contexts/AppConfig.ts
new file mode 100644
index 0000000000..9785bf502d
--- /dev/null
+++ b/packages/backoffice/src/contexts/AppConfig.ts
@@ -0,0 +1,4 @@
+import { createSimpleContext } from '@marble/shared';
+import { AppConfigDto } from 'marble-api';
+
+export const AppConfigContext = createSimpleContext('AppConfig');
diff --git a/packages/backoffice/src/contexts/StickyRoots.tsx b/packages/backoffice/src/contexts/StickyRoots.tsx
new file mode 100644
index 0000000000..37186de706
--- /dev/null
+++ b/packages/backoffice/src/contexts/StickyRoots.tsx
@@ -0,0 +1,74 @@
+import useIntersection from '@bo/hooks/useIntersection';
+import { RefObject, useEffect, useRef } from 'react';
+import { createSharpFactory } from 'sharpstate';
+
+type StickyRootsStoreValue = {
+ stickyRoots: Record>;
+};
+
+export const StickyRootsSharp = createSharpFactory({
+ name: 'StickyRoots',
+ initializer: (): StickyRootsStoreValue => ({
+ stickyRoots: {},
+ }),
+}).withActions({
+ addStickyRoot(api, name: string, ref: RefObject) {
+ api.value.stickyRoots[name] = ref;
+ },
+ removeStickyRoot(api, name: string) {
+ delete api.value.stickyRoots[name];
+ },
+});
+
+export const StickyRootsProvider = ({ children }: { children: React.ReactNode }) => {
+ const stickyRootsSharp = StickyRootsSharp.createSharp();
+
+ return {children};
+};
+
+export const useStickyRoot = (name: string, ref: RefObject) => {
+ const stickyRootsSharp = StickyRootsSharp.useSharp();
+
+ useEffect(() => {
+ stickyRootsSharp.actions.addStickyRoot(name, ref);
+ return () => {
+ stickyRootsSharp.actions.removeStickyRoot(name);
+ };
+ }, [ref, stickyRootsSharp]);
+};
+
+type StickyIntersectionOptions = Omit & {
+ root?: string;
+};
+
+export const useStickyIntersection = (sentinelRef: RefObject, options: StickyIntersectionOptions) => {
+ const stickyRootsSharp = StickyRootsSharp.useSharp();
+
+ const intersection = useIntersection(sentinelRef, {
+ ...options,
+ root: options.root ? stickyRootsSharp.value.stickyRoots[options.root]?.$current?.value : undefined,
+ });
+
+ return intersection;
+};
+
+type StickySentinelProps = {
+ children: React.ReactNode;
+ root?: string;
+ className?: string;
+ threshold?: number;
+ rootMargin?: string;
+};
+
+export const StickySentinel = ({ className, children, ...intersectionOptions }: StickySentinelProps) => {
+ const ref = useRef(null);
+ const intersection = useStickyIntersection(ref, intersectionOptions);
+ const isStickied = intersection ? !intersection.isIntersecting : false;
+
+ return (
+
+ );
+};
diff --git a/packages/backoffice/src/data/licenses.ts b/packages/backoffice/src/data/licenses.ts
new file mode 100644
index 0000000000..26131bae74
--- /dev/null
+++ b/packages/backoffice/src/data/licenses.ts
@@ -0,0 +1,34 @@
+import {
+ createLicenseFn,
+ getLicensesFn,
+ type LicensePayload,
+ type UpdateLicenseInput,
+ updateLicenseFn,
+} from '@bo/server-fns/licenses';
+import { mutationOptions, queryOptions } from '@tanstack/react-query';
+
+// ------- Queries -------
+
+export const listLicensesQueryOptions = () =>
+ queryOptions({
+ queryKey: ['licenses'],
+ queryFn: getLicensesFn,
+ });
+
+// -------- Mutations --------
+
+export const createLicense = () =>
+ mutationOptions({
+ mutationFn: (payload: LicensePayload) => createLicenseFn({ data: payload }),
+ meta: {
+ invalidates: () => [['licenses']],
+ },
+ });
+
+export const updateLicense = () =>
+ mutationOptions({
+ mutationFn: (payload: UpdateLicenseInput) => updateLicenseFn({ data: payload }),
+ meta: {
+ invalidates: () => [['licenses']],
+ },
+ });
diff --git a/packages/backoffice/src/data/organization.ts b/packages/backoffice/src/data/organization.ts
new file mode 100644
index 0000000000..0284c3e3c1
--- /dev/null
+++ b/packages/backoffice/src/data/organization.ts
@@ -0,0 +1,107 @@
+import { PatchOrganizationFeaturesPayload } from '@bo/schemas/features';
+import { OrgImportSpec } from '@bo/schemas/org-import';
+import { CreateUserPayload } from '@bo/schemas/user';
+import {
+ applyOrganizationArchetypeFn,
+ createEmptyOrganizationFn,
+ createOrganizationUserFn,
+ getOrganizationFeaturesFn,
+ getOrganizationFn,
+ getOrganizationsFn,
+ getOrganizationUsersFn,
+ importOrganizationFn,
+ listOrganizationArchetypesFn,
+ patchOrganizationFeaturesFn,
+} from '@bo/server-fns/organization';
+import { mutationOptions, queryOptions } from '@tanstack/react-query';
+import { useServerFn } from '@tanstack/react-start';
+
+// ------- Queries -------
+
+export const listOrganizationsQueryOptions = () =>
+ queryOptions({
+ queryKey: ['organizations'],
+ queryFn: getOrganizationsFn,
+ });
+
+export const getOrganizationQueryOptions = (orgId: string) =>
+ queryOptions({
+ queryKey: ['organizations', orgId],
+ queryFn: () => getOrganizationFn({ data: { orgId } }),
+ });
+
+export const listOrganizationUsersQueryOptions = (orgId: string) =>
+ queryOptions({
+ queryKey: ['organizations', orgId, 'users'],
+ queryFn: () => getOrganizationUsersFn({ data: { orgId } }),
+ });
+
+export const listOrganizationFeatures = (orgId: string) =>
+ queryOptions({
+ queryKey: ['organizations', orgId, 'features'],
+ queryFn: () => getOrganizationFeaturesFn({ data: { orgId } }),
+ });
+
+export const listOrganizationArchetypes = () =>
+ queryOptions({
+ queryKey: ['organization-archetypes'],
+ queryFn: listOrganizationArchetypesFn,
+ });
+
+// -------- Mutations --------
+
+export const applyOrganizationArchetype = () =>
+ mutationOptions({
+ mutationFn: (payload: {
+ name: string;
+ org_name: string;
+ admins: { email: string; first_name?: string; last_name?: string }[];
+ }) => applyOrganizationArchetypeFn({ data: payload }),
+ meta: {
+ invalidates: () => [['organizations']],
+ },
+ });
+
+export const patchOrganizationFeatures = () =>
+ mutationOptions({
+ mutationFn: (payload: { orgId: string; features: PatchOrganizationFeaturesPayload }) =>
+ patchOrganizationFeaturesFn({ data: payload }),
+ meta: {
+ invalidates: (data: { orgId: string }) => [['organizations', data.orgId, 'features']],
+ },
+ });
+
+export const createEmptyOrganization = () =>
+ mutationOptions({
+ mutationFn: (payload: { name: string }) => createEmptyOrganizationFn({ data: payload }),
+ meta: {
+ invalidates: () => [['organizations']],
+ },
+ });
+
+export const importOrganization = () =>
+ mutationOptions({
+ mutationFn: (payload: OrgImportSpec) => importOrganizationFn({ data: payload }),
+ meta: {
+ invalidates: () => [['organizations']],
+ },
+ });
+
+// -------- Mutation hooks --------
+
+/**
+ * `useServerFn` is required here: TanStack Start does not rethrow redirects on the client
+ * (it only rethrows `Error` instances), so a redirect thrown by `needAuth` on an expired
+ * session would otherwise resolve as mutation *data* and read as a success.
+ */
+export const useCreateOrganizationUserMutationOptions = () => {
+ const createOrganizationUser = useServerFn(createOrganizationUserFn);
+
+ return mutationOptions({
+ mutationFn: (payload: { orgId: string; userPayload: CreateUserPayload }) =>
+ createOrganizationUser({ data: payload }),
+ meta: {
+ invalidates: (data: { orgId: string }) => [['organizations', data.orgId, 'users']],
+ },
+ });
+};
diff --git a/packages/backoffice/src/env.ts b/packages/backoffice/src/env.ts
new file mode 100644
index 0000000000..8b57faeeec
--- /dev/null
+++ b/packages/backoffice/src/env.ts
@@ -0,0 +1,40 @@
+import { createEnv } from '@t3-oss/env-core';
+import { z } from 'zod';
+
+export const env = createEnv({
+ server: {
+ API_BASE_URL: z.string().url(),
+ SESSION_SECRET: z.string(),
+ },
+
+ /**
+ * The prefix that client-side variables must have. This is enforced both at
+ * a type-level and at runtime.
+ */
+ clientPrefix: 'VITE_',
+
+ client: {
+ VITE_APP_TITLE: z.string().min(1).optional(),
+ },
+
+ /**
+ * What object holds the environment variables at runtime. This is usually
+ * `process.env` or `import.meta.env`.
+ */
+ runtimeEnv: process.env,
+
+ /**
+ * By default, this library will feed the environment variables directly to
+ * the Zod validator.
+ *
+ * This means that if you have an empty string for a value that is supposed
+ * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag
+ * it as a type mismatch violation. Additionally, if you have an empty string
+ * for a value that is supposed to be a string with a default value (e.g.
+ * `DOMAIN=` in an ".env" file), the default value will never be applied.
+ *
+ * In order to solve these issues, we recommend that all new projects
+ * explicitly specify this option as true.
+ */
+ emptyStringAsUndefined: true,
+});
diff --git a/packages/backoffice/src/global.d.ts b/packages/backoffice/src/global.d.ts
new file mode 100644
index 0000000000..9f18e93bc4
--- /dev/null
+++ b/packages/backoffice/src/global.d.ts
@@ -0,0 +1,11 @@
+import '@tanstack/react-query';
+
+interface MutationMeta extends Record {
+ invalidates: (variables: any) => string[][];
+}
+
+declare module '@tanstack/react-query' {
+ interface Register {
+ mutationMeta: MutationMeta;
+ }
+}
diff --git a/packages/backoffice/src/hooks/useFirebase.ts b/packages/backoffice/src/hooks/useFirebase.ts
new file mode 100644
index 0000000000..9f8fdaba92
--- /dev/null
+++ b/packages/backoffice/src/hooks/useFirebase.ts
@@ -0,0 +1,57 @@
+import { AppConfigContext } from '@bo/contexts/AppConfig';
+import { initializeApp } from 'firebase/app';
+import { connectAuthEmulator, GoogleAuthProvider, getAuth, signInWithPopup } from 'firebase/auth';
+import { AppConfigDto } from 'marble-api';
+import { useMemo } from 'react';
+
+export const useFirebase = () => {
+ const appConfig = AppConfigContext.useValue();
+
+ const firebaseClient = useMemo(() => {
+ return initializeFirebaseClient(appConfig.auth.firebase);
+ }, [appConfig]);
+
+ return firebaseClient;
+};
+
+const initializeFirebaseClient = (config: AppConfigDto['auth']['firebase']) => {
+ const app = initializeApp({
+ apiKey: config.api_key,
+ authDomain: config.auth_domain,
+ projectId: config.project_id,
+ });
+
+ const clientAuth = getAuth(app);
+
+ if (config.is_emulator) {
+ connectAuthEmulator(clientAuth, `http://${config.emulator_host}`, {
+ disableWarnings: process.env['NODE_ENV'] !== 'production',
+ });
+ }
+
+ const googleAuthProvider = new GoogleAuthProvider();
+ googleAuthProvider.setCustomParameters({ prompt: 'select_account' });
+
+ return {
+ app,
+ clientAuth,
+ signInWithGoogle: async () => {
+ clientAuth.useDeviceLanguage();
+ const credentials = await signInWithPopup(clientAuth, googleAuthProvider);
+
+ return credentials.user.getIdToken();
+ },
+ getIdToken: async () => {
+ return new Promise((resolve, reject) => {
+ const unsubscribe = clientAuth.onAuthStateChanged((user) => {
+ unsubscribe();
+ if (user) {
+ user.getIdToken().then(resolve);
+ } else {
+ reject(new Error('No authenticated user, no token'));
+ }
+ });
+ });
+ },
+ };
+};
diff --git a/packages/backoffice/src/hooks/useIntersection.ts b/packages/backoffice/src/hooks/useIntersection.ts
new file mode 100644
index 0000000000..aceb02322f
--- /dev/null
+++ b/packages/backoffice/src/hooks/useIntersection.ts
@@ -0,0 +1,28 @@
+import { type RefObject, useEffect, useState } from 'react';
+
+const useIntersection = (
+ ref: RefObject,
+ options: IntersectionObserverInit,
+): IntersectionObserverEntry | null => {
+ const [intersectionObserverEntry, setIntersectionObserverEntry] = useState(null);
+
+ useEffect(() => {
+ if (ref.current && typeof IntersectionObserver === 'function') {
+ const handler = (entries: IntersectionObserverEntry[]) => {
+ setIntersectionObserverEntry(entries[0] ?? null);
+ };
+
+ const observer = new IntersectionObserver(handler, options);
+ observer.observe(ref.current);
+
+ return () => {
+ setIntersectionObserverEntry(null);
+ observer.disconnect();
+ };
+ }
+ }, [ref.current, options.threshold, options.root, options.rootMargin]);
+
+ return intersectionObserverEntry;
+};
+
+export default useIntersection;
diff --git a/packages/backoffice/src/hooks/useInterval.ts b/packages/backoffice/src/hooks/useInterval.ts
new file mode 100644
index 0000000000..0a0e9003d1
--- /dev/null
+++ b/packages/backoffice/src/hooks/useInterval.ts
@@ -0,0 +1,24 @@
+import { useCallbackRef } from '@marble/shared';
+import { useEffect } from 'react';
+
+type UseIntervalOptions = {
+ delay: number | null;
+ executeImmediately?: boolean;
+};
+
+export const useInterval = (callback: () => void, { delay, executeImmediately = false }: UseIntervalOptions) => {
+ const _callback = useCallbackRef(callback);
+
+ useEffect(() => {
+ if (delay === null) {
+ return;
+ }
+
+ if (executeImmediately) {
+ _callback();
+ }
+
+ const intervalId = setInterval(() => _callback(), delay);
+ return () => clearInterval(intervalId);
+ }, [_callback, delay, executeImmediately]);
+};
diff --git a/packages/backoffice/src/integrations/tanstack-query/devtools.tsx b/packages/backoffice/src/integrations/tanstack-query/devtools.tsx
new file mode 100644
index 0000000000..e1c0fcf0c4
--- /dev/null
+++ b/packages/backoffice/src/integrations/tanstack-query/devtools.tsx
@@ -0,0 +1,6 @@
+import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';
+
+export default {
+ name: 'Tanstack Query',
+ render: ,
+};
diff --git a/packages/backoffice/src/integrations/tanstack-query/root-provider.tsx b/packages/backoffice/src/integrations/tanstack-query/root-provider.tsx
new file mode 100644
index 0000000000..0c7b13e1af
--- /dev/null
+++ b/packages/backoffice/src/integrations/tanstack-query/root-provider.tsx
@@ -0,0 +1,84 @@
+import { TRPCProvider } from '@bo/integrations/trpc/react';
+import type { TRPCRouter } from '@bo/integrations/trpc/router';
+import { MutationCache, matchQuery, QueryClient } from '@tanstack/react-query';
+import { createTRPCClient, httpBatchStreamLink } from '@trpc/client';
+import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
+import superjson from 'superjson';
+
+function getUrl() {
+ const base = (() => {
+ if (typeof window !== 'undefined') return '';
+ return `http://localhost:${process.env['PORT'] ?? 3000}`;
+ })();
+ return `${base}/api/trpc`;
+}
+
+export const trpcClient = createTRPCClient({
+ links: [
+ httpBatchStreamLink({
+ transformer: superjson,
+ url: getUrl(),
+ }),
+ ],
+});
+
+export function getContext() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 10,
+ retry(failureCount, error) {
+ // Abort retries for redirects status codes
+ if (error instanceof Response && error.status >= 300 && error.status < 400) {
+ return false;
+ }
+ return failureCount < 3;
+ },
+ },
+ dehydrate: { serializeData: superjson.serialize },
+ hydrate: { deserializeData: superjson.deserialize },
+ },
+ mutationCache: new MutationCache({
+ onSuccess: (_data, variables, _context, mutation) => {
+ const invalidates = mutation.meta?.invalidates;
+ if (!invalidates) {
+ return;
+ }
+ const queryKeys = invalidates(variables);
+ if (queryKeys.length === 0) {
+ return;
+ }
+
+ queryClient.invalidateQueries({
+ predicate: (query) => {
+ // Invalidates all queries matching the invalidate meta or none
+ return queryKeys.some((queryKey) => matchQuery({ queryKey }, query));
+ },
+ });
+ },
+ }),
+ });
+
+ const serverHelpers = createTRPCOptionsProxy({
+ client: trpcClient,
+ queryClient: queryClient,
+ });
+ return {
+ queryClient,
+ trpc: serverHelpers,
+ };
+}
+
+export function Provider({
+ children,
+ queryClient,
+}: {
+ children: Parameters[0]['children'];
+ queryClient: QueryClient;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/backoffice/src/integrations/trpc/init.ts b/packages/backoffice/src/integrations/trpc/init.ts
new file mode 100644
index 0000000000..7a695fe3ed
--- /dev/null
+++ b/packages/backoffice/src/integrations/trpc/init.ts
@@ -0,0 +1,9 @@
+import { initTRPC } from '@trpc/server';
+import superjson from 'superjson';
+
+const t = initTRPC.create({
+ transformer: superjson,
+});
+
+export const createTRPCRouter = t.router;
+export const publicProcedure = t.procedure;
diff --git a/packages/backoffice/src/integrations/trpc/react.ts b/packages/backoffice/src/integrations/trpc/react.ts
new file mode 100644
index 0000000000..b274a455b3
--- /dev/null
+++ b/packages/backoffice/src/integrations/trpc/react.ts
@@ -0,0 +1,4 @@
+import type { TRPCRouter } from '@bo/integrations/trpc/router';
+import { createTRPCContext } from '@trpc/tanstack-react-query';
+
+export const { TRPCProvider, useTRPC } = createTRPCContext();
diff --git a/packages/backoffice/src/integrations/trpc/router.ts b/packages/backoffice/src/integrations/trpc/router.ts
new file mode 100644
index 0000000000..f9238371fa
--- /dev/null
+++ b/packages/backoffice/src/integrations/trpc/router.ts
@@ -0,0 +1,23 @@
+import type { TRPCRouterRecord } from '@trpc/server';
+import { z } from 'zod/v4';
+import { createTRPCRouter, publicProcedure } from './init';
+
+const todos = [
+ { id: 1, name: 'Get groceries' },
+ { id: 2, name: 'Buy a new phone' },
+ { id: 3, name: 'Finish the project' },
+];
+
+const todosRouter = {
+ list: publicProcedure.query(() => todos),
+ add: publicProcedure.input(z.object({ name: z.string() })).mutation(({ input }) => {
+ const newTodo = { id: todos.length + 1, name: input.name };
+ todos.push(newTodo);
+ return newTodo;
+ }),
+} satisfies TRPCRouterRecord;
+
+export const trpcRouter = createTRPCRouter({
+ todos: todosRouter,
+});
+export type TRPCRouter = typeof trpcRouter;
diff --git a/packages/backoffice/src/logo.svg b/packages/backoffice/src/logo.svg
new file mode 100644
index 0000000000..fe53fe8d0d
--- /dev/null
+++ b/packages/backoffice/src/logo.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/packages/backoffice/src/middlewares/app-config.ts b/packages/backoffice/src/middlewares/app-config.ts
new file mode 100644
index 0000000000..2acedfe14f
--- /dev/null
+++ b/packages/backoffice/src/middlewares/app-config.ts
@@ -0,0 +1,7 @@
+import { getAppConfigFn } from '@bo/server-fns/core';
+import { createMiddleware } from '@tanstack/react-start';
+
+export const appConfigMiddleware = createMiddleware().server(async ({ next }) => {
+ const appConfig = await getAppConfigFn();
+ return next({ context: { appConfig } });
+});
diff --git a/packages/backoffice/src/middlewares/auth.ts b/packages/backoffice/src/middlewares/auth.ts
new file mode 100644
index 0000000000..d3761effdf
--- /dev/null
+++ b/packages/backoffice/src/middlewares/auth.ts
@@ -0,0 +1,59 @@
+import { useAuthSession } from '@bo/utils/session';
+import { redirect } from '@tanstack/react-router';
+import { createMiddleware } from '@tanstack/react-start';
+import { appConfigMiddleware } from './app-config';
+
+const fetchWithToken = (token: string, input: RequestInfo | URL, init?: RequestInit) => {
+ const headers = new Headers(init?.headers);
+ headers.set('Authorization', `Bearer ${token}`);
+ return fetch(input, { ...init, headers });
+};
+
+export const authMiddleware = createMiddleware()
+ .middleware([appConfigMiddleware])
+ .server(async ({ context, next }) => {
+ const authSession = await useAuthSession();
+ const authToken = authSession.data.authToken;
+
+ let authFetch: ((input: RequestInfo | URL, init?: RequestInit) => Promise) | null = null;
+
+ if (authToken && authToken.expires_at >= new Date().toISOString()) {
+ authFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const response = await fetchWithToken(authToken.access_token, input, init);
+
+ if (response.status === 401) {
+ if (context.appConfig.auth.provider === 'oidc') {
+ // TODO: Manage OIDC refresh token
+ }
+
+ await authSession.clear();
+ throw redirect({ to: '/sign-in' });
+ }
+
+ return response;
+ };
+ }
+
+ return next({ context: { authFetch: authFetch as typeof fetch } });
+ });
+
+export const needAuth = createMiddleware()
+ .middleware([authMiddleware])
+ .server(async ({ context, next }) => {
+ if (!context?.authFetch) {
+ throw redirect({ to: '/sign-in' });
+ }
+
+ const result = await next({ context: { authFetch: context.authFetch } });
+ if (
+ 'error' in result &&
+ result.error instanceof Response &&
+ result.error.status >= 300 &&
+ result.error.status < 400
+ ) {
+ console.log('result.error', result.error);
+ throw result.error;
+ }
+
+ return result;
+ });
diff --git a/packages/backoffice/src/middlewares/globals.ts b/packages/backoffice/src/middlewares/globals.ts
new file mode 100644
index 0000000000..6a88920e34
--- /dev/null
+++ b/packages/backoffice/src/middlewares/globals.ts
@@ -0,0 +1,12 @@
+import { isRedirect } from '@tanstack/react-router';
+import { createMiddleware } from '@tanstack/react-start';
+
+export const convertRedirectErrorToExceptionMiddleware = createMiddleware({ type: 'function' }).server(
+ async ({ next }) => {
+ const result = await next();
+ if ('error' in result && isRedirect(result.error)) {
+ throw result.error;
+ }
+ return result;
+ },
+);
diff --git a/packages/backoffice/src/routeTree.gen.ts b/packages/backoffice/src/routeTree.gen.ts
new file mode 100644
index 0000000000..319a448deb
--- /dev/null
+++ b/packages/backoffice/src/routeTree.gen.ts
@@ -0,0 +1,358 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as AppRouteImport } from './routes/_app'
+import { Route as IndexRouteImport } from './routes/index'
+import { Route as AppPublicRouteImport } from './routes/_app/_public'
+import { Route as AppPrivateRouteImport } from './routes/_app/_private'
+import { Route as ApiTrpcSplatRouteImport } from './routes/api.trpc.$'
+import { Route as AppPublicSignInRouteImport } from './routes/_app/_public/sign-in'
+import { Route as AppPrivateDashboardRouteImport } from './routes/_app/_private/dashboard'
+import { Route as AppPrivateLicensesIndexRouteImport } from './routes/_app/_private/licenses/index'
+import { Route as AppPrivateOrganizationsOrgIdRouteImport } from './routes/_app/_private/organizations/$orgId'
+import { Route as AppPrivateOrganizationsOrgIdIndexRouteImport } from './routes/_app/_private/organizations/$orgId.index'
+import { Route as AppPrivateOrganizationsOrgIdUsersRouteImport } from './routes/_app/_private/organizations/$orgId.users'
+import { Route as AppPrivateOrganizationsOrgIdSettingsRouteImport } from './routes/_app/_private/organizations/$orgId.settings'
+import { Route as AppPrivateOrganizationsOrgIdOverviewRouteImport } from './routes/_app/_private/organizations/$orgId.overview'
+
+const AppRoute = AppRouteImport.update({
+ id: '/_app',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AppPublicRoute = AppPublicRouteImport.update({
+ id: '/_public',
+ getParentRoute: () => AppRoute,
+} as any)
+const AppPrivateRoute = AppPrivateRouteImport.update({
+ id: '/_private',
+ getParentRoute: () => AppRoute,
+} as any)
+const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({
+ id: '/api/trpc/$',
+ path: '/api/trpc/$',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const AppPublicSignInRoute = AppPublicSignInRouteImport.update({
+ id: '/sign-in',
+ path: '/sign-in',
+ getParentRoute: () => AppPublicRoute,
+} as any)
+const AppPrivateDashboardRoute = AppPrivateDashboardRouteImport.update({
+ id: '/dashboard',
+ path: '/dashboard',
+ getParentRoute: () => AppPrivateRoute,
+} as any)
+const AppPrivateLicensesIndexRoute = AppPrivateLicensesIndexRouteImport.update({
+ id: '/licenses/',
+ path: '/licenses/',
+ getParentRoute: () => AppPrivateRoute,
+} as any)
+const AppPrivateOrganizationsOrgIdRoute =
+ AppPrivateOrganizationsOrgIdRouteImport.update({
+ id: '/organizations/$orgId',
+ path: '/organizations/$orgId',
+ getParentRoute: () => AppPrivateRoute,
+ } as any)
+const AppPrivateOrganizationsOrgIdIndexRoute =
+ AppPrivateOrganizationsOrgIdIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => AppPrivateOrganizationsOrgIdRoute,
+ } as any)
+const AppPrivateOrganizationsOrgIdUsersRoute =
+ AppPrivateOrganizationsOrgIdUsersRouteImport.update({
+ id: '/users',
+ path: '/users',
+ getParentRoute: () => AppPrivateOrganizationsOrgIdRoute,
+ } as any)
+const AppPrivateOrganizationsOrgIdSettingsRoute =
+ AppPrivateOrganizationsOrgIdSettingsRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => AppPrivateOrganizationsOrgIdRoute,
+ } as any)
+const AppPrivateOrganizationsOrgIdOverviewRoute =
+ AppPrivateOrganizationsOrgIdOverviewRouteImport.update({
+ id: '/overview',
+ path: '/overview',
+ getParentRoute: () => AppPrivateOrganizationsOrgIdRoute,
+ } as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/dashboard': typeof AppPrivateDashboardRoute
+ '/sign-in': typeof AppPublicSignInRoute
+ '/api/trpc/$': typeof ApiTrpcSplatRoute
+ '/organizations/$orgId': typeof AppPrivateOrganizationsOrgIdRouteWithChildren
+ '/licenses/': typeof AppPrivateLicensesIndexRoute
+ '/organizations/$orgId/overview': typeof AppPrivateOrganizationsOrgIdOverviewRoute
+ '/organizations/$orgId/settings': typeof AppPrivateOrganizationsOrgIdSettingsRoute
+ '/organizations/$orgId/users': typeof AppPrivateOrganizationsOrgIdUsersRoute
+ '/organizations/$orgId/': typeof AppPrivateOrganizationsOrgIdIndexRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/dashboard': typeof AppPrivateDashboardRoute
+ '/sign-in': typeof AppPublicSignInRoute
+ '/api/trpc/$': typeof ApiTrpcSplatRoute
+ '/licenses': typeof AppPrivateLicensesIndexRoute
+ '/organizations/$orgId/overview': typeof AppPrivateOrganizationsOrgIdOverviewRoute
+ '/organizations/$orgId/settings': typeof AppPrivateOrganizationsOrgIdSettingsRoute
+ '/organizations/$orgId/users': typeof AppPrivateOrganizationsOrgIdUsersRoute
+ '/organizations/$orgId': typeof AppPrivateOrganizationsOrgIdIndexRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/_app': typeof AppRouteWithChildren
+ '/_app/_private': typeof AppPrivateRouteWithChildren
+ '/_app/_public': typeof AppPublicRouteWithChildren
+ '/_app/_private/dashboard': typeof AppPrivateDashboardRoute
+ '/_app/_public/sign-in': typeof AppPublicSignInRoute
+ '/api/trpc/$': typeof ApiTrpcSplatRoute
+ '/_app/_private/organizations/$orgId': typeof AppPrivateOrganizationsOrgIdRouteWithChildren
+ '/_app/_private/licenses/': typeof AppPrivateLicensesIndexRoute
+ '/_app/_private/organizations/$orgId/overview': typeof AppPrivateOrganizationsOrgIdOverviewRoute
+ '/_app/_private/organizations/$orgId/settings': typeof AppPrivateOrganizationsOrgIdSettingsRoute
+ '/_app/_private/organizations/$orgId/users': typeof AppPrivateOrganizationsOrgIdUsersRoute
+ '/_app/_private/organizations/$orgId/': typeof AppPrivateOrganizationsOrgIdIndexRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/dashboard'
+ | '/sign-in'
+ | '/api/trpc/$'
+ | '/organizations/$orgId'
+ | '/licenses/'
+ | '/organizations/$orgId/overview'
+ | '/organizations/$orgId/settings'
+ | '/organizations/$orgId/users'
+ | '/organizations/$orgId/'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/dashboard'
+ | '/sign-in'
+ | '/api/trpc/$'
+ | '/licenses'
+ | '/organizations/$orgId/overview'
+ | '/organizations/$orgId/settings'
+ | '/organizations/$orgId/users'
+ | '/organizations/$orgId'
+ id:
+ | '__root__'
+ | '/'
+ | '/_app'
+ | '/_app/_private'
+ | '/_app/_public'
+ | '/_app/_private/dashboard'
+ | '/_app/_public/sign-in'
+ | '/api/trpc/$'
+ | '/_app/_private/organizations/$orgId'
+ | '/_app/_private/licenses/'
+ | '/_app/_private/organizations/$orgId/overview'
+ | '/_app/_private/organizations/$orgId/settings'
+ | '/_app/_private/organizations/$orgId/users'
+ | '/_app/_private/organizations/$orgId/'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ AppRoute: typeof AppRouteWithChildren
+ ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/_app': {
+ id: '/_app'
+ path: ''
+ fullPath: '/'
+ preLoaderRoute: typeof AppRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_app/_public': {
+ id: '/_app/_public'
+ path: ''
+ fullPath: '/'
+ preLoaderRoute: typeof AppPublicRouteImport
+ parentRoute: typeof AppRoute
+ }
+ '/_app/_private': {
+ id: '/_app/_private'
+ path: ''
+ fullPath: '/'
+ preLoaderRoute: typeof AppPrivateRouteImport
+ parentRoute: typeof AppRoute
+ }
+ '/api/trpc/$': {
+ id: '/api/trpc/$'
+ path: '/api/trpc/$'
+ fullPath: '/api/trpc/$'
+ preLoaderRoute: typeof ApiTrpcSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/_app/_public/sign-in': {
+ id: '/_app/_public/sign-in'
+ path: '/sign-in'
+ fullPath: '/sign-in'
+ preLoaderRoute: typeof AppPublicSignInRouteImport
+ parentRoute: typeof AppPublicRoute
+ }
+ '/_app/_private/dashboard': {
+ id: '/_app/_private/dashboard'
+ path: '/dashboard'
+ fullPath: '/dashboard'
+ preLoaderRoute: typeof AppPrivateDashboardRouteImport
+ parentRoute: typeof AppPrivateRoute
+ }
+ '/_app/_private/licenses/': {
+ id: '/_app/_private/licenses/'
+ path: '/licenses'
+ fullPath: '/licenses/'
+ preLoaderRoute: typeof AppPrivateLicensesIndexRouteImport
+ parentRoute: typeof AppPrivateRoute
+ }
+ '/_app/_private/organizations/$orgId': {
+ id: '/_app/_private/organizations/$orgId'
+ path: '/organizations/$orgId'
+ fullPath: '/organizations/$orgId'
+ preLoaderRoute: typeof AppPrivateOrganizationsOrgIdRouteImport
+ parentRoute: typeof AppPrivateRoute
+ }
+ '/_app/_private/organizations/$orgId/': {
+ id: '/_app/_private/organizations/$orgId/'
+ path: '/'
+ fullPath: '/organizations/$orgId/'
+ preLoaderRoute: typeof AppPrivateOrganizationsOrgIdIndexRouteImport
+ parentRoute: typeof AppPrivateOrganizationsOrgIdRoute
+ }
+ '/_app/_private/organizations/$orgId/users': {
+ id: '/_app/_private/organizations/$orgId/users'
+ path: '/users'
+ fullPath: '/organizations/$orgId/users'
+ preLoaderRoute: typeof AppPrivateOrganizationsOrgIdUsersRouteImport
+ parentRoute: typeof AppPrivateOrganizationsOrgIdRoute
+ }
+ '/_app/_private/organizations/$orgId/settings': {
+ id: '/_app/_private/organizations/$orgId/settings'
+ path: '/settings'
+ fullPath: '/organizations/$orgId/settings'
+ preLoaderRoute: typeof AppPrivateOrganizationsOrgIdSettingsRouteImport
+ parentRoute: typeof AppPrivateOrganizationsOrgIdRoute
+ }
+ '/_app/_private/organizations/$orgId/overview': {
+ id: '/_app/_private/organizations/$orgId/overview'
+ path: '/overview'
+ fullPath: '/organizations/$orgId/overview'
+ preLoaderRoute: typeof AppPrivateOrganizationsOrgIdOverviewRouteImport
+ parentRoute: typeof AppPrivateOrganizationsOrgIdRoute
+ }
+ }
+}
+
+interface AppPrivateOrganizationsOrgIdRouteChildren {
+ AppPrivateOrganizationsOrgIdOverviewRoute: typeof AppPrivateOrganizationsOrgIdOverviewRoute
+ AppPrivateOrganizationsOrgIdSettingsRoute: typeof AppPrivateOrganizationsOrgIdSettingsRoute
+ AppPrivateOrganizationsOrgIdUsersRoute: typeof AppPrivateOrganizationsOrgIdUsersRoute
+ AppPrivateOrganizationsOrgIdIndexRoute: typeof AppPrivateOrganizationsOrgIdIndexRoute
+}
+
+const AppPrivateOrganizationsOrgIdRouteChildren: AppPrivateOrganizationsOrgIdRouteChildren =
+ {
+ AppPrivateOrganizationsOrgIdOverviewRoute:
+ AppPrivateOrganizationsOrgIdOverviewRoute,
+ AppPrivateOrganizationsOrgIdSettingsRoute:
+ AppPrivateOrganizationsOrgIdSettingsRoute,
+ AppPrivateOrganizationsOrgIdUsersRoute:
+ AppPrivateOrganizationsOrgIdUsersRoute,
+ AppPrivateOrganizationsOrgIdIndexRoute:
+ AppPrivateOrganizationsOrgIdIndexRoute,
+ }
+
+const AppPrivateOrganizationsOrgIdRouteWithChildren =
+ AppPrivateOrganizationsOrgIdRoute._addFileChildren(
+ AppPrivateOrganizationsOrgIdRouteChildren,
+ )
+
+interface AppPrivateRouteChildren {
+ AppPrivateDashboardRoute: typeof AppPrivateDashboardRoute
+ AppPrivateOrganizationsOrgIdRoute: typeof AppPrivateOrganizationsOrgIdRouteWithChildren
+ AppPrivateLicensesIndexRoute: typeof AppPrivateLicensesIndexRoute
+}
+
+const AppPrivateRouteChildren: AppPrivateRouteChildren = {
+ AppPrivateDashboardRoute: AppPrivateDashboardRoute,
+ AppPrivateOrganizationsOrgIdRoute:
+ AppPrivateOrganizationsOrgIdRouteWithChildren,
+ AppPrivateLicensesIndexRoute: AppPrivateLicensesIndexRoute,
+}
+
+const AppPrivateRouteWithChildren = AppPrivateRoute._addFileChildren(
+ AppPrivateRouteChildren,
+)
+
+interface AppPublicRouteChildren {
+ AppPublicSignInRoute: typeof AppPublicSignInRoute
+}
+
+const AppPublicRouteChildren: AppPublicRouteChildren = {
+ AppPublicSignInRoute: AppPublicSignInRoute,
+}
+
+const AppPublicRouteWithChildren = AppPublicRoute._addFileChildren(
+ AppPublicRouteChildren,
+)
+
+interface AppRouteChildren {
+ AppPrivateRoute: typeof AppPrivateRouteWithChildren
+ AppPublicRoute: typeof AppPublicRouteWithChildren
+}
+
+const AppRouteChildren: AppRouteChildren = {
+ AppPrivateRoute: AppPrivateRouteWithChildren,
+ AppPublicRoute: AppPublicRouteWithChildren,
+}
+
+const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ AppRoute: AppRouteWithChildren,
+ ApiTrpcSplatRoute: ApiTrpcSplatRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { startInstance } from './start.ts'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ config: Awaited>
+ }
+}
diff --git a/packages/backoffice/src/router.tsx b/packages/backoffice/src/router.tsx
new file mode 100644
index 0000000000..c9a5fe6462
--- /dev/null
+++ b/packages/backoffice/src/router.tsx
@@ -0,0 +1,34 @@
+import * as Sentry from '@sentry/tanstackstart-react';
+import { createRouter } from '@tanstack/react-router';
+import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
+import * as TanstackQuery from './integrations/tanstack-query/root-provider';
+
+// Import the generated route tree
+import { routeTree } from './routeTree.gen';
+
+// Create a new router instance
+export const getRouter = () => {
+ const rqContext = TanstackQuery.getContext();
+
+ const router = createRouter({
+ routeTree,
+ context: { ...rqContext },
+ defaultPreload: false,
+ Wrap: (props: { children: React.ReactNode }) => {
+ return {props.children};
+ },
+ });
+
+ setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient });
+
+ if (!router.isServer) {
+ Sentry.init({
+ dsn: import.meta.env['VITE_SENTRY_DSN'],
+ integrations: [],
+ tracesSampleRate: 1.0,
+ sendDefaultPii: true,
+ });
+ }
+
+ return router;
+};
diff --git a/packages/backoffice/src/routes/__root.tsx b/packages/backoffice/src/routes/__root.tsx
new file mode 100644
index 0000000000..c3209248fa
--- /dev/null
+++ b/packages/backoffice/src/routes/__root.tsx
@@ -0,0 +1,83 @@
+import { Toaster } from '@bo/components/common/Toaster';
+import { env } from '@bo/env';
+import type { TRPCRouter } from '@bo/integrations/trpc/router';
+import { getUserPreferencesFn } from '@bo/server-fns/core';
+import { TanStackDevtools } from '@tanstack/react-devtools';
+import type { QueryClient } from '@tanstack/react-query';
+import { ClientOnly, createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router';
+import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
+import type { TRPCOptionsProxy } from '@trpc/tanstack-react-query';
+import { cn } from 'ui-design-system';
+import TanStackQueryDevtools from '../integrations/tanstack-query/devtools';
+import appCss from '../styles.css?url';
+
+interface MyRouterContext {
+ queryClient: QueryClient;
+
+ trpc: TRPCOptionsProxy;
+}
+
+export const Route = createRootRouteWithContext()({
+ head: () => ({
+ meta: [
+ {
+ charSet: 'utf-8',
+ },
+ {
+ name: 'viewport',
+ content: 'width=device-width, initial-scale=1',
+ },
+ {
+ title: env.VITE_APP_TITLE,
+ },
+ ],
+ links: [
+ {
+ rel: 'stylesheet',
+ href: appCss,
+ },
+ ],
+ }),
+
+ beforeLoad: async () => {
+ return {
+ userPreferences: await getUserPreferencesFn(),
+ };
+ },
+
+ shellComponent: RootDocument,
+});
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ const {
+ userPreferences: { theme = 'light' },
+ } = Route.useRouteContext();
+
+ return (
+
+
+
+
+
+ {children}
+
+
+ ,
+ },
+ TanStackQueryDevtools,
+ ]}
+ />
+
+
+
+
+ );
+}
diff --git a/packages/backoffice/src/routes/_app.tsx b/packages/backoffice/src/routes/_app.tsx
new file mode 100644
index 0000000000..27c2444f13
--- /dev/null
+++ b/packages/backoffice/src/routes/_app.tsx
@@ -0,0 +1,39 @@
+import { AppConfigContext } from '@bo/contexts/AppConfig';
+import { StickyRootsProvider } from '@bo/contexts/StickyRoots';
+import { getAppConfigFn } from '@bo/server-fns/core';
+import { createFileRoute, ErrorComponentProps, Outlet } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app')({
+ component: RouteComponent,
+
+ loader: async () => {
+ return {
+ appConfig: await getAppConfigFn(),
+ };
+ },
+
+ errorComponent: ErrorComponent,
+});
+
+function ErrorComponent({ error }: ErrorComponentProps) {
+ return (
+
+
+
An error occured
+ {import.meta.env.DEV ?
{error.stack}
: null}
+
+
+ );
+}
+
+function RouteComponent() {
+ const { appConfig } = Route.useLoaderData();
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/backoffice/src/routes/_app/_private.tsx b/packages/backoffice/src/routes/_app/_private.tsx
new file mode 100644
index 0000000000..33ba08cb3c
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private.tsx
@@ -0,0 +1,122 @@
+import { StickySentinel } from '@bo/contexts/StickyRoots';
+import { useFirebase } from '@bo/hooks/useFirebase';
+import { useInterval } from '@bo/hooks/useInterval';
+import { getCurrentUserFn, logoutFn, refreshTokenFn } from '@bo/server-fns/auth';
+import { getAppConfigFn, updateUserPreferencesFn } from '@bo/server-fns/core';
+import { ClientOnly, createFileRoute, Link, Outlet, redirect, useRouter } from '@tanstack/react-router';
+import { useServerFn } from '@tanstack/react-start';
+import { Button, MenuCommand, Switch } from 'ui-design-system';
+import { Icon } from 'ui-icons';
+
+export const Route = createFileRoute('/_app/_private')({
+ beforeLoad: async () => {
+ const currentUser = await getCurrentUserFn();
+
+ if (!currentUser) {
+ throw redirect({ to: '/sign-in' });
+ }
+
+ // TODO: If user is not a MARBLE_ADMIN, logout the user and redirect to /sign-in with an error message
+ if (currentUser.role !== 'MARBLE_ADMIN') {
+ await logoutFn();
+ throw redirect({ to: '/sign-in' });
+ }
+ },
+ loader: async () => {
+ const currentUser = await getCurrentUserFn();
+ const appConfig = await getAppConfigFn();
+
+ return { currentUser, appConfig };
+ },
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const {
+ userPreferences: { theme = 'light' },
+ } = Route.useRouteContext();
+ const callLogoutFn = useServerFn(logoutFn);
+ const { currentUser, appConfig } = Route.useLoaderData();
+ const callUpdateUserPreferences = useServerFn(updateUserPreferencesFn);
+ const router = useRouter();
+
+ const handleToggleTheme = async () => {
+ console.log('test');
+ await callUpdateUserPreferences({ data: { theme: theme === 'light' ? 'dark' : 'light' } });
+ router.invalidate();
+ };
+
+ return (
+ <>
+ {appConfig.auth.provider === 'firebase' ? : null}
+
+
+
+
Marble Backoffice
+
+
+ Licences Management
+
+
+
+
+ {currentUser.actor_identity.email}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+const TokenRefresher = () => {
+ const firebaseClient = useFirebase();
+ const callRefreshTokenFn = useServerFn(refreshTokenFn);
+ const callLogoutFn = useServerFn(logoutFn);
+
+ useInterval(
+ () => {
+ firebaseClient.getIdToken().then(
+ (idToken) => {
+ callRefreshTokenFn({ data: { idToken } });
+ },
+ () => {
+ callLogoutFn();
+ },
+ );
+ },
+ { delay: 1000 * 60 * 20, executeImmediately: true },
+ );
+ return null;
+};
diff --git a/packages/backoffice/src/routes/_app/_private/dashboard.tsx b/packages/backoffice/src/routes/_app/_private/dashboard.tsx
new file mode 100644
index 0000000000..c598d3921d
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/dashboard.tsx
@@ -0,0 +1,14 @@
+import { DashboardPage } from '@bo/components/pages/dashboard';
+import { listOrganizationsQueryOptions } from '@bo/data/organization';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/dashboard')({
+ component: RouteComponent,
+ loader: ({ context }) => {
+ context.queryClient.prefetchQuery(listOrganizationsQueryOptions());
+ },
+});
+
+function RouteComponent() {
+ return ;
+}
diff --git a/packages/backoffice/src/routes/_app/_private/licenses/index.tsx b/packages/backoffice/src/routes/_app/_private/licenses/index.tsx
new file mode 100644
index 0000000000..9d307c2402
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/licenses/index.tsx
@@ -0,0 +1,10 @@
+import { LicensesPage } from '@bo/components/pages/licenses';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/licenses/')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ return ;
+}
diff --git a/packages/backoffice/src/routes/_app/_private/organizations/$orgId.index.tsx b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.index.tsx
new file mode 100644
index 0000000000..21bc3e0268
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute, redirect } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/organizations/$orgId/')({
+ beforeLoad: async ({ params }) => {
+ throw redirect({ from: '/organizations/$orgId/', to: './overview' });
+ },
+});
diff --git a/packages/backoffice/src/routes/_app/_private/organizations/$orgId.overview.tsx b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.overview.tsx
new file mode 100644
index 0000000000..3f2ff67853
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.overview.tsx
@@ -0,0 +1,12 @@
+import { OrganizationOverviewPage } from '@bo/components/pages/organization.overview';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/organizations/$orgId/overview')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const { orgId } = Route.useParams();
+
+ return ;
+}
diff --git a/packages/backoffice/src/routes/_app/_private/organizations/$orgId.settings.tsx b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.settings.tsx
new file mode 100644
index 0000000000..d30e4a3f30
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.settings.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/organizations/$orgId/settings')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ return Hello "/_private/organizations/$orgId/settings"!
;
+}
diff --git a/packages/backoffice/src/routes/_app/_private/organizations/$orgId.tsx b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.tsx
new file mode 100644
index 0000000000..8afa9d02bb
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.tsx
@@ -0,0 +1,22 @@
+import { OrganizationLayout } from '@bo/components/pages/organization._layout';
+import { getOrganizationQueryOptions } from '@bo/data/organization';
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { createFileRoute, Outlet } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/organizations/$orgId')({
+ component: RouteComponent,
+ loader: ({ params, context }) => {
+ context.queryClient.prefetchQuery(getOrganizationQueryOptions(params.orgId));
+ },
+});
+
+function RouteComponent() {
+ const { orgId } = Route.useParams();
+ const { data: organization } = useSuspenseQuery(getOrganizationQueryOptions(orgId));
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/backoffice/src/routes/_app/_private/organizations/$orgId.users.tsx b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.users.tsx
new file mode 100644
index 0000000000..bd391c7cb0
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_private/organizations/$orgId.users.tsx
@@ -0,0 +1,20 @@
+import { ErrorComponent } from '@bo/components/common/ErrorComponent';
+import { OrganizationUsersPage } from '@bo/components/pages/organization.users';
+import { listOrganizationUsersQueryOptions } from '@bo/data/organization';
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_private/organizations/$orgId/users')({
+ component: RouteComponent,
+ loader: ({ params, context }) => {
+ context.queryClient.prefetchQuery(listOrganizationUsersQueryOptions(params.orgId));
+ },
+ errorComponent: ({ error }) => {
+ return ;
+ },
+});
+
+function RouteComponent() {
+ const { orgId } = Route.useParams();
+
+ return ;
+}
diff --git a/packages/backoffice/src/routes/_app/_public.tsx b/packages/backoffice/src/routes/_app/_public.tsx
new file mode 100644
index 0000000000..694ea8c840
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_public.tsx
@@ -0,0 +1,11 @@
+import { isAuthenticatedFn } from '@bo/server-fns/auth';
+import { createFileRoute, redirect } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_app/_public')({
+ beforeLoad: async () => {
+ const isAuthenticated = await isAuthenticatedFn();
+ if (isAuthenticated) {
+ throw redirect({ to: '/dashboard' });
+ }
+ },
+});
diff --git a/packages/backoffice/src/routes/_app/_public/sign-in.tsx b/packages/backoffice/src/routes/_app/_public/sign-in.tsx
new file mode 100644
index 0000000000..e4a368e7ad
--- /dev/null
+++ b/packages/backoffice/src/routes/_app/_public/sign-in.tsx
@@ -0,0 +1,119 @@
+import { useFirebase } from '@bo/hooks/useFirebase';
+import { signinFn } from '@bo/server-fns/auth';
+import { ClientOnly, createFileRoute } from '@tanstack/react-router';
+import { useServerFn } from '@tanstack/react-start';
+import { useState } from 'react';
+import { Button } from 'ui-design-system';
+import { Icon, Logo } from 'ui-icons';
+
+export const Route = createFileRoute('/_app/_public/sign-in')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ return (
+
+
+ {/* Brand lockup — the signal that this is the internal console, not the customer app */}
+
+
+
+
+ Backoffice
+
+
+
+
Sign in to the operator console
+
+ Internal tool for Checkmarble staff. Use your Checkmarble Google account to continue.
+
+
+
+
+ }>
+
+
+
+
+
+ Staff access only — sign-in activity is recorded.
+
+
+
+ );
+}
+
+/* --------------------------------- Sign in --------------------------------- */
+
+const FIREBASE_ERROR_COPY: Record = {
+ 'auth/popup-closed-by-user': 'Sign-in was cancelled. Try again when you’re ready.',
+ 'auth/cancelled-popup-request': 'Sign-in was cancelled. Try again when you’re ready.',
+ 'auth/popup-blocked': 'Your browser blocked the sign-in popup. Allow popups for this site, then try again.',
+ 'auth/network-request-failed': 'Network error. Check your connection and try again.',
+};
+
+function toErrorCopy(error: unknown): string {
+ const code =
+ typeof error === 'object' && error !== null && 'code' in error ? String((error as { code: unknown }).code) : '';
+ return (
+ FIREBASE_ERROR_COPY[code] ??
+ 'We couldn’t sign you in. Try again, or contact the platform team if it keeps happening.'
+ );
+}
+
+function GoogleSignIn() {
+ const callSigninFn = useServerFn(signinFn);
+ const firebaseClient = useFirebase();
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState(null);
+
+ const signIn = async () => {
+ setError(null);
+ setPending(true);
+
+ let idToken: string;
+ try {
+ idToken = await firebaseClient.signInWithGoogle();
+ } catch (popupError) {
+ // Client-side auth (popup cancelled / blocked / network) — surface and let them retry.
+ setError(toErrorCopy(popupError));
+ setPending(false);
+ return;
+ }
+
+ // On success `signinFn` throws a redirect to /dashboard; on failure it redirects
+ // back here. Either way we let the router follow it — no manual navigation.
+ await callSigninFn({ data: { idToken } });
+ setPending(false);
+ };
+
+ return (
+
+
+ {error ? (
+
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+function GoogleButton({ onClick, loading, disabled }: { onClick?: () => void; loading?: boolean; disabled?: boolean }) {
+ return (
+
+ );
+}
diff --git a/packages/backoffice/src/routes/api.trpc.$.tsx b/packages/backoffice/src/routes/api.trpc.$.tsx
new file mode 100644
index 0000000000..deec7139d0
--- /dev/null
+++ b/packages/backoffice/src/routes/api.trpc.$.tsx
@@ -0,0 +1,20 @@
+import { trpcRouter } from '@bo/integrations/trpc/router';
+import { createFileRoute } from '@tanstack/react-router';
+import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
+
+function handler({ request }: { request: Request }) {
+ return fetchRequestHandler({
+ req: request,
+ router: trpcRouter,
+ endpoint: '/api/trpc',
+ });
+}
+
+export const Route = createFileRoute('/api/trpc/$')({
+ server: {
+ handlers: {
+ GET: handler,
+ POST: handler,
+ },
+ },
+});
diff --git a/packages/backoffice/src/routes/index.tsx b/packages/backoffice/src/routes/index.tsx
new file mode 100644
index 0000000000..c8f66aa050
--- /dev/null
+++ b/packages/backoffice/src/routes/index.tsx
@@ -0,0 +1,18 @@
+import { useAuthSession } from '@bo/utils/session';
+import { createFileRoute, redirect } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/')({
+ server: {
+ handlers: {
+ GET: async () => {
+ const authSession = await useAuthSession();
+
+ if (!authSession.data?.authToken) {
+ return redirect({ to: '/sign-in' });
+ }
+
+ return redirect({ to: '/dashboard' });
+ },
+ },
+ },
+});
diff --git a/packages/backoffice/src/schemas/features.ts b/packages/backoffice/src/schemas/features.ts
new file mode 100644
index 0000000000..0a215cdd45
--- /dev/null
+++ b/packages/backoffice/src/schemas/features.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod/v4';
+
+export const OVERRIDABLE_FEATURES = [
+ 'test_run',
+ 'sanctions',
+ 'case_auto_assign',
+ 'case_ai_assist',
+ 'continuous_screening',
+ 'ai_rule_building',
+ 'lexisnexis',
+] as const;
+
+export const featureValueSchema = z.enum(['allowed', 'restricted', 'test']);
+
+export type FeatureValue = z.infer;
+
+export const patchOrganizationFeaturesPayloadSchema = z.record(z.enum(OVERRIDABLE_FEATURES), featureValueSchema);
+
+export type PatchOrganizationFeaturesPayload = z.infer;
diff --git a/packages/backoffice/src/schemas/org-import.ts b/packages/backoffice/src/schemas/org-import.ts
new file mode 100644
index 0000000000..2b912d0ffc
--- /dev/null
+++ b/packages/backoffice/src/schemas/org-import.ts
@@ -0,0 +1,265 @@
+import { z } from 'zod/v4';
+
+/**
+ * Wire schema for `dto.OrgImport` (marble-backend/dto/org_import.go), as produced by
+ * `GET /org-export` and consumed by `POST /org-import`.
+ *
+ * Two things shape this file:
+ *
+ * 1. The parse output *is* the payload. It is stripped at the dropzone (ChoiceStep) and
+ * again by the server-fn validator, then POSTed as-is — the backend types the body as
+ * a bare `object`, so anything not declared here never reaches it. Hence `looseObject`
+ * everywhere: the backend DTO grows between releases, and `z.object` would silently
+ * delete the next version's fields.
+ * 2. Empty collections arrive as `null`, not `[]` — every collection in `OrgImport` is a
+ * Go slice/map on a json tag without `omitempty`, so nil serialises to `null`. The
+ * exporter never populates `admins` or `seeds` at all.
+ *
+ * It is deliberately tolerant: only `org.name` and `tags[].name` are `binding:"required"`
+ * on the backend, and the backend is authoritative for the rest. Business validation of
+ * the operator-editable subset lives in `importEditSchema` (ImportFlow), where a bad
+ * value can actually be fixed — rejecting a valid export at the dropzone is a dead end.
+ */
+
+/**
+ * Accepts `null` (Go nil) and absence, emits `undefined`. The inferred type is
+ * `T | undefined` rather than `T | null | undefined`, and `JSON.stringify` drops the key
+ * on the way back out, which Go decodes as nil exactly like an explicit `null`.
+ *
+ * `.optional()` alone would not work: in zod it means "key absent" and rejects `null`.
+ */
+const opt = (schema: T) => schema.nullish().transform((value) => value ?? undefined);
+
+/**
+ * Opaque identifiers. Most ids in the spec are plain Go strings, used only as keys into
+ * the importer's id remap table (every id is regenerated on import), so `z.uuid()` here
+ * would reject valid specs for no benefit. `z.uuid()` is used only where the DTO really
+ * declares `uuid.UUID`.
+ */
+const specId = z.string();
+
+const metadataSpec = z.looseObject({
+ label: opt(z.string()),
+ description: opt(z.string()),
+ app_version: opt(z.string()),
+});
+
+/** `ImportOrg` = `{ name }` + embedded `UpdateOrganizationBodyDto`. */
+const orgSpec = z.looseObject({
+ name: z.string().nonempty(),
+ default_scenario_timezone: opt(z.string()),
+ sanctions_threshold: opt(z.int()),
+ sanctions_limit: opt(z.int()),
+ /** Keyed by screening feature, valued by provider (`opensanctions` | `lexisnexis`). */
+ screening_providers: opt(z.record(z.string(), z.string())),
+ auto_assign_queue_limit: opt(z.int()),
+ sentry_replay_enabled: opt(z.boolean()),
+ environment: opt(z.string()),
+});
+
+/** `CreateUser`. `role` and `organization_id` are ignored by the importer. */
+const adminSpec = z.looseObject({
+ email: z.string(),
+ first_name: opt(z.string()),
+ last_name: opt(z.string()),
+});
+
+/**
+ * `dto.Field`. `unicity_constraint` and `semantic_type` stay `z.string()`: the backend
+ * enum has three values while the export uses two, and `semantic_type` is exported as
+ * `''`, so an enum would reject valid specs.
+ */
+const fieldSpec = z.looseObject({
+ id: specId,
+ name: opt(z.string()),
+ data_type: z.string(),
+ description: opt(z.string()),
+ nullable: opt(z.boolean()),
+ is_enum: opt(z.boolean()),
+ table_id: opt(specId),
+ values: opt(z.array(z.any())),
+ unicity_constraint: opt(z.string()),
+ alias: opt(z.string()),
+ semantic_type: opt(z.string()),
+ ftm_property: opt(z.string()),
+ metadata: z.any().optional(),
+});
+
+/**
+ * `dto.Table`. The exporter hoists `links_to_single` and per-table `navigation_options`
+ * to the top level of `data_model`, so they are absent here in practice.
+ */
+const tableSpec = z.looseObject({
+ id: specId,
+ name: z.string(),
+ description: opt(z.string()),
+ fields: opt(z.record(z.string(), fieldSpec)),
+ alias: opt(z.string()),
+ semantic_type: opt(z.string()),
+ caption_field: opt(z.string()),
+ primary_ordering_field: opt(z.string()),
+ ftm_entity: opt(z.string()),
+ metadata: z.any().optional(),
+});
+
+const linkSpec = z.looseObject({
+ id: specId,
+ name: opt(z.string()),
+ link_type: opt(z.string()),
+ parent_table_name: z.string(),
+ parent_table_id: specId,
+ parent_field_name: opt(z.string()),
+ parent_field_id: specId,
+ child_table_name: z.string(),
+ child_table_id: specId,
+ child_field_name: opt(z.string()),
+ child_field_id: specId,
+});
+
+const pivotSpec = z.looseObject({
+ id: z.uuid(),
+ base_table_id: specId,
+ field_id: opt(specId),
+ path_link_ids: opt(z.array(specId)),
+ created_at: opt(z.string()),
+ organization_id: opt(z.uuid()),
+});
+
+const navigationOptionSpec = z.looseObject({
+ source_field_id: specId,
+ target_table_id: specId,
+ filter_field_id: opt(z.string()),
+ ordering_field_id: opt(z.string()),
+});
+
+const dataModelSpec = z.looseObject({
+ tables: opt(z.array(tableSpec)),
+ links: opt(z.array(linkSpec)),
+ pivots: opt(z.array(pivotSpec)),
+ /** `map[string][]CreateNavigationOptionInput` — an array per source table id. */
+ navigation_options: opt(z.record(z.string(), z.array(navigationOptionSpec))),
+});
+
+/** Only `name`, `description` and `trigger_object_type` are read by the importer. */
+const scenarioDataSpec = z.looseObject({
+ id: specId,
+ name: z.string(),
+ description: opt(z.string()),
+ trigger_object_type: z.string(),
+ live_version_id: opt(z.string()),
+ organization_id: opt(z.uuid()),
+ archived: opt(z.boolean()),
+ created_at: opt(z.string()),
+});
+
+/** `dto.RuleDto`. `display_order` is re-derived from array index by the importer. */
+const ruleSpec = z.looseObject({
+ id: opt(specId),
+ stable_id: specId,
+ name: z.string(),
+ description: opt(z.string()),
+ formula_ast_expression: z.any().optional(),
+ score_modifier: opt(z.int()),
+ rule_group: opt(z.string()),
+ display_order: opt(z.int()),
+});
+
+const iterationSpec = z.looseObject({
+ trigger_condition_ast_expression: z.any().optional(),
+ rules: opt(z.array(ruleSpec)),
+ /** Passed through verbatim — the shape is large and nothing here reads into it. */
+ screening_configs: opt(z.array(z.looseObject({}))),
+ score_review_threshold: opt(z.int()),
+ score_block_and_review_threshold: opt(z.int()),
+ score_decline_threshold: opt(z.int()),
+ schedule: opt(z.string()),
+});
+
+const scenarioSpec = z.looseObject({
+ scenario: scenarioDataSpec,
+ iteration: iterationSpec,
+});
+
+/** `dto.ImportTag` = `CreateTagBody` + `Id`. `color` is `binding:"required,hexcolor"`. */
+const tagSpec = z.looseObject({
+ id: specId,
+ name: z.string().nonempty(),
+ color: z.string().regex(/^#?[0-9a-fA-F]{3,8}$/, 'Expected a hex color.'),
+ target: opt(z.enum(['case', 'object']).or(z.literal(''))),
+});
+
+/** `dto.ImportCustomList`. `kind` must be `text` or `cidrs` or the import aborts. */
+const customListSpec = z.looseObject({
+ id: specId,
+ name: z.string(),
+ description: opt(z.string()),
+ kind: opt(z.string()),
+ values: opt(z.array(z.string())),
+});
+
+/** `dto.InboxDto`. The importer reads only `name` (and `id`, to remap it). */
+const inboxSpec = z.looseObject({
+ id: specId,
+ name: z.string(),
+});
+
+/** `params` is polymorphic — an array for `outcome_in`, an object for the rest. */
+const workflowConditionSpec = z.looseObject({
+ id: opt(z.uuid()),
+ function: z.string(),
+ params: z.any().optional(),
+});
+
+const workflowActionSpec = z.looseObject({
+ id: opt(z.uuid()),
+ action: z.string(),
+ params: z.any().optional(),
+});
+
+/** `fallthrough` is exported but dropped on import. */
+const workflowSpec = z.looseObject({
+ id: z.uuid(),
+ scenario_id: z.uuid(),
+ name: z.string(),
+ fallthrough: opt(z.boolean()),
+ conditions: opt(z.array(workflowConditionSpec)),
+ actions: opt(z.array(workflowActionSpec)),
+});
+
+const ingestionFieldSpec = z.looseObject({
+ ref: opt(z.string()),
+ constant: z.any().optional(),
+ enum: opt(z.array(z.any())),
+ int_range: opt(z.array(z.int())),
+ float_range: opt(z.array(z.number())),
+ generator: opt(z.string()),
+ cast: opt(z.string()),
+});
+
+const ingestionSpec = z.looseObject({
+ table: z.string(),
+ count: z.int(),
+ fields: opt(z.record(z.string(), ingestionFieldSpec)),
+});
+
+const seedSpec = z.looseObject({
+ ingestion: opt(z.record(z.string(), ingestionSpec)),
+ decisions: opt(z.record(z.string(), z.int())),
+});
+
+export const orgImportSpecSchema = z.looseObject({
+ metadata: opt(metadataSpec),
+ org: orgSpec,
+ admins: opt(z.array(adminSpec)),
+ data_model: dataModelSpec,
+ scenarios: opt(z.array(scenarioSpec)),
+ tags: opt(z.array(tagSpec)),
+ custom_lists: opt(z.array(customListSpec)),
+ inboxes: opt(z.array(inboxSpec)),
+ workflows: opt(z.array(workflowSpec)),
+ seeds: opt(seedSpec),
+});
+
+export type OrgImportSpec = z.infer;
+/** Use when typing a value *before* parsing (collections still accept `null`). */
+export type OrgImportSpecInput = z.input;
diff --git a/packages/backoffice/src/schemas/user.ts b/packages/backoffice/src/schemas/user.ts
new file mode 100644
index 0000000000..c4cb217e72
--- /dev/null
+++ b/packages/backoffice/src/schemas/user.ts
@@ -0,0 +1,24 @@
+import { z } from 'zod/v4';
+
+const ROLE_ADMIN = 'ADMIN';
+const ROLE_PUBLISHER = 'PUBLISHER';
+const ROLE_BUILDER = 'BUILDER';
+const ROLE_VIEWER = 'VIEWER';
+const ROLE_ANALYST = 'ANALYST';
+
+export const USER_ROLES = [ROLE_ADMIN, ROLE_PUBLISHER, ROLE_BUILDER, ROLE_VIEWER, ROLE_ANALYST] as const;
+
+export const createUserPayloadSchema = z.object({
+ first_name: z.string().min(1),
+ last_name: z.string().min(1),
+ email: z.email(),
+ role: z.enum(USER_ROLES),
+});
+
+export type CreateUserPayload = z.infer;
+
+/**
+ * Error code thrown by `createOrganizationUserFn` when the email is already taken, so the
+ * client can tell that case apart from a generic failure.
+ */
+export const DUPLICATE_EMAIL_ERROR = 'duplicate_email';
diff --git a/packages/backoffice/src/server-fns/auth.ts b/packages/backoffice/src/server-fns/auth.ts
new file mode 100644
index 0000000000..4d0c399189
--- /dev/null
+++ b/packages/backoffice/src/server-fns/auth.ts
@@ -0,0 +1,70 @@
+import { env } from '@bo/env';
+import { authMiddleware, needAuth } from '@bo/middlewares/auth';
+import { useAuthSession } from '@bo/utils/session';
+import { redirect } from '@tanstack/react-router';
+import { createServerFn } from '@tanstack/react-start';
+import { marblecoreApi } from 'marble-api';
+import z from 'zod';
+
+export const signinFn = createServerFn({ method: 'POST' })
+ .validator(
+ z.object({
+ idToken: z.string(),
+ }),
+ )
+ .handler(async ({ data: { idToken } }) => {
+ const authorization = `Bearer ${idToken}`;
+
+ try {
+ const marbleToken = await marblecoreApi.postToken({ authorization }, { baseUrl: env.API_BASE_URL });
+
+ const authSession = await useAuthSession();
+ await authSession.update({
+ authToken: marbleToken,
+ });
+
+ throw redirect({ to: '/dashboard' });
+ } catch {
+ throw redirect({ to: '/sign-in' });
+ }
+ });
+
+export const logoutFn = createServerFn({ method: 'POST' }).handler(async () => {
+ const authSession = await useAuthSession();
+ await authSession.clear();
+
+ throw redirect({ to: '/sign-in' });
+});
+
+export const refreshTokenFn = createServerFn({ method: 'POST' })
+ .validator(z.object({ idToken: z.string() }))
+ .handler(async ({ data: { idToken } }) => {
+ const authorization = `Bearer ${idToken}`;
+
+ try {
+ const marbleToken = await marblecoreApi.postToken({ authorization }, { baseUrl: env.API_BASE_URL });
+
+ const authSession = await useAuthSession();
+ await authSession.update({
+ authToken: marbleToken,
+ });
+ } catch {
+ const authSession = await useAuthSession();
+ await authSession.clear();
+
+ throw redirect({ to: '/sign-in' });
+ }
+ });
+
+export const isAuthenticatedFn = createServerFn({ method: 'GET' })
+ .middleware([authMiddleware])
+ .handler(async ({ context }) => {
+ return !!context.authFetch;
+ });
+
+export const getCurrentUserFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .handler(async ({ context }) => {
+ const credentialsDto = await marblecoreApi.getCredentials({ baseUrl: env.API_BASE_URL, fetch: context.authFetch });
+ return credentialsDto.credentials;
+ });
diff --git a/packages/backoffice/src/server-fns/core.ts b/packages/backoffice/src/server-fns/core.ts
new file mode 100644
index 0000000000..aa76968863
--- /dev/null
+++ b/packages/backoffice/src/server-fns/core.ts
@@ -0,0 +1,23 @@
+import { env } from '@bo/env';
+import { useUserPreferences } from '@bo/utils/user-preferences';
+import { createServerFn } from '@tanstack/react-start';
+import { marblecoreApi } from 'marble-api';
+import { z } from 'zod/v4';
+
+export const getAppConfigFn = createServerFn({ method: 'GET' }).handler(async () => {
+ const appConfig = await marblecoreApi.getAppConfig({ baseUrl: env.API_BASE_URL });
+ return appConfig;
+});
+
+export const getUserPreferencesFn = createServerFn({ method: 'GET' }).handler(async () => {
+ const userPreferencesCookie = await useUserPreferences();
+ return userPreferencesCookie.data;
+});
+
+export const updateUserPreferencesFn = createServerFn({ method: 'POST' })
+ .validator(z.object({ theme: z.enum(['light', 'dark']).optional() }))
+ .handler(async ({ data }) => {
+ const userPreferencesCookie = await useUserPreferences();
+
+ await userPreferencesCookie.update(data);
+ });
diff --git a/packages/backoffice/src/server-fns/licenses.ts b/packages/backoffice/src/server-fns/licenses.ts
new file mode 100644
index 0000000000..3b25c5ffa4
--- /dev/null
+++ b/packages/backoffice/src/server-fns/licenses.ts
@@ -0,0 +1,75 @@
+import { env } from '@bo/env';
+import { needAuth } from '@bo/middlewares/auth';
+import { createServerFn } from '@tanstack/react-start';
+import { backofficeApi } from 'marble-api';
+import { z } from 'zod/v4';
+
+export const getLicensesFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .handler(async ({ context }) => {
+ const { licenses } = await backofficeApi.getLicenses({
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return licenses;
+ });
+
+const licenseEntitlementsSchema = z.object({
+ sso: z.boolean(),
+ workflows: z.boolean(),
+ analytics: z.boolean(),
+ data_enrichment: z.boolean(),
+ user_roles: z.boolean(),
+ webhooks: z.boolean(),
+ rule_snoozes: z.boolean(),
+ test_run: z.boolean(),
+ sanctions: z.boolean(),
+ auto_assignment: z.boolean(),
+ case_ai_assist: z.boolean(),
+ continuous_screening: z.boolean(),
+ user_scoring: z.boolean(),
+ lexisnexis: z.boolean(),
+});
+
+export const licensePayloadSchema = z.object({
+ expiration_date: z.string(),
+ organization_name: z.string().min(1),
+ description: z.string(),
+ license_entitlements: licenseEntitlementsSchema,
+});
+
+export type LicensePayload = z.infer;
+
+export const createLicenseFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(licensePayloadSchema)
+ .handler(async ({ context, data }) => {
+ const { license } = await backofficeApi.createLicense(data, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return license;
+ });
+
+export const updateLicenseFnInputSchema = z.object({
+ licenseId: z.uuid(),
+ payload: licensePayloadSchema.extend({
+ suspend: z.boolean().optional(),
+ }),
+});
+
+export type UpdateLicenseInput = z.infer;
+
+export const updateLicenseFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(updateLicenseFnInputSchema)
+ .handler(async ({ context, data }) => {
+ const { license } = await backofficeApi.updateLicense(data.licenseId, data.payload, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return license;
+ });
diff --git a/packages/backoffice/src/server-fns/organization.ts b/packages/backoffice/src/server-fns/organization.ts
new file mode 100644
index 0000000000..0d08988e7c
--- /dev/null
+++ b/packages/backoffice/src/server-fns/organization.ts
@@ -0,0 +1,208 @@
+import { env } from '@bo/env';
+import { needAuth } from '@bo/middlewares/auth';
+import { OVERRIDABLE_FEATURES, patchOrganizationFeaturesPayloadSchema } from '@bo/schemas/features';
+import { orgImportSpecSchema } from '@bo/schemas/org-import';
+import { createUserPayloadSchema, DUPLICATE_EMAIL_ERROR } from '@bo/schemas/user';
+import { isRedirect } from '@tanstack/react-router';
+import { createServerFn } from '@tanstack/react-start';
+import { backofficeApi, marblecoreApi } from 'marble-api';
+import * as R from 'remeda';
+import { z } from 'zod/v4';
+
+export const getOrganizationsFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .handler(async ({ context }) => {
+ const { organizations } = await marblecoreApi.listOrganizations({
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return organizations;
+ });
+
+export const getOrganizationFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .validator(
+ z.object({
+ orgId: z.uuid(),
+ }),
+ )
+ .handler(async ({ context, data }) => {
+ const { organization } = await marblecoreApi.getOrganization(data.orgId, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return organization;
+ });
+
+export const getOrganizationUsersFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .validator(
+ z.object({
+ orgId: z.uuid(),
+ }),
+ )
+ .handler(async ({ context, data }) => {
+ const { users } = await marblecoreApi.listOrganizationUsers(
+ data.orgId,
+ { withTfa: false },
+ {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ },
+ );
+
+ return users;
+ });
+
+export const getOrganizationFeaturesFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .validator(
+ z.object({
+ orgId: z.uuid(),
+ }),
+ )
+ .handler(async ({ context, data }) => {
+ const { feature_access } = await backofficeApi.getOrganizationFeatures(data.orgId, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return feature_access;
+ });
+
+export const listOrganizationArchetypesFn = createServerFn({ method: 'GET' })
+ .middleware([needAuth])
+ .handler(async ({ context }) => {
+ const { archetypes } = await marblecoreApi.listArchetypes({
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return archetypes;
+ });
+
+export const patchOrganizationFeaturesFnInputSchema = z.object({
+ orgId: z.uuid(),
+ features: patchOrganizationFeaturesPayloadSchema,
+});
+
+export const patchOrganizationFeaturesFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(patchOrganizationFeaturesFnInputSchema)
+ .handler(async ({ context, data }) => {
+ await backofficeApi.patchOrganizationFeatures(data.orgId, data.features, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+ });
+
+export const createOrganizationUserFnInputSchema = z.object({
+ orgId: z.uuid(),
+ userPayload: createUserPayloadSchema,
+});
+
+const CONFLICT_STATUS = 409;
+
+const isConflictError = (error: unknown) =>
+ error instanceof Error && (error as { status?: number }).status === CONFLICT_STATUS;
+
+export const createOrganizationUserFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(createOrganizationUserFnInputSchema)
+ .handler(async ({ context, data }) => {
+ const payload = { ...data.userPayload, organization_id: data.orgId };
+
+ try {
+ const { user } = await marblecoreApi.createUser(payload, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return user;
+ } catch (error) {
+ // `authFetch` throws a router redirect on 401, from inside this try — never swallow it.
+ if (isRedirect(error)) throw error;
+ if (isConflictError(error)) throw new Error(DUPLICATE_EMAIL_ERROR);
+ throw new Error('Failed to create user');
+ }
+ });
+
+export const createEmptyOrganizationFnInputSchema = z.object({
+ name: z.string().min(1),
+});
+
+export const createEmptyOrganizationFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(createEmptyOrganizationFnInputSchema)
+ .handler(async ({ context, data }) => {
+ const { organization } = await marblecoreApi.createOrganization(data, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ const allRestrictedFeatures = R.fromEntries(
+ OVERRIDABLE_FEATURES.map((feature) => [feature, 'restricted'] as const),
+ );
+ await backofficeApi.patchOrganizationFeatures(organization.id, allRestrictedFeatures, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return organization;
+ });
+
+export const importOrganizationFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(orgImportSpecSchema)
+ .handler(async ({ context, data }) => {
+ await backofficeApi.importOrganization(data, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+ });
+
+export const archetypeAdminSchema = z.object({
+ email: z.email(),
+ first_name: z.string().optional(),
+ last_name: z.string().optional(),
+});
+
+export const applyOrganizationArchetypeFnInputSchema = z.object({
+ name: z.string().min(1),
+ org_name: z.string().min(1),
+ admins: z.array(archetypeAdminSchema).min(1),
+});
+
+export const applyOrganizationArchetypeFn = createServerFn({ method: 'POST' })
+ .middleware([needAuth])
+ .validator(applyOrganizationArchetypeFnInputSchema)
+ .handler(async ({ context, data }) => {
+ const { org_id } = await marblecoreApi.applyArchetype(
+ {
+ name: data.name,
+ org_name: data.org_name,
+ admins: data.admins.map((admin) => ({
+ email: admin.email,
+ first_name: admin.first_name || undefined,
+ last_name: admin.last_name || undefined,
+ })),
+ },
+ {},
+ {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ },
+ );
+
+ const allRestrictedFeatures = R.fromEntries(
+ OVERRIDABLE_FEATURES.map((feature) => [feature, 'restricted'] as const),
+ );
+ await backofficeApi.patchOrganizationFeatures(org_id, allRestrictedFeatures, {
+ baseUrl: env.API_BASE_URL,
+ fetch: context.authFetch,
+ });
+
+ return { orgId: org_id };
+ });
diff --git a/packages/backoffice/src/start.ts b/packages/backoffice/src/start.ts
new file mode 100644
index 0000000000..8d2ef33cff
--- /dev/null
+++ b/packages/backoffice/src/start.ts
@@ -0,0 +1,8 @@
+import { createStart } from '@tanstack/react-start';
+import { convertRedirectErrorToExceptionMiddleware } from './middlewares/globals';
+
+export const startInstance = createStart(() => {
+ return {
+ functionMiddleware: [convertRedirectErrorToExceptionMiddleware],
+ };
+});
diff --git a/packages/backoffice/src/styles.css b/packages/backoffice/src/styles.css
new file mode 100644
index 0000000000..0e3d89e100
--- /dev/null
+++ b/packages/backoffice/src/styles.css
@@ -0,0 +1,43 @@
+@import "tailwindcss";
+@import "../../tailwind-preset/src/tailwind.css";
+
+@config "../tailwind.config.ts";
+
+@layer base {
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+}
+
+@utility scrollbar-stable {
+ scrollbar-gutter: stable;
+}
+
+@custom-variant stickied {
+ &:where([data-sticky='true'] *) {
+ @slot;
+ }
+}
+
+/* Launchpad entrance — one authored moment: rise + de-blur, exponential ease-out */
+@keyframes launchpad-rise {
+ from {
+ opacity: 0;
+ transform: translateY(14px);
+ filter: blur(8px);
+ }
+}
+
+.animate-launchpad-rise {
+ animation: launchpad-rise 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .animate-launchpad-rise {
+ animation: none;
+ }
+}
diff --git a/packages/backoffice/src/utils/session.ts b/packages/backoffice/src/utils/session.ts
new file mode 100644
index 0000000000..845f366750
--- /dev/null
+++ b/packages/backoffice/src/utils/session.ts
@@ -0,0 +1,20 @@
+import { env } from '@bo/env';
+import { useSession } from '@tanstack/react-start/server';
+import { marblecoreApi } from 'marble-api';
+
+type AuthSession = {
+ authToken: marblecoreApi.Token;
+};
+
+export function useAuthSession() {
+ return useSession({
+ name: 'auth-session',
+ password: env.SESSION_SECRET,
+
+ cookie: {
+ secure: false,
+ sameSite: 'lax',
+ httpOnly: true,
+ },
+ });
+}
diff --git a/packages/backoffice/src/utils/user-preferences.ts b/packages/backoffice/src/utils/user-preferences.ts
new file mode 100644
index 0000000000..e880fb2701
--- /dev/null
+++ b/packages/backoffice/src/utils/user-preferences.ts
@@ -0,0 +1,19 @@
+import { env } from '@bo/env';
+import { useSession } from '@tanstack/react-start/server';
+
+type UserPreferences = {
+ theme: 'dark' | 'light';
+};
+
+export function useUserPreferences() {
+ return useSession({
+ name: 'user-preferences',
+ password: env.SESSION_SECRET,
+
+ cookie: {
+ secure: false,
+ sameSite: 'lax',
+ httpOnly: true,
+ },
+ });
+}
diff --git a/packages/backoffice/tailwind.config.ts b/packages/backoffice/tailwind.config.ts
new file mode 100644
index 0000000000..da298a43e1
--- /dev/null
+++ b/packages/backoffice/tailwind.config.ts
@@ -0,0 +1,3 @@
+export default {
+ content: ['./src/**/*.{ts,tsx,jsx,js}', '../ui-design-system/src/**/*.{ts,tsx,jsx,js}', '!.output/**', '!.nitro/**'],
+};
diff --git a/packages/backoffice/tsconfig.json b/packages/backoffice/tsconfig.json
new file mode 100644
index 0000000000..4e585f69ad
--- /dev/null
+++ b/packages/backoffice/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "types": ["vite/client"],
+ "jsx": "react-jsx",
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ }
+}
diff --git a/packages/backoffice/vite.config.ts b/packages/backoffice/vite.config.ts
new file mode 100644
index 0000000000..94ede3e8fa
--- /dev/null
+++ b/packages/backoffice/vite.config.ts
@@ -0,0 +1,75 @@
+import tailwindcss from '@tailwindcss/vite';
+import { devtools } from '@tanstack/devtools-vite';
+import { tanstackStart } from '@tanstack/react-start/plugin/vite';
+import viteReact from '@vitejs/plugin-react';
+import { nitro } from 'nitro/vite';
+import { defineConfig, type Plugin } from 'vite';
+import viteTsConfigPaths from 'vite-tsconfig-paths';
+
+// Prevent Rollup from trying to parse native .node binaries (e.g. fsevents)
+const externalNativeModules: Plugin = {
+ name: 'external-native-modules',
+ enforce: 'pre',
+ resolveId(id) {
+ if (id.endsWith('.node')) return { id, external: true };
+ },
+};
+
+const plugins = [
+ devtools(),
+ tanstackStart(),
+ nitro({
+ config: {
+ preset: 'node-server',
+ },
+ }),
+ externalNativeModules,
+ tailwindcss(),
+ viteTsConfigPaths(),
+ viteReact(),
+] as Plugin[];
+
+const config = defineConfig({
+ plugins,
+ resolve: {
+ dedupe: ['react', 'react-dom'],
+ },
+ optimizeDeps: {
+ include: [
+ 'react',
+ 'react-dom',
+ 'react/jsx-runtime',
+ 'react/jsx-dev-runtime',
+ '@tanstack/history',
+ '@tanstack/router-core',
+ '@tanstack/router-core/isServer',
+ '@tanstack/router-core/ssr/client',
+ '@tanstack/router-core/ssr/server',
+ 'h3-v2',
+ 'seroval',
+ ],
+ },
+ environments: {
+ client: {
+ resolve: {
+ dedupe: ['react', 'react-dom'],
+ },
+ build: {
+ assetsInlineLimit: (filePath) => (filePath.endsWith('.svg') ? false : undefined),
+ },
+ },
+ ssr: {
+ resolve: {
+ dedupe: ['react', 'react-dom'],
+ },
+ optimizeDeps: {
+ include: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
+ },
+ build: {
+ assetsInlineLimit: (filePath) => (filePath.endsWith('.svg') ? false : undefined),
+ },
+ },
+ },
+});
+
+export default config;
diff --git a/packages/marble-api/openapis/backoffice.yaml b/packages/marble-api/openapis/backoffice.yaml
new file mode 100644
index 0000000000..a4ff6049b0
--- /dev/null
+++ b/packages/marble-api/openapis/backoffice.yaml
@@ -0,0 +1,313 @@
+openapi: 3.0.3
+info:
+ version: 1.0.0
+ title: "Backoffice API"
+ description: "API endpoints dedidacted to the backoffice"
+servers:
+ - url: "http://localhost:8080"
+ description: Local development server
+
+paths:
+ /organizations/{organizationId}/feature_access:
+ get:
+ summary: Retrieve organization features
+ description: Returns the features an organization has access
+ operationId: getOrganizationFeatures
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: organizationId
+ description: The organization id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Feature access response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ feature_access:
+ $ref: "#/components/schemas/FeatureAccessDto"
+ required:
+ - feature_access
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ patch:
+ summary: Update organization features
+ description: Update the features an organization has access
+ operationId: patchOrganizationFeatures
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: organizationId
+ description: The organization id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: The features to update
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties:
+ type: string
+ enum: ["allowed", "test", "restricted"]
+ responses:
+ "204":
+ description: The features have been updated successfully
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+
+ /org-import:
+ post:
+ summary: Import org from JSON
+ operationId: importOrganization
+ requestBody:
+ description: The spec
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ "204":
+ description: The org has been created successfully
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+
+ /licenses:
+ get:
+ summary: Retrieve licenses
+ description: Returns the list of licenses
+ operationId: getLicenses
+ security:
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Licenses response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ licenses:
+ type: array
+ items:
+ $ref: "#/components/schemas/LicenseDto"
+ required:
+ - licenses
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ post:
+ summary: Create a license
+ description: Creates a new license
+ operationId: createLicense
+ security:
+ - bearerAuth: []
+ requestBody:
+ description: The license to create
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ expiration_date:
+ type: string
+ format: date-time
+ organization_name:
+ type: string
+ description:
+ type: string
+ license_entitlements:
+ $ref: "#/components/schemas/LicenseEntitlementsDto"
+ required:
+ - expiration_date
+ - organization_name
+ - description
+ - license_entitlements
+ responses:
+ "200":
+ description: License created response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ license:
+ $ref: "#/components/schemas/LicenseDto"
+ required:
+ - license
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+
+ /licenses/{licenseId}:
+ patch:
+ summary: Update a license
+ description: Updates an existing license
+ operationId: updateLicense
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: licenseId
+ description: The license id
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: The license fields to update
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ expiration_date:
+ type: string
+ format: date-time
+ organization_name:
+ type: string
+ description:
+ type: string
+ license_entitlements:
+ $ref: "#/components/schemas/LicenseEntitlementsDto"
+ suspend:
+ type: boolean
+ required:
+ - expiration_date
+ - organization_name
+ - description
+ - license_entitlements
+ responses:
+ "200":
+ description: License updated response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ license:
+ $ref: "#/components/schemas/LicenseDto"
+ required:
+ - license
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+
+components:
+ schemas:
+ FeatureAccessDto:
+ $ref: "feature-access-api.yaml#/components/schemas/FeatureAccessDto"
+ LicenseEntitlementsDto:
+ type: object
+ properties:
+ sso:
+ type: boolean
+ workflows:
+ type: boolean
+ analytics:
+ type: boolean
+ data_enrichment:
+ type: boolean
+ user_roles:
+ type: boolean
+ webhooks:
+ type: boolean
+ rule_snoozes:
+ type: boolean
+ test_run:
+ type: boolean
+ sanctions:
+ type: boolean
+ auto_assignment:
+ type: boolean
+ case_ai_assist:
+ type: boolean
+ continuous_screening:
+ type: boolean
+ user_scoring:
+ type: boolean
+ lexisnexis:
+ type: boolean
+ required:
+ - sso
+ - workflows
+ - analytics
+ - data_enrichment
+ - user_roles
+ - webhooks
+ - rule_snoozes
+ - test_run
+ - sanctions
+ - auto_assignment
+ - case_ai_assist
+ - continuous_screening
+ - user_scoring
+ - lexisnexis
+ LicenseDto:
+ type: object
+ properties:
+ id:
+ type: string
+ format: uuid
+ key:
+ type: string
+ format: uuid
+ created_at:
+ type: string
+ format: date-time
+ suspended_at:
+ type: string
+ format: date-time
+ nullable: true
+ expiration_date:
+ type: string
+ format: date-time
+ organization_name:
+ type: string
+ description:
+ type: string
+ license_entitlements:
+ $ref: "#/components/schemas/LicenseEntitlementsDto"
+ required:
+ - id
+ - key
+ - created_at
+ - suspended_at
+ - expiration_date
+ - organization_name
+ - description
+ - license_entitlements
+
+ responses:
+ "401":
+ description: Unauthorized
+ content:
+ application/json:
+ schema:
+ type: string
+ example: Unauthorized
+ "403":
+ description: Forbidden
+ content:
+ application/json:
+ schema:
+ type: string
+ example: Forbidden
diff --git a/packages/marble-api/openapis/marblecore-api/_schemas.yml b/packages/marble-api/openapis/marblecore-api/_schemas.yml
index 5c4c197812..530df143be 100644
--- a/packages/marble-api/openapis/marblecore-api/_schemas.yml
+++ b/packages/marble-api/openapis/marblecore-api/_schemas.yml
@@ -424,6 +424,8 @@ UserDto:
$ref: admin.yml#/components/schemas/UserDto
CreateUser:
$ref: admin.yml#/components/schemas/CreateUser
+CreateNewOrgAdmin:
+ $ref: admin.yml#/components/schemas/CreateNewOrgAdmin
UpdateUser:
$ref: admin.yml#/components/schemas/UpdateUser
ApiKeyDto:
diff --git a/packages/marble-api/openapis/marblecore-api/admin.yml b/packages/marble-api/openapis/marblecore-api/admin.yml
index 4107f908c9..4c69edc43b 100644
--- a/packages/marble-api/openapis/marblecore-api/admin.yml
+++ b/packages/marble-api/openapis/marblecore-api/admin.yml
@@ -7,7 +7,7 @@
security:
- bearerAuth: []
responses:
- '200':
+ "200":
description: The apikeys corresponding to the current organization (present in the JWT)
content:
application/json:
@@ -19,13 +19,13 @@
api_keys:
type: array
items:
- $ref: '#/components/schemas/ApiKeyDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/ApiKeyDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
post:
tags:
- ApiKeys
@@ -34,14 +34,14 @@
security:
- bearerAuth: []
requestBody:
- description: 'Describe the api key to create'
+ description: "Describe the api key to create"
content:
application/json:
schema:
- $ref: '#/components/schemas/CreateApiKeyBody'
+ $ref: "#/components/schemas/CreateApiKeyBody"
required: true
responses:
- '200':
+ "200":
description: The created api key
content:
application/json:
@@ -51,13 +51,13 @@
- api_key
properties:
api_key:
- $ref: '#/components/schemas/CreatedApiKeyDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/CreatedApiKeyDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
/apikeys/{apiKeyId}:
delete:
tags:
@@ -75,14 +75,14 @@
type: string
format: uuid
responses:
- '204':
+ "204":
description: The api key has been deleted
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
/users:
get:
tags:
@@ -93,7 +93,7 @@
security:
- bearerAuth: []
responses:
- '200':
+ "200":
description: The list of users present in the database
content:
application/json:
@@ -105,13 +105,13 @@
users:
type: array
items:
- $ref: '#/components/schemas/UserDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/UserDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
post:
tags:
- Admin
@@ -125,10 +125,10 @@
content:
application/json:
schema:
- $ref: '#/components/schemas/CreateUser'
+ $ref: "#/components/schemas/CreateUser"
required: true
responses:
- '200':
+ "200":
description: The created user
content:
application/json:
@@ -138,13 +138,13 @@
- user
properties:
user:
- $ref: '#/components/schemas/UserDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/UserDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
/users/{userId}:
get:
tags:
@@ -163,7 +163,7 @@
type: string
format: uuid
responses:
- '200':
+ "200":
description: The user corresponding to the provided `userId`
content:
application/json:
@@ -173,13 +173,13 @@
- user
properties:
user:
- $ref: '#/components/schemas/UserDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/UserDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
delete:
tags:
- Admin
@@ -197,14 +197,14 @@
type: string
format: uuid
responses:
- '204':
+ "204":
description: The user has been deleted
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
patch:
tags:
- Admin
@@ -226,10 +226,10 @@
content:
application/json:
schema:
- $ref: '#/components/schemas/UpdateUser'
+ $ref: "#/components/schemas/UpdateUser"
required: true
responses:
- '200':
+ "200":
description: The updated user
content:
application/json:
@@ -239,11 +239,11 @@
- user
properties:
user:
- $ref: '#/components/schemas/UserDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
+ $ref: "#/components/schemas/UserDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
/organizations:
get:
tags:
@@ -254,7 +254,7 @@
security:
- bearerAuth: []
responses:
- '200':
+ "200":
description: The list of organizations present in the database
content:
application/json:
@@ -266,13 +266,13 @@
organizations:
type: array
items:
- $ref: '#/components/schemas/OrganizationDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/OrganizationDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
post:
tags:
- Admin
@@ -286,10 +286,10 @@
content:
application/json:
schema:
- $ref: '#/components/schemas/CreateOrganizationBodyDto'
+ $ref: "#/components/schemas/CreateOrganizationBodyDto"
required: true
responses:
- '200':
+ "200":
description: The created organization
content:
application/json:
@@ -299,11 +299,11 @@
- organization
properties:
organization:
- $ref: '#/components/schemas/OrganizationDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
+ $ref: "#/components/schemas/OrganizationDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
/organizations/{organizationId}:
get:
tags:
@@ -322,7 +322,7 @@
type: string
format: uuid
responses:
- '200':
+ "200":
description: The organization corresponding to the provided `organizationId`
content:
application/json:
@@ -332,13 +332,13 @@
- organization
properties:
organization:
- $ref: '#/components/schemas/OrganizationDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/OrganizationDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
patch:
tags:
- Admin
@@ -360,10 +360,10 @@
content:
application/json:
schema:
- $ref: '#/components/schemas/UpdateOrganizationBodyDto'
+ $ref: "#/components/schemas/UpdateOrganizationBodyDto"
required: true
responses:
- '200':
+ "200":
description: The updated organization
content:
application/json:
@@ -373,13 +373,13 @@
- organization
properties:
organization:
- $ref: '#/components/schemas/OrganizationDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/OrganizationDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
delete:
tags:
- Admin
@@ -397,14 +397,14 @@
type: string
format: uuid
responses:
- '204':
+ "204":
description: The organization has been deleted
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
/organizations/{organizationId}/subnets:
put:
tags:
@@ -427,10 +427,10 @@
content:
application/json:
schema:
- $ref: '#/components/schemas/OrganizationSubnetsDto'
+ $ref: "#/components/schemas/OrganizationSubnetsDto"
required: true
responses:
- '200':
+ "200":
description: The list of users of the organization
content:
application/json:
@@ -438,10 +438,10 @@
type: array
items:
type: string
- '400':
- $ref: 'components.yml#/responses/400'
- '422':
- $ref: 'components.yml#/responses/422'
+ "400":
+ $ref: "components.yml#/responses/400"
+ "422":
+ $ref: "components.yml#/responses/422"
/organizations/{organizationId}/users:
get:
tags:
@@ -466,7 +466,7 @@
schema:
type: boolean
responses:
- '200':
+ "200":
description: The list of users of the organization
content:
application/json:
@@ -478,13 +478,13 @@
users:
type: array
items:
- $ref: '#/components/schemas/UserDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
- '404':
- $ref: 'components.yml#/responses/404'
+ $ref: "#/components/schemas/UserDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
+ "404":
+ $ref: "components.yml#/responses/404"
/admin/audit-events:
get:
@@ -545,7 +545,7 @@
type: string
format: uuid
responses:
- '200':
+ "200":
description: List of audit events matching the filters
content:
application/json:
@@ -558,11 +558,11 @@
events:
type: array
items:
- $ref: '#/components/schemas/AuditEventDto'
- '401':
- $ref: 'components.yml#/responses/401'
- '403':
- $ref: 'components.yml#/responses/403'
+ $ref: "#/components/schemas/AuditEventDto"
+ "401":
+ $ref: "components.yml#/responses/401"
+ "403":
+ $ref: "components.yml#/responses/403"
components:
schemas:
@@ -697,6 +697,17 @@ components:
type: string
last_name:
type: string
+ CreateNewOrgAdmin:
+ type: object
+ required:
+ - email
+ properties:
+ email:
+ type: string
+ first_name:
+ type: string
+ last_name:
+ type: string
UpdateUser:
type: object
required:
@@ -750,7 +761,7 @@ components:
type: string
CreatedApiKeyDto:
allOf:
- - $ref: '#/components/schemas/ApiKeyDto'
+ - $ref: "#/components/schemas/ApiKeyDto"
- type: object
required:
- key
diff --git a/packages/marble-api/openapis/marblecore-api/org-import.yml b/packages/marble-api/openapis/marblecore-api/org-import.yml
index 8fdacb48c7..3ae8431f32 100644
--- a/packages/marble-api/openapis/marblecore-api/org-import.yml
+++ b/packages/marble-api/openapis/marblecore-api/org-import.yml
@@ -205,4 +205,4 @@ components:
type: array
minItems: 1
items:
- $ref: "admin.yml#/components/schemas/CreateUser"
+ $ref: "admin.yml#/components/schemas/CreateNewOrgAdmin"
diff --git a/packages/marble-api/scripts/config.ts b/packages/marble-api/scripts/config.ts
index 2152a314f1..53d2636610 100644
--- a/packages/marble-api/scripts/config.ts
+++ b/packages/marble-api/scripts/config.ts
@@ -33,3 +33,15 @@ export const featureAccessApiConfig: Config = {
mergeReadWriteOnly: true,
},
};
+
+export const backofficeApiConfig: Config = {
+ apiName: 'Backoffice API',
+ apiSpec: join('openapis', 'backoffice.yaml'),
+ generatedApi: join(GENERATED_FOLDER, 'backoffice-api.ts'),
+ apiOptions: {
+ optimistic: true,
+ useEnumType: false,
+ unionUndefined: false,
+ mergeReadWriteOnly: true,
+ },
+};
diff --git a/packages/marble-api/scripts/generate.ts b/packages/marble-api/scripts/generate.ts
index 5a18e910d6..e19448912c 100644
--- a/packages/marble-api/scripts/generate.ts
+++ b/packages/marble-api/scripts/generate.ts
@@ -2,7 +2,13 @@ import { mkdir, rm, writeFile } from 'fs/promises';
import * as Oazapfts from 'oazapfts';
import ora from 'ora';
-import { type Config, featureAccessApiConfig, GENERATED_FOLDER, marbleCoreApiConfig } from './config';
+import {
+ backofficeApiConfig,
+ type Config,
+ featureAccessApiConfig,
+ GENERATED_FOLDER,
+ marbleCoreApiConfig,
+} from './config';
async function openapiGenerator({ apiName, apiSpec, generatedApi, apiOptions }: Config) {
const spinner = ora(`Start to generate ${apiName} client...`).start();
@@ -25,6 +31,7 @@ async function main() {
await openapiGenerator(marbleCoreApiConfig);
await openapiGenerator(featureAccessApiConfig);
+ await openapiGenerator(backofficeApiConfig);
} catch (error) {
console.error('\n', error);
process.exit(1);
diff --git a/packages/marble-api/src/generated/backoffice-api.ts b/packages/marble-api/src/generated/backoffice-api.ts
new file mode 100644
index 0000000000..4710fdbc4d
--- /dev/null
+++ b/packages/marble-api/src/generated/backoffice-api.ts
@@ -0,0 +1,191 @@
+/**
+ * Backoffice API
+ * 1.0.0
+ * DO NOT MODIFY - This file has been generated using oazapfts.
+ * See https://www.npmjs.com/package/oazapfts
+ */
+import * as Oazapfts from "@oazapfts/runtime";
+import * as QS from "@oazapfts/runtime/query";
+export const defaults: Oazapfts.Defaults = {
+ headers: {},
+ baseUrl: "http://localhost:8080"
+};
+const oazapfts = Oazapfts.runtime(defaults);
+export const servers = {
+ localDevelopmentServer: "http://localhost:8080"
+};
+export type Roles = "allowed" | "restricted" | "test" | "missing_configuration";
+export type FeatureAccessDto = {
+ workflows: Roles;
+ analytics: Roles;
+ roles: "allowed" | "restricted" | "test" | "missing_configuration";
+ webhooks: Roles;
+ rule_snoozes: Roles;
+ test_run: Roles;
+ sanctions: Roles;
+ name_recognition: Roles;
+ /** Deprecated feature flag. Only used for the hidden 'AI assist' modale in the case manager, do not use for other things. */
+ ai_assist: Roles;
+ case_auto_assign: Roles;
+ case_ai_assist: Roles;
+ continuous_screening: Roles;
+ ai_rule_building: Roles;
+ user_scoring: Roles;
+ /** Entitlement for the LexisNexis screening provider. OpenSanctions is always available. */
+ lexisnexis: Roles;
+};
+export type LicenseEntitlementsDto = {
+ sso: boolean;
+ workflows: boolean;
+ analytics: boolean;
+ data_enrichment: boolean;
+ user_roles: boolean;
+ webhooks: boolean;
+ rule_snoozes: boolean;
+ test_run: boolean;
+ sanctions: boolean;
+ auto_assignment: boolean;
+ case_ai_assist: boolean;
+ continuous_screening: boolean;
+ user_scoring: boolean;
+ lexisnexis: boolean;
+};
+export type LicenseDto = {
+ id: string;
+ key: string;
+ created_at: string;
+ suspended_at: string | null;
+ expiration_date: string;
+ organization_name: string;
+ description: string;
+ license_entitlements: LicenseEntitlementsDto;
+};
+/**
+ * Retrieve organization features
+ */
+export function getOrganizationFeatures(organizationId: string, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: {
+ feature_access: FeatureAccessDto;
+ };
+ } | {
+ status: 401;
+ data: string;
+ } | {
+ status: 403;
+ data: string;
+ }>(`/organizations/${encodeURIComponent(organizationId)}/feature_access`, {
+ ...opts
+ }));
+}
+/**
+ * Update organization features
+ */
+export function patchOrganizationFeatures(organizationId: string, body?: {
+ [key: string]: "allowed" | "test" | "restricted";
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 204;
+ } | {
+ status: 401;
+ data: string;
+ } | {
+ status: 403;
+ data: string;
+ }>(`/organizations/${encodeURIComponent(organizationId)}/feature_access`, oazapfts.json({
+ ...opts,
+ method: "PATCH",
+ body
+ })));
+}
+/**
+ * Import org from JSON
+ */
+export function importOrganization(body?: object, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 204;
+ } | {
+ status: 401;
+ data: string;
+ } | {
+ status: 403;
+ data: string;
+ }>("/org-import", oazapfts.json({
+ ...opts,
+ method: "POST",
+ body
+ })));
+}
+/**
+ * Retrieve licenses
+ */
+export function getLicenses(opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: {
+ licenses: LicenseDto[];
+ };
+ } | {
+ status: 401;
+ data: string;
+ } | {
+ status: 403;
+ data: string;
+ }>("/licenses", {
+ ...opts
+ }));
+}
+/**
+ * Create a license
+ */
+export function createLicense(body: {
+ expiration_date: string;
+ organization_name: string;
+ description: string;
+ license_entitlements: LicenseEntitlementsDto;
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: {
+ license: LicenseDto;
+ };
+ } | {
+ status: 401;
+ data: string;
+ } | {
+ status: 403;
+ data: string;
+ }>("/licenses", oazapfts.json({
+ ...opts,
+ method: "POST",
+ body
+ })));
+}
+/**
+ * Update a license
+ */
+export function updateLicense(licenseId: string, body: {
+ expiration_date: string;
+ organization_name: string;
+ description: string;
+ license_entitlements: LicenseEntitlementsDto;
+ suspend?: boolean;
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: {
+ license: LicenseDto;
+ };
+ } | {
+ status: 401;
+ data: string;
+ } | {
+ status: 403;
+ data: string;
+ }>(`/licenses/${encodeURIComponent(licenseId)}`, oazapfts.json({
+ ...opts,
+ method: "PATCH",
+ body
+ })));
+}
diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts
index 229a73c8d9..59703f45be 100644
--- a/packages/marble-api/src/generated/marblecore-api.ts
+++ b/packages/marble-api/src/generated/marblecore-api.ts
@@ -1683,19 +1683,17 @@ export type ArchetypeDto = {
label?: string;
description?: string;
};
-export type CreateUser = {
+export type CreateNewOrgAdmin = {
email: string;
- role: string;
- organization_id: string;
- first_name: string;
- last_name: string;
+ first_name?: string;
+ last_name?: string;
};
export type ArchetypeApplyDto = {
name: string;
} | {
name: string;
org_name: string;
- admins: CreateUser[];
+ admins: CreateNewOrgAdmin[];
};
export type ApiKeyDto = {
id: string;
@@ -1722,6 +1720,13 @@ export type UserDto = {
/** Whether the user has at least one MFA factor enrolled. Only present when requested with `with_tfa=true`. */
tfa_enabled?: boolean;
};
+export type CreateUser = {
+ email: string;
+ role: string;
+ organization_id: string;
+ first_name: string;
+ last_name: string;
+};
export type UpdateUser = {
email: string;
role: string;
diff --git a/packages/marble-api/src/index.ts b/packages/marble-api/src/index.ts
index 058c725c9d..085d62dad8 100644
--- a/packages/marble-api/src/index.ts
+++ b/packages/marble-api/src/index.ts
@@ -1,3 +1,4 @@
+export * as backofficeApi from './generated/backoffice-api';
export * as featureAccessApi from './generated/feature-access-api';
export * as marblecoreApi from './generated/marblecore-api';
export * from './generated/marblecore-api';
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 5c542ed6cc..76064c579a 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -29,7 +29,7 @@
"zustand": "^5.0.14"
},
"peerDependencies": {
- "react": "18.3.1",
- "react-dom": "18.3.1"
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
}
}
diff --git a/packages/ui-design-system/package.json b/packages/ui-design-system/package.json
index e396d83766..9945a8af85 100644
--- a/packages/ui-design-system/package.json
+++ b/packages/ui-design-system/package.json
@@ -52,6 +52,7 @@
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
+ "@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toggle-group": "^1.1.19",
diff --git a/packages/ui-design-system/src/Collapsible/Collapsible.tsx b/packages/ui-design-system/src/Collapsible/Collapsible.tsx
index ad6e0dadea..62629b4562 100644
--- a/packages/ui-design-system/src/Collapsible/Collapsible.tsx
+++ b/packages/ui-design-system/src/Collapsible/Collapsible.tsx
@@ -11,12 +11,28 @@ import { forwardRef } from 'react';
import { Icon } from 'ui-icons';
import { cn } from '../utils';
+const HeadlessCollapsibleRoot = Root;
+const HeadlessCollapsibleTrigger = Trigger;
+const HeadlessCollapsibleContent = ({ children }: { children?: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const HeadlessCollapsible = {
+ Root: HeadlessCollapsibleRoot,
+ Trigger: HeadlessCollapsibleTrigger,
+ Content: HeadlessCollapsibleContent,
+};
+
const CollapsibleContainer = forwardRef(function CollapsibleContainer(
{ className, ...props },
ref,
) {
return (
- (function CollapsibleTitle({ className, children, size, iconPosition = 'right', ...props }, ref) {
return (
-
+
)}
-
+
);
});
diff --git a/packages/ui-design-system/src/Command/Command.tsx b/packages/ui-design-system/src/Command/Command.tsx
index 70afd9f933..45416fcbbe 100644
--- a/packages/ui-design-system/src/Command/Command.tsx
+++ b/packages/ui-design-system/src/Command/Command.tsx
@@ -1,6 +1,6 @@
-import clsx from 'clsx';
import { Command as CommandPrimitive } from 'cmdk';
import * as React from 'react';
+import { cn } from '../utils';
const Command = React.forwardRef<
React.ElementRef,
@@ -8,10 +8,7 @@ const Command = React.forwardRef<
>(({ className, ...props }, ref) => (
));
@@ -24,7 +21,7 @@ const CommandInput = React.forwardRef<
>(({ className, ...props }, ref) => (
(({ className, ...props }, ref) => (
));
@@ -66,7 +63,7 @@ const CommandGroup = React.forwardRef<
>(({ className, ...props }, ref) => (
,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
@@ -91,7 +88,7 @@ const CommandItem = React.forwardRef<
>(({ className, ...props }, ref) => (
) => (
-
+
);
CommandShortcut.displayName = 'CommandShortcut';
diff --git a/packages/app-builder/src/components/Panel/Panel.tsx b/packages/ui-design-system/src/Panel/Panel.tsx
similarity index 92%
rename from packages/app-builder/src/components/Panel/Panel.tsx
rename to packages/ui-design-system/src/Panel/Panel.tsx
index cd43385352..861052a157 100644
--- a/packages/app-builder/src/components/Panel/Panel.tsx
+++ b/packages/ui-design-system/src/Panel/Panel.tsx
@@ -1,11 +1,22 @@
import { Slot } from '@radix-ui/react-slot';
import { IconProps } from 'packages/ui-icons/src/Icon';
-import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, useEffect, useRef } from 'react';
+import {
+ type ComponentPropsWithoutRef,
+ forwardRef,
+ type MouseEventHandler,
+ type ReactNode,
+ useEffect,
+ useRef,
+} from 'react';
import { createPortal } from 'react-dom';
import { createSharpFactory } from 'sharpstate';
import { match } from 'ts-pattern';
-import { Button, ButtonAppearance, ButtonVariant, cn, StickyComponent, Typo, UnstyledInput } from 'ui-design-system';
import { Icon } from 'ui-icons';
+import { Button, type ButtonAppearance, type ButtonVariant } from '../Button/Button';
+import { UnstyledInput } from '../Input/Input';
+import { StickyComponent } from '../StickyComponent/StickyComponent';
+import { Typo } from '../Typography/Typo';
+import { cn } from '../utils';
import { PanelOverlay } from './PanelOverlay';
export type PanelSize = 'small' | 'medium' | 'large';
@@ -79,8 +90,9 @@ function PanelRoot({ children, open, onOpenChange }: PanelRootProps) {
return {children};
}
-interface PanelTriggerProps extends ComponentPropsWithoutRef<'button'> {
+interface PanelTriggerProps extends Omit, 'onClick'> {
asChild?: boolean;
+ onClick?: MouseEventHandler;
}
const PanelTrigger = forwardRef(function PanelTrigger(
@@ -90,19 +102,14 @@ const PanelTrigger = forwardRef(function P
const sharp = PanelSharpFactory.useSharp();
const Comp = asChild ? Slot : 'button';
- return (
- {
- onClick?.(event);
- if (!event.defaultPrevented) {
- sharp.actions.open();
- }
- }}
- />
- );
+ const handleClick: MouseEventHandler = (event) => {
+ onClick?.(event);
+ if (!event.defaultPrevented) {
+ sharp.actions.open();
+ }
+ };
+
+ return ;
});
PanelTrigger.displayName = 'PanelTrigger';
diff --git a/packages/app-builder/src/components/Panel/PanelOverlay.tsx b/packages/ui-design-system/src/Panel/PanelOverlay.tsx
similarity index 100%
rename from packages/app-builder/src/components/Panel/PanelOverlay.tsx
rename to packages/ui-design-system/src/Panel/PanelOverlay.tsx
diff --git a/packages/app-builder/src/components/Panel/index.ts b/packages/ui-design-system/src/Panel/index.ts
similarity index 100%
rename from packages/app-builder/src/components/Panel/index.ts
rename to packages/ui-design-system/src/Panel/index.ts
diff --git a/packages/ui-design-system/src/index.ts b/packages/ui-design-system/src/index.ts
index 2cbe26e677..9db592b9db 100644
--- a/packages/ui-design-system/src/index.ts
+++ b/packages/ui-design-system/src/index.ts
@@ -21,6 +21,7 @@ export * from './Markdown/Markdown';
export * from './Markdown/ReleaseMarkdown';
export * from './MenuCommand/MenuCommand';
export * from './Modal/Modal';
+export * from './Panel';
export * from './Popover/Popover';
export * from './Radio/Radio';
export * from './RadioGroup/RadioGroup';
diff --git a/packages/ui-design-system/src/utils.ts b/packages/ui-design-system/src/utils.ts
index b99cc4050c..99d8b876d5 100644
--- a/packages/ui-design-system/src/utils.ts
+++ b/packages/ui-design-system/src/utils.ts
@@ -20,8 +20,8 @@ const twMerge = extendTailwindMerge({
},
},
extend: {
- classGroups: {
- p: ['p-3xl', 'p-2xl', 'p-xl', 'p-lg', 'p-md', 'p-sm', 'p-xs', 'p-2xs'],
+ theme: {
+ spacing: ['3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs', '2xs'],
},
},
});
diff --git a/packages/ui-icons/package.json b/packages/ui-icons/package.json
index 7e8813e3dd..9b6f9c0a83 100644
--- a/packages/ui-icons/package.json
+++ b/packages/ui-icons/package.json
@@ -14,13 +14,13 @@
"@types/react": "18.3.24",
"@types/svg-sprite": "^0.0.39",
"ora": "^8.2.0",
- "react": "18.3.1",
+ "react": "^18.3.1",
"svg-sprite": "^2.0.4",
"tsx": "^4.22.4"
},
"author": "",
"license": "ISC",
"peerDependencies": {
- "react": "18.3.1"
+ "react": "^18.3.1"
}
}
diff --git a/packages/ui-icons/src/generated/icon-names.ts b/packages/ui-icons/src/generated/icon-names.ts
index af9204d7c5..968a0e3e87 100644
--- a/packages/ui-icons/src/generated/icon-names.ts
+++ b/packages/ui-icons/src/generated/icon-names.ts
@@ -83,6 +83,7 @@ export const iconNames = [
'manually_accepted',
'manually_denied',
'map-pin',
+ 'menu-burger',
'minus',
'modeling',
'monitor',
diff --git a/packages/ui-icons/src/generated/icons-svg-sprite.svg b/packages/ui-icons/src/generated/icons-svg-sprite.svg
index a6bb882b8c..e4eed6c0d3 100644
--- a/packages/ui-icons/src/generated/icons-svg-sprite.svg
+++ b/packages/ui-icons/src/generated/icons-svg-sprite.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/ui-icons/svgs/icons/menu-burger.svg b/packages/ui-icons/svgs/icons/menu-burger.svg
new file mode 100644
index 0000000000..61ec735a40
--- /dev/null
+++ b/packages/ui-icons/svgs/icons/menu-burger.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/scripts/link-single-react.mjs b/scripts/link-single-react.mjs
new file mode 100644
index 0000000000..0e6d556849
--- /dev/null
+++ b/scripts/link-single-react.mjs
@@ -0,0 +1,38 @@
+import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
+import { dirname, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
+const bunDir = join(root, 'node_modules/.bun');
+
+if (!existsSync(bunDir)) {
+ process.exit(0);
+}
+
+const entries = readdirSync(bunDir);
+const reactStore = entries.find((name) => name.startsWith('react@18.'));
+const reactDomStore = entries.find((name) => name.startsWith('react-dom@18.'));
+
+if (!reactStore || !reactDomStore) {
+ process.exit(0);
+}
+
+function linkPackage(dest, storeName, subpath) {
+ const target = join(bunDir, storeName, 'node_modules', subpath);
+ mkdirSync(dirname(dest), { recursive: true });
+
+ if (existsSync(dest)) {
+ const stat = lstatSync(dest);
+ if (stat.isSymbolicLink()) {
+ return;
+ }
+ rmSync(dest, { recursive: true, force: true });
+ }
+
+ symlinkSync(relative(dirname(dest), target), dest);
+}
+
+linkPackage(join(root, 'node_modules/react'), reactStore, 'react');
+linkPackage(join(root, 'node_modules/react-dom'), reactDomStore, 'react-dom');
+linkPackage(join(root, 'packages/backoffice/node_modules/react'), reactStore, 'react');
+linkPackage(join(root, 'packages/backoffice/node_modules/react-dom'), reactDomStore, 'react-dom');
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0a67c865b4..29e7236659 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -26,6 +26,7 @@
"ui-design-system": ["packages/ui-design-system/src/index.ts"],
"@app-builder/*": ["packages/app-builder/src/*"],
"@ast-builder/*": ["packages/app-builder/src/components/AstBuilder/*"],
+ "@bo/*": ["packages/backoffice/src/*"],
"@marble/shared": ["packages/shared/src/index.ts"]
},
"pretty": true