Compare commits

...

8 commits

Author SHA1 Message Date
91eee92282 feat(navigation): replace EE-only pages with Acadenice OSS equivalents
Routes /ai, /ai/chat, /templates, /settings/audit, /settings/api-keys,
/settings/account/api-keys, /settings/ai, /settings/verifications now
point at the Acadenice OSS replacements (TemplatesAdminPage,
AcadeniceAuditLogPage, AcadeniceApiKeysPage) — or are removed when the
feature is intentionally dropped from the OSS build. Settings sidebar
entries follow the same change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:54 +00:00
f2e9d2205c refactor(rbac): seed service updates and spec alignment
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:46 +00:00
11e003e71e refactor(graph): camelCase row keys and drop dead try/catch lazy import
Server: rows from kysely with camelCase plugin already arrive as
sourcePageId / targetPageId / spaceId / spaceName. Drop the snake_case
indexing and update the spec accordingly.

Client: remove the unreachable try/catch around React.lazy for
react-force-graph-2d — lazy() never throws synchronously, the catch
was dead code from an earlier wip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:39 +00:00
47dee1eb12 refactor(client): unwrap .data from TransformHttpResponseInterceptor
The server-side TransformHttpResponseInterceptor wraps every body in
{ data, success, status }. The axios interceptor only unwraps the
transport layer, so calls return that envelope object — read .data
to get the actual payload.

Aligns the remaining Acadenice REST clients (backlinks, clipper,
slash-commands, sync-blocks, templates, graph, rbac) with the
existing convention and drops a few hardcoded /api/v1 prefixes that
duplicated the api baseURL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:32 +00:00
843986d5c2 feat(database-view): admin UI and create-database slash command
Adds two new entry points around the bridge-backed database view:
- /database admin modal to manage fields (field-admin-modal)
- slash command to create a database from the editor
  (create-database-modal + create-database-slash)
Wires the new components into the editor slash menu and the
database-view module index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:06 +00:00
41ce6308fa feat(page-permission): OSS-ify page-level permission module
Adds a native page-permission controller + service under
apps/server/src/core/page/page-permission, wired into PageModule.

LicenseCheckService now declares PAGE_PERMISSIONS and SHARING_CONTROLS
as Acadenice OSS features so hasFeature() / resolveFeatures() always
expose them regardless of EE plan, keeping useHasFeature() and the
server-side guards consistent.

tsconfig.build.json excludes vitest.config.ts from the Nest build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:54:00 +00:00
9e686af2e3 chore: gitignore .pnpm-store
Local pnpm content-addressable store should not be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:53:48 +00:00
730e52acd2 fix(build): skip extension-clipper build to unblock Docker build
Why: clipper Vite build fails on missing icons/icon16.png asset.
We don't ship the clipper inside the main image — overriding its
build script to a no-op lets pnpm build complete without touching
the clipper workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 09:53:41 +00:00
31 changed files with 1740 additions and 278 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ data
# compiled output # compiled output
/dist /dist
/node_modules /node_modules
.pnpm-store
# Logs # Logs
logs logs

View file

@ -10,6 +10,9 @@ WORKDIR /app
COPY . . COPY . .
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN node -e "const f='apps/extension-clipper/package.json';const p=require('./'+f);p.scripts.build='echo skip-clipper';require('fs').writeFileSync(f,JSON.stringify(p,null,2))"
RUN pnpm build RUN pnpm build
FROM base AS installer FROM base AS installer

View file

@ -36,15 +36,7 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx";
import TemplateList from "@/ee/template/pages/template-list";
import TemplateEditor from "@/ee/template/pages/template-editor";
import FavoritesPage from "@/pages/favorites/favorites-page"; import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx";
import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page"; import RolesListPage from "@/features/acadenice/rbac/pages/roles-list.page";
import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page"; import RoleDetailPage from "@/features/acadenice/rbac/pages/role-detail.page";
@ -113,15 +105,9 @@ export default function App() {
<Route path={"/graph"} element={<GraphPage />} /> <Route path={"/graph"} element={<GraphPage />} />
{/* Acadenice R3.7 — notifications full page */} {/* Acadenice R3.7 — notifications full page */}
<Route path={"/notifications"} element={<AcadeniceNotificationsPage />} /> <Route path={"/notifications"} element={<AcadeniceNotificationsPage />} />
<Route path={"/ai"} element={<AiChat />} />
<Route path={"/ai/chat/:chatId"} element={<AiChat />} />
<Route path={"/spaces"} element={<SpacesPage />} /> <Route path={"/spaces"} element={<SpacesPage />} />
<Route path={"/favorites"} element={<FavoritesPage />} /> <Route path={"/favorites"} element={<FavoritesPage />} />
<Route path={"/templates"} element={<TemplateList />} /> <Route path={"/templates"} element={<TemplatesAdminPage />} />
<Route
path={"/templates/:templateId"}
element={<TemplateEditor />}
/>
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} /> <Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} /> <Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
{/* Acadenice R4.6 — space-scoped graph view */} {/* Acadenice R4.6 — space-scoped graph view */}
@ -137,19 +123,15 @@ export default function App() {
path={"account/preferences"} path={"account/preferences"}
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} /> <Route path={"api-keys"} element={<AcadeniceApiKeysPage />} />
<Route path={"groups"} element={<Groups />} /> <Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} /> <Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} /> <Route path={"audit"} element={<AcadeniceAuditLogPage />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"verifications"} element={<VerifiedPages />} />
{/* Acadenice R2.2 — RBAC dynamique */} {/* Acadenice R2.2 — RBAC dynamique */}
<Route path={"roles"} element={<RolesListPage />} /> <Route path={"roles"} element={<RolesListPage />} />
<Route path={"roles/:id"} element={<RoleDetailPage />} /> <Route path={"roles/:id"} element={<RoleDetailPage />} />

View file

@ -149,31 +149,15 @@ const groupedData: DataGroup[] = [
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" }, { label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{ {
label: "Verified pages",
icon: IconShieldCheck,
path: "/settings/verifications",
feature: Feature.PAGE_VERIFICATION,
},
{
// Acadenice R4.5 — workspace-level API keys view removed (use account-level)
// kept here for EE environments but hidden on open source installs
label: "API management", label: "API management",
icon: IconKey, icon: IconKey,
path: "/settings/api-keys", path: "/settings/api-keys",
feature: Feature.API_KEYS,
role: "admin", role: "admin",
}, },
{ {
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
role: "admin",
},
{
// Acadenice R4.5 — open source audit log (replaces EE-gated page)
label: "Audit log", label: "Audit log",
icon: IconHistory, icon: IconHistory,
path: "/settings/acadenice/audit-log", path: "/settings/audit",
role: "admin", role: "admin",
env: "selfhosted", env: "selfhosted",
}, },

View file

@ -28,7 +28,7 @@ export interface BacklinksResult {
async function fetchBacklinks(pageId: string): Promise<BacklinksResult> { async function fetchBacklinks(pageId: string): Promise<BacklinksResult> {
const res = await api.get<BacklinksResult>( const res = await api.get<BacklinksResult>(
`/acadenice/pages/${pageId}/backlinks`, `/v1/pages/${pageId}/backlinks`,
); );
return res.data; return res.data;
} }

View file

@ -1,6 +1,6 @@
import axios from 'axios'; import api from '@/lib/api-client';
const BASE = '/api/v1/clipper'; const BASE = '/v1/clipper';
export interface ClipperTokenInfo { export interface ClipperTokenInfo {
id: string; id: string;
@ -23,17 +23,17 @@ export interface CreateTokenResponse {
} }
export const clipperClient = { export const clipperClient = {
listTokens(): Promise<ClipperTokenInfo[]> { async listTokens(): Promise<ClipperTokenInfo[]> {
return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data); const r = await api.get<ClipperTokenInfo[]>(`${BASE}/tokens`);
return r.data;
}, },
createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> { async createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
return axios const r = await api.post<CreateTokenResponse>(`${BASE}/tokens`, payload);
.post<CreateTokenResponse>(`${BASE}/tokens`, payload) return r.data;
.then((r) => r.data);
}, },
revokeToken(tokenId: string): Promise<void> { async revokeToken(tokenId: string): Promise<void> {
return axios.delete(`${BASE}/tokens/${tokenId}`).then(() => undefined); await api.delete(`${BASE}/tokens/${tokenId}`);
}, },
}; };

View file

@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import {
Modal,
Stack,
TextInput,
Select,
NumberInput,
Textarea,
Button,
Group,
Alert,
} from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import {
type FieldType,
createField,
updateField,
} from "../services/admin-client";
import type { BridgeField } from "../types/database-view.types";
const FIELD_TYPE_OPTIONS: Array<{ value: FieldType; label: string }> = [
{ value: "text", label: "Texte court" },
{ value: "long_text", label: "Texte long" },
{ value: "number", label: "Nombre" },
{ value: "rating", label: "Note (étoiles)" },
{ value: "boolean", label: "Case à cocher" },
{ value: "date", label: "Date" },
{ value: "single_select", label: "Choix unique" },
{ value: "multiple_select", label: "Choix multiple" },
{ value: "url", label: "URL" },
{ value: "email", label: "Email" },
{ value: "phone_number", label: "Téléphone" },
{ value: "duration", label: "Durée" },
{ value: "formula", label: "Formule (calcul)" },
];
interface FieldAdminModalProps {
opened: boolean;
onClose: () => void;
/** Provided in create mode. */
tableId?: number;
/** Provided in edit mode. */
field?: BridgeField | null;
bridgeUrl?: string | null;
onSuccess?: () => void;
}
export function FieldAdminModal({
opened,
onClose,
tableId,
field,
bridgeUrl,
onSuccess,
}: FieldAdminModalProps) {
const isEdit = Boolean(field);
const [name, setName] = useState("");
const [type, setType] = useState<FieldType>("text");
const [formula, setFormula] = useState("");
const [decimals, setDecimals] = useState<number>(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (opened) {
setError(null);
setSubmitting(false);
if (field) {
setName(field.name ?? "");
setType((field.type as FieldType) ?? "text");
setFormula(((field as unknown) as { formula?: string }).formula ?? "");
setDecimals(0);
} else {
setName("");
setType("text");
setFormula("");
setDecimals(0);
}
}
}, [opened, field]);
async function handleSubmit() {
setError(null);
setSubmitting(true);
try {
const payload: Record<string, unknown> = { name: name.trim(), type };
if (type === "formula") payload.formula = formula.trim();
if (type === "number") payload.number_decimal_places = decimals;
if (isEdit && field) {
await updateField(Number(field.id), payload as never, bridgeUrl);
} else if (tableId) {
await createField(tableId, payload as never, bridgeUrl);
} else {
throw new Error("tableId manquant en mode create");
}
onSuccess?.();
onClose();
} catch (e) {
setError((e as Error).message);
setSubmitting(false);
}
}
const canSubmit =
name.trim().length > 0 &&
(type !== "formula" || formula.trim().length > 0);
return (
<Modal
opened={opened}
onClose={onClose}
title={isEdit ? "Modifier la colonne" : "Ajouter une colonne"}
centered
>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red">
{error}
</Alert>
)}
<TextInput
label="Nom"
placeholder="Ex: Heures donnees"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
data-autofocus
required
/>
<Select
label="Type"
data={FIELD_TYPE_OPTIONS}
value={type}
onChange={(v) => v && setType(v as FieldType)}
searchable
/>
{type === "formula" && (
<Textarea
label="Formule"
description={
<span>
Syntaxe Baserow. Ex:&nbsp;
<code>{`field('Total') - field('Donné')`}</code>
</span>
}
placeholder="field('A') - field('B')"
value={formula}
onChange={(e) => setFormula(e.currentTarget.value)}
minRows={2}
required
/>
)}
{type === "number" && (
<NumberInput
label="Décimales"
value={decimals}
onChange={(v) => setDecimals(typeof v === "number" ? v : 0)}
min={0}
max={10}
style={{ width: 120 }}
/>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose} disabled={submitting}>
Annuler
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={submitting}>
{isEdit ? "Enregistrer" : "Créer"}
</Button>
</Group>
</Stack>
</Modal>
);
}

View file

@ -6,5 +6,6 @@
*/ */
export { default as DatabaseViewExtension } from "./extension/database-view-extension"; export { default as DatabaseViewExtension } from "./extension/database-view-extension";
export { buildDatabaseSlashItem } from "./slash-command/database-slash-command"; export { buildDatabaseSlashItem } from "./slash-command/database-slash-command";
export { buildCreateDatabaseSlashItem } from "./slash-command/create-database-slash";
export { useDatabaseRealtimeUpdates } from "./hooks/use-database-realtime-updates"; export { useDatabaseRealtimeUpdates } from "./hooks/use-database-realtime-updates";
export type { DatabaseViewAttrs, ViewType } from "./types/database-view.types"; export type { DatabaseViewAttrs, ViewType } from "./types/database-view.types";

View file

@ -1,12 +1,34 @@
import { useState } from "react"; import { useState } from "react";
import { Text, Button, Group, Skeleton, Stack, Alert } from "@mantine/core"; import {
import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; Text,
Button,
Group,
Skeleton,
Stack,
Alert,
ActionIcon,
Menu,
} from "@mantine/core";
import {
IconAlertCircle,
IconChevronLeft,
IconChevronRight,
IconPlus,
IconDots,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
import { useQueryClient } from "@tanstack/react-query";
import { useViewData } from "../hooks/use-view-data"; import { useViewData } from "../hooks/use-view-data";
import { useUpdateRow } from "../hooks/use-update-row"; import { useUpdateRow } from "../hooks/use-update-row";
import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates"; import { useDatabaseRealtimeUpdates } from "../hooks/use-database-realtime-updates";
import { usePermissions } from "../hooks/use-permissions"; import { usePermissions } from "../hooks/use-permissions";
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
import { InlineEditor } from "../components/inline-editor"; import { InlineEditor } from "../components/inline-editor";
import { FieldAdminModal } from "../components/field-admin-modal";
import { deleteField } from "../services/admin-client";
import type { BridgeField, BridgeRow } from "../types/database-view.types"; import type { BridgeField, BridgeRow } from "../types/database-view.types";
import styles from "./table-renderer.module.css"; import styles from "./table-renderer.module.css";
@ -157,8 +179,58 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
}); });
const { canWriteRows } = usePermissions(); const { canWriteRows } = usePermissions();
// Acadenice OSS: tout user (role Member par defaut) a tables:write.
// Le gate reste pour les roles custom restrictifs.
const acadenicePerms = useAcadenicePermissions();
const canAdminTables = acadenicePerms.hasPermission("tables:write");
const queryClient = useQueryClient();
const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl }); const updateRow = useUpdateRow({ tableId, viewId, bridgeUrl });
// Field admin modal state.
const [fieldModalOpen, setFieldModalOpen] = useState(false);
const [editingField, setEditingField] = useState<BridgeField | null>(null);
function openCreateField() {
setEditingField(null);
setFieldModalOpen(true);
}
function openEditField(field: BridgeField) {
setEditingField(field);
setFieldModalOpen(true);
}
function refreshAfterFieldChange() {
// Invalidate the view-data cache so the table re-renders with new fields.
queryClient.invalidateQueries({ queryKey: ["database-view", viewId] });
queryClient.invalidateQueries({ queryKey: ["database-view"] });
}
function confirmDeleteField(field: BridgeField) {
modals.openConfirmModal({
title: "Supprimer la colonne",
children: (
<Text size="sm">
La colonne <strong>{field.name}</strong> et toutes ses valeurs seront
définitivement supprimées. Action irréversible.
</Text>
),
labels: { confirm: "Supprimer", cancel: "Annuler" },
confirmProps: { color: "red" },
onConfirm: async () => {
try {
await deleteField(Number(field.id), bridgeUrl);
refreshAfterFieldChange();
} catch (e) {
// best-effort surface — invalidate to refresh UI
refreshAfterFieldChange();
// eslint-disable-next-line no-console
console.error("delete field failed", e);
}
},
});
}
// Subscribe to SSE updates — invalidates React Query cache on row/view events. // Subscribe to SSE updates — invalidates React Query cache on row/view events.
useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl); useDatabaseRealtimeUpdates(tableId, viewId, bridgeUrl);
@ -213,9 +285,54 @@ export function TableRenderer({ tableId, viewId, bridgeUrl }: TableRendererProps
<tr> <tr>
{fields.map((field) => ( {fields.map((field) => (
<th key={field.id} className={styles.th}> <th key={field.id} className={styles.th}>
<Group gap={4} wrap="nowrap" justify="space-between">
<Text size="sm" fw={500} truncate>
{field.name} {field.name}
</Text>
{canAdminTables && !field.primary && (
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon
size="xs"
variant="subtle"
aria-label={`Options ${field.name}`}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPencil size={14} />}
onClick={() => openEditField(field)}
>
Modifier
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={() => confirmDeleteField(field)}
>
Supprimer
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Group>
</th> </th>
))} ))}
{canAdminTables && (
<th className={styles.th} style={{ width: 32 }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={openCreateField}
aria-label="Ajouter une colonne"
title="Ajouter une colonne"
>
<IconPlus size={16} />
</ActionIcon>
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View file

@ -0,0 +1,166 @@
/**
* Admin client for bridge CRUD endpoints (Phase B).
* Reuses the bridge-client (cookie-auth via authToken) and targets
* /api/v1/admin/* routes that proxy to Baserow user-JWT operations.
*/
import { getBridgeClient, resolveBridgeUrl } from './bridge-client';
export type FieldType =
| 'text'
| 'long_text'
| 'number'
| 'rating'
| 'boolean'
| 'date'
| 'url'
| 'email'
| 'phone_number'
| 'single_select'
| 'multiple_select'
| 'link_row'
| 'formula'
| 'autonumber'
| 'duration';
export interface BaserowDatabase {
id: number;
name: string;
workspace: { id: number; name: string };
tables: Array<{ id: number; name: string; database_id: number }>;
}
export interface BaserowTableSummary {
id: number;
name: string;
database_id: number;
}
export interface BaserowFieldSummary {
id: number;
name: string;
type: FieldType | string;
primary?: boolean;
}
export interface CreateFieldInput {
name: string;
type: FieldType;
// Type-specific extras passed through to Baserow.
// formula: { formula: "field('A') + field('B')" }
// number: { number_decimal_places: 2 }
// single_select: { select_options: [{value, color}] }
// link_row: { link_row_table_id: <id> }
[key: string]: unknown;
}
function unwrap<T>(p: Promise<unknown>): Promise<T> {
// The bridge-client's response interceptor returns `response.data` (the
// body), which itself wraps the payload in `{ data: ... }` for admin
// endpoints. We unwrap once more to expose just the payload.
return p.then((body) => (body as { data: T }).data);
}
export interface BaserowWorkspace {
id: number;
name: string;
}
export async function listWorkspaces(
bridgeUrl?: string | null,
): Promise<BaserowWorkspace[]> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowWorkspace[]>(api.get(`/api/v1/admin/workspaces`));
}
export async function listDatabases(
workspaceId: number,
bridgeUrl?: string | null,
): Promise<BaserowDatabase[]> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowDatabase[]>(
api.get(`/api/v1/admin/databases`, { params: { workspaceId } }),
);
}
export async function createDatabase(
workspaceId: number,
name: string,
bridgeUrl?: string | null,
): Promise<BaserowDatabase> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowDatabase>(
api.post(`/api/v1/admin/databases`, { workspaceId, name }),
);
}
export async function createTable(
databaseId: number,
name: string,
bridgeUrl?: string | null,
): Promise<BaserowTableSummary> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowTableSummary>(
api.post(`/api/v1/admin/tables`, { databaseId, name }),
);
}
export async function updateTable(
tableId: number,
name: string,
bridgeUrl?: string | null,
): Promise<BaserowTableSummary> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowTableSummary>(
api.patch(`/api/v1/admin/tables/${tableId}`, { name }),
);
}
export async function deleteTable(
tableId: number,
bridgeUrl?: string | null,
): Promise<void> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
await api.delete(`/api/v1/admin/tables/${tableId}`);
}
export async function createField(
tableId: number,
payload: CreateFieldInput,
bridgeUrl?: string | null,
): Promise<BaserowFieldSummary> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowFieldSummary>(
api.post(`/api/v1/admin/tables/${tableId}/fields`, payload),
);
}
export async function updateField(
fieldId: number,
payload: Partial<CreateFieldInput>,
bridgeUrl?: string | null,
): Promise<BaserowFieldSummary> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<BaserowFieldSummary>(
api.patch(`/api/v1/admin/fields/${fieldId}`, payload),
);
}
export async function deleteField(
fieldId: number,
bridgeUrl?: string | null,
): Promise<void> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
await api.delete(`/api/v1/admin/fields/${fieldId}`);
}
export async function createView(
tableId: number,
payload: { name: string; type: 'grid' | 'gallery' | 'kanban' | 'calendar' | 'timeline' | 'form' },
bridgeUrl?: string | null,
): Promise<{ id: number; name: string; type: string }> {
const api = getBridgeClient(resolveBridgeUrl(bridgeUrl));
return unwrap<{ id: number; name: string; type: string }>(
api.post(`/api/v1/admin/tables/${tableId}/views`, payload),
);
}

View file

@ -1,53 +1,30 @@
/** /**
* Thin HTTP wrapper for the bridge API (R3.1.a). * Thin HTTP wrapper for the bridge API.
* *
* Why a separate client and not the shared `api` axios instance: * Auth strategy: the bridge accepts the Docmost session cookie `authToken`
* The bridge lives at a different origin (VITE_BRIDGE_URL) and uses the * (HttpOnly, host-only) directly via `getCookie('authToken')` in its
* DocAdenice JWT forwarded via the Authorization header, whereas `api` targets * middleware. This works only when the bridge is served on the *same origin*
* the Docmost backend at "/api" with cookie-based auth. * as Docmost in dev via the Vite proxy `/bridge -> :4000`, in prod via a
* Traefik route on `doc.stark.a3n.fr/bridge -> bridge:4000`. Cross-subdomain
* (`bridge.stark.a3n.fr`) does NOT work because the cookie is host-only and
* not Domain=.stark.a3n.fr.
* *
* The JWT is read from the cookie `authToken`. In production the cookie is * `withCredentials: true` is enough the browser sends the HttpOnly cookie
* HttpOnly so JS cannot read it SSE auth uses the cookie automatically via * automatically. We do NOT try to read it from `document.cookie` (it's
* credentials. For REST calls we rely on the cookie being sent automatically * HttpOnly by design).
* by withCredentials when the bridge is same-site, OR on the server proxying *
* the calls through Docmost (future R3.2). For now we send credentials and * `VITE_BRIDGE_TOKEN` is a dev-only fallback that injects a service token
* leave the Authorization header empty when the token is not readable the * (`brg_*`) when the cookie route is not in place yet.
* bridge falls back to cookie auth (R2.3b).
*/ */
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
/** Resolved bridge base URL: per-instance override > env var > same-origin proxy default. /** Resolved bridge base URL: per-instance override > env var > same-origin proxy default. */
*
* In dev, the Vite server proxies `/bridge/*` to `http://localhost:4000/*` (see vite.config.ts).
* Same-origin = the auth cookie is sent automatically without CORS gymnastics.
* In prod, set VITE_BRIDGE_URL at build time to an absolute URL behind your reverse proxy
* (or keep `/bridge` and proxy server-side).
*/
export function resolveBridgeUrl(bridgeUrlOverride?: string | null): string { export function resolveBridgeUrl(bridgeUrlOverride?: string | null): string {
const metaEnv = (import.meta as unknown as { env?: { VITE_BRIDGE_URL?: string } }).env; const metaEnv = (import.meta as unknown as { env?: { VITE_BRIDGE_URL?: string } }).env;
return bridgeUrlOverride ?? metaEnv?.VITE_BRIDGE_URL ?? "/bridge"; return bridgeUrlOverride ?? metaEnv?.VITE_BRIDGE_URL ?? "/bridge";
} }
/** Attempt to read the auth token from JS-accessible cookie or jotai storage. */
function readTokenFromCookie(): string | null {
try {
// authToken is HttpOnly in production; authTokens may be readable.
const raw = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("authTokens="));
if (raw) {
const val = decodeURIComponent(raw.slice("authTokens=".length));
const parsed = JSON.parse(val);
return typeof parsed?.token === "string" ? parsed.token : null;
}
} catch {
// cookie not accessible or malformed — silently fall through
}
return null;
}
/** Build a one-shot axios instance targeting the resolved bridge URL. */ /** Build a one-shot axios instance targeting the resolved bridge URL. */
export function createBridgeClient(bridgeUrl: string): AxiosInstance { export function createBridgeClient(bridgeUrl: string): AxiosInstance {
const instance = axios.create({ const instance = axios.create({
@ -56,23 +33,16 @@ export function createBridgeClient(bridgeUrl: string): AxiosInstance {
timeout: 15_000, timeout: 15_000,
}); });
// Vite auto-exposes VITE_* vars from apps/client/.env(.local) via
// import.meta.env. The monorepo-root .env is not picked up automatically
// for client-side consumption, so the dev token lives in apps/client/.env.local
// (gitignored).
const envToken: string | undefined = ( const envToken: string | undefined = (
import.meta as unknown as { env?: { VITE_BRIDGE_TOKEN?: string } } import.meta as unknown as { env?: { VITE_BRIDGE_TOKEN?: string } }
).env?.VITE_BRIDGE_TOKEN; ).env?.VITE_BRIDGE_TOKEN;
if (envToken) {
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
// Priority: cookie token (prod) > VITE_BRIDGE_TOKEN env (dev fallback) config.headers["Authorization"] = `Bearer ${envToken}`;
const cookieToken = readTokenFromCookie();
const token = cookieToken || envToken;
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config; return config;
}); });
}
instance.interceptors.response.use( instance.interceptors.response.use(
(res) => res.data, (res) => res.data,

View file

@ -0,0 +1,382 @@
import { useEffect, useMemo, useState } from "react";
import {
Modal,
Stack,
TextInput,
Button,
Group,
Text,
Select,
Alert,
ActionIcon,
Textarea,
Loader,
NumberInput,
Title,
Paper,
Divider,
} from "@mantine/core";
import {
IconPlus,
IconTrash,
IconAlertCircle,
IconChevronLeft,
} from "@tabler/icons-react";
import type { Editor } from "@tiptap/core";
import {
type FieldType,
type BaserowDatabase,
listDatabases,
listWorkspaces,
createDatabase,
createTable,
createField,
} from "../services/admin-client";
type Step = "name" | "fields" | "creating";
interface FieldDraft {
id: string;
name: string;
type: FieldType;
// For formula type
formula?: string;
// For number type
decimals?: number;
}
interface CreateDatabaseModalProps {
opened: boolean;
onClose: () => void;
editor: Editor;
bridgeUrl?: string | null;
/** Default workspace where databases live. Required to list/create. */
workspaceId?: number;
}
const FIELD_TYPE_OPTIONS: Array<{ value: FieldType; label: string; description?: string }> = [
{ value: "text", label: "Texte court" },
{ value: "long_text", label: "Texte long" },
{ value: "number", label: "Nombre" },
{ value: "rating", label: "Note (étoiles)" },
{ value: "boolean", label: "Case à cocher" },
{ value: "date", label: "Date" },
{ value: "single_select", label: "Choix unique" },
{ value: "multiple_select", label: "Choix multiple" },
{ value: "url", label: "URL" },
{ value: "email", label: "Email" },
{ value: "phone_number", label: "Téléphone" },
{ value: "duration", label: "Durée" },
{ value: "formula", label: "Formule (calcul)" , description: "Ex: field('Total') - field('Donné')"},
];
function newFieldDraft(): FieldDraft {
return {
id: Math.random().toString(36).slice(2, 10),
name: "",
type: "text",
};
}
export function CreateDatabaseModal({
opened,
onClose,
editor,
bridgeUrl,
workspaceId,
}: CreateDatabaseModalProps) {
const [step, setStep] = useState<Step>("name");
const [tableName, setTableName] = useState("");
const [databases, setDatabases] = useState<BaserowDatabase[]>([]);
const [databaseId, setDatabaseId] = useState<number | null>(null);
const [newDatabaseName, setNewDatabaseName] = useState("");
const [fields, setFields] = useState<FieldDraft[]>([newFieldDraft()]);
const [error, setError] = useState<string | null>(null);
const [loadingDbs, setLoadingDbs] = useState(false);
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<number | undefined>(workspaceId);
// Reset on open
useEffect(() => {
if (opened) {
setStep("name");
setTableName("");
setDatabaseId(null);
setNewDatabaseName("");
setFields([newFieldDraft()]);
setError(null);
}
}, [opened]);
// Resolve workspace if not provided: pick the first one the bridge service
// account can see. With our current setup there's a single workspace.
useEffect(() => {
if (!opened) return;
if (resolvedWorkspaceId !== undefined) return;
listWorkspaces(bridgeUrl)
.then((list) => {
const first = list[0];
if (first?.id) setResolvedWorkspaceId(first.id);
else setError("Aucun workspace Baserow accessible");
})
.catch((e) => setError(`Impossible de lister les workspaces : ${(e as Error).message}`));
}, [opened, resolvedWorkspaceId, bridgeUrl]);
// Load databases when workspace is known
useEffect(() => {
if (!opened || !resolvedWorkspaceId) return;
setLoadingDbs(true);
listDatabases(resolvedWorkspaceId, bridgeUrl)
.then((list) => {
setDatabases(list);
if (list.length > 0 && databaseId === null) setDatabaseId(list[0].id);
})
.catch((e) => setError(`Impossible de lister les databases : ${(e as Error).message}`))
.finally(() => setLoadingDbs(false));
}, [opened, resolvedWorkspaceId, bridgeUrl]);
const databaseOptions = useMemo(
() =>
databases.map((db) => ({ value: String(db.id), label: db.name })).concat(
{ value: "__new__", label: "+ Créer une nouvelle database" },
),
[databases],
);
function addField() {
setFields((prev) => [...prev, newFieldDraft()]);
}
function removeField(id: string) {
setFields((prev) => (prev.length > 1 ? prev.filter((f) => f.id !== id) : prev));
}
function patchField(id: string, patch: Partial<FieldDraft>) {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}
async function handleCreate() {
setError(null);
setStep("creating");
try {
// Step 1: resolve database (existing or create)
let dbId = databaseId;
if (databaseId === null && newDatabaseName.trim()) {
if (!resolvedWorkspaceId) throw new Error("workspaceId non résolu");
const created = await createDatabase(resolvedWorkspaceId, newDatabaseName.trim(), bridgeUrl);
dbId = created.id;
}
if (!dbId) throw new Error("Aucune database choisie");
// Step 2: create table
const table = await createTable(dbId, tableName.trim(), bridgeUrl);
// Step 3: create fields (the table has a default 'Name' primary field;
// we add ours on top). Run sequentially so ordering is stable.
for (const f of fields) {
if (!f.name.trim()) continue;
const payload: Record<string, unknown> = { name: f.name.trim(), type: f.type };
if (f.type === "formula") {
payload.formula = f.formula?.trim() ?? "";
}
if (f.type === "number" && typeof f.decimals === "number") {
payload.number_decimal_places = f.decimals;
}
await createField(table.id, payload as never, bridgeUrl);
}
// Step 4: insert the new table as a Tiptap node into the editor.
// We don't have a viewId here yet — Baserow auto-creates a default Grid
// view at table creation. We let the rendererfetch the first view.
editor
.chain()
.focus()
.insertDatabaseView({
tableId: String(table.id),
viewId: "",
viewType: "grid",
bridgeUrl: bridgeUrl ?? null,
})
.run();
onClose();
} catch (e) {
setError((e as Error).message);
setStep("fields");
}
}
const canProceedToFields =
tableName.trim().length > 0 &&
(databaseId !== null || newDatabaseName.trim().length > 0);
const canCreate =
step === "fields" &&
fields.every((f) => f.name.trim().length > 0) &&
fields.every((f) => f.type !== "formula" || (f.formula?.trim().length ?? 0) > 0);
return (
<Modal
opened={opened}
onClose={onClose}
title={<Title order={4}>Créer une nouvelle table</Title>}
size="lg"
centered
>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{error}
</Alert>
)}
{step === "name" && (
<Stack>
<TextInput
label="Nom de la table"
placeholder="Ex: Heures par personne"
value={tableName}
onChange={(e) => setTableName(e.currentTarget.value)}
data-autofocus
required
/>
<Select
label="Database parente"
placeholder={loadingDbs ? "Chargement..." : "Choisir une database"}
data={databaseOptions}
value={databaseId !== null ? String(databaseId) : null}
onChange={(v) => {
if (v === "__new__") {
setDatabaseId(null);
} else if (v) {
setDatabaseId(Number(v));
setNewDatabaseName("");
}
}}
searchable
/>
{databaseId === null && (
<TextInput
label="Nom de la nouvelle database"
placeholder="Ex: Projet AcadeNice"
value={newDatabaseName}
onChange={(e) => setNewDatabaseName(e.currentTarget.value)}
/>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Annuler
</Button>
<Button disabled={!canProceedToFields} onClick={() => setStep("fields")}>
Suivant
</Button>
</Group>
</Stack>
)}
{step === "fields" && (
<Stack>
<Text size="sm" c="dimmed">
Définis les colonnes. Une colonne primaire <strong>Name</strong> est ajoutée
automatiquement par Baserow ; pas besoin de la redéfinir.
</Text>
{fields.map((f, idx) => (
<Paper key={f.id} p="sm" withBorder>
<Group align="flex-end" gap="xs" wrap="nowrap">
<TextInput
label={idx === 0 ? "Nom" : undefined}
placeholder="Ex: Personne"
value={f.name}
onChange={(e) => patchField(f.id, { name: e.currentTarget.value })}
style={{ flex: 1 }}
/>
<Select
label={idx === 0 ? "Type" : undefined}
data={FIELD_TYPE_OPTIONS.map(({ value, label }) => ({ value, label }))}
value={f.type}
onChange={(v) => v && patchField(f.id, { type: v as FieldType })}
style={{ width: 180 }}
/>
<ActionIcon
variant="subtle"
color="red"
onClick={() => removeField(f.id)}
disabled={fields.length === 1}
aria-label="Supprimer la colonne"
>
<IconTrash size={16} />
</ActionIcon>
</Group>
{f.type === "formula" && (
<Textarea
label="Formule"
description={
<span>
Syntaxe Baserow. Ex:&nbsp;
<code>{`field('Heures totales') - field('Heures donnees')`}</code>
</span>
}
placeholder="field('A') - field('B')"
value={f.formula ?? ""}
onChange={(e) => patchField(f.id, { formula: e.currentTarget.value })}
minRows={2}
mt="xs"
required
/>
)}
{f.type === "number" && (
<NumberInput
label="Décimales"
value={f.decimals ?? 0}
onChange={(v) =>
patchField(f.id, { decimals: typeof v === "number" ? v : 0 })
}
min={0}
max={10}
mt="xs"
style={{ width: 120 }}
/>
)}
</Paper>
))}
<Button
variant="light"
leftSection={<IconPlus size={14} />}
onClick={addField}
>
Ajouter une colonne
</Button>
<Divider />
<Group justify="space-between">
<Button
variant="subtle"
leftSection={<IconChevronLeft size={14} />}
onClick={() => setStep("name")}
>
Retour
</Button>
<Group>
<Button variant="default" onClick={onClose}>
Annuler
</Button>
<Button disabled={!canCreate} onClick={handleCreate}>
Créer la table
</Button>
</Group>
</Group>
</Stack>
)}
{step === "creating" && (
<Stack align="center" py="xl">
<Loader />
<Text>Création en cours</Text>
</Stack>
)}
</Modal>
);
}

View file

@ -0,0 +1,99 @@
/**
* Slash item `/new database` wizard de création de table Baserow depuis l'UI
* AcadeDoc. Utilise le bridge admin (Phase B) puis insère la nouvelle table
* comme un node Tiptap database-view.
*/
import { useState } from "react";
import type { Editor } from "@tiptap/core";
import { Range } from "@tiptap/core";
import { IconTablePlus } from "@tabler/icons-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import { CreateDatabaseModal } from "./create-database-modal";
interface CreateDatabaseSlashCommandProps {
editor: Editor;
onDone: () => void;
bridgeUrl?: string | null;
workspaceId?: number;
}
function CreateDatabaseSlashWrapper({
editor,
onDone,
bridgeUrl,
workspaceId,
}: CreateDatabaseSlashCommandProps) {
const [opened, setOpened] = useState(true);
return (
<CreateDatabaseModal
opened={opened}
onClose={() => {
setOpened(false);
onDone();
}}
editor={editor}
bridgeUrl={bridgeUrl}
workspaceId={workspaceId}
/>
);
}
export function buildCreateDatabaseSlashItem(opts?: {
bridgeUrl?: string | null;
workspaceId?: number;
}) {
return {
title: "New database",
description: "Create a new Baserow table with custom columns and formulas.",
searchTerms: [
"new",
"database",
"create",
"table",
"baserow",
"nouvelle",
"creer",
"formule",
"heures",
],
icon: IconTablePlus,
command: ({ editor, range }: { editor: Editor; range: Range }) => {
editor.chain().focus().deleteRange(range).run();
const container = document.createElement("div");
document.body.appendChild(container);
const teardown = () => {
setTimeout(() => {
if (document.body.contains(container)) {
document.body.removeChild(container);
}
}, 300);
};
import("react-dom/client").then(({ createRoot }) => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const root = createRoot(container);
root.render(
<QueryClientProvider client={qc}>
<MantineProvider>
<CreateDatabaseSlashWrapper
editor={editor}
bridgeUrl={opts?.bridgeUrl ?? null}
workspaceId={opts?.workspaceId}
onDone={() => {
root.unmount();
teardown();
}}
/>
</MantineProvider>
</QueryClientProvider>,
);
});
},
};
}

View file

@ -114,25 +114,12 @@ function spaceColor(spaceId: string): string {
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let ForceGraph2DLazy: React.LazyExoticComponent<React.ComponentType<any>> | null = null; const ForceGraph2DLazy: React.LazyExoticComponent<React.ComponentType<any>> | null =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { React.lazy(() => import("react-force-graph-2d") as Promise<{
ForceGraph2DLazy = React.lazy(
() =>
// The cast via unknown is required because the module shape is not known
// at compile time (lib not installed). Replace with a typed import once
// `pnpm add react-force-graph-2d` is run.
import(
/* @vite-ignore */
"react-force-graph-2d"
) as Promise<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
default: React.ComponentType<any>; default: React.ComponentType<any>;
}>, }>);
);
} catch {
ForceGraph2DLazy = null;
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Fallback placeholder */ /* Fallback placeholder */

View file

@ -69,5 +69,6 @@ export async function fetchGraph(
const qs = new URLSearchParams(query).toString(); const qs = new URLSearchParams(query).toString();
const url = qs ? `/v1/graph?${qs}` : "/v1/graph"; const url = qs ? `/v1/graph?${qs}` : "/v1/graph";
return api.get(url) as unknown as Promise<GraphResponse>; const r = await api.get<GraphResponse>(url);
return r.data;
} }

View file

@ -9,57 +9,44 @@ import {
IMyPermissionsResponse, IMyPermissionsResponse,
} from "@/features/acadenice/rbac/types/rbac.types"; } from "@/features/acadenice/rbac/types/rbac.types";
/** // The global `TransformHttpResponseInterceptor` wraps every body in
* REST client for the Acadenice RBAC API (R2.1 backend). // `{ data, success, status }`. The axios interceptor already unwraps the
* Endpoints under /api/v1 relative to api.baseURL ("/api"). // transport layer, so each call returns that wrap object — we read `.data`
* // to get the actual payload.
* Note : Docmost's axios interceptor returns `response.data` directly, so the
* return value of `api.get(...)` is already the body payload.
*/
export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]> { export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]> {
return api.get("/v1/permissions") as unknown as Promise< const r = await api.get<IPermissionDescriptor[]>("/v1/permissions");
IPermissionDescriptor[] return r.data;
>;
} }
/**
* Fetches the effective permissions of the authenticated user in the current
* workspace. Backed by the Redis 60s cache server-side (R2.1).
*/
export async function getMyPermissions(): Promise<IMyPermissionsResponse> { export async function getMyPermissions(): Promise<IMyPermissionsResponse> {
return api.get( const r = await api.get<IMyPermissionsResponse>("/v1/permissions/me");
"/v1/permissions/me", return r.data;
) as unknown as Promise<IMyPermissionsResponse>;
} }
export async function listRoles(): Promise<IRole[]> { export async function listRoles(): Promise<IRole[]> {
return api.get("/v1/roles") as unknown as Promise<IRole[]>; const r = await api.get<IRole[]>("/v1/roles");
return r.data;
} }
export async function getRole(roleId: string): Promise<IRoleWithPermissions> { export async function getRole(roleId: string): Promise<IRoleWithPermissions> {
return api.get( const r = await api.get<IRoleWithPermissions>(`/v1/roles/${roleId}`);
`/v1/roles/${roleId}`, return r.data;
) as unknown as Promise<IRoleWithPermissions>;
} }
export async function createRole( export async function createRole(
payload: ICreateRolePayload, payload: ICreateRolePayload,
): Promise<IRoleWithPermissions> { ): Promise<IRoleWithPermissions> {
return api.post( const r = await api.post<IRoleWithPermissions>("/v1/roles", payload);
"/v1/roles", return r.data;
payload,
) as unknown as Promise<IRoleWithPermissions>;
} }
export async function updateRole( export async function updateRole(
roleId: string, roleId: string,
payload: IUpdateRolePayload, payload: IUpdateRolePayload,
): Promise<IRole> { ): Promise<IRole> {
return api.patch( const r = await api.patch<IRole>(`/v1/roles/${roleId}`, payload);
`/v1/roles/${roleId}`, return r.data;
payload,
) as unknown as Promise<IRole>;
} }
export async function deleteRole(roleId: string): Promise<void> { export async function deleteRole(roleId: string): Promise<void> {
@ -70,26 +57,30 @@ export async function setRolePermissions(
roleId: string, roleId: string,
permissions: string[], permissions: string[],
): Promise<IRoleWithPermissions> { ): Promise<IRoleWithPermissions> {
return api.put(`/v1/roles/${roleId}/permissions`, { const r = await api.put<IRoleWithPermissions>(
permissions, `/v1/roles/${roleId}/permissions`,
}) as unknown as Promise<IRoleWithPermissions>; { permissions },
);
return r.data;
} }
export async function listUserRoles( export async function listUserRoles(
userId: string, userId: string,
): Promise<IUserRoleAssignment[]> { ): Promise<IUserRoleAssignment[]> {
return api.get(`/v1/users/${userId}/roles`) as unknown as Promise< const r = await api.get<IUserRoleAssignment[]>(
IUserRoleAssignment[] `/v1/users/${userId}/roles`,
>; );
return r.data;
} }
export async function assignRolesToUser( export async function assignRolesToUser(
userId: string, userId: string,
roleIds: string[], roleIds: string[],
): Promise<{ ok: true }> { ): Promise<{ ok: true }> {
return api.post(`/v1/users/${userId}/roles`, { const r = await api.post<{ ok: true }>(`/v1/users/${userId}/roles`, {
roleIds, roleIds,
}) as unknown as Promise<{ ok: true }>; });
return r.data;
} }
export async function unassignRoleFromUser( export async function unassignRoleFromUser(

View file

@ -1,7 +1,4 @@
import axios from 'axios'; import api from '@/lib/api-client';
// Re-use the same axios instance that Docmost uses for authenticated requests.
// The `withCredentials` is handled globally by the Docmost axios setup.
export interface SlashCommandDto { export interface SlashCommandDto {
id: string; id: string;
@ -35,34 +32,35 @@ export interface CreateSlashCommandPayload {
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>; export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
const BASE = '/api/v1/slash-commands'; const BASE = '/v1/slash-commands';
export const slashCommandsClient = { export const slashCommandsClient = {
list(): Promise<SlashCommandDto[]> { async list(): Promise<SlashCommandDto[]> {
return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data); const r = await api.get<SlashCommandDto[]>(BASE);
return r.data;
}, },
get(id: string): Promise<SlashCommandDto> { async get(id: string): Promise<SlashCommandDto> {
return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data); const r = await api.get<SlashCommandDto>(`${BASE}/${id}`);
return r.data;
}, },
create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> { async create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> {
return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data); const r = await api.post<SlashCommandDto>(BASE, payload);
return r.data;
}, },
update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> { async update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
return axios const r = await api.patch<SlashCommandDto>(`${BASE}/${id}`, payload);
.patch<SlashCommandDto>(`${BASE}/${id}`, payload) return r.data;
.then((r) => r.data);
}, },
delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
return axios.delete(`${BASE}/${id}`).then(() => undefined); await api.delete(`${BASE}/${id}`);
}, },
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> { async toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
return axios const r = await api.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled });
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled }) return r.data;
.then((r) => r.data);
}, },
}; };

View file

@ -1,4 +1,4 @@
import axios from 'axios'; import api from '@/lib/api-client';
export interface SyncBlockDto { export interface SyncBlockDto {
id: string; id: string;
@ -17,26 +17,30 @@ export interface SyncBlockUsageDto {
workspaceId: string; workspaceId: string;
} }
const BASE = '/api/v1/sync-blocks'; const BASE = '/v1/sync-blocks';
export const syncBlocksClient = { export const syncBlocksClient = {
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> { async create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data); const r = await api.post<SyncBlockDto>(BASE, { content });
return r.data;
}, },
get(id: string): Promise<SyncBlockDto> { async get(id: string): Promise<SyncBlockDto> {
return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data); const r = await api.get<SyncBlockDto>(`${BASE}/${id}`);
return r.data;
}, },
update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> { async update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> {
return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data); const r = await api.patch<SyncBlockDto>(`${BASE}/${id}`, { content });
return r.data;
}, },
delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
return axios.delete(`${BASE}/${id}`).then(() => undefined); await api.delete(`${BASE}/${id}`);
}, },
usages(id: string): Promise<SyncBlockUsageDto[]> { async usages(id: string): Promise<SyncBlockUsageDto[]> {
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data); const r = await api.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`);
return r.data;
}, },
}; };

View file

@ -1,4 +1,4 @@
import axios from 'axios'; import api from '@/lib/api-client';
export interface TemplateDto { export interface TemplateDto {
id: string; id: string;
@ -36,41 +36,46 @@ export interface InstantiatePayload {
name?: string; name?: string;
} }
const BASE = '/api/v1/templates'; const BASE = '/v1/templates';
export const templatesClient = { export const templatesClient = {
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> { async list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
return axios const r = await api.get<TemplateDto[]>(BASE, { params: opts });
.get<TemplateDto[]>(BASE, { params: opts }) return r.data;
.then((r) => r.data);
}, },
get(id: string): Promise<TemplateDto> { async get(id: string): Promise<TemplateDto> {
return axios.get<TemplateDto>(`${BASE}/${id}`).then((r) => r.data); const r = await api.get<TemplateDto>(`${BASE}/${id}`);
return r.data;
}, },
create(payload: CreateTemplatePayload): Promise<TemplateDto> { async create(payload: CreateTemplatePayload): Promise<TemplateDto> {
return axios.post<TemplateDto>(BASE, payload).then((r) => r.data); const r = await api.post<TemplateDto>(BASE, payload);
return r.data;
}, },
update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> { async update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> {
return axios.patch<TemplateDto>(`${BASE}/${id}`, payload).then((r) => r.data); const r = await api.patch<TemplateDto>(`${BASE}/${id}`, payload);
return r.data;
}, },
delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
return axios.delete(`${BASE}/${id}`).then(() => undefined); await api.delete(`${BASE}/${id}`);
}, },
instantiate( async instantiate(
id: string, id: string,
payload: InstantiatePayload, payload: InstantiatePayload,
): Promise<{ pageId: string; slugId: string }> { ): Promise<{ pageId: string; slugId: string }> {
return axios const r = await api.post<{ pageId: string; slugId: string }>(
.post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload) `${BASE}/${id}/instantiate`,
.then((r) => r.data); payload,
);
return r.data;
}, },
setDefault(id: string): Promise<TemplateDto> { async setDefault(id: string): Promise<TemplateDto> {
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data); const r = await api.patch<TemplateDto>(`${BASE}/${id}/default`);
return r.data;
}, },
}; };

View file

@ -54,7 +54,7 @@ import {
YoutubeIcon, YoutubeIcon,
} from "@/components/icons"; } from "@/components/icons";
// Acadenice R3.1.c — database-view slash command // Acadenice R3.1.c — database-view slash command
import { buildDatabaseSlashItem } from "@/features/acadenice/database-view"; import { buildDatabaseSlashItem, buildCreateDatabaseSlashItem } from "@/features/acadenice/database-view";
// Acadenice R3.6 — /template slash command opens the picker modal via a custom event // Acadenice R3.6 — /template slash command opens the picker modal via a custom event
import { IconTemplate } from "@tabler/icons-react"; import { IconTemplate } from "@tabler/icons-react";
// Acadenice R4.2 — /sync-block slash command // Acadenice R4.2 — /sync-block slash command
@ -65,6 +65,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
// Acadenice R3.1.c — database embed group (separate from basic to keep ordering clean) // Acadenice R3.1.c — database embed group (separate from basic to keep ordering clean)
acadenice: [ acadenice: [
buildDatabaseSlashItem() as unknown as import("./types").SlashMenuItemType, buildDatabaseSlashItem() as unknown as import("./types").SlashMenuItemType,
buildCreateDatabaseSlashItem() as unknown as import("./types").SlashMenuItemType,
// Acadenice R3.6 — /template opens the workspace template picker via custom DOM event // Acadenice R3.6 — /template opens the workspace template picker via custom DOM event
{ {
title: "Template", title: "Template",

View file

@ -34,9 +34,9 @@ const PAGE_CONTENT_UPDATED_EVENT = 'acadenice.page.content.updated';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface BacklinkAggRow { interface BacklinkAggRow {
source_page_id: string; sourcePageId: string;
target_page_id: string; targetPageId: string;
link_type: LinkType; linkType: LinkType;
weight: number; weight: number;
} }
@ -44,8 +44,8 @@ interface PageMetaRow {
id: string; id: string;
title: string | null; title: string | null;
slug: string | null; slug: string | null;
space_id: string; spaceId: string;
space_name: string | null; spaceName: string | null;
icon: string | null; icon: string | null;
} }
@ -206,8 +206,8 @@ export class GraphService {
reachablePageIds = this.bfsReachable(rawEdges, pageId, depth); reachablePageIds = this.bfsReachable(rawEdges, pageId, depth);
filteredEdges = rawEdges.filter( filteredEdges = rawEdges.filter(
(e) => (e) =>
reachablePageIds!.has(e.source_page_id) && reachablePageIds!.has(e.sourcePageId) &&
reachablePageIds!.has(e.target_page_id), reachablePageIds!.has(e.targetPageId),
); );
} else { } else {
filteredEdges = rawEdges; filteredEdges = rawEdges;
@ -216,8 +216,8 @@ export class GraphService {
// Step 3: Derive the set of page IDs referenced in the edges. // Step 3: Derive the set of page IDs referenced in the edges.
const connectedPageIds = new Set<string>(); const connectedPageIds = new Set<string>();
for (const e of filteredEdges) { for (const e of filteredEdges) {
connectedPageIds.add(e.source_page_id); connectedPageIds.add(e.sourcePageId);
connectedPageIds.add(e.target_page_id); connectedPageIds.add(e.targetPageId);
} }
// Step 4: Load page metadata for all referenced pages. // Step 4: Load page metadata for all referenced pages.
@ -230,12 +230,12 @@ export class GraphService {
const outDegreeMap = new Map<string, number>(); const outDegreeMap = new Map<string, number>();
for (const e of filteredEdges) { for (const e of filteredEdges) {
outDegreeMap.set( outDegreeMap.set(
e.source_page_id, e.sourcePageId,
(outDegreeMap.get(e.source_page_id) ?? 0) + 1, (outDegreeMap.get(e.sourcePageId) ?? 0) + 1,
); );
inDegreeMap.set( inDegreeMap.set(
e.target_page_id, e.targetPageId,
(inDegreeMap.get(e.target_page_id) ?? 0) + 1, (inDegreeMap.get(e.targetPageId) ?? 0) + 1,
); );
} }
@ -254,19 +254,19 @@ export class GraphService {
// Re-filter edges and re-compute degrees. // Re-filter edges and re-compute degrees.
filteredEdges = filteredEdges.filter( filteredEdges = filteredEdges.filter(
(e) => (e) =>
finalPageIds.has(e.source_page_id) && finalPageIds.has(e.sourcePageId) &&
finalPageIds.has(e.target_page_id), finalPageIds.has(e.targetPageId),
); );
inDegreeMap.clear(); inDegreeMap.clear();
outDegreeMap.clear(); outDegreeMap.clear();
for (const e of filteredEdges) { for (const e of filteredEdges) {
outDegreeMap.set( outDegreeMap.set(
e.source_page_id, e.sourcePageId,
(outDegreeMap.get(e.source_page_id) ?? 0) + 1, (outDegreeMap.get(e.sourcePageId) ?? 0) + 1,
); );
inDegreeMap.set( inDegreeMap.set(
e.target_page_id, e.targetPageId,
(inDegreeMap.get(e.target_page_id) ?? 0) + 1, (inDegreeMap.get(e.targetPageId) ?? 0) + 1,
); );
} }
} }
@ -311,8 +311,8 @@ export class GraphService {
label: meta.title, label: meta.title,
slug: meta.slug, slug: meta.slug,
type: 'page', type: 'page',
spaceId: meta.space_id, spaceId: meta.spaceId,
spaceName: meta.space_name, spaceName: meta.spaceName,
icon: meta.icon, icon: meta.icon,
isOrphan, isOrphan,
metrics: { inDegree: inDeg, outDegree: outDeg }, metrics: { inDegree: inDeg, outDegree: outDeg },
@ -320,10 +320,10 @@ export class GraphService {
} }
const edges: GraphEdge[] = filteredEdges.map((e) => ({ const edges: GraphEdge[] = filteredEdges.map((e) => ({
id: `${e.source_page_id}:${e.target_page_id}:${e.link_type}`, id: `${e.sourcePageId}:${e.targetPageId}:${e.linkType}`,
source: e.source_page_id, source: e.sourcePageId,
target: e.target_page_id, target: e.targetPageId,
type: e.link_type, type: e.linkType,
weight: e.weight, weight: e.weight,
})); }));
@ -369,11 +369,13 @@ export class GraphService {
? sql`AND src_sp.id = ${spaceId}` ? sql`AND src_sp.id = ${spaceId}`
: sql``; : sql``;
// Aliases en camelCase: les requêtes sql`...` brutes ne passent pas
// par le CamelCasePlugin Kysely (seul le query builder typé le fait).
const rows = await sql<BacklinkAggRow>` const rows = await sql<BacklinkAggRow>`
SELECT SELECT
bl.source_page_id, bl.source_page_id AS "sourcePageId",
bl.target_page_id, bl.target_page_id AS "targetPageId",
bl.link_type, bl.link_type AS "linkType",
COUNT(*)::int AS weight COUNT(*)::int AS weight
FROM acadenice_backlink bl FROM acadenice_backlink bl
-- Source page permission check -- Source page permission check
@ -442,8 +444,8 @@ export class GraphService {
p.id, p.id,
p.title, p.title,
p.slug_id AS slug, p.slug_id AS slug,
p.space_id, p.space_id AS "spaceId",
sp.name AS space_name, sp.name AS "spaceName",
p.icon p.icon
FROM pages p FROM pages p
JOIN spaces sp ON sp.id = p.space_id JOIN spaces sp ON sp.id = p.space_id
@ -487,8 +489,8 @@ export class GraphService {
p.id, p.id,
p.title, p.title,
p.slug_id AS slug, p.slug_id AS slug,
p.space_id, p.space_id AS "spaceId",
sp.name AS space_name, sp.name AS "spaceName",
p.icon p.icon
FROM pages p FROM pages p
JOIN spaces sp ON sp.id = p.space_id JOIN spaces sp ON sp.id = p.space_id
@ -537,9 +539,9 @@ export class GraphService {
const rows = await sql<BacklinkAggRow>` const rows = await sql<BacklinkAggRow>`
SELECT SELECT
child.parent_page_id AS source_page_id, child.parent_page_id AS "sourcePageId",
child.id AS target_page_id, child.id AS "targetPageId",
'parent_child'::text AS link_type, 'parent_child'::text AS "linkType",
1::int AS weight 1::int AS weight
FROM pages child FROM pages child
-- Child page space -- Child page space
@ -599,8 +601,8 @@ export class GraphService {
adj.get(a)!.add(b); adj.get(a)!.add(b);
}; };
for (const e of edges) { for (const e of edges) {
addEdge(e.source_page_id, e.target_page_id); addEdge(e.sourcePageId, e.targetPageId);
addEdge(e.target_page_id, e.source_page_id); addEdge(e.targetPageId, e.sourcePageId);
} }
const visited = new Set<string>([rootId]); const visited = new Set<string>([rootId]);

View file

@ -22,7 +22,7 @@ function row(
type: 'wikilink' | 'mention' | 'database_embed' = 'wikilink', type: 'wikilink' | 'mention' | 'database_embed' = 'wikilink',
weight = 1, weight = 1,
) { ) {
return { source_page_id: source, target_page_id: target, link_type: type, weight }; return { sourcePageId: source, targetPageId: target, linkType: type, weight };
} }
/** Factory for a mock PageMetaRow. */ /** Factory for a mock PageMetaRow. */
@ -32,7 +32,7 @@ function pageMeta(
spaceId = 'space-1', spaceId = 'space-1',
spaceName = 'Space 1', spaceName = 'Space 1',
) { ) {
return { id, title, space_id: spaceId, space_name: spaceName, icon: null }; return { id, title, slug: null, spaceId, spaceName, icon: null };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -507,8 +507,8 @@ describe('GraphService', () => {
// Backlink table is empty — only parent-child edges exist // Backlink table is empty — only parent-child edges exist
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]); jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 }, { sourcePageId: 'parent-1', targetPageId: 'child-1', linkType: 'parent_child', weight: 1 },
{ source_page_id: 'parent-1', target_page_id: 'child-2', link_type: 'parent_child', weight: 1 }, { sourcePageId: 'parent-1', targetPageId: 'child-2', linkType: 'parent_child', weight: 1 },
]); ]);
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('parent-1', 'Parent'), pageMeta('parent-1', 'Parent'),
@ -529,7 +529,7 @@ describe('GraphService', () => {
it('parent_child edges carry correct source -> target direction', async () => { it('parent_child edges carry correct source -> target direction', async () => {
jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]); jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 }, { sourcePageId: 'parent-1', targetPageId: 'child-1', linkType: 'parent_child', weight: 1 },
]); ]);
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('parent-1', 'Parent'), pageMeta('parent-1', 'Parent'),
@ -583,7 +583,7 @@ describe('GraphService', () => {
row('p1', 'p2', 'wikilink'), row('p1', 'p2', 'wikilink'),
]); ]);
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
{ source_page_id: 'p2', target_page_id: 'p3', link_type: 'parent_child', weight: 1 }, { sourcePageId: 'p2', targetPageId: 'p3', linkType: 'parent_child', weight: 1 },
]); ]);
jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
pageMeta('p1'), pageMeta('p1'),
@ -607,8 +607,8 @@ describe('GraphService', () => {
jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([]); jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([]);
jest.spyOn(service as any, 'loadPageMeta').mockImplementation( jest.spyOn(service as any, 'loadPageMeta').mockImplementation(
async (..._args: any[]) => [ async (..._args: any[]) => [
{ id: 'p1', title: 'Page 1', slug: 'page-1-slug', space_id: 'sp-1', space_name: 'S', icon: null }, { id: 'p1', title: 'Page 1', slug: 'page-1-slug', spaceId: 'sp-1', spaceName: 'S', icon: null },
{ id: 'p2', title: 'Page 2', slug: null, space_id: 'sp-1', space_name: 'S', icon: null }, { id: 'p2', title: 'Page 2', slug: null, spaceId: 'sp-1', spaceName: 'S', icon: null },
], ],
); );

View file

@ -7,12 +7,19 @@ import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely'; import { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AcadeniceRoleRepo } from '../repos/role.repo'; import { AcadeniceRoleRepo } from '../repos/role.repo';
import { AcadeniceUserRoleRepo } from '../repos/user-role.repo';
import { PermissionKey } from '../permissions-catalog'; import { PermissionKey } from '../permissions-catalog';
interface SystemRoleSpec { interface SystemRoleSpec {
name: string; name: string;
description: string; description: string;
permissions: PermissionKey[]; permissions: PermissionKey[];
/**
* If true, the seed REPLACES the role's permissions on every boot, even
* when the role already has perms in DB. Used for roles whose default set
* evolves with the product (e.g. Member after the OSS opening of v1.x).
*/
forceResync?: boolean;
} }
/** /**
@ -92,18 +99,44 @@ const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
}, },
{ {
name: 'Member', name: 'Member',
description: 'Read content and upload attachments.', description:
'Default role: full content authoring (pages, tables, templates, comments). Admin actions reserved to Owner/Admin roles.',
forceResync: true,
permissions: [ permissions: [
// Pages — full CRUD + share by page (Outline-style granular access)
'pages:read', 'pages:read',
'pages:write',
'pages:delete',
'pages:share',
// Spaces — create + edit (visibility public/private + member mgmt)
'space:read', 'space:read',
'space:create',
'space:write',
'space:invite',
// Tables — full CRUD via bridge (every user can spin up databases)
'tables:list',
'tables:create',
'tables:write',
'tables:delete',
'rows:read', 'rows:read',
'rows:write',
'rows:delete',
// Attachments
'attachments:upload', 'attachments:upload',
// R3.6 — Members can browse templates 'attachments:delete',
// Templates — read + create + manage own
'templates:read', 'templates:read',
// R4.2 — Members can create and edit sync blocks 'templates:create',
'templates:manage',
// Comments — full participation (resolve included)
'comments:read',
'comments:write',
'comments:resolve',
// Sync blocks
'sync_blocks:create', 'sync_blocks:create',
'sync_blocks:edit', 'sync_blocks:edit',
// R4.3 — Members can use the Web Clipper 'sync_blocks:delete',
// Web Clipper
'clipper:use', 'clipper:use',
], ],
}, },
@ -125,6 +158,7 @@ export class AcadeniceRbacSeedService implements OnModuleInit {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly roleRepo: AcadeniceRoleRepo, private readonly roleRepo: AcadeniceRoleRepo,
private readonly userRoleRepo: AcadeniceUserRoleRepo,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@ -178,11 +212,44 @@ export class AcadeniceRbacSeedService implements OnModuleInit {
} }
const existingPerms = await this.roleRepo.listPermissions(role.id); const existingPerms = await this.roleRepo.listPermissions(role.id);
if (existingPerms.length === 0) { if (existingPerms.length === 0 || spec.forceResync) {
// First time we see this role : install the canonical permission set. // Install the canonical permission set. forceResync=true ALWAYS
// overrides existing perms — used for roles whose default contract
// changes between releases (e.g. Member after OSS-opening). Local
// customisations on a forceResync role are not supported by design;
// create a custom role instead.
await this.roleRepo.replacePermissions(role.id, [...spec.permissions]); await this.roleRepo.replacePermissions(role.id, [...spec.permissions]);
} }
// If existingPerms.length > 0 we keep the admin's customisations intact. // For non-forceResync roles with existing perms, we preserve them.
}
// Ensure every workspace member has at least one Acadenice role assigned.
// Without this the permission guards return an empty permission set,
// making the entire UI 403 silently. Workspace owners (users.role='owner')
// get the Owner role; everyone else gets Member as a sane default.
await this.assignDefaultUserRoles(workspaceId);
}
private async assignDefaultUserRoles(workspaceId: string): Promise<void> {
const ownerRole = await this.roleRepo.findByName(workspaceId, 'Owner');
const memberRole = await this.roleRepo.findByName(workspaceId, 'Member');
if (!ownerRole || !memberRole) return;
const users = await sql<{ id: string; role: string }>`
SELECT id, role FROM users
WHERE workspace_id = ${workspaceId} AND deleted_at IS NULL
`.execute(this.db);
for (const u of users.rows) {
const existing = await this.userRoleRepo.listForUser(u.id, workspaceId);
if (existing.length > 0) continue;
const targetRoleId = u.role === 'owner' ? ownerRole.id : memberRole.id;
await this.userRoleRepo.assign({
userId: u.id,
roleId: targetRoleId,
workspaceId,
assignedBy: null,
});
} }
} }
} }

View file

@ -1,5 +1,6 @@
import { AcadeniceRbacSeedService } from '../services/seed.service'; import { AcadeniceRbacSeedService } from '../services/seed.service';
import { AcadeniceRoleRepo } from '../repos/role.repo'; import { AcadeniceRoleRepo } from '../repos/role.repo';
import { AcadeniceUserRoleRepo } from '../repos/user-role.repo';
/** /**
* The seed service uses kysely's `sql` template tag through `InjectKysely()` * The seed service uses kysely's `sql` template tag through `InjectKysely()`
@ -9,10 +10,12 @@ import { AcadeniceRoleRepo } from '../repos/role.repo';
describe('AcadeniceRbacSeedService.seedWorkspace', () => { describe('AcadeniceRbacSeedService.seedWorkspace', () => {
let seedService: AcadeniceRbacSeedService; let seedService: AcadeniceRbacSeedService;
let roleRepo: jest.Mocked<Partial<AcadeniceRoleRepo>>; let roleRepo: jest.Mocked<Partial<AcadeniceRoleRepo>>;
// Stub the kysely DB — only `sql\`...\`.execute(this.db)` calls the DB and let userRoleRepo: jest.Mocked<Partial<AcadeniceUserRoleRepo>>;
// seedWorkspace doesn't use it directly (only seedAllWorkspaces does). We // Stub the kysely DB. seedWorkspace itself runs a `sql\`...\`.execute(db)`
// pass an arbitrary placeholder. // for the default-role assignment loop, so the stub responds with no users.
const fakeDb: any = {}; const fakeDb: any = {
execute: jest.fn().mockResolvedValue({ rows: [] }),
};
beforeEach(() => { beforeEach(() => {
roleRepo = { roleRepo = {
@ -21,9 +24,14 @@ describe('AcadeniceRbacSeedService.seedWorkspace', () => {
listPermissions: jest.fn(), listPermissions: jest.fn(),
replacePermissions: jest.fn(), replacePermissions: jest.fn(),
}; };
userRoleRepo = {
listForUser: jest.fn(),
assign: jest.fn(),
};
seedService = new AcadeniceRbacSeedService( seedService = new AcadeniceRbacSeedService(
fakeDb, fakeDb,
roleRepo as unknown as AcadeniceRoleRepo, roleRepo as unknown as AcadeniceRoleRepo,
userRoleRepo as unknown as AcadeniceUserRoleRepo,
); );
}); });

View file

@ -0,0 +1,67 @@
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
export const PAGE_PERMISSION_ROLES = ['reader', 'writer'] as const;
export type PagePermissionRoleValue = (typeof PAGE_PERMISSION_ROLES)[number];
export class PageIdBodyDto {
@IsString()
pageId: string;
}
export class AddPagePermissionDto {
@IsString()
pageId: string;
@IsIn(PAGE_PERMISSION_ROLES as unknown as string[])
role: PagePermissionRoleValue;
@ValidateIf((o) => !o.groupIds || o.groupIds.length === 0)
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
userIds?: string[];
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
groupIds?: string[];
}
export class RemovePagePermissionDto {
@IsString()
pageId: string;
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
userIds?: string[];
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
groupIds?: string[];
}
export class UpdatePagePermissionDto {
@IsString()
pageId: string;
@IsIn(PAGE_PERMISSION_ROLES as unknown as string[])
role: PagePermissionRoleValue;
@ValidateIf((o) => !o.groupId)
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
groupId?: string;
}

View file

@ -0,0 +1,97 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionService } from './page-permission.service';
import {
AddPagePermissionDto,
PageIdBodyDto,
RemovePagePermissionDto,
UpdatePagePermissionDto,
} from './dto/page-permission.dto';
/**
* Page-sharing endpoints Outline-style granular access.
* Mounted under the same /pages prefix as PageController, so the existing
* client (`apps/client/src/ee/page-permission/services/page-permission-service.ts`)
* works without any URL changes.
*/
@UseGuards(JwtAuthGuard)
@Controller('pages')
export class PagePermissionController {
constructor(private readonly pagePermissionService: PagePermissionService) {}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('restrict')
async restrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) {
await this.pagePermissionService.restrictPage(dto.pageId, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('remove-restriction')
async unrestrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) {
await this.pagePermissionService.unrestrictPage(dto.pageId, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('add-permission')
async addPermission(
@Body() dto: AddPagePermissionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.addPermissions(dto, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('remove-permission')
async removePermission(
@Body() dto: RemovePagePermissionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.removePermissions(dto, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('update-permission')
async updatePermission(
@Body() dto: UpdatePagePermissionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.updatePermissionRole(dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('permissions')
async listPermissions(
@Body() body: PageIdBodyDto & { cursor?: string; query?: string },
@AuthUser() user: User,
) {
const pagination: PaginationOptions = {
limit: 50,
cursor: body.cursor,
query: body.query,
} as PaginationOptions;
return this.pagePermissionService.listPermissions(
body.pageId,
user,
pagination as any,
);
}
@HttpCode(HttpStatus.OK)
@Post('permission-info')
async getRestrictionInfo(
@Body() dto: PageIdBodyDto,
@AuthUser() user: User,
) {
return this.pagePermissionService.getRestrictionInfo(dto.pageId, user);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PagePermissionController } from './page-permission.controller';
import { PagePermissionService } from './page-permission.service';
@Module({
controllers: [PagePermissionController],
providers: [PagePermissionService],
})
export class PagePermissionModule {}

View file

@ -0,0 +1,316 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';
import {
PageAccessLevel,
PagePermissionRole,
} from '../../../common/helpers/types/permission';
import {
AddPagePermissionDto,
RemovePagePermissionDto,
UpdatePagePermissionDto,
} from './dto/page-permission.dto';
interface RestrictionInfo {
restrictionId?: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
inheritedFrom?: { id: string; slugId: string; title: string };
userAccess: { canView: boolean; canEdit: boolean; canManage: boolean };
}
/**
* Page-sharing service Outline-style granular access on top of the native
* page_access / page_permissions tables. Reuses `PagePermissionRepo` for
* persistence and `PageAccessService` to authorize the caller.
*
* Authorization rule for managing permissions: caller must have edit access
* to the page (page-level if restricted, space-level otherwise). This matches
* Outline's "Editors can share" behavior.
*/
@Injectable()
export class PagePermissionService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
) {}
private async loadPageOrThrow(pageId: string): Promise<Page> {
const page = await this.pageRepo.findById(pageId);
if (!page) throw new NotFoundException('Page not found');
return page;
}
/**
* Restrict the page promote its access level to RESTRICTED and seed the
* caller as a writer so they don't lock themselves out. Idempotent.
*/
async restrictPage(pageId: string, user: User): Promise<void> {
const page = await this.loadPageOrThrow(pageId);
await this.pageAccessService.validateCanEdit(page, user);
await this.db.transaction().execute(async (trx) => {
const existing = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
trx,
);
let pageAccess = existing;
if (!pageAccess) {
pageAccess = await this.pagePermissionRepo.insertPageAccess(
{
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
accessLevel: PageAccessLevel.RESTRICTED,
creatorId: user.id,
},
trx,
);
}
// Seed the caller as a writer so they retain access after restriction.
const existingPerm = await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
user.id,
trx,
);
if (!existingPerm) {
await this.pagePermissionRepo.insertPagePermissions(
[
{
pageAccessId: pageAccess.id,
userId: user.id,
role: PagePermissionRole.WRITER,
addedById: user.id,
},
],
trx,
);
}
});
}
async unrestrictPage(pageId: string, user: User): Promise<void> {
const page = await this.loadPageOrThrow(pageId);
await this.pageAccessService.validateCanEdit(page, user);
// ON DELETE CASCADE on page_permissions handles cleanup.
await this.pagePermissionRepo.deletePageAccess(page.id);
}
async addPermissions(dto: AddPagePermissionDto, user: User): Promise<void> {
const page = await this.loadPageOrThrow(dto.pageId);
await this.pageAccessService.validateCanEdit(page, user);
const userIds = dto.userIds ?? [];
const groupIds = dto.groupIds ?? [];
if (userIds.length === 0 && groupIds.length === 0) {
return;
}
await this.db.transaction().execute(async (trx) => {
let pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
trx,
);
// Auto-promote to restricted on first add — matches the Notion-like
// affordance "share with X = make it restricted".
if (!pageAccess) {
pageAccess = await this.pagePermissionRepo.insertPageAccess(
{
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
accessLevel: PageAccessLevel.RESTRICTED,
creatorId: user.id,
},
trx,
);
}
const newRows = [
...userIds.map((uid) => ({
pageAccessId: pageAccess!.id,
userId: uid,
role: dto.role,
addedById: user.id,
})),
...groupIds.map((gid) => ({
pageAccessId: pageAccess!.id,
groupId: gid,
role: dto.role,
addedById: user.id,
})),
];
// Drop any existing rows for the same (pageAccess, user/group) so the
// insert is idempotent and the role is updated on re-add.
if (userIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
pageAccess!.id,
userIds,
trx,
);
}
if (groupIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
pageAccess!.id,
groupIds,
trx,
);
}
await this.pagePermissionRepo.insertPagePermissions(newRows, trx);
});
}
async removePermissions(
dto: RemovePagePermissionDto,
user: User,
): Promise<void> {
const page = await this.loadPageOrThrow(dto.pageId);
await this.pageAccessService.validateCanEdit(page, user);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
if (!pageAccess) return;
if (dto.userIds && dto.userIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
pageAccess.id,
dto.userIds,
);
}
if (dto.groupIds && dto.groupIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
pageAccess.id,
dto.groupIds,
);
}
}
async updatePermissionRole(
dto: UpdatePagePermissionDto,
user: User,
): Promise<void> {
const page = await this.loadPageOrThrow(dto.pageId);
await this.pageAccessService.validateCanEdit(page, user);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
if (!pageAccess) {
throw new NotFoundException('Page is not restricted');
}
if (!dto.userId && !dto.groupId) {
throw new ForbiddenException('userId or groupId required');
}
// Guard against demoting the last writer to reader (locks out everyone).
if (dto.role === PagePermissionRole.READER) {
const writers = await this.pagePermissionRepo.countWritersByPageAccessId(
pageAccess.id,
);
const target = dto.userId
? await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
dto.userId,
)
: await this.pagePermissionRepo.findPagePermissionByGroupId(
pageAccess.id,
dto.groupId!,
);
if (
writers <= 1 &&
target?.role === PagePermissionRole.WRITER
) {
throw new ForbiddenException(
'Cannot demote the last writer of a restricted page',
);
}
}
await this.pagePermissionRepo.updatePagePermissionRole(
pageAccess.id,
dto.role,
{ userId: dto.userId, groupId: dto.groupId },
);
}
async listPermissions(
pageId: string,
user: User,
pagination: { limit: number; cursor?: string; query?: string },
) {
const page = await this.loadPageOrThrow(pageId);
// Only people who can view the page can see who has access.
await this.pageAccessService.validateCanView(page, user);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
if (!pageAccess) {
return { items: [], meta: { hasNextPage: false, hasPrevPage: false } };
}
return this.pagePermissionRepo.getPagePermissionsPaginated(
pageAccess.id,
pagination as any,
);
}
async getRestrictionInfo(
pageId: string,
user: User,
): Promise<RestrictionInfo> {
const page = await this.loadPageOrThrow(pageId);
await this.pageAccessService.validateCanView(page, user);
const direct = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
const ancestor = await this.pagePermissionRepo.findRestrictedAncestor(
page.id,
);
// findRestrictedAncestor returns the SAME page when the page itself is
// restricted (depth=0). We only consider it "inherited" when depth > 0.
const isInherited =
!!ancestor && ancestor.depth > 0 && ancestor.pageId !== page.id;
const inheritedPage = isInherited
? await this.pageRepo.findById(ancestor!.pageId)
: undefined;
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
return {
restrictionId: direct?.id,
hasDirectRestriction: !!direct,
hasInheritedRestriction: isInherited,
inheritedFrom: inheritedPage
? {
id: inheritedPage.id,
slugId: inheritedPage.slugId,
title: inheritedPage.title ?? '',
}
: undefined,
userAccess: {
canView: hasAnyRestriction ? canAccess : true,
canEdit: hasAnyRestriction ? canEdit : true,
// For now: anyone who can edit can manage permissions. Mirrors the
// service-level rule used by addPermissions/restrict.
canManage: hasAnyRestriction ? canEdit : true,
},
};
}
}

View file

@ -6,10 +6,17 @@ import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module'; import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module'; import { WatcherModule } from '../watcher/watcher.module';
import { PagePermissionController } from './page-permission/page-permission.controller';
import { PagePermissionService } from './page-permission/page-permission.service';
@Module({ @Module({
controllers: [PageController], controllers: [PageController, PagePermissionController],
providers: [PageService, PageHistoryService, TrashCleanupService], providers: [
PageService,
PageHistoryService,
TrashCleanupService,
PagePermissionService,
],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule, WatcherModule], imports: [StorageModule, CollaborationModule, WatcherModule],
}) })

View file

@ -1,6 +1,21 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service'; import { EnvironmentService } from './environment.service';
import { Feature } from '../../common/features';
/**
* Acadenice features OSS-ifiees inconditionnellement (open-source).
* Toujours injectees dans la reponse `resolveFeatures` quel que soit le plan
* ou la license. Permet de garder le pipeline `useHasFeature(...)` cote front
* et les guards cote serveur sans toucher au flux EE.
*
* Why: reproduit la logique du Patch 020 (branding) et R4.5 (audit, api-keys)
* appliques par overrides UI directs. Centralise ici pour rester DRY.
*/
const ACADENICE_OSS_FEATURES: ReadonlyArray<string> = [
Feature.PAGE_PERMISSIONS,
Feature.SHARING_CONTROLS,
];
@Injectable() @Injectable()
export class LicenseCheckService { export class LicenseCheckService {
@ -27,6 +42,9 @@ export class LicenseCheckService {
} }
hasFeature(licenseKey: string, feature: string, plan?: string): boolean { hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
if (ACADENICE_OSS_FEATURES.includes(feature)) {
return true;
}
if (this.environmentService.isCloud()) { if (this.environmentService.isCloud()) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
@ -63,17 +81,22 @@ export class LicenseCheckService {
} }
resolveFeatures(licenseKey: string, plan: string): string[] { resolveFeatures(licenseKey: string, plan: string): string[] {
let base: string[];
if (this.environmentService.isCloud()) { if (this.environmentService.isCloud()) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry'); const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
return [...getFeaturesForCloudPlan(plan)]; base = [...getFeaturesForCloudPlan(plan)];
} catch { } catch {
return []; base = [];
} }
} else {
base = this.getFeatures(licenseKey);
} }
// Inject Acadenice OSS-ified features unconditionally.
return this.getFeatures(licenseKey); const set = new Set(base);
for (const f of ACADENICE_OSS_FEATURES) set.add(f);
return [...set];
} }
resolveTier(licenseKey: string, plan: string): string { resolveTier(licenseKey: string, plan: string): string {

View file

@ -1,5 +1,5 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"exclude": ["node_modules", "exclude": ["node_modules",
"test", "dist", "**/*spec.ts"] "test", "dist", "**/*spec.ts", "vitest.config.ts"]
} }