Compare commits
No commits in common. "91eee922821229d411d3799f47517c64d0a41c6a" and "d120619245232356caf22b32db6798ab09c34d03" have entirely different histories.
91eee92282
...
d120619245
31 changed files with 278 additions and 1740 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,7 +5,6 @@ data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
.pnpm-store
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,15 @@ 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";
|
||||||
|
|
@ -105,9 +113,15 @@ 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={<TemplatesAdminPage />} />
|
<Route path={"/templates"} element={<TemplateList />} />
|
||||||
|
<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 */}
|
||||||
|
|
@ -123,15 +137,19 @@ 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={<AcadeniceApiKeysPage />} />
|
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||||
<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={"audit"} element={<AcadeniceAuditLogPage />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
|
<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 />} />
|
||||||
|
|
|
||||||
|
|
@ -149,15 +149,31 @@ 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/audit",
|
path: "/settings/acadenice/audit-log",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
env: "selfhosted",
|
env: "selfhosted",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>(
|
||||||
`/v1/pages/${pageId}/backlinks`,
|
`/acadenice/pages/${pageId}/backlinks`,
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import api from '@/lib/api-client';
|
import axios from 'axios';
|
||||||
|
|
||||||
const BASE = '/v1/clipper';
|
const BASE = '/api/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 = {
|
||||||
async listTokens(): Promise<ClipperTokenInfo[]> {
|
listTokens(): Promise<ClipperTokenInfo[]> {
|
||||||
const r = await api.get<ClipperTokenInfo[]>(`${BASE}/tokens`);
|
return axios.get<ClipperTokenInfo[]>(`${BASE}/tokens`).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
|
createToken(payload: CreateTokenPayload): Promise<CreateTokenResponse> {
|
||||||
const r = await api.post<CreateTokenResponse>(`${BASE}/tokens`, payload);
|
return axios
|
||||||
return r.data;
|
.post<CreateTokenResponse>(`${BASE}/tokens`, payload)
|
||||||
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async revokeToken(tokenId: string): Promise<void> {
|
revokeToken(tokenId: string): Promise<void> {
|
||||||
await api.delete(`${BASE}/tokens/${tokenId}`);
|
return axios.delete(`${BASE}/tokens/${tokenId}`).then(() => undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
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:
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,6 +6,5 @@
|
||||||
*/
|
*/
|
||||||
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";
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,12 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Text, Button, Group, Skeleton, Stack, Alert } from "@mantine/core";
|
||||||
Text,
|
import { IconAlertCircle, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -179,58 +157,8 @@ 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);
|
||||||
|
|
||||||
|
|
@ -285,54 +213,9 @@ 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">
|
{field.name}
|
||||||
<Text size="sm" fw={500} truncate>
|
|
||||||
{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>
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +1,53 @@
|
||||||
/**
|
/**
|
||||||
* Thin HTTP wrapper for the bridge API.
|
* Thin HTTP wrapper for the bridge API (R3.1.a).
|
||||||
*
|
*
|
||||||
* Auth strategy: the bridge accepts the Docmost session cookie `authToken`
|
* Why a separate client and not the shared `api` axios instance:
|
||||||
* (HttpOnly, host-only) directly via `getCookie('authToken')` in its
|
* The bridge lives at a different origin (VITE_BRIDGE_URL) and uses the
|
||||||
* middleware. This works only when the bridge is served on the *same origin*
|
* DocAdenice JWT forwarded via the Authorization header, whereas `api` targets
|
||||||
* as Docmost — in dev via the Vite proxy `/bridge -> :4000`, in prod via a
|
* the Docmost backend at "/api" with cookie-based auth.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* `withCredentials: true` is enough — the browser sends the HttpOnly cookie
|
* The JWT is read from the cookie `authToken`. In production the cookie is
|
||||||
* automatically. We do NOT try to read it from `document.cookie` (it's
|
* HttpOnly so JS cannot read it — SSE auth uses the cookie automatically via
|
||||||
* HttpOnly by design).
|
* credentials. For REST calls we rely on the cookie being sent automatically
|
||||||
*
|
* by withCredentials when the bridge is same-site, OR on the server proxying
|
||||||
* `VITE_BRIDGE_TOKEN` is a dev-only fallback that injects a service token
|
* the calls through Docmost (future R3.2). For now we send credentials and
|
||||||
* (`brg_*`) when the cookie route is not in place yet.
|
* leave the Authorization header empty when the token is not readable — the
|
||||||
|
* 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({
|
||||||
|
|
@ -33,16 +56,23 @@ 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();
|
||||||
return config;
|
const token = cookieToken || envToken;
|
||||||
});
|
if (token) {
|
||||||
}
|
config.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(res) => res.data,
|
(res) => res.data,
|
||||||
|
|
|
||||||
|
|
@ -1,382 +0,0 @@
|
||||||
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:
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -114,12 +114,25 @@ function spaceColor(spaceId: string): string {
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const ForceGraph2DLazy: React.LazyExoticComponent<React.ComponentType<any>> | null =
|
let ForceGraph2DLazy: React.LazyExoticComponent<React.ComponentType<any>> | null = null;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
React.lazy(() => import("react-force-graph-2d") as Promise<{
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
ForceGraph2DLazy = React.lazy(
|
||||||
default: React.ComponentType<any>;
|
() =>
|
||||||
}>);
|
// 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
|
||||||
|
default: React.ComponentType<any>;
|
||||||
|
}>,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
ForceGraph2DLazy = null;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Fallback placeholder */
|
/* Fallback placeholder */
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,5 @@ 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";
|
||||||
|
|
||||||
const r = await api.get<GraphResponse>(url);
|
return api.get(url) as unknown as Promise<GraphResponse>;
|
||||||
return r.data;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,44 +9,57 @@ import {
|
||||||
IMyPermissionsResponse,
|
IMyPermissionsResponse,
|
||||||
} from "@/features/acadenice/rbac/types/rbac.types";
|
} from "@/features/acadenice/rbac/types/rbac.types";
|
||||||
|
|
||||||
// The global `TransformHttpResponseInterceptor` wraps every body in
|
/**
|
||||||
// `{ data, success, status }`. The axios interceptor already unwraps the
|
* REST client for the Acadenice RBAC API (R2.1 backend).
|
||||||
// transport layer, so each call returns that wrap object — we read `.data`
|
* Endpoints under /api/v1 — relative to api.baseURL ("/api").
|
||||||
// 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[]> {
|
||||||
const r = await api.get<IPermissionDescriptor[]>("/v1/permissions");
|
return api.get("/v1/permissions") as unknown as Promise<
|
||||||
return r.data;
|
IPermissionDescriptor[]
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
||||||
const r = await api.get<IMyPermissionsResponse>("/v1/permissions/me");
|
return api.get(
|
||||||
return r.data;
|
"/v1/permissions/me",
|
||||||
|
) as unknown as Promise<IMyPermissionsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRoles(): Promise<IRole[]> {
|
export async function listRoles(): Promise<IRole[]> {
|
||||||
const r = await api.get<IRole[]>("/v1/roles");
|
return api.get("/v1/roles") as unknown as Promise<IRole[]>;
|
||||||
return r.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRole(roleId: string): Promise<IRoleWithPermissions> {
|
export async function getRole(roleId: string): Promise<IRoleWithPermissions> {
|
||||||
const r = await api.get<IRoleWithPermissions>(`/v1/roles/${roleId}`);
|
return api.get(
|
||||||
return r.data;
|
`/v1/roles/${roleId}`,
|
||||||
|
) as unknown as Promise<IRoleWithPermissions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRole(
|
export async function createRole(
|
||||||
payload: ICreateRolePayload,
|
payload: ICreateRolePayload,
|
||||||
): Promise<IRoleWithPermissions> {
|
): Promise<IRoleWithPermissions> {
|
||||||
const r = await api.post<IRoleWithPermissions>("/v1/roles", payload);
|
return api.post(
|
||||||
return r.data;
|
"/v1/roles",
|
||||||
|
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> {
|
||||||
const r = await api.patch<IRole>(`/v1/roles/${roleId}`, payload);
|
return api.patch(
|
||||||
return r.data;
|
`/v1/roles/${roleId}`,
|
||||||
|
payload,
|
||||||
|
) as unknown as Promise<IRole>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(roleId: string): Promise<void> {
|
export async function deleteRole(roleId: string): Promise<void> {
|
||||||
|
|
@ -57,30 +70,26 @@ export async function setRolePermissions(
|
||||||
roleId: string,
|
roleId: string,
|
||||||
permissions: string[],
|
permissions: string[],
|
||||||
): Promise<IRoleWithPermissions> {
|
): Promise<IRoleWithPermissions> {
|
||||||
const r = await api.put<IRoleWithPermissions>(
|
return api.put(`/v1/roles/${roleId}/permissions`, {
|
||||||
`/v1/roles/${roleId}/permissions`,
|
permissions,
|
||||||
{ permissions },
|
}) as unknown as Promise<IRoleWithPermissions>;
|
||||||
);
|
|
||||||
return r.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listUserRoles(
|
export async function listUserRoles(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<IUserRoleAssignment[]> {
|
): Promise<IUserRoleAssignment[]> {
|
||||||
const r = await api.get<IUserRoleAssignment[]>(
|
return api.get(`/v1/users/${userId}/roles`) as unknown as Promise<
|
||||||
`/v1/users/${userId}/roles`,
|
IUserRoleAssignment[]
|
||||||
);
|
>;
|
||||||
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 }> {
|
||||||
const r = await api.post<{ ok: true }>(`/v1/users/${userId}/roles`, {
|
return api.post(`/v1/users/${userId}/roles`, {
|
||||||
roleIds,
|
roleIds,
|
||||||
});
|
}) as unknown as Promise<{ ok: true }>;
|
||||||
return r.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unassignRoleFromUser(
|
export async function unassignRoleFromUser(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import api from '@/lib/api-client';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
@ -32,35 +35,34 @@ export interface CreateSlashCommandPayload {
|
||||||
|
|
||||||
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
|
export type UpdateSlashCommandPayload = Partial<CreateSlashCommandPayload>;
|
||||||
|
|
||||||
const BASE = '/v1/slash-commands';
|
const BASE = '/api/v1/slash-commands';
|
||||||
|
|
||||||
export const slashCommandsClient = {
|
export const slashCommandsClient = {
|
||||||
async list(): Promise<SlashCommandDto[]> {
|
list(): Promise<SlashCommandDto[]> {
|
||||||
const r = await api.get<SlashCommandDto[]>(BASE);
|
return axios.get<SlashCommandDto[]>(BASE).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(id: string): Promise<SlashCommandDto> {
|
get(id: string): Promise<SlashCommandDto> {
|
||||||
const r = await api.get<SlashCommandDto>(`${BASE}/${id}`);
|
return axios.get<SlashCommandDto>(`${BASE}/${id}`).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> {
|
create(payload: CreateSlashCommandPayload): Promise<SlashCommandDto> {
|
||||||
const r = await api.post<SlashCommandDto>(BASE, payload);
|
return axios.post<SlashCommandDto>(BASE, payload).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
|
update(id: string, payload: UpdateSlashCommandPayload): Promise<SlashCommandDto> {
|
||||||
const r = await api.patch<SlashCommandDto>(`${BASE}/${id}`, payload);
|
return axios
|
||||||
return r.data;
|
.patch<SlashCommandDto>(`${BASE}/${id}`, payload)
|
||||||
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
delete(id: string): Promise<void> {
|
||||||
await api.delete(`${BASE}/${id}`);
|
return axios.delete(`${BASE}/${id}`).then(() => undefined);
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
|
toggle(id: string, isEnabled: boolean): Promise<SlashCommandDto> {
|
||||||
const r = await api.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled });
|
return axios
|
||||||
return r.data;
|
.patch<SlashCommandDto>(`${BASE}/${id}`, { isEnabled })
|
||||||
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import api from '@/lib/api-client';
|
import axios from 'axios';
|
||||||
|
|
||||||
export interface SyncBlockDto {
|
export interface SyncBlockDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -17,30 +17,26 @@ export interface SyncBlockUsageDto {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = '/v1/sync-blocks';
|
const BASE = '/api/v1/sync-blocks';
|
||||||
|
|
||||||
export const syncBlocksClient = {
|
export const syncBlocksClient = {
|
||||||
async create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
|
create(content: Record<string, unknown> = {}): Promise<SyncBlockDto> {
|
||||||
const r = await api.post<SyncBlockDto>(BASE, { content });
|
return axios.post<SyncBlockDto>(BASE, { content }).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(id: string): Promise<SyncBlockDto> {
|
get(id: string): Promise<SyncBlockDto> {
|
||||||
const r = await api.get<SyncBlockDto>(`${BASE}/${id}`);
|
return axios.get<SyncBlockDto>(`${BASE}/${id}`).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> {
|
update(id: string, content: Record<string, unknown>): Promise<SyncBlockDto> {
|
||||||
const r = await api.patch<SyncBlockDto>(`${BASE}/${id}`, { content });
|
return axios.patch<SyncBlockDto>(`${BASE}/${id}`, { content }).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
delete(id: string): Promise<void> {
|
||||||
await api.delete(`${BASE}/${id}`);
|
return axios.delete(`${BASE}/${id}`).then(() => undefined);
|
||||||
},
|
},
|
||||||
|
|
||||||
async usages(id: string): Promise<SyncBlockUsageDto[]> {
|
usages(id: string): Promise<SyncBlockUsageDto[]> {
|
||||||
const r = await api.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`);
|
return axios.get<SyncBlockUsageDto[]>(`${BASE}/${id}/usages`).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import api from '@/lib/api-client';
|
import axios from 'axios';
|
||||||
|
|
||||||
export interface TemplateDto {
|
export interface TemplateDto {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -36,46 +36,41 @@ export interface InstantiatePayload {
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = '/v1/templates';
|
const BASE = '/api/v1/templates';
|
||||||
|
|
||||||
export const templatesClient = {
|
export const templatesClient = {
|
||||||
async list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
|
list(opts: { category?: string; search?: string } = {}): Promise<TemplateDto[]> {
|
||||||
const r = await api.get<TemplateDto[]>(BASE, { params: opts });
|
return axios
|
||||||
return r.data;
|
.get<TemplateDto[]>(BASE, { params: opts })
|
||||||
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(id: string): Promise<TemplateDto> {
|
get(id: string): Promise<TemplateDto> {
|
||||||
const r = await api.get<TemplateDto>(`${BASE}/${id}`);
|
return axios.get<TemplateDto>(`${BASE}/${id}`).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(payload: CreateTemplatePayload): Promise<TemplateDto> {
|
create(payload: CreateTemplatePayload): Promise<TemplateDto> {
|
||||||
const r = await api.post<TemplateDto>(BASE, payload);
|
return axios.post<TemplateDto>(BASE, payload).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> {
|
update(id: string, payload: UpdateTemplatePayload): Promise<TemplateDto> {
|
||||||
const r = await api.patch<TemplateDto>(`${BASE}/${id}`, payload);
|
return axios.patch<TemplateDto>(`${BASE}/${id}`, payload).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
delete(id: string): Promise<void> {
|
||||||
await api.delete(`${BASE}/${id}`);
|
return axios.delete(`${BASE}/${id}`).then(() => undefined);
|
||||||
},
|
},
|
||||||
|
|
||||||
async instantiate(
|
instantiate(
|
||||||
id: string,
|
id: string,
|
||||||
payload: InstantiatePayload,
|
payload: InstantiatePayload,
|
||||||
): Promise<{ pageId: string; slugId: string }> {
|
): Promise<{ pageId: string; slugId: string }> {
|
||||||
const r = await api.post<{ pageId: string; slugId: string }>(
|
return axios
|
||||||
`${BASE}/${id}/instantiate`,
|
.post<{ pageId: string; slugId: string }>(`${BASE}/${id}/instantiate`, payload)
|
||||||
payload,
|
.then((r) => r.data);
|
||||||
);
|
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async setDefault(id: string): Promise<TemplateDto> {
|
setDefault(id: string): Promise<TemplateDto> {
|
||||||
const r = await api.patch<TemplateDto>(`${BASE}/${id}/default`);
|
return axios.patch<TemplateDto>(`${BASE}/${id}/default`).then((r) => r.data);
|
||||||
return r.data;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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, buildCreateDatabaseSlashItem } from "@/features/acadenice/database-view";
|
import { buildDatabaseSlashItem } 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,7 +65,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@ const PAGE_CONTENT_UPDATED_EVENT = 'acadenice.page.content.updated';
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface BacklinkAggRow {
|
interface BacklinkAggRow {
|
||||||
sourcePageId: string;
|
source_page_id: string;
|
||||||
targetPageId: string;
|
target_page_id: string;
|
||||||
linkType: LinkType;
|
link_type: 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;
|
||||||
spaceId: string;
|
space_id: string;
|
||||||
spaceName: string | null;
|
space_name: 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.sourcePageId) &&
|
reachablePageIds!.has(e.source_page_id) &&
|
||||||
reachablePageIds!.has(e.targetPageId),
|
reachablePageIds!.has(e.target_page_id),
|
||||||
);
|
);
|
||||||
} 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.sourcePageId);
|
connectedPageIds.add(e.source_page_id);
|
||||||
connectedPageIds.add(e.targetPageId);
|
connectedPageIds.add(e.target_page_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.sourcePageId,
|
e.source_page_id,
|
||||||
(outDegreeMap.get(e.sourcePageId) ?? 0) + 1,
|
(outDegreeMap.get(e.source_page_id) ?? 0) + 1,
|
||||||
);
|
);
|
||||||
inDegreeMap.set(
|
inDegreeMap.set(
|
||||||
e.targetPageId,
|
e.target_page_id,
|
||||||
(inDegreeMap.get(e.targetPageId) ?? 0) + 1,
|
(inDegreeMap.get(e.target_page_id) ?? 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.sourcePageId) &&
|
finalPageIds.has(e.source_page_id) &&
|
||||||
finalPageIds.has(e.targetPageId),
|
finalPageIds.has(e.target_page_id),
|
||||||
);
|
);
|
||||||
inDegreeMap.clear();
|
inDegreeMap.clear();
|
||||||
outDegreeMap.clear();
|
outDegreeMap.clear();
|
||||||
for (const e of filteredEdges) {
|
for (const e of filteredEdges) {
|
||||||
outDegreeMap.set(
|
outDegreeMap.set(
|
||||||
e.sourcePageId,
|
e.source_page_id,
|
||||||
(outDegreeMap.get(e.sourcePageId) ?? 0) + 1,
|
(outDegreeMap.get(e.source_page_id) ?? 0) + 1,
|
||||||
);
|
);
|
||||||
inDegreeMap.set(
|
inDegreeMap.set(
|
||||||
e.targetPageId,
|
e.target_page_id,
|
||||||
(inDegreeMap.get(e.targetPageId) ?? 0) + 1,
|
(inDegreeMap.get(e.target_page_id) ?? 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.spaceId,
|
spaceId: meta.space_id,
|
||||||
spaceName: meta.spaceName,
|
spaceName: meta.space_name,
|
||||||
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.sourcePageId}:${e.targetPageId}:${e.linkType}`,
|
id: `${e.source_page_id}:${e.target_page_id}:${e.link_type}`,
|
||||||
source: e.sourcePageId,
|
source: e.source_page_id,
|
||||||
target: e.targetPageId,
|
target: e.target_page_id,
|
||||||
type: e.linkType,
|
type: e.link_type,
|
||||||
weight: e.weight,
|
weight: e.weight,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -369,14 +369,12 @@ 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 AS "sourcePageId",
|
bl.source_page_id,
|
||||||
bl.target_page_id AS "targetPageId",
|
bl.target_page_id,
|
||||||
bl.link_type AS "linkType",
|
bl.link_type,
|
||||||
COUNT(*)::int AS weight
|
COUNT(*)::int AS weight
|
||||||
FROM acadenice_backlink bl
|
FROM acadenice_backlink bl
|
||||||
-- Source page permission check
|
-- Source page permission check
|
||||||
JOIN pages src_p ON src_p.id = bl.source_page_id
|
JOIN pages src_p ON src_p.id = bl.source_page_id
|
||||||
|
|
@ -444,8 +442,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 AS "spaceId",
|
p.space_id,
|
||||||
sp.name AS "spaceName",
|
sp.name AS space_name,
|
||||||
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
|
||||||
|
|
@ -489,8 +487,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 AS "spaceId",
|
p.space_id,
|
||||||
sp.name AS "spaceName",
|
sp.name AS space_name,
|
||||||
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
|
||||||
|
|
@ -539,9 +537,9 @@ export class GraphService {
|
||||||
|
|
||||||
const rows = await sql<BacklinkAggRow>`
|
const rows = await sql<BacklinkAggRow>`
|
||||||
SELECT
|
SELECT
|
||||||
child.parent_page_id AS "sourcePageId",
|
child.parent_page_id AS source_page_id,
|
||||||
child.id AS "targetPageId",
|
child.id AS target_page_id,
|
||||||
'parent_child'::text AS "linkType",
|
'parent_child'::text AS link_type,
|
||||||
1::int AS weight
|
1::int AS weight
|
||||||
FROM pages child
|
FROM pages child
|
||||||
-- Child page space
|
-- Child page space
|
||||||
|
|
@ -601,8 +599,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.sourcePageId, e.targetPageId);
|
addEdge(e.source_page_id, e.target_page_id);
|
||||||
addEdge(e.targetPageId, e.sourcePageId);
|
addEdge(e.target_page_id, e.source_page_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visited = new Set<string>([rootId]);
|
const visited = new Set<string>([rootId]);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ function row(
|
||||||
type: 'wikilink' | 'mention' | 'database_embed' = 'wikilink',
|
type: 'wikilink' | 'mention' | 'database_embed' = 'wikilink',
|
||||||
weight = 1,
|
weight = 1,
|
||||||
) {
|
) {
|
||||||
return { sourcePageId: source, targetPageId: target, linkType: type, weight };
|
return { source_page_id: source, target_page_id: target, link_type: 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, slug: null, spaceId, spaceName, icon: null };
|
return { id, title, space_id: spaceId, space_name: 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([
|
||||||
{ sourcePageId: 'parent-1', targetPageId: 'child-1', linkType: 'parent_child', weight: 1 },
|
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
|
||||||
{ sourcePageId: 'parent-1', targetPageId: 'child-2', linkType: 'parent_child', weight: 1 },
|
{ source_page_id: 'parent-1', target_page_id: 'child-2', link_type: '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([
|
||||||
{ sourcePageId: 'parent-1', targetPageId: 'child-1', linkType: 'parent_child', weight: 1 },
|
{ source_page_id: 'parent-1', target_page_id: 'child-1', link_type: '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([
|
||||||
{ sourcePageId: 'p2', targetPageId: 'p3', linkType: 'parent_child', weight: 1 },
|
{ source_page_id: 'p2', target_page_id: 'p3', link_type: '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', spaceId: 'sp-1', spaceName: 'S', icon: null },
|
{ id: 'p1', title: 'Page 1', slug: 'page-1-slug', space_id: 'sp-1', space_name: 'S', icon: null },
|
||||||
{ id: 'p2', title: 'Page 2', slug: null, spaceId: 'sp-1', spaceName: 'S', icon: null },
|
{ id: 'p2', title: 'Page 2', slug: null, space_id: 'sp-1', space_name: 'S', icon: null },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,12 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,44 +92,18 @@ const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Member',
|
name: 'Member',
|
||||||
description:
|
description: 'Read content and upload attachments.',
|
||||||
'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',
|
||||||
'attachments:delete',
|
// R3.6 — Members can browse templates
|
||||||
// Templates — read + create + manage own
|
|
||||||
'templates:read',
|
'templates:read',
|
||||||
'templates:create',
|
// R4.2 — Members can create and edit sync blocks
|
||||||
'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',
|
||||||
'sync_blocks:delete',
|
// R4.3 — Members can use the Web Clipper
|
||||||
// Web Clipper
|
|
||||||
'clipper:use',
|
'clipper:use',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -158,7 +125,6 @@ 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> {
|
||||||
|
|
@ -212,44 +178,11 @@ 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 || spec.forceResync) {
|
if (existingPerms.length === 0) {
|
||||||
// Install the canonical permission set. forceResync=true ALWAYS
|
// First time we see this role : install the canonical permission set.
|
||||||
// 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]);
|
||||||
}
|
}
|
||||||
// For non-forceResync roles with existing perms, we preserve them.
|
// If existingPerms.length > 0 we keep the admin's customisations intact.
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
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()`
|
||||||
|
|
@ -10,12 +9,10 @@ import { AcadeniceUserRoleRepo } from '../repos/user-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>>;
|
||||||
let userRoleRepo: jest.Mocked<Partial<AcadeniceUserRoleRepo>>;
|
// Stub the kysely DB — only `sql\`...\`.execute(this.db)` calls the DB and
|
||||||
// Stub the kysely DB. seedWorkspace itself runs a `sql\`...\`.execute(db)`
|
// seedWorkspace doesn't use it directly (only seedAllWorkspaces does). We
|
||||||
// for the default-role assignment loop, so the stub responds with no users.
|
// pass an arbitrary placeholder.
|
||||||
const fakeDb: any = {
|
const fakeDb: any = {};
|
||||||
execute: jest.fn().mockResolvedValue({ rows: [] }),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
roleRepo = {
|
roleRepo = {
|
||||||
|
|
@ -24,14 +21,9 @@ 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,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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 {}
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,17 +6,10 @@ 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, PagePermissionController],
|
controllers: [PageController],
|
||||||
providers: [
|
providers: [PageService, PageHistoryService, TrashCleanupService],
|
||||||
PageService,
|
|
||||||
PageHistoryService,
|
|
||||||
TrashCleanupService,
|
|
||||||
PagePermissionService,
|
|
||||||
],
|
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
imports: [StorageModule, CollaborationModule, WatcherModule],
|
imports: [StorageModule, CollaborationModule, WatcherModule],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,6 @@
|
||||||
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 {
|
||||||
|
|
@ -42,9 +27,6 @@ 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
|
||||||
|
|
@ -81,22 +63,17 @@ 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');
|
||||||
base = [...getFeaturesForCloudPlan(plan)];
|
return [...getFeaturesForCloudPlan(plan)];
|
||||||
} catch {
|
} catch {
|
||||||
base = [];
|
return [];
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
base = this.getFeatures(licenseKey);
|
|
||||||
}
|
}
|
||||||
// Inject Acadenice OSS-ified features unconditionally.
|
|
||||||
const set = new Set(base);
|
return this.getFeatures(licenseKey);
|
||||||
for (const f of ACADENICE_OSS_FEATURES) set.add(f);
|
|
||||||
return [...set];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveTier(licenseKey: string, plan: string): string {
|
resolveTier(licenseKey: string, plan: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules",
|
"exclude": ["node_modules",
|
||||||
"test", "dist", "**/*spec.ts", "vitest.config.ts"]
|
"test", "dist", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue