Add docs CMS: blog authoring, access policies, sharing, version history, templates, metadata, search, Gitea auto-setup
7 documentation system features: - Blog authoring: frontmatter panel, new post wizard, authors management - Access policies: per-file/directory edit restrictions with role/user granularity - Public sharing: share links with collaborative editing via dual JWT auth - Version history: Gitea auto-commit on save, diff viewer, restore - Document templates: 8 built-in templates (blog, guide, API ref, ADR, FAQ, etc.) - Metadata dashboard: overview of all docs with warnings (no-tags, stale, etc.) - Content search: in-file text search with line-level matches Gitea auto-setup: one-click configuration of API token, repos, labels, OAuth app - Backend service + startup hook (auto-configures if GITEA_ADMIN_PASSWORD set) - Admin GUI wizard at /app/services/gitea/setup - config.sh now prompts for Gitea admin password Backend: 10 new files, 5 modified (3 models, 1 enum, 2 migrations, 30+ API endpoints) Frontend: 13 new files, 3 modified (hooks, components, pages) Bunker Admin
This commit is contained in:
parent
0fc9ea80bf
commit
8b9ab93856
@ -32,6 +32,7 @@ import CodeEditorPage from '@/pages/CodeEditorPage';
|
|||||||
import NocoDBPage from '@/pages/NocoDBPage';
|
import NocoDBPage from '@/pages/NocoDBPage';
|
||||||
import N8nPage from '@/pages/N8nPage';
|
import N8nPage from '@/pages/N8nPage';
|
||||||
import GiteaPage from '@/pages/GiteaPage';
|
import GiteaPage from '@/pages/GiteaPage';
|
||||||
|
import GiteaSetupPage from '@/pages/GiteaSetupPage';
|
||||||
import MailHogPage from '@/pages/MailHogPage';
|
import MailHogPage from '@/pages/MailHogPage';
|
||||||
import MiniQRPage from '@/pages/MiniQRPage';
|
import MiniQRPage from '@/pages/MiniQRPage';
|
||||||
import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
||||||
@ -45,6 +46,7 @@ import PangolinPage from '@/pages/PangolinPage';
|
|||||||
import ObservabilityPage from '@/pages/ObservabilityPage';
|
import ObservabilityPage from '@/pages/ObservabilityPage';
|
||||||
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
||||||
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
||||||
|
import DocsMetadataPage from '@/pages/DocsMetadataPage';
|
||||||
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
||||||
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
||||||
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
||||||
@ -153,6 +155,7 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
|
|||||||
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
|
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
|
||||||
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
|
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
|
||||||
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
|
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
|
||||||
|
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
|
||||||
import NotFoundPage from '@/pages/NotFoundPage';
|
import NotFoundPage from '@/pages/NotFoundPage';
|
||||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
|
|
||||||
@ -313,6 +316,9 @@ export default function App() {
|
|||||||
<Route index element={<ContactProfilePage />} />
|
<Route index element={<ContactProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Shared doc editor (no auth, token-based access) */}
|
||||||
|
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
|
||||||
|
|
||||||
{/* Public Media Gallery (purple theme) — feature-gated */}
|
{/* Public Media Gallery (purple theme) — feature-gated */}
|
||||||
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<MediaGalleryPage />} />
|
<Route index element={<MediaGalleryPage />} />
|
||||||
@ -604,6 +610,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="docs/metadata"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
|
<DocsMetadataPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="navigation"
|
path="navigation"
|
||||||
element={
|
element={
|
||||||
@ -644,6 +658,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="services/gitea/setup"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
|
<GiteaSetupPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="services/mailhog"
|
path="services/mailhog"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -231,6 +231,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
|||||||
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
|
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
|
||||||
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
|
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
|
||||||
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
|
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
|
||||||
|
webChildren.push({ key: '/app/docs/metadata', icon: <DatabaseOutlined />, label: 'Metadata' });
|
||||||
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
||||||
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
||||||
items.push({
|
items.push({
|
||||||
@ -338,6 +339,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
|||||||
{ type: 'group', label: 'Tools', children: [
|
{ type: 'group', label: 'Tools', children: [
|
||||||
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
||||||
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
||||||
|
{ key: '/app/services/gitea/setup', icon: <SettingOutlined />, label: 'Gitea Setup' },
|
||||||
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
|
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
|
||||||
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
|
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
|
||||||
]},
|
]},
|
||||||
|
|||||||
391
admin/src/components/docs/AuthorsManagementModal.tsx
Normal file
391
admin/src/components/docs/AuthorsManagementModal.tsx
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
Divider,
|
||||||
|
Empty,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
|
||||||
|
|
||||||
|
type AuthorsMap = Record<string, AuthorEntry>;
|
||||||
|
|
||||||
|
interface AuthorsManagementModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
authors: AuthorsMap;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorFormState {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: AuthorFormState = { id: '', name: '', description: '', avatar: '' };
|
||||||
|
|
||||||
|
export function AuthorsManagementModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
authors,
|
||||||
|
onSaved,
|
||||||
|
}: AuthorsManagementModalProps) {
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [localAuthors, setLocalAuthors] = useState<AuthorsMap>({});
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<AuthorFormState>(emptyForm);
|
||||||
|
const [addingNew, setAddingNew] = useState(false);
|
||||||
|
const [newForm, setNewForm] = useState<AuthorFormState>(emptyForm);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
// Sync from props when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setLocalAuthors({ ...authors });
|
||||||
|
setEditingKey(null);
|
||||||
|
setAddingNew(false);
|
||||||
|
setNewForm(emptyForm);
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
}, [open, authors]);
|
||||||
|
|
||||||
|
const startEdit = (key: string) => {
|
||||||
|
const entry = localAuthors[key];
|
||||||
|
if (!entry) return;
|
||||||
|
setEditingKey(key);
|
||||||
|
setEditForm({
|
||||||
|
id: key,
|
||||||
|
name: entry.name,
|
||||||
|
description: entry.description ?? '',
|
||||||
|
avatar: entry.avatar ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingKey(null);
|
||||||
|
setEditForm(emptyForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
messageApi.warning('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = { ...localAuthors };
|
||||||
|
// If key changed, remove old and add new
|
||||||
|
if (editingKey && editingKey !== editForm.id && editForm.id.trim()) {
|
||||||
|
delete updated[editingKey];
|
||||||
|
}
|
||||||
|
const key = editForm.id.trim() || editingKey!;
|
||||||
|
updated[key] = buildEntry(editForm);
|
||||||
|
setLocalAuthors(updated);
|
||||||
|
setEditingKey(null);
|
||||||
|
setEditForm(emptyForm);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAuthor = (key: string) => {
|
||||||
|
const updated = { ...localAuthors };
|
||||||
|
delete updated[key];
|
||||||
|
setLocalAuthors(updated);
|
||||||
|
setDirty(true);
|
||||||
|
if (editingKey === key) cancelEdit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNewAuthor = () => {
|
||||||
|
if (!newForm.id.trim()) {
|
||||||
|
messageApi.warning('Author ID is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newForm.name.trim()) {
|
||||||
|
messageApi.warning('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localAuthors[newForm.id.trim()]) {
|
||||||
|
messageApi.warning('An author with this ID already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = { ...localAuthors };
|
||||||
|
updated[newForm.id.trim()] = buildEntry(newForm);
|
||||||
|
setLocalAuthors(updated);
|
||||||
|
setNewForm(emptyForm);
|
||||||
|
setAddingNew(false);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.put('/docs/blog/authors', { authors: localAuthors });
|
||||||
|
messageApi.success('Authors saved');
|
||||||
|
setDirty(false);
|
||||||
|
onSaved();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
|
||||||
|
?.error?.message || 'Failed to save authors';
|
||||||
|
messageApi.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorEntries = Object.entries(localAuthors);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<UserOutlined style={{ marginRight: 8 }} />
|
||||||
|
Manage Authors
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!dirty}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
destroyOnHidden
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 400, overflow: 'auto' }}>
|
||||||
|
{authorEntries.length === 0 && !addingNew && (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="No authors defined yet"
|
||||||
|
style={{ margin: '24px 0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authorEntries.map(([key, entry]) => (
|
||||||
|
<div key={key}>
|
||||||
|
{editingKey === key ? (
|
||||||
|
<AuthorEditForm
|
||||||
|
form={editForm}
|
||||||
|
onChange={setEditForm}
|
||||||
|
onSave={saveEdit}
|
||||||
|
onCancel={cancelEdit}
|
||||||
|
|
||||||
|
showId
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AuthorRow
|
||||||
|
id={key}
|
||||||
|
entry={entry}
|
||||||
|
|
||||||
|
onEdit={() => startEdit(key)}
|
||||||
|
onDelete={() => deleteAuthor(key)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* New author form */}
|
||||||
|
{addingNew ? (
|
||||||
|
<AuthorEditForm
|
||||||
|
form={newForm}
|
||||||
|
onChange={setNewForm}
|
||||||
|
onSave={saveNewAuthor}
|
||||||
|
onCancel={() => {
|
||||||
|
setAddingNew(false);
|
||||||
|
setNewForm(emptyForm);
|
||||||
|
}}
|
||||||
|
showId
|
||||||
|
isNew
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setAddingNew(true)}
|
||||||
|
block
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
Add Author
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntry(form: AuthorFormState): AuthorEntry {
|
||||||
|
const entry: AuthorEntry = { name: form.name.trim() };
|
||||||
|
if (form.description.trim()) entry.description = form.description.trim();
|
||||||
|
if (form.avatar.trim()) entry.avatar = form.avatar.trim();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only author row */
|
||||||
|
function AuthorRow({
|
||||||
|
id,
|
||||||
|
entry,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
entry: AuthorEntry;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
padding: '8px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Typography.Text strong>{entry.name}</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 11, fontFamily: 'monospace' }}
|
||||||
|
>
|
||||||
|
{id}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
{entry.description && (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, display: 'block', marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{entry.description}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
{entry.avatar && (
|
||||||
|
<Typography.Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
display: 'block',
|
||||||
|
marginTop: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Avatar: {entry.avatar}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Space size={4}>
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} />
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this author?"
|
||||||
|
onConfirm={onDelete}
|
||||||
|
okText="Delete"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline edit / create form for an author */
|
||||||
|
function AuthorEditForm({
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
showId,
|
||||||
|
isNew,
|
||||||
|
}: {
|
||||||
|
form: AuthorFormState;
|
||||||
|
onChange: (f: AuthorFormState) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
showId?: boolean;
|
||||||
|
isNew?: boolean;
|
||||||
|
}) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showId && (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Author ID (e.g. jdoe)"
|
||||||
|
value={form.id}
|
||||||
|
onChange={(e) => onChange({ ...form, id: e.target.value })}
|
||||||
|
addonBefore="ID"
|
||||||
|
disabled={!isNew && !!form.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => onChange({ ...form, name: e.target.value })}
|
||||||
|
addonBefore="Name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Short description (optional)"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => onChange({ ...form, description: e.target.value })}
|
||||||
|
addonBefore="Desc"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="Avatar URL (optional)"
|
||||||
|
value={form.avatar}
|
||||||
|
onChange={(e) => onChange({ ...form, avatar: e.target.value })}
|
||||||
|
addonBefore="Avatar"
|
||||||
|
/>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button size="small" type="primary" icon={<SaveOutlined />} onClick={onSave}>
|
||||||
|
{isNew ? 'Add' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" icon={<CloseOutlined />} onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
admin/src/components/docs/BlogFrontmatterPanel.tsx
Normal file
265
admin/src/components/docs/BlogFrontmatterPanel.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { DatePicker, Select, Switch, Input, Typography, theme, Button, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
CalendarOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
EyeInvisibleOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { BlogFrontmatter } from '@/hooks/useBlogFrontmatter';
|
||||||
|
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
|
||||||
|
|
||||||
|
interface BlogFrontmatterPanelProps {
|
||||||
|
frontmatter: BlogFrontmatter | null;
|
||||||
|
onUpdate: (field: string, value: unknown) => void;
|
||||||
|
authors: Record<string, AuthorEntry>;
|
||||||
|
categories: string[];
|
||||||
|
loading: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
onCollapsedChange: (collapsed: boolean) => void;
|
||||||
|
onManageAuthors: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_WIDTH = 250;
|
||||||
|
|
||||||
|
export function BlogFrontmatterPanel({
|
||||||
|
frontmatter,
|
||||||
|
onUpdate,
|
||||||
|
authors,
|
||||||
|
categories,
|
||||||
|
loading,
|
||||||
|
collapsed,
|
||||||
|
onCollapsedChange,
|
||||||
|
onManageAuthors,
|
||||||
|
}: BlogFrontmatterPanelProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
if (!frontmatter) return null;
|
||||||
|
|
||||||
|
const authorOptions = Object.entries(authors).map(([key, entry]) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categoryOptions = categories.map((cat) => ({
|
||||||
|
label: cat,
|
||||||
|
value: cat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 8,
|
||||||
|
borderLeft: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="Show blog panel" placement="left">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
onClick={() => onCollapsedChange(false)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: PANEL_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderLeft: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||||
|
Blog
|
||||||
|
</Typography.Text>
|
||||||
|
<Tooltip title="Collapse panel">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={() => onCollapsedChange(true)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Date */}
|
||||||
|
<FieldGroup icon={<CalendarOutlined />} label="Date">
|
||||||
|
<DatePicker
|
||||||
|
value={frontmatter.date ? dayjs(frontmatter.date) : undefined}
|
||||||
|
onChange={(d) => onUpdate('date', d ? d.format('YYYY-MM-DD') : undefined)}
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Authors */}
|
||||||
|
<FieldGroup
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
label="Authors"
|
||||||
|
extra={
|
||||||
|
<Tooltip title="Manage authors">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
onClick={onManageAuthors}
|
||||||
|
style={{ padding: 0, height: 'auto', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
size="small"
|
||||||
|
placeholder="Select authors"
|
||||||
|
value={frontmatter.authors ?? []}
|
||||||
|
onChange={(val) => onUpdate('authors', val)}
|
||||||
|
options={authorOptions}
|
||||||
|
loading={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<FieldGroup icon={<FolderOutlined />} label="Categories">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
size="small"
|
||||||
|
placeholder="Add categories"
|
||||||
|
value={frontmatter.categories ?? []}
|
||||||
|
onChange={(val) => onUpdate('categories', val)}
|
||||||
|
options={categoryOptions}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<FieldGroup icon={<TagsOutlined />} label="Tags">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
size="small"
|
||||||
|
placeholder="Add tags"
|
||||||
|
value={frontmatter.tags ?? []}
|
||||||
|
onChange={(val) => onUpdate('tags', val)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Draft */}
|
||||||
|
<FieldGroup icon={<EyeInvisibleOutlined />} label="Draft">
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={frontmatter.draft ?? false}
|
||||||
|
onChange={(checked) => onUpdate('draft', checked || undefined)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<FieldGroup icon={<LinkOutlined />} label="Slug">
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
placeholder="auto-generated"
|
||||||
|
value={frontmatter.slug ?? ''}
|
||||||
|
onChange={(e) => onUpdate('slug', e.target.value || undefined)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<FieldGroup icon={<FileTextOutlined />} label="Description">
|
||||||
|
<Input.TextArea
|
||||||
|
size="small"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Post description / excerpt"
|
||||||
|
value={frontmatter.description ?? ''}
|
||||||
|
onChange={(e) => onUpdate('description', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small labeled field wrapper */
|
||||||
|
function FieldGroup({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
extra,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>{icon}</span>
|
||||||
|
<Typography.Text
|
||||||
|
style={{ fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography.Text>
|
||||||
|
{extra && <span style={{ marginLeft: 'auto' }}>{extra}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
admin/src/components/docs/DocAccessPolicyPanel.tsx
Normal file
322
admin/src/components/docs/DocAccessPolicyPanel.tsx
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
message,
|
||||||
|
Alert,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
LockOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface DocAccessPolicyPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectivePolicy {
|
||||||
|
id: string | null;
|
||||||
|
documentPath: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
allowedEditors: string[];
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_ROLES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'role:SUPER_ADMIN', label: 'Super Admin' },
|
||||||
|
{ value: 'role:CONTENT_ADMIN', label: 'Content Admin' },
|
||||||
|
{ value: 'role:INFLUENCE_ADMIN', label: 'Influence Admin' },
|
||||||
|
{ value: 'role:MAP_ADMIN', label: 'Map Admin' },
|
||||||
|
{ value: 'role:BROADCAST_ADMIN', label: 'Broadcast Admin' },
|
||||||
|
{ value: 'role:MEDIA_ADMIN', label: 'Media Admin' },
|
||||||
|
{ value: 'role:PAYMENTS_ADMIN', label: 'Payments Admin' },
|
||||||
|
{ value: 'role:EVENTS_ADMIN', label: 'Events Admin' },
|
||||||
|
{ value: 'role:SOCIAL_ADMIN', label: 'Social Admin' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function editorLabel(editor: string): string {
|
||||||
|
if (editor === 'all_content_editors') return 'All Content Editors';
|
||||||
|
if (editor.startsWith('role:')) {
|
||||||
|
const roleName = editor.substring(5);
|
||||||
|
return roleName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
if (editor.startsWith('user:')) return editor.substring(5);
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorColor(editor: string): string {
|
||||||
|
if (editor === 'all_content_editors') return 'green';
|
||||||
|
if (editor.startsWith('role:')) return 'blue';
|
||||||
|
if (editor.startsWith('user:')) return 'purple';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocAccessPolicyPanel({ open, onClose, documentPath }: DocAccessPolicyPanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [policy, setPolicy] = useState<EffectivePolicy | null>(null);
|
||||||
|
const [allContentEditors, setAllContentEditors] = useState(true);
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||||
|
const [userEmails, setUserEmails] = useState<string[]>([]);
|
||||||
|
const [isDirectory, setIsDirectory] = useState(false);
|
||||||
|
|
||||||
|
const fetchPolicy = useCallback(async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<EffectivePolicy>('/docs-access/policy', {
|
||||||
|
params: { path: documentPath },
|
||||||
|
});
|
||||||
|
setPolicy(data);
|
||||||
|
|
||||||
|
// Parse allowedEditors into form state
|
||||||
|
const hasAllContentEditors = data.allowedEditors.includes('all_content_editors');
|
||||||
|
setAllContentEditors(hasAllContentEditors);
|
||||||
|
setIsDirectory(data.isDirectory);
|
||||||
|
|
||||||
|
const roles: string[] = [];
|
||||||
|
const users: string[] = [];
|
||||||
|
for (const editor of data.allowedEditors) {
|
||||||
|
if (editor === 'all_content_editors') continue;
|
||||||
|
if (editor.startsWith('role:')) roles.push(editor);
|
||||||
|
else if (editor.startsWith('user:')) users.push(editor.substring(5));
|
||||||
|
}
|
||||||
|
setSelectedRoles(roles);
|
||||||
|
setUserEmails(users);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load access policy');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && documentPath) {
|
||||||
|
fetchPolicy();
|
||||||
|
}
|
||||||
|
}, [open, documentPath, fetchPolicy]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const allowedEditors: string[] = [];
|
||||||
|
if (allContentEditors) {
|
||||||
|
allowedEditors.push('all_content_editors');
|
||||||
|
}
|
||||||
|
allowedEditors.push(...selectedRoles);
|
||||||
|
allowedEditors.push(...userEmails.map((e) => `user:${e}`));
|
||||||
|
|
||||||
|
if (allowedEditors.length === 0) {
|
||||||
|
message.warning('At least one editor must be specified');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put('/docs-access/policy', {
|
||||||
|
documentPath,
|
||||||
|
isDirectory,
|
||||||
|
allowedEditors,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('Access policy saved');
|
||||||
|
fetchPolicy();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to save access policy');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.delete('/docs-access/policy', {
|
||||||
|
params: { path: documentPath },
|
||||||
|
});
|
||||||
|
message.success('Policy reset to default');
|
||||||
|
fetchPolicy();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to reset policy');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = documentPath?.split('/').pop() || 'Document';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<LockOutlined />
|
||||||
|
<span>Access Policy</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={480}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : !documentPath ? (
|
||||||
|
<Alert message="No document selected" type="info" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">Document</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{fileName}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{documentPath}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{policy?.isDefault && !policy.id && (
|
||||||
|
<Alert
|
||||||
|
message="Default policy"
|
||||||
|
description="No custom policy is set. All content editors can edit this file."
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{policy && !policy.isDefault && (
|
||||||
|
<Alert
|
||||||
|
message="Custom policy active"
|
||||||
|
description={
|
||||||
|
policy.isDirectory
|
||||||
|
? `Directory policy applied from: ${policy.documentPath}`
|
||||||
|
: 'File-specific policy'
|
||||||
|
}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Current Editors
|
||||||
|
</Title>
|
||||||
|
<div>
|
||||||
|
{policy?.allowedEditors.map((editor) => (
|
||||||
|
<Tag
|
||||||
|
key={editor}
|
||||||
|
color={editorColor(editor)}
|
||||||
|
icon={
|
||||||
|
editor === 'all_content_editors' ? (
|
||||||
|
<TeamOutlined />
|
||||||
|
) : editor.startsWith('user:') ? (
|
||||||
|
<UserOutlined />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 4 }}
|
||||||
|
>
|
||||||
|
{editorLabel(editor)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
Edit Policy
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Form layout="vertical" style={{ width: '100%' }}>
|
||||||
|
<Form.Item label="All content editors (default)">
|
||||||
|
<Switch
|
||||||
|
checked={allContentEditors}
|
||||||
|
onChange={setAllContentEditors}
|
||||||
|
checkedChildren="On"
|
||||||
|
unCheckedChildren="Off"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Additional roles">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
value={selectedRoles}
|
||||||
|
onChange={setSelectedRoles}
|
||||||
|
options={AVAILABLE_ROLES}
|
||||||
|
placeholder="Select roles..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Specific users (by email)">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
value={userEmails}
|
||||||
|
onChange={setUserEmails}
|
||||||
|
placeholder="Type email and press Enter..."
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Apply to directory">
|
||||||
|
<Switch
|
||||||
|
checked={isDirectory}
|
||||||
|
onChange={setIsDirectory}
|
||||||
|
checkedChildren="Directory"
|
||||||
|
unCheckedChildren="File only"
|
||||||
|
/>
|
||||||
|
{isDirectory && (
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
|
||||||
|
This policy will apply to all files within this directory and subdirectories.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
Save Policy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<UndoOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
loading={saving}
|
||||||
|
disabled={policy?.isDefault ?? true}
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
admin/src/components/docs/DocHistoryDrawer.tsx
Normal file
377
admin/src/components/docs/DocHistoryDrawer.tsx
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Timeline,
|
||||||
|
Button,
|
||||||
|
Spin,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
Empty,
|
||||||
|
Tag,
|
||||||
|
Divider,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
HistoryOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
RollbackOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface DocHistoryDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentPath: string | null;
|
||||||
|
currentContent: string;
|
||||||
|
onRestore: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryCommit {
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
message: string;
|
||||||
|
author: { name: string; email: string; date: string };
|
||||||
|
committer: { name: string; email: string; date: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocHistoryDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
documentPath,
|
||||||
|
currentContent,
|
||||||
|
onRestore,
|
||||||
|
}: DocHistoryDrawerProps) {
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [commits, setCommits] = useState<HistoryCommit[]>([]);
|
||||||
|
const [selectedSha, setSelectedSha] = useState<string | null>(null);
|
||||||
|
const [revisionContent, setRevisionContent] = useState<string | null>(null);
|
||||||
|
const [loadingRevision, setLoadingRevision] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ commits: HistoryCommit[] }>(
|
||||||
|
`/docs/history/${documentPath}`,
|
||||||
|
);
|
||||||
|
setCommits(data.commits);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load version history');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && documentPath) {
|
||||||
|
fetchHistory();
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
}
|
||||||
|
}, [open, documentPath, fetchHistory]);
|
||||||
|
|
||||||
|
const handleSelectCommit = async (sha: string) => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
if (selectedSha === sha) {
|
||||||
|
// Toggle off
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedSha(sha);
|
||||||
|
setLoadingRevision(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ sha: string; path: string; content: string }>(
|
||||||
|
`/docs/revision/${sha}/${documentPath}`,
|
||||||
|
);
|
||||||
|
setRevisionContent(data.content);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load revision');
|
||||||
|
setRevisionContent(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingRevision(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
if (!documentPath || !selectedSha) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post<{ success: boolean; path: string }>(
|
||||||
|
`/docs/restore/${selectedSha}/${documentPath}`,
|
||||||
|
);
|
||||||
|
if (data.success && revisionContent) {
|
||||||
|
onRestore(revisionContent);
|
||||||
|
message.success('File restored to selected version');
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
fetchHistory();
|
||||||
|
} else {
|
||||||
|
message.error('Failed to restore revision');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to restore revision');
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = documentPath?.split('/').pop() || 'Document';
|
||||||
|
|
||||||
|
// Simple line-by-line diff display
|
||||||
|
const renderDiffView = () => {
|
||||||
|
if (loadingRevision) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (revisionContent === null) return null;
|
||||||
|
|
||||||
|
const currentLines = currentContent.split('\n');
|
||||||
|
const historicalLines = revisionContent.split('\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Space style={{ marginBottom: 8 }}>
|
||||||
|
<Tag color="blue">Viewing revision {selectedSha?.substring(0, 7)}</Tag>
|
||||||
|
<Popconfirm
|
||||||
|
title="Restore this version?"
|
||||||
|
description="The current file content will be replaced with this revision."
|
||||||
|
onConfirm={handleRestore}
|
||||||
|
okText="Restore"
|
||||||
|
cancelText="Cancel"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<RollbackOutlined />}
|
||||||
|
loading={restoring}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSha(null);
|
||||||
|
setRevisionContent(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
|
||||||
|
Historical ({selectedSha?.substring(0, 7)})
|
||||||
|
</Text>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: themeToken.colorBgLayout,
|
||||||
|
border: `1px solid ${themeToken.colorBorderSecondary}`,
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: 400,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historicalLines.map((line, i) => {
|
||||||
|
const isDiff = i < currentLines.length && line !== currentLines[i];
|
||||||
|
const isAdded = i >= currentLines.length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: isDiff
|
||||||
|
? 'rgba(82, 196, 26, 0.1)'
|
||||||
|
: isAdded
|
||||||
|
? 'rgba(82, 196, 26, 0.15)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
|
||||||
|
Current
|
||||||
|
</Text>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
background: themeToken.colorBgLayout,
|
||||||
|
border: `1px solid ${themeToken.colorBorderSecondary}`,
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: 400,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentLines.map((line, i) => {
|
||||||
|
const isDiff = i < historicalLines.length && line !== historicalLines[i];
|
||||||
|
const isAdded = i >= historicalLines.length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
background: isDiff
|
||||||
|
? 'rgba(245, 34, 45, 0.1)'
|
||||||
|
: isAdded
|
||||||
|
? 'rgba(245, 34, 45, 0.15)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
|
||||||
|
{i + 1}
|
||||||
|
</Text>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<HistoryOutlined />
|
||||||
|
<span>Version History</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={selectedSha ? 800 : 420}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : !documentPath ? (
|
||||||
|
<Empty description="No document selected" />
|
||||||
|
) : commits.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
image={<FileTextOutlined style={{ fontSize: 48, color: themeToken.colorTextDisabled }} />}
|
||||||
|
description="No version history available"
|
||||||
|
>
|
||||||
|
<Text type="secondary">
|
||||||
|
History is recorded when files are saved with Gitea integration enabled.
|
||||||
|
</Text>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">File: </Text>
|
||||||
|
<Text strong>{fileName}</Text>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{commits.length} revision{commits.length !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<Timeline
|
||||||
|
items={commits.map((commit) => ({
|
||||||
|
color: selectedSha === commit.sha ? themeToken.colorPrimary : themeToken.colorTextSecondary,
|
||||||
|
children: (
|
||||||
|
<div
|
||||||
|
key={commit.sha}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
background:
|
||||||
|
selectedSha === commit.sha
|
||||||
|
? themeToken.colorPrimaryBg
|
||||||
|
: undefined,
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
}}
|
||||||
|
onClick={() => handleSelectCommit(commit.sha)}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 2 }}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>
|
||||||
|
{commit.commit.message}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
<UserOutlined style={{ marginRight: 4 }} />
|
||||||
|
{commit.commit.author.name}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{dayjs(commit.commit.author.date).fromNow()}
|
||||||
|
</Text>
|
||||||
|
<Tag style={{ fontSize: 10 }}>{commit.sha.substring(0, 7)}</Tag>
|
||||||
|
</Space>
|
||||||
|
{selectedSha !== commit.sha && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSelectCommit(commit.sha);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{renderDiffView()}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
390
admin/src/components/docs/DocSharePanel.tsx
Normal file
390
admin/src/components/docs/DocSharePanel.tsx
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
Alert,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
ShareAltOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface DocSharePanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShareLink {
|
||||||
|
id: string;
|
||||||
|
documentPath: string;
|
||||||
|
shareToken: string;
|
||||||
|
status: 'ACTIVE' | 'REVOKED' | 'EXPIRED';
|
||||||
|
canEdit: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
maxUses: number | null;
|
||||||
|
useCount: number;
|
||||||
|
guestName: string | null;
|
||||||
|
createdBy: { id: string; name: string | null; email: string };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRY_OPTIONS = [
|
||||||
|
{ value: 1, label: '1 hour' },
|
||||||
|
{ value: 24, label: '24 hours' },
|
||||||
|
{ value: 168, label: '7 days' },
|
||||||
|
{ value: 720, label: '30 days' },
|
||||||
|
{ value: 0, label: 'No expiry' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusTag(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'ACTIVE':
|
||||||
|
return (
|
||||||
|
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||||
|
Active
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'REVOKED':
|
||||||
|
return (
|
||||||
|
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||||
|
Revoked
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'EXPIRED':
|
||||||
|
return (
|
||||||
|
<Tag icon={<ClockCircleOutlined />} color="default">
|
||||||
|
Expired
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <Tag>{status}</Tag>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocSharePanel({ open, onClose, documentPath }: DocSharePanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [links, setLinks] = useState<ShareLink[]>([]);
|
||||||
|
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [canEdit, setCanEdit] = useState(true);
|
||||||
|
const [expiryHours, setExpiryHours] = useState<number>(168); // 7 days default
|
||||||
|
const [maxUses, setMaxUses] = useState<number | null>(null);
|
||||||
|
const [guestName, setGuestName] = useState('');
|
||||||
|
|
||||||
|
const fetchLinks = useCallback(async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<{ links: ShareLink[] }>('/docs-access/share/links', {
|
||||||
|
params: { path: documentPath },
|
||||||
|
});
|
||||||
|
setLinks(data.links);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load share links');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && documentPath) {
|
||||||
|
fetchLinks();
|
||||||
|
setGeneratedUrl(null);
|
||||||
|
}
|
||||||
|
}, [open, documentPath, fetchLinks]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!documentPath) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
documentPath,
|
||||||
|
canEdit,
|
||||||
|
};
|
||||||
|
if (expiryHours > 0) {
|
||||||
|
payload.expiresInHours = expiryHours;
|
||||||
|
}
|
||||||
|
if (maxUses && maxUses > 0) {
|
||||||
|
payload.maxUses = maxUses;
|
||||||
|
}
|
||||||
|
if (guestName.trim()) {
|
||||||
|
payload.guestName = guestName.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post<{ id: string; shareToken: string; documentPath: string }>(
|
||||||
|
'/docs-access/share/create',
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/docs/share/${data.shareToken}`;
|
||||||
|
setGeneratedUrl(url);
|
||||||
|
message.success('Share link created');
|
||||||
|
fetchLinks();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to create share link');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
message.success('Link copied to clipboard');
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to copy link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/docs-access/share/${id}/revoke`);
|
||||||
|
message.success('Share link revoked');
|
||||||
|
fetchLinks();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to revoke share link');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<ShareLink> = [
|
||||||
|
{
|
||||||
|
title: 'Token',
|
||||||
|
dataIndex: 'shareToken',
|
||||||
|
key: 'token',
|
||||||
|
width: 100,
|
||||||
|
render: (token: string) => (
|
||||||
|
<Tooltip title={token}>
|
||||||
|
<Text code copyable={{ text: `${window.location.origin}/docs/share/${token}` }}>
|
||||||
|
{token.substring(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Guest',
|
||||||
|
dataIndex: 'guestName',
|
||||||
|
key: 'guest',
|
||||||
|
width: 100,
|
||||||
|
render: (name: string | null) => name || <Text type="secondary">--</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Created',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'created',
|
||||||
|
width: 110,
|
||||||
|
render: (date: string) => (
|
||||||
|
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
|
||||||
|
<span>{dayjs(date).fromNow()}</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Expiry',
|
||||||
|
dataIndex: 'expiresAt',
|
||||||
|
key: 'expiry',
|
||||||
|
width: 110,
|
||||||
|
render: (date: string | null) =>
|
||||||
|
date ? (
|
||||||
|
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
|
||||||
|
<span>{dayjs(date).fromNow()}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Never</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Uses',
|
||||||
|
key: 'uses',
|
||||||
|
width: 70,
|
||||||
|
render: (_: unknown, record: ShareLink) => (
|
||||||
|
<span>
|
||||||
|
{record.useCount}
|
||||||
|
{record.maxUses ? ` / ${record.maxUses}` : ''}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (status: string) => statusTag(status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 60,
|
||||||
|
render: (_: unknown, record: ShareLink) =>
|
||||||
|
record.status === 'ACTIVE' ? (
|
||||||
|
<Tooltip title="Revoke">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<StopOutlined />}
|
||||||
|
onClick={() => handleRevoke(record.id)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileName = documentPath?.split('/').pop() || 'Document';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ShareAltOutlined />
|
||||||
|
<span>Share Document</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={640}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{!documentPath ? (
|
||||||
|
<Alert message="No document selected" type="info" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary">Document</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{fileName}</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{documentPath}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }}>Create Share Link</Divider>
|
||||||
|
|
||||||
|
<Form layout="vertical" style={{ width: '100%' }}>
|
||||||
|
<Form.Item label="Can edit">
|
||||||
|
<Switch
|
||||||
|
checked={canEdit}
|
||||||
|
onChange={setCanEdit}
|
||||||
|
checkedChildren="Edit"
|
||||||
|
unCheckedChildren="View only"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Expiry">
|
||||||
|
<Select
|
||||||
|
value={expiryHours}
|
||||||
|
onChange={setExpiryHours}
|
||||||
|
options={EXPIRY_OPTIONS}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Max uses (optional)">
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={maxUses}
|
||||||
|
onChange={(v) => setMaxUses(v)}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Guest name (optional)">
|
||||||
|
<Input
|
||||||
|
value={guestName}
|
||||||
|
onChange={(e) => setGuestName(e.target.value)}
|
||||||
|
placeholder="Name shown to collaborators"
|
||||||
|
maxLength={200}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
loading={creating}
|
||||||
|
>
|
||||||
|
Generate Link
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{generatedUrl && (
|
||||||
|
<Alert
|
||||||
|
message="Share link created"
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Paragraph
|
||||||
|
copyable={{
|
||||||
|
text: generatedUrl,
|
||||||
|
onCopy: () => message.success('Copied'),
|
||||||
|
}}
|
||||||
|
style={{ margin: 0, wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{generatedUrl}
|
||||||
|
</Paragraph>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => handleCopyUrl(generatedUrl)}
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
onClose={() => setGeneratedUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }}>Active Links</Divider>
|
||||||
|
|
||||||
|
<Table<ShareLink>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={links}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
locale={{ emptyText: 'No share links for this document' }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
admin/src/components/docs/NewBlogPostModal.tsx
Normal file
165
admin/src/components/docs/NewBlogPostModal.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Form, Input, DatePicker, Select, Switch, message, theme } from 'antd';
|
||||||
|
import { FileMarkdownOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
|
||||||
|
|
||||||
|
interface NewBlogPostModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (path: string) => void;
|
||||||
|
authors: Record<string, AuthorEntry>;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewBlogPostModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
authors,
|
||||||
|
categories,
|
||||||
|
}: NewBlogPostModalProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
|
const titleValue = Form.useWatch('title', form) as string | undefined;
|
||||||
|
const dateValue = Form.useWatch('date', form) as dayjs.Dayjs | undefined;
|
||||||
|
|
||||||
|
const slug = titleValue ? slugify(titleValue) : '';
|
||||||
|
const dateStr = dateValue ? dateValue.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||||
|
const previewFilename = slug ? `blog/posts/${dateStr}-${slug}.md` : '';
|
||||||
|
|
||||||
|
const authorOptions = Object.entries(authors).map(([key, entry]) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categoryOptions = categories.map((cat) => ({
|
||||||
|
label: cat,
|
||||||
|
value: cat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const res = await api.post<{ path: string }>('/docs/blog/posts', {
|
||||||
|
title: values.title,
|
||||||
|
date: values.date ? values.date.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
|
||||||
|
authors: values.authors ?? [],
|
||||||
|
categories: values.categories ?? [],
|
||||||
|
draft: values.draft ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
messageApi.success('Blog post created');
|
||||||
|
form.resetFields();
|
||||||
|
onCreated(res.data.path);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
|
||||||
|
?.error?.message || 'Failed to create blog post';
|
||||||
|
messageApi.error(msg);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<FileMarkdownOutlined style={{ marginRight: 8 }} />
|
||||||
|
New Blog Post
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
okText="Create"
|
||||||
|
confirmLoading={submitting}
|
||||||
|
destroyOnHidden
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
date: dayjs(),
|
||||||
|
draft: true,
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="Title"
|
||||||
|
rules={[{ required: true, message: 'Title is required' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="My New Blog Post" autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{previewFilename && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: -12,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
fontSize: 12,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewFilename}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Item name="date" label="Date">
|
||||||
|
<DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="authors" label="Author(s)">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="Select authors"
|
||||||
|
options={authorOptions}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="categories" label="Categories">
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
placeholder="Add categories"
|
||||||
|
options={categoryOptions}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="draft" label="Draft" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
admin/src/hooks/useBlogAuthors.ts
Normal file
47
admin/src/hooks/useBlogAuthors.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export interface AuthorSocial {
|
||||||
|
icon: string;
|
||||||
|
link: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorEntry {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
avatar?: string;
|
||||||
|
social?: AuthorSocial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthorsMap = Record<string, AuthorEntry>;
|
||||||
|
|
||||||
|
interface UseBlogAuthorsReturn {
|
||||||
|
authors: AuthorsMap;
|
||||||
|
loading: boolean;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlogAuthors(): UseBlogAuthorsReturn {
|
||||||
|
const [authors, setAuthors] = useState<AuthorsMap>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<AuthorsMap>('/docs/blog/authors');
|
||||||
|
setAuthors(res.data);
|
||||||
|
} catch {
|
||||||
|
// If no authors file exists yet, keep empty
|
||||||
|
setAuthors({});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
return { authors, loading, refetch };
|
||||||
|
}
|
||||||
31
admin/src/hooks/useBlogCategories.ts
Normal file
31
admin/src/hooks/useBlogCategories.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface UseBlogCategoriesReturn {
|
||||||
|
categories: string[];
|
||||||
|
loading: boolean;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlogCategories(): UseBlogCategoriesReturn {
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<string[]>('/docs/blog/categories');
|
||||||
|
setCategories(res.data);
|
||||||
|
} catch {
|
||||||
|
setCategories([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
return { categories, loading, refetch };
|
||||||
|
}
|
||||||
74
admin/src/hooks/useBlogFrontmatter.ts
Normal file
74
admin/src/hooks/useBlogFrontmatter.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import { parse, stringify } from 'yaml';
|
||||||
|
|
||||||
|
export interface BlogFrontmatter {
|
||||||
|
date?: string;
|
||||||
|
authors?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
draft?: boolean;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
function parseFrontmatter(content: string): { frontmatter: BlogFrontmatter | null; body: string } {
|
||||||
|
const match = content.match(FRONTMATTER_RE);
|
||||||
|
if (!match) return { frontmatter: null, body: content };
|
||||||
|
try {
|
||||||
|
const parsed = parse(match[1] as string) as BlogFrontmatter | null;
|
||||||
|
return { frontmatter: parsed ?? {}, body: (match[2] as string) ?? '' };
|
||||||
|
} catch {
|
||||||
|
return { frontmatter: null, body: content };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildContent(frontmatter: BlogFrontmatter, body: string): string {
|
||||||
|
const yamlStr = stringify(frontmatter, { lineWidth: 0 }).trimEnd();
|
||||||
|
return `---\n${yamlStr}\n---\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBlogFrontmatterReturn {
|
||||||
|
isBlogPost: boolean;
|
||||||
|
frontmatter: BlogFrontmatter | null;
|
||||||
|
updateFrontmatter: (field: string, value: unknown) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlogFrontmatter(
|
||||||
|
selectedFile: string | null,
|
||||||
|
fileContent: string,
|
||||||
|
): UseBlogFrontmatterReturn {
|
||||||
|
const isBlogPost = useMemo(
|
||||||
|
() =>
|
||||||
|
!!selectedFile &&
|
||||||
|
selectedFile.startsWith('blog/posts/') &&
|
||||||
|
selectedFile.endsWith('.md'),
|
||||||
|
[selectedFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { frontmatter, body } = useMemo(() => {
|
||||||
|
if (!isBlogPost) return { frontmatter: null, body: fileContent };
|
||||||
|
return parseFrontmatter(fileContent);
|
||||||
|
}, [isBlogPost, fileContent]);
|
||||||
|
|
||||||
|
const updateFrontmatter = useCallback(
|
||||||
|
(field: string, value: unknown): string => {
|
||||||
|
const current = frontmatter ?? {};
|
||||||
|
const updated = { ...current, [field]: value };
|
||||||
|
|
||||||
|
// Remove empty optional fields to keep frontmatter clean
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
delete (updated as Record<string, unknown>)[field];
|
||||||
|
}
|
||||||
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
|
delete (updated as Record<string, unknown>)[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rebuildContent(updated, body);
|
||||||
|
},
|
||||||
|
[frontmatter, body],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isBlogPost, frontmatter, updateFrontmatter };
|
||||||
|
}
|
||||||
120
admin/src/hooks/useDocShareCollaboration.ts
Normal file
120
admin/src/hooks/useDocShareCollaboration.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||||
|
import type { Collaborator, UseDocsCollaborationReturn } from '@/hooks/useDocsCollaboration';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A variant of useDocsCollaboration for share-link guests.
|
||||||
|
* Uses a collabToken (short-lived JWT) instead of the auth store token,
|
||||||
|
* and sets awareness with the provided guestIdentity.
|
||||||
|
*/
|
||||||
|
export function useDocShareCollaboration(
|
||||||
|
filePath: string | null,
|
||||||
|
enabled: boolean,
|
||||||
|
collabToken: string | null,
|
||||||
|
guestIdentity: { id: string; name: string; color: string } | null,
|
||||||
|
): UseDocsCollaborationReturn {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [synced, setSynced] = useState(false);
|
||||||
|
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
|
||||||
|
|
||||||
|
const [collabState, setCollabState] = useState<{
|
||||||
|
yDoc: Y.Doc;
|
||||||
|
yText: Y.Text;
|
||||||
|
provider: HocuspocusProvider;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Build WebSocket URL
|
||||||
|
const wsUrl = useMemo(() => {
|
||||||
|
if (!filePath || !enabled || !collabToken) return null;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}/api/docs/collaborate`;
|
||||||
|
}, [filePath, enabled, collabToken]);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
setCollabState((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
prev.provider.disconnect();
|
||||||
|
prev.provider.destroy();
|
||||||
|
prev.yDoc.destroy();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setConnected(false);
|
||||||
|
setSynced(false);
|
||||||
|
setCollaborators([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wsUrl || !filePath || !enabled || !collabToken || !guestIdentity) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Y.Doc and provider
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const yText = doc.getText('content');
|
||||||
|
|
||||||
|
const provider = new HocuspocusProvider({
|
||||||
|
url: wsUrl,
|
||||||
|
name: filePath,
|
||||||
|
document: doc,
|
||||||
|
token: collabToken,
|
||||||
|
onConnect: () => setConnected(true),
|
||||||
|
onDisconnect: () => {
|
||||||
|
setConnected(false);
|
||||||
|
setSynced(false);
|
||||||
|
},
|
||||||
|
onSynced: () => setSynced(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set local awareness state with guest identity
|
||||||
|
provider.setAwarenessField('user', {
|
||||||
|
id: guestIdentity.id,
|
||||||
|
name: guestIdentity.name,
|
||||||
|
color: guestIdentity.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track collaborators via awareness changes
|
||||||
|
const handleAwarenessChange = () => {
|
||||||
|
const states = provider.awareness?.getStates();
|
||||||
|
if (!states) return;
|
||||||
|
const collab: Collaborator[] = [];
|
||||||
|
states.forEach((state, clientId) => {
|
||||||
|
const u = state.user as { id: string; name: string; color: string } | undefined;
|
||||||
|
if (u && u.id !== guestIdentity.id) {
|
||||||
|
collab.push({ id: u.id, name: u.name, color: u.color, clientId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setCollaborators(collab);
|
||||||
|
};
|
||||||
|
provider.awareness?.on('change', handleAwarenessChange);
|
||||||
|
|
||||||
|
// Set state LAST so the component re-renders with everything ready
|
||||||
|
setCollabState({ yDoc: doc, yText, provider });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
provider.awareness?.off('change', handleAwarenessChange);
|
||||||
|
provider.disconnect();
|
||||||
|
provider.destroy();
|
||||||
|
doc.destroy();
|
||||||
|
setCollabState(null);
|
||||||
|
setConnected(false);
|
||||||
|
setSynced(false);
|
||||||
|
setCollaborators([]);
|
||||||
|
};
|
||||||
|
// We intentionally only recreate when these core values change
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [wsUrl, filePath, enabled, collabToken, guestIdentity?.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
yDoc: collabState?.yDoc ?? null,
|
||||||
|
yText: collabState?.yText ?? null,
|
||||||
|
provider: collabState?.provider ?? null,
|
||||||
|
connected,
|
||||||
|
synced,
|
||||||
|
collaborators,
|
||||||
|
active: enabled && !!collabState && synced && !!filePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
318
admin/src/pages/DocsMetadataPage.tsx
Normal file
318
admin/src/pages/DocsMetadataPage.tsx
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Row, Col, Card, Statistic, Table, Tag, Select, Button, Space, Spin, Typography, theme } from 'antd';
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AppOutletContext } from '@/types/api';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
interface PageMeta {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
lastModified: string | null;
|
||||||
|
wordCount: number;
|
||||||
|
hasAccessPolicy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Warning {
|
||||||
|
type: string;
|
||||||
|
paths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataResponse {
|
||||||
|
totalPages: number;
|
||||||
|
pages: PageMeta[];
|
||||||
|
warnings: Warning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterMode = 'all' | 'no-tags' | 'no-description' | 'stale';
|
||||||
|
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'blue', 'green', 'purple', 'cyan', 'magenta', 'orange', 'gold', 'lime', 'geekblue', 'volcano',
|
||||||
|
];
|
||||||
|
|
||||||
|
function tagColor(tag: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < tag.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? 'blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocsMetadataPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<MetadataResponse | null>(null);
|
||||||
|
const [filter, setFilter] = useState<FilterMode>('all');
|
||||||
|
|
||||||
|
const fetchMetadata = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: res } = await api.get<MetadataResponse>('/docs/metadata');
|
||||||
|
setData(res);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({ title: 'Documentation Metadata' });
|
||||||
|
fetchMetadata();
|
||||||
|
}, [setPageHeader, fetchMetadata]);
|
||||||
|
|
||||||
|
const staleDays = 30;
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!data) return { total: 0, noTags: 0, noDesc: 0, stale: 0, restricted: 0 };
|
||||||
|
const now = dayjs();
|
||||||
|
const noTags = data.pages.filter(p => !p.tags || p.tags.length === 0).length;
|
||||||
|
const noDesc = data.pages.filter(p => !p.description).length;
|
||||||
|
const stale = data.pages.filter(p => {
|
||||||
|
if (!p.lastModified) return true;
|
||||||
|
return now.diff(dayjs(p.lastModified), 'day') > staleDays;
|
||||||
|
}).length;
|
||||||
|
const restricted = data.pages.filter(p => p.hasAccessPolicy).length;
|
||||||
|
return { total: data.totalPages, noTags, noDesc, stale, restricted };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const filteredPages = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const now = dayjs();
|
||||||
|
switch (filter) {
|
||||||
|
case 'no-tags':
|
||||||
|
return data.pages.filter(p => !p.tags || p.tags.length === 0);
|
||||||
|
case 'no-description':
|
||||||
|
return data.pages.filter(p => !p.description);
|
||||||
|
case 'stale':
|
||||||
|
return data.pages.filter(p => {
|
||||||
|
if (!p.lastModified) return true;
|
||||||
|
return now.diff(dayjs(p.lastModified), 'day') > staleDays;
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return data.pages;
|
||||||
|
}
|
||||||
|
}, [data, filter]);
|
||||||
|
|
||||||
|
const columns: ColumnsType<PageMeta> = [
|
||||||
|
{
|
||||||
|
title: 'Path',
|
||||||
|
dataIndex: 'path',
|
||||||
|
key: 'path',
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: (a, b) => a.path.localeCompare(b.path),
|
||||||
|
render: (path: string) => (
|
||||||
|
<Typography.Link
|
||||||
|
onClick={() => navigate('/app/docs', { state: { openFile: path } })}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</Typography.Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Title',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
ellipsis: true,
|
||||||
|
sorter: (a, b) => (a.title || '').localeCompare(b.title || ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tags',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
key: 'tags',
|
||||||
|
width: 240,
|
||||||
|
filters: (() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const allTags = new Set<string>();
|
||||||
|
data.pages.forEach(p => (p.tags || []).forEach(t => allTags.add(t)));
|
||||||
|
return Array.from(allTags).sort().map(t => ({ text: t, value: t }));
|
||||||
|
})(),
|
||||||
|
onFilter: (value, record) => (record.tags || []).includes(value as string),
|
||||||
|
render: (tags: string[]) =>
|
||||||
|
tags && tags.length > 0 ? (
|
||||||
|
<Space size={[0, 4]} wrap>
|
||||||
|
{tags.map(t => (
|
||||||
|
<Tag key={t} color={tagColor(t)}>
|
||||||
|
{t}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
--
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Words',
|
||||||
|
dataIndex: 'wordCount',
|
||||||
|
key: 'wordCount',
|
||||||
|
width: 90,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => a.wordCount - b.wordCount,
|
||||||
|
render: (count: number) => count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Last Modified',
|
||||||
|
dataIndex: 'lastModified',
|
||||||
|
key: 'lastModified',
|
||||||
|
width: 140,
|
||||||
|
sorter: (a, b) => {
|
||||||
|
const da = a.lastModified ? dayjs(a.lastModified).unix() : 0;
|
||||||
|
const db = b.lastModified ? dayjs(b.lastModified).unix() : 0;
|
||||||
|
return da - db;
|
||||||
|
},
|
||||||
|
render: (val: string | null) =>
|
||||||
|
val ? (
|
||||||
|
<Typography.Text title={dayjs(val).format('YYYY-MM-DD HH:mm')}>
|
||||||
|
{dayjs(val).fromNow()}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">--</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Access',
|
||||||
|
dataIndex: 'hasAccessPolicy',
|
||||||
|
key: 'hasAccessPolicy',
|
||||||
|
width: 70,
|
||||||
|
align: 'center',
|
||||||
|
filters: [
|
||||||
|
{ text: 'Restricted', value: true },
|
||||||
|
{ text: 'Public', value: false },
|
||||||
|
],
|
||||||
|
onFilter: (value, record) => record.hasAccessPolicy === value,
|
||||||
|
render: (restricted: boolean) =>
|
||||||
|
restricted ? (
|
||||||
|
<LockOutlined style={{ color: token.colorWarning }} title="Access policy applied" />
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Total Pages"
|
||||||
|
value={stats.total}
|
||||||
|
prefix={<FileTextOutlined />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="No Tags"
|
||||||
|
value={stats.noTags}
|
||||||
|
prefix={<TagOutlined />}
|
||||||
|
valueStyle={stats.noTags > 0 ? { color: token.colorWarning } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="No Description"
|
||||||
|
value={stats.noDesc}
|
||||||
|
prefix={<WarningOutlined />}
|
||||||
|
valueStyle={stats.noDesc > 0 ? { color: token.colorWarning } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title={`Stale (>${staleDays}d)`}
|
||||||
|
value={stats.stale}
|
||||||
|
prefix={<ClockCircleOutlined />}
|
||||||
|
valueStyle={stats.stale > 0 ? { color: token.colorError } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
<Card size="small">
|
||||||
|
<Statistic
|
||||||
|
title="Restricted"
|
||||||
|
value={stats.restricted}
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Select<FilterMode>
|
||||||
|
value={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
options={[
|
||||||
|
{ label: 'Show All', value: 'all' },
|
||||||
|
{ label: 'Missing Tags', value: 'no-tags' },
|
||||||
|
{ label: 'Missing Description', value: 'no-description' },
|
||||||
|
{ label: `Stale (>${staleDays} days)`, value: 'stale' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={fetchMetadata} loading={loading}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table<PageMeta>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredPages}
|
||||||
|
rowKey="path"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: ['25', '50', '100'] }}
|
||||||
|
scroll={{ x: 800 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data?.warnings && data.warnings.length > 0 && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<><WarningOutlined style={{ color: token.colorWarning }} /> Warnings</>}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
>
|
||||||
|
{data.warnings.map((w, i) => (
|
||||||
|
<div key={i} style={{ marginBottom: 8 }}>
|
||||||
|
<Typography.Text strong>{w.type}:</Typography.Text>{' '}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{w.paths.length} page{w.paths.length !== 1 ? 's' : ''} — {w.paths.slice(0, 5).join(', ')}
|
||||||
|
{w.paths.length > 5 ? ` (+${w.paths.length - 5} more)` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -60,6 +60,10 @@ import {
|
|||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
ClearOutlined,
|
ClearOutlined,
|
||||||
|
FormOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } from '@monaco-editor/react';
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
@ -92,6 +96,18 @@ import { MonacoBinding } from 'y-monaco';
|
|||||||
import type { SiteSettings } from '@/types/api';
|
import type { SiteSettings } from '@/types/api';
|
||||||
import { registerWikiLinkCompletion } from '@/utils/wikiLinkCompletion';
|
import { registerWikiLinkCompletion } from '@/utils/wikiLinkCompletion';
|
||||||
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
|
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
|
||||||
|
// Blog authoring
|
||||||
|
import { useBlogFrontmatter } from '@/hooks/useBlogFrontmatter';
|
||||||
|
import { useBlogAuthors } from '@/hooks/useBlogAuthors';
|
||||||
|
import { useBlogCategories } from '@/hooks/useBlogCategories';
|
||||||
|
import { BlogFrontmatterPanel } from '@/components/docs/BlogFrontmatterPanel';
|
||||||
|
import { NewBlogPostModal } from '@/components/docs/NewBlogPostModal';
|
||||||
|
import { AuthorsManagementModal } from '@/components/docs/AuthorsManagementModal';
|
||||||
|
// Access policies & sharing
|
||||||
|
import { DocAccessPolicyPanel } from '@/components/docs/DocAccessPolicyPanel';
|
||||||
|
import { DocSharePanel } from '@/components/docs/DocSharePanel';
|
||||||
|
// Version history
|
||||||
|
import { DocHistoryDrawer } from '@/components/docs/DocHistoryDrawer';
|
||||||
|
|
||||||
type LayoutMode = 'split' | 'editor' | 'preview';
|
type LayoutMode = 'split' | 'editor' | 'preview';
|
||||||
type PreviewMode = 'desktop' | 'mobile';
|
type PreviewMode = 'desktop' | 'mobile';
|
||||||
@ -651,6 +667,15 @@ export default function DocsPage() {
|
|||||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||||
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
||||||
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
|
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
|
||||||
|
// New feature panels
|
||||||
|
const [newBlogPostOpen, setNewBlogPostOpen] = useState(false);
|
||||||
|
const [authorsModalOpen, setAuthorsModalOpen] = useState(false);
|
||||||
|
const [accessPolicyOpen, setAccessPolicyOpen] = useState(false);
|
||||||
|
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||||||
|
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||||||
|
const [blogPanelCollapsed, setBlogPanelCollapsed] = useState(
|
||||||
|
() => localStorage.getItem('docs-blog-panel-collapsed') === 'true',
|
||||||
|
);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -678,6 +703,11 @@ export default function DocsPage() {
|
|||||||
collabEnabled,
|
collabEnabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Blog hooks
|
||||||
|
const blogFrontmatter = useBlogFrontmatter(selectedFile, fileContent);
|
||||||
|
const blogAuthors = useBlogAuthors();
|
||||||
|
const blogCategories = useBlogCategories();
|
||||||
|
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
// Keep fileTreeRef in sync for Monaco autocomplete callback
|
// Keep fileTreeRef in sync for Monaco autocomplete callback
|
||||||
@ -1900,6 +1930,9 @@ export default function DocsPage() {
|
|||||||
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
|
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
|
||||||
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={handleNewFolderRoot} aria-label="Create new folder" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={handleNewFolderRoot} aria-label="Create new folder" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title="New Blog Post" mouseEnterDelay={0.4}>
|
||||||
|
<Button type="text" size="small" icon={<FormOutlined />} onClick={() => setNewBlogPostOpen(true)} aria-label="New blog post" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="Upload File" mouseEnterDelay={0.4}>
|
<Tooltip title="Upload File" mouseEnterDelay={0.4}>
|
||||||
<Button type="text" size="small" icon={<UploadOutlined />} onClick={handleUploadButtonClick} aria-label="Upload file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
<Button type="text" size="small" icon={<UploadOutlined />} onClick={handleUploadButtonClick} aria-label="Upload file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -2076,6 +2109,16 @@ export default function DocsPage() {
|
|||||||
)}
|
)}
|
||||||
{dirty && !collab.active && <span style={{ color: token.colorWarning, fontWeight: 600 }}>Modified</span>}
|
{dirty && !collab.active && <span style={{ color: token.colorWarning, fontWeight: 600 }}>Modified</span>}
|
||||||
{collab.active && <span style={{ color: token.colorSuccess, fontSize: 11 }}>Auto-saving</span>}
|
{collab.active && <span style={{ color: token.colorSuccess, fontSize: 11 }}>Auto-saving</span>}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Tooltip title="Version History" mouseEnterDelay={0.4}>
|
||||||
|
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={() => setHistoryDrawerOpen(true)} style={{ width: 24, height: 22, color: token.colorTextSecondary }} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Share" mouseEnterDelay={0.4}>
|
||||||
|
<Button type="text" size="small" icon={<ShareAltOutlined />} onClick={() => setSharePanelOpen(true)} style={{ width: 24, height: 22, color: token.colorTextSecondary }} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Access Policy" mouseEnterDelay={0.4}>
|
||||||
|
<Button type="text" size="small" icon={<LockOutlined />} onClick={() => setAccessPolicyOpen(true)} style={{ width: 24, height: 22, color: token.colorTextSecondary }} />
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>Select a file from the tree</span>
|
<span>Select a file from the tree</span>
|
||||||
@ -2156,8 +2199,31 @@ export default function DocsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editor / Image Viewer */}
|
{/* Editor / Image Viewer + Blog Panel */}
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'row' }}>
|
||||||
|
{/* Blog Frontmatter Panel (right side when blog post selected) */}
|
||||||
|
{blogFrontmatter.isBlogPost && !isMobile && (
|
||||||
|
<BlogFrontmatterPanel
|
||||||
|
frontmatter={blogFrontmatter.frontmatter}
|
||||||
|
onUpdate={(field, value) => {
|
||||||
|
const newContent = blogFrontmatter.updateFrontmatter(field, value);
|
||||||
|
if (newContent !== null) {
|
||||||
|
setFileContent(newContent);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
authors={blogAuthors.authors}
|
||||||
|
categories={blogCategories.categories}
|
||||||
|
loading={blogAuthors.loading || blogCategories.loading}
|
||||||
|
collapsed={blogPanelCollapsed}
|
||||||
|
onCollapsedChange={(c) => {
|
||||||
|
setBlogPanelCollapsed(c);
|
||||||
|
localStorage.setItem('docs-blog-panel-collapsed', String(c));
|
||||||
|
}}
|
||||||
|
onManageAuthors={() => setAuthorsModalOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
{fileLoading ? (
|
{fileLoading ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
<Spin />
|
<Spin />
|
||||||
@ -2224,6 +2290,7 @@ export default function DocsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2403,6 +2470,52 @@ export default function DocsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blog Authoring Modals */}
|
||||||
|
<NewBlogPostModal
|
||||||
|
open={newBlogPostOpen}
|
||||||
|
onClose={() => setNewBlogPostOpen(false)}
|
||||||
|
onCreated={(path) => {
|
||||||
|
setNewBlogPostOpen(false);
|
||||||
|
fetchTree(false, true);
|
||||||
|
loadFile(path);
|
||||||
|
}}
|
||||||
|
authors={blogAuthors.authors}
|
||||||
|
categories={blogCategories.categories}
|
||||||
|
/>
|
||||||
|
<AuthorsManagementModal
|
||||||
|
open={authorsModalOpen}
|
||||||
|
onClose={() => setAuthorsModalOpen(false)}
|
||||||
|
authors={blogAuthors.authors}
|
||||||
|
onSaved={() => blogAuthors.refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Access Policy & Sharing Drawers */}
|
||||||
|
<DocAccessPolicyPanel
|
||||||
|
open={accessPolicyOpen}
|
||||||
|
onClose={() => setAccessPolicyOpen(false)}
|
||||||
|
documentPath={selectedFile}
|
||||||
|
/>
|
||||||
|
<DocSharePanel
|
||||||
|
open={sharePanelOpen}
|
||||||
|
onClose={() => setSharePanelOpen(false)}
|
||||||
|
documentPath={selectedFile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Version History Drawer */}
|
||||||
|
<DocHistoryDrawer
|
||||||
|
open={historyDrawerOpen}
|
||||||
|
onClose={() => setHistoryDrawerOpen(false)}
|
||||||
|
documentPath={selectedFile}
|
||||||
|
currentContent={fileContent}
|
||||||
|
onRestore={(content) => {
|
||||||
|
setFileContent(content);
|
||||||
|
setOriginalContent(content);
|
||||||
|
setDirty(false);
|
||||||
|
setHistoryDrawerOpen(false);
|
||||||
|
message.success('File restored to previous version');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
510
admin/src/pages/GiteaSetupPage.tsx
Normal file
510
admin/src/pages/GiteaSetupPage.tsx
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Card, Button, Form, Input, Space, Typography, Spin, Alert,
|
||||||
|
Steps, App, Grid, Divider, Result, Tag, theme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined,
|
||||||
|
SettingOutlined, ApiOutlined, DatabaseOutlined, LockOutlined,
|
||||||
|
BranchesOutlined, ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
|
|
||||||
|
const { Text, Paragraph, Title } = Typography;
|
||||||
|
|
||||||
|
interface SetupStatus {
|
||||||
|
giteaOnline: boolean;
|
||||||
|
installComplete: boolean;
|
||||||
|
tokenConfigured: boolean;
|
||||||
|
reposCreated: boolean;
|
||||||
|
oauthConfigured: boolean;
|
||||||
|
setupComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestConnectionResult {
|
||||||
|
success: boolean;
|
||||||
|
giteaVersion?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupStepResult {
|
||||||
|
step: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupRunResult {
|
||||||
|
success: boolean;
|
||||||
|
steps: SetupStepResult[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<string, string> = {
|
||||||
|
check_connection: 'Check Connection',
|
||||||
|
create_token: 'Create API Token',
|
||||||
|
create_comments_repo: 'Create Comments Repository',
|
||||||
|
create_history_repo: 'Create History Repository',
|
||||||
|
create_labels: 'Create Labels',
|
||||||
|
create_oauth_app: 'Create OAuth Application',
|
||||||
|
save_config: 'Save Configuration',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
check_connection: <ApiOutlined />,
|
||||||
|
create_token: <LockOutlined />,
|
||||||
|
create_comments_repo: <DatabaseOutlined />,
|
||||||
|
create_history_repo: <DatabaseOutlined />,
|
||||||
|
create_labels: <SettingOutlined />,
|
||||||
|
create_oauth_app: <LockOutlined />,
|
||||||
|
save_config: <SettingOutlined />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GiteaSetupPage() {
|
||||||
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
const { token: themeToken } = theme.useToken();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [status, setStatus] = useState<SetupStatus | null>(null);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
// Step 1: Auth
|
||||||
|
const [username, setUsername] = useState('admin');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [testResult, setTestResult] = useState<TestConnectionResult | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
// Step 2: Run
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [runResult, setRunResult] = useState<SetupRunResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageHeader({ title: 'Gitea Setup' });
|
||||||
|
return () => setPageHeader(null);
|
||||||
|
}, [setPageHeader]);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<SetupStatus>('/gitea/setup/status');
|
||||||
|
setStatus(res.data);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load Gitea setup status');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.post<TestConnectionResult>('/gitea/setup/test-connection', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setTestResult(res.data);
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success('Connected to Gitea successfully');
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Connection test failed';
|
||||||
|
setTestResult({ success: false, error: errorMsg });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRunSetup = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setRunResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api.post<SetupRunResult>('/gitea/setup/run', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
setRunResult(res.data);
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success('Gitea setup completed successfully');
|
||||||
|
} else {
|
||||||
|
message.error('Setup completed with errors');
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Setup failed';
|
||||||
|
setRunResult({ success: false, steps: [], error: errorMsg });
|
||||||
|
message.error('Setup failed');
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRerun = () => {
|
||||||
|
setCurrentStep(0);
|
||||||
|
setTestResult(null);
|
||||||
|
setRunResult(null);
|
||||||
|
setPassword('');
|
||||||
|
fetchStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already complete — show success overview
|
||||||
|
if (status?.setupComplete && !runResult) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="Gitea Setup Complete"
|
||||||
|
subTitle="All Gitea integrations have been configured successfully."
|
||||||
|
extra={[
|
||||||
|
<Button key="rerun" onClick={handleRerun} icon={<ReloadOutlined />}>
|
||||||
|
Re-run Setup
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="gitea"
|
||||||
|
type="primary"
|
||||||
|
icon={<BranchesOutlined />}
|
||||||
|
onClick={() => navigate('/app/services/gitea')}
|
||||||
|
>
|
||||||
|
Open Gitea
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<StatusRow label="Gitea Online" ok={status.giteaOnline} />
|
||||||
|
<StatusRow label="Initial Install Complete" ok={status.installComplete} />
|
||||||
|
<StatusRow label="API Token Configured" ok={status.tokenConfigured} />
|
||||||
|
<StatusRow label="Repositories Created" ok={status.reposCreated} />
|
||||||
|
<StatusRow label="OAuth Configured" ok={status.oauthConfigured} />
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
<SettingOutlined style={{ marginRight: 8 }} />
|
||||||
|
Gitea Auto-Setup Wizard
|
||||||
|
</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
This wizard configures Gitea for documentation comments, page history tracking, and OAuth integration.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Steps
|
||||||
|
current={currentStep}
|
||||||
|
style={{ marginBottom: 32 }}
|
||||||
|
direction={isMobile ? 'vertical' : 'horizontal'}
|
||||||
|
items={[
|
||||||
|
{ title: 'Status Check', icon: <ApiOutlined /> },
|
||||||
|
{ title: 'Authenticate', icon: <LockOutlined /> },
|
||||||
|
{ title: 'Run Setup', icon: <SettingOutlined /> },
|
||||||
|
{ title: 'Complete', icon: <CheckCircleOutlined /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Step 0: Status Check */}
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
{!status?.giteaOnline && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="Gitea is not running"
|
||||||
|
description="Start the Gitea container before proceeding. Run: docker compose up -d gitea"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.giteaOnline && !status.installComplete && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="Complete Gitea Initial Setup"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Gitea needs its initial setup completed first. Visit{' '}
|
||||||
|
<a
|
||||||
|
href="/app/services/gitea"
|
||||||
|
onClick={(e) => { e.preventDefault(); navigate('/app/services/gitea'); }}
|
||||||
|
>
|
||||||
|
the Gitea page
|
||||||
|
</a>{' '}
|
||||||
|
and complete the installation wizard to create your admin account.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.giteaOnline && status.installComplete && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
message="Gitea is ready for setup"
|
||||||
|
description="Gitea is running and the initial installation is complete. Proceed to authenticate."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
<StatusRow label="Gitea Online" ok={status?.giteaOnline ?? false} />
|
||||||
|
<StatusRow label="Initial Install Complete" ok={status?.installComplete ?? false} />
|
||||||
|
<StatusRow label="API Token Configured" ok={status?.tokenConfigured ?? false} />
|
||||||
|
<StatusRow label="Repositories Created" ok={status?.reposCreated ?? false} />
|
||||||
|
<StatusRow label="OAuth Configured" ok={status?.oauthConfigured ?? false} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button onClick={fetchStatus} icon={<ReloadOutlined />}>
|
||||||
|
Refresh Status
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => setCurrentStep(1)}
|
||||||
|
disabled={!status?.giteaOnline || !status?.installComplete}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Authenticate */}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="Gitea Admin Credentials"
|
||||||
|
description="Enter the admin username and password you created during Gitea's initial setup. These are used to create an API token for the integration."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form layout="vertical" style={{ maxWidth: 400 }}>
|
||||||
|
<Form.Item label="Username">
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Password">
|
||||||
|
<Input.Password
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter Gitea admin password"
|
||||||
|
onPressEnter={handleTestConnection}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Space wrap>
|
||||||
|
<Button
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
loading={testing}
|
||||||
|
icon={<ApiOutlined />}
|
||||||
|
disabled={!username || !password}
|
||||||
|
>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<Alert
|
||||||
|
type={testResult.success ? 'success' : 'error'}
|
||||||
|
showIcon
|
||||||
|
message={
|
||||||
|
testResult.success
|
||||||
|
? `Connected successfully — Gitea ${testResult.giteaVersion || ''}`
|
||||||
|
: 'Connection failed'
|
||||||
|
}
|
||||||
|
description={testResult.error || undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setCurrentStep(0)}>Back</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => setCurrentStep(2)}
|
||||||
|
disabled={!testResult?.success}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Run Setup */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message="Ready to Configure"
|
||||||
|
description="This will create the API token, repositories, labels, and OAuth application in Gitea. Existing resources will be reused if found."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!runResult && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={handleRunSetup}
|
||||||
|
loading={running}
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
>
|
||||||
|
{running ? 'Running Setup...' : 'Run Setup'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running && !runResult && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<Spin indicator={<LoadingOutlined style={{ fontSize: 32 }} spin />} />
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 12 }}>
|
||||||
|
Configuring Gitea resources...
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runResult && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
{runResult.steps.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.step}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: themeToken.borderRadius,
|
||||||
|
background: step.success
|
||||||
|
? 'rgba(82, 196, 26, 0.06)'
|
||||||
|
: 'rgba(255, 77, 79, 0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.success ? (
|
||||||
|
<CheckCircleOutlined style={{ color: themeToken.colorSuccess, fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<CloseCircleOutlined style={{ color: themeToken.colorError, fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ marginRight: 8 }}>{STEP_ICONS[step.step]}</span>
|
||||||
|
<Text style={{ flex: 1 }}>
|
||||||
|
{STEP_LABELS[step.step] || step.step}
|
||||||
|
</Text>
|
||||||
|
<Tag color={step.success ? 'success' : 'error'}>
|
||||||
|
{step.success ? 'OK' : 'Failed'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{runResult.error && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
message="Setup Error"
|
||||||
|
description={runResult.error}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runResult.success && (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
message="All steps completed successfully"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => { setRunResult(null); setCurrentStep(1); }}>Back</Button>
|
||||||
|
{runResult && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => setCurrentStep(3)}
|
||||||
|
disabled={!runResult.success}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{runResult && !runResult.success && (
|
||||||
|
<Button onClick={() => setRunResult(null)} icon={<ReloadOutlined />}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Complete */}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="Gitea Setup Complete"
|
||||||
|
subTitle="All Gitea integrations have been configured. Documentation comments, page history, and OAuth are ready to use."
|
||||||
|
extra={[
|
||||||
|
<Button
|
||||||
|
key="docs-comments"
|
||||||
|
onClick={() => navigate('/app/docs/comments')}
|
||||||
|
icon={<DatabaseOutlined />}
|
||||||
|
>
|
||||||
|
Documentation Comments
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="docs"
|
||||||
|
onClick={() => navigate('/app/docs')}
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="gitea"
|
||||||
|
type="primary"
|
||||||
|
icon={<BranchesOutlined />}
|
||||||
|
onClick={() => navigate('/app/services/gitea')}
|
||||||
|
>
|
||||||
|
Open Gitea
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small helper component for status rows */
|
||||||
|
function StatusRow({ label, ok }: { label: string; ok: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{ok ? (
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
) : (
|
||||||
|
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
)}
|
||||||
|
<Text>{label}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
293
admin/src/pages/public/SharedDocEditorPage.tsx
Normal file
293
admin/src/pages/public/SharedDocEditorPage.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { ConfigProvider, Layout, Typography, Spin, Result, Space, theme, Tag } from 'antd';
|
||||||
|
import {
|
||||||
|
LockOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Editor from '@monaco-editor/react';
|
||||||
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
|
import type { editor as monacoEditor } from 'monaco-editor';
|
||||||
|
import { MonacoBinding } from 'y-monaco';
|
||||||
|
import { useDocShareCollaboration } from '@/hooks/useDocShareCollaboration';
|
||||||
|
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
|
||||||
|
|
||||||
|
const { Header, Content } = Layout;
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface ShareValidation {
|
||||||
|
documentPath: string;
|
||||||
|
documentName: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
collabToken: string;
|
||||||
|
guestIdentity: { id: string; name: string; color: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'ready'; data: ShareValidation }
|
||||||
|
| { status: 'error'; code: string; message: string };
|
||||||
|
|
||||||
|
export default function SharedDocEditorPage() {
|
||||||
|
const { shareToken } = useParams<{ shareToken: string }>();
|
||||||
|
const [pageState, setPageState] = useState<PageState>({ status: 'loading' });
|
||||||
|
|
||||||
|
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
|
const monacoBindingRef = useRef<MonacoBinding | null>(null);
|
||||||
|
const [editorReady, setEditorReady] = useState(false);
|
||||||
|
|
||||||
|
// Extract data for hooks (hooks must be called unconditionally)
|
||||||
|
const shareData = pageState.status === 'ready' ? pageState.data : null;
|
||||||
|
|
||||||
|
const collab = useDocShareCollaboration(
|
||||||
|
shareData?.documentPath ?? null,
|
||||||
|
!!shareData,
|
||||||
|
shareData?.collabToken ?? null,
|
||||||
|
shareData?.guestIdentity ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate share token on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareToken) {
|
||||||
|
setPageState({ status: 'error', code: 'INVALID', message: 'No share token provided' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function validate() {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<ShareValidation>(
|
||||||
|
`/api/docs-access/share/public/${shareToken}`,
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setPageState({ status: 'ready', data });
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const resp = (err as { response?: { data?: { error?: { message?: string; code?: string } }; status?: number } })?.response;
|
||||||
|
const errorCode = resp?.data?.error?.code || 'UNKNOWN';
|
||||||
|
const errorMessage = resp?.data?.error?.message || 'Failed to validate share link';
|
||||||
|
|
||||||
|
if (resp?.status === 404) {
|
||||||
|
setPageState({ status: 'error', code: errorCode, message: 'This share link was not found.' });
|
||||||
|
} else if (resp?.status === 410) {
|
||||||
|
setPageState({
|
||||||
|
status: 'error',
|
||||||
|
code: errorCode,
|
||||||
|
message: errorCode === 'SHARE_LINK_REVOKED'
|
||||||
|
? 'This share link has been revoked by the document owner.'
|
||||||
|
: 'This share link has expired.',
|
||||||
|
});
|
||||||
|
} else if (resp?.status === 429) {
|
||||||
|
setPageState({ status: 'error', code: errorCode, message: 'This share link has reached its maximum number of uses.' });
|
||||||
|
} else {
|
||||||
|
setPageState({ status: 'error', code: errorCode, message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [shareToken]);
|
||||||
|
|
||||||
|
// Monaco editor mount handler
|
||||||
|
const handleEditorMount: OnMount = useCallback((editor) => {
|
||||||
|
monacoEditorRef.current = editor;
|
||||||
|
setEditorReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// MonacoBinding effect: binds Y.Text to Monaco editor when both are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (monacoBindingRef.current) {
|
||||||
|
monacoBindingRef.current.destroy();
|
||||||
|
monacoBindingRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ed = monacoEditorRef.current;
|
||||||
|
if (!collab.active || !collab.yText || !collab.provider || !ed || !editorReady) return;
|
||||||
|
|
||||||
|
const model = ed.getModel();
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
const binding = new MonacoBinding(
|
||||||
|
collab.yText,
|
||||||
|
model,
|
||||||
|
new Set([ed]),
|
||||||
|
collab.provider.awareness,
|
||||||
|
);
|
||||||
|
monacoBindingRef.current = binding;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
binding.destroy();
|
||||||
|
monacoBindingRef.current = null;
|
||||||
|
};
|
||||||
|
}, [collab.active, collab.yText, collab.provider, editorReady]);
|
||||||
|
|
||||||
|
// Determine file language for Monaco
|
||||||
|
const getLanguage = (path: string): string => {
|
||||||
|
if (path.endsWith('.md')) return 'markdown';
|
||||||
|
if (path.endsWith('.yml') || path.endsWith('.yaml')) return 'yaml';
|
||||||
|
if (path.endsWith('.json')) return 'json';
|
||||||
|
if (path.endsWith('.html')) return 'html';
|
||||||
|
if (path.endsWith('.css')) return 'css';
|
||||||
|
if (path.endsWith('.js') || path.endsWith('.ts')) return 'typescript';
|
||||||
|
if (path.endsWith('.py')) return 'python';
|
||||||
|
return 'plaintext';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#3498db',
|
||||||
|
colorBgBase: '#0d1b2a',
|
||||||
|
colorBgContainer: '#1b2838',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layout style={{ minHeight: '100vh', background: '#0d1b2a' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
background: '#1b2838',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
padding: '0 24px',
|
||||||
|
height: 56,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space align="center">
|
||||||
|
<LinkOutlined style={{ fontSize: 18, color: '#3498db' }} />
|
||||||
|
<Title level={5} style={{ margin: 0, color: '#fff' }}>
|
||||||
|
Shared Document
|
||||||
|
</Title>
|
||||||
|
{shareData && (
|
||||||
|
<>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
{shareData.documentName}
|
||||||
|
</Text>
|
||||||
|
<Tag
|
||||||
|
icon={shareData.canEdit ? <EditOutlined /> : <EyeOutlined />}
|
||||||
|
color={shareData.canEdit ? 'blue' : 'default'}
|
||||||
|
>
|
||||||
|
{shareData.canEdit ? 'Edit' : 'View only'}
|
||||||
|
</Tag>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space align="center">
|
||||||
|
{shareData && (
|
||||||
|
<CollaboratorAvatars
|
||||||
|
collaborators={collab.collaborators}
|
||||||
|
connected={collab.connected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Content style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{pageState.status === 'loading' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" align="center">
|
||||||
|
<Spin size="large" />
|
||||||
|
<Text type="secondary">Validating share link...</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageState.status === 'error' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
status={
|
||||||
|
pageState.code === 'SHARE_LINK_REVOKED' || pageState.code === 'SHARE_LINK_EXPIRED'
|
||||||
|
? 'warning'
|
||||||
|
: pageState.code === 'SHARE_LINK_NOT_FOUND'
|
||||||
|
? '404'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
pageState.code === 'SHARE_LINK_REVOKED' ? (
|
||||||
|
<LockOutlined style={{ color: '#faad14' }} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
pageState.code === 'SHARE_LINK_EXPIRED'
|
||||||
|
? 'Link Expired'
|
||||||
|
: pageState.code === 'SHARE_LINK_REVOKED'
|
||||||
|
? 'Link Revoked'
|
||||||
|
: pageState.code === 'SHARE_LINK_NOT_FOUND'
|
||||||
|
? 'Not Found'
|
||||||
|
: 'Error'
|
||||||
|
}
|
||||||
|
subTitle={pageState.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageState.status === 'ready' && (
|
||||||
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
|
{!collab.active && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
background: 'rgba(13,27,42,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" align="center">
|
||||||
|
<Spin size="large" />
|
||||||
|
<Text type="secondary">Connecting to collaboration session...</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Editor
|
||||||
|
height="100%"
|
||||||
|
language={getLanguage(shareData?.documentPath ?? '')}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
readOnly: !shareData?.canEdit,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
wordWrap: 'on',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
fontSize: 14,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
automaticLayout: true,
|
||||||
|
padding: { top: 12 },
|
||||||
|
}}
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DocShareLinkStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ContactActivityType" ADD VALUE IF NOT EXISTS 'POLL_VOTED';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "doc_access_policies" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"document_path" TEXT NOT NULL,
|
||||||
|
"is_directory" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"allowed_editors" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"created_by_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "doc_access_policies_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "doc_share_links" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"document_path" TEXT NOT NULL,
|
||||||
|
"share_token" TEXT NOT NULL,
|
||||||
|
"status" "DocShareLinkStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"can_edit" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"expires_at" TIMESTAMP(3),
|
||||||
|
"max_uses" INTEGER,
|
||||||
|
"use_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"guest_name" TEXT,
|
||||||
|
"created_by_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "doc_share_links_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "doc_watches" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"file_path" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "doc_watches_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "doc_access_policies_document_path_key" ON "doc_access_policies"("document_path");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "doc_access_policies_created_by_id_idx" ON "doc_access_policies"("created_by_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "doc_share_links_share_token_key" ON "doc_share_links"("share_token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "doc_share_links_document_path_idx" ON "doc_share_links"("document_path");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "doc_share_links_created_by_id_idx" ON "doc_share_links"("created_by_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "doc_watches_file_path_idx" ON "doc_watches"("file_path");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "doc_watches_user_id_file_path_key" ON "doc_watches"("user_id", "file_path");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "doc_access_policies" ADD CONSTRAINT "doc_access_policies_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "doc_share_links" ADD CONSTRAINT "doc_share_links_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "doc_watches" ADD CONSTRAINT "doc_watches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "site_settings" ADD COLUMN "gitea_setup_complete" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -210,6 +210,11 @@ model User {
|
|||||||
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
|
sharedViewReactions SharedViewReaction[] @relation("SharedViewReactionUser")
|
||||||
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
|
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
|
||||||
|
|
||||||
|
// Docs access & sharing
|
||||||
|
docAccessPoliciesCreated DocAccessPolicy[] @relation("DocAccessPolicyCreator")
|
||||||
|
docShareLinksCreated DocShareLink[] @relation("DocShareLinkCreator")
|
||||||
|
docWatches DocWatch[] @relation("DocWatcher")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -975,6 +980,7 @@ model SiteSettings {
|
|||||||
giteaCommentsRepoName String @default("docs-comments")
|
giteaCommentsRepoName String @default("docs-comments")
|
||||||
giteaOauthClientId String @default("")
|
giteaOauthClientId String @default("")
|
||||||
giteaOauthClientSecret String @default("") // Encrypted at rest
|
giteaOauthClientSecret String @default("") // Encrypted at rest
|
||||||
|
giteaSetupComplete Boolean @default(false) @map("gitea_setup_complete")
|
||||||
|
|
||||||
// Notification settings
|
// Notification settings
|
||||||
notifyAdminShiftSignup Boolean @default(true)
|
notifyAdminShiftSignup Boolean @default(true)
|
||||||
@ -5169,6 +5175,60 @@ model DocCollabState {
|
|||||||
@@map("doc_collab_state")
|
@@map("doc_collab_state")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Document Access Policies ---
|
||||||
|
|
||||||
|
enum DocShareLinkStatus {
|
||||||
|
ACTIVE
|
||||||
|
REVOKED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
model DocAccessPolicy {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
documentPath String @unique @map("document_path") // e.g. "admin/index.md" or "guides/" (trailing slash = directory)
|
||||||
|
isDirectory Boolean @default(false) @map("is_directory")
|
||||||
|
allowedEditors Json @default("[]") @map("allowed_editors") // ["role:CONTENT_ADMIN", "user:clxyz", "all_content_editors"]
|
||||||
|
createdById String @map("created_by_id")
|
||||||
|
createdBy User @relation("DocAccessPolicyCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([createdById])
|
||||||
|
@@map("doc_access_policies")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DocShareLink {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
documentPath String @map("document_path")
|
||||||
|
shareToken String @unique @map("share_token")
|
||||||
|
status DocShareLinkStatus @default(ACTIVE)
|
||||||
|
canEdit Boolean @default(true) @map("can_edit")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
maxUses Int? @map("max_uses")
|
||||||
|
useCount Int @default(0) @map("use_count")
|
||||||
|
guestName String? @map("guest_name")
|
||||||
|
createdById String @map("created_by_id")
|
||||||
|
createdBy User @relation("DocShareLinkCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([documentPath])
|
||||||
|
@@index([createdById])
|
||||||
|
@@map("doc_share_links")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DocWatch {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
filePath String @map("file_path")
|
||||||
|
user User @relation("DocWatcher", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([userId, filePath])
|
||||||
|
@@index([filePath])
|
||||||
|
@@map("doc_watches")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PARTICIPANT NEEDS
|
// PARTICIPANT NEEDS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -191,6 +191,14 @@ const envSchema = z.object({
|
|||||||
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
|
GITEA_OAUTH_CLIENT_ID: z.string().default(''),
|
||||||
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
|
GITEA_OAUTH_CLIENT_SECRET: z.string().default(''),
|
||||||
|
|
||||||
|
// Gitea Docs Version History
|
||||||
|
GITEA_DOCS_REPO: z.string().default('admin/changemaker.lite'),
|
||||||
|
GITEA_DOCS_PREFIX: z.string().default('mkdocs/docs'),
|
||||||
|
GITEA_DOCS_BRANCH: z.string().default('v2'),
|
||||||
|
|
||||||
|
// Gitea Auto-Setup (password used once to create API token, then cleared)
|
||||||
|
GITEA_ADMIN_PASSWORD: z.string().default(''),
|
||||||
|
|
||||||
// SMS Campaigns (Termux Android bridge)
|
// SMS Campaigns (Termux Android bridge)
|
||||||
ENABLE_SMS: z.string().default('false'),
|
ENABLE_SMS: z.string().default('false'),
|
||||||
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),
|
TERMUX_API_URL: z.string().default('http://10.0.0.193:5001'),
|
||||||
|
|||||||
51
api/src/modules/docs/blog.schemas.ts
Normal file
51
api/src/modules/docs/blog.schemas.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// --- Author entry (for .authors.yml) ---
|
||||||
|
|
||||||
|
export const authorSocialLinkSchema = z.object({
|
||||||
|
icon: z.string().min(1).max(100),
|
||||||
|
link: z.string().url().max(500),
|
||||||
|
name: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authorEntrySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
avatar: z.string().max(500).optional(),
|
||||||
|
social: z.array(authorSocialLinkSchema).max(10).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authorsFileSchema = z.object({
|
||||||
|
authors: z.record(z.string().min(1).max(50), authorEntrySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthorEntry = z.infer<typeof authorEntrySchema>;
|
||||||
|
export type AuthorsFile = z.infer<typeof authorsFileSchema>;
|
||||||
|
|
||||||
|
// --- Blog post frontmatter ---
|
||||||
|
|
||||||
|
export const blogFrontmatterSchema = z.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format'),
|
||||||
|
authors: z.array(z.string().min(1)).min(1),
|
||||||
|
categories: z.array(z.string().min(1)).default([]),
|
||||||
|
tags: z.array(z.string().min(1)).optional(),
|
||||||
|
draft: z.boolean().optional(),
|
||||||
|
slug: z.string().regex(/^[a-z0-9-]+$/).max(200).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BlogFrontmatter = z.infer<typeof blogFrontmatterSchema>;
|
||||||
|
|
||||||
|
// --- New blog post wizard ---
|
||||||
|
|
||||||
|
export const newBlogPostSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
authors: z.array(z.string()).min(1),
|
||||||
|
categories: z.array(z.string()).default([]),
|
||||||
|
draft: z.boolean().default(true),
|
||||||
|
slug: z.string().regex(/^[a-z0-9-]+$/).max(200).optional(),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NewBlogPost = z.infer<typeof newBlogPostSchema>;
|
||||||
198
api/src/modules/docs/blog.service.ts
Normal file
198
api/src/modules/docs/blog.service.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
|
||||||
|
import { redis } from '../../config/redis';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { docsFilesService, FileNotFoundError } from './docs-files.service';
|
||||||
|
import { authorsFileSchema, type AuthorsFile, type AuthorEntry, type NewBlogPost } from './blog.schemas';
|
||||||
|
|
||||||
|
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||||
|
const CATEGORIES_CACHE_KEY = 'DOCS_CACHE:blog:categories';
|
||||||
|
const CATEGORIES_CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
export interface ParsedFrontmatter {
|
||||||
|
frontmatter: Record<string, unknown>;
|
||||||
|
body: string;
|
||||||
|
rawYaml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YAML frontmatter from markdown content.
|
||||||
|
* Returns null if no frontmatter block found.
|
||||||
|
*/
|
||||||
|
function parseFrontmatter(content: string): ParsedFrontmatter | null {
|
||||||
|
const match = content.match(FRONTMATTER_REGEX);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const rawYaml = match[1];
|
||||||
|
const body = match[2];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontmatter = yamlParse(rawYaml) as Record<string, unknown>;
|
||||||
|
return { frontmatter: frontmatter || {}, body, rawYaml };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize frontmatter object + body back to markdown.
|
||||||
|
* Uses YAML stringify with options to produce MkDocs Material-compatible output
|
||||||
|
* (no quoted dates, block arrays).
|
||||||
|
*/
|
||||||
|
function serializeFrontmatter(frontmatter: Record<string, unknown>, body: string): string {
|
||||||
|
// Order keys for consistent output
|
||||||
|
const ordered: Record<string, unknown> = {};
|
||||||
|
const keyOrder = ['date', 'authors', 'categories', 'tags', 'draft', 'slug', 'description'];
|
||||||
|
for (const key of keyOrder) {
|
||||||
|
if (frontmatter[key] !== undefined) ordered[key] = frontmatter[key];
|
||||||
|
}
|
||||||
|
// Include any extra keys not in the order
|
||||||
|
for (const key of Object.keys(frontmatter)) {
|
||||||
|
if (!(key in ordered)) ordered[key] = frontmatter[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const yaml = yamlStringify(ordered, {
|
||||||
|
lineWidth: 0,
|
||||||
|
defaultStringType: 'PLAIN',
|
||||||
|
defaultKeyType: 'PLAIN',
|
||||||
|
}).trimEnd();
|
||||||
|
|
||||||
|
return `---\n${yaml}\n---\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse the blog/.authors.yml file.
|
||||||
|
*/
|
||||||
|
async function readAuthorsFile(): Promise<Record<string, AuthorEntry>> {
|
||||||
|
try {
|
||||||
|
const content = await docsFilesService.readFileContent('blog/.authors.yml');
|
||||||
|
const parsed = yamlParse(content) as { authors?: Record<string, AuthorEntry> };
|
||||||
|
return parsed?.authors || {};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof FileNotFoundError) {
|
||||||
|
// Create default empty authors file
|
||||||
|
await docsFilesService.writeFileContent('blog/.authors.yml', 'authors: {}\n');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the blog/.authors.yml file.
|
||||||
|
* Validates structure with Zod before writing.
|
||||||
|
*/
|
||||||
|
async function writeAuthorsFile(authors: Record<string, AuthorEntry>): Promise<void> {
|
||||||
|
// Validate
|
||||||
|
authorsFileSchema.parse({ authors });
|
||||||
|
|
||||||
|
const yaml = yamlStringify({ authors }, {
|
||||||
|
lineWidth: 0,
|
||||||
|
defaultStringType: 'PLAIN',
|
||||||
|
defaultKeyType: 'PLAIN',
|
||||||
|
});
|
||||||
|
|
||||||
|
await docsFilesService.writeFileContent('blog/.authors.yml', yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all unique categories from existing blog posts.
|
||||||
|
* Results are cached in Redis for 5 minutes.
|
||||||
|
*/
|
||||||
|
async function extractCategories(): Promise<string[]> {
|
||||||
|
// Try cache
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(CATEGORIES_CACHE_KEY);
|
||||||
|
if (cached) return JSON.parse(cached) as string[];
|
||||||
|
} catch {
|
||||||
|
// Ignore cache errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = await docsFilesService.listTree();
|
||||||
|
const categories = new Set<string>();
|
||||||
|
|
||||||
|
// Find blog/posts directory
|
||||||
|
function findBlogPosts(nodes: typeof tree): typeof tree {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.path === 'blog/posts' && node.isDirectory && node.children) {
|
||||||
|
return node.children;
|
||||||
|
}
|
||||||
|
if (node.isDirectory && node.children) {
|
||||||
|
const result = findBlogPosts(node.children);
|
||||||
|
if (result.length > 0) return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = findBlogPosts(tree);
|
||||||
|
|
||||||
|
// Read each post and extract categories
|
||||||
|
await Promise.all(
|
||||||
|
posts
|
||||||
|
.filter(n => !n.isDirectory && n.name.endsWith('.md'))
|
||||||
|
.map(async (node) => {
|
||||||
|
try {
|
||||||
|
const content = await docsFilesService.readFileContent(node.path);
|
||||||
|
const parsed = parseFrontmatter(content);
|
||||||
|
if (parsed?.frontmatter.categories && Array.isArray(parsed.frontmatter.categories)) {
|
||||||
|
for (const cat of parsed.frontmatter.categories) {
|
||||||
|
if (typeof cat === 'string') categories.add(cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip files that can't be read
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = Array.from(categories).sort();
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
try {
|
||||||
|
await redis.setex(CATEGORIES_CACHE_KEY, CATEGORIES_CACHE_TTL, JSON.stringify(sorted));
|
||||||
|
} catch {
|
||||||
|
// Ignore cache errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scaffold a new blog post with frontmatter.
|
||||||
|
*/
|
||||||
|
function scaffoldBlogPost(opts: NewBlogPost): { content: string; suggestedPath: string } {
|
||||||
|
const slug = opts.slug || slugify(opts.title);
|
||||||
|
const frontmatter: Record<string, unknown> = {
|
||||||
|
date: opts.date,
|
||||||
|
authors: opts.authors,
|
||||||
|
};
|
||||||
|
if (opts.categories.length > 0) frontmatter.categories = opts.categories;
|
||||||
|
if (opts.draft) frontmatter.draft = true;
|
||||||
|
if (opts.slug) frontmatter.slug = opts.slug;
|
||||||
|
if (opts.description) frontmatter.description = opts.description;
|
||||||
|
|
||||||
|
const body = `\n# ${opts.title}\n\nWrite your post content here.\n\n<!-- more -->\n\nContinue writing below the fold...\n`;
|
||||||
|
const content = serializeFrontmatter(frontmatter, body);
|
||||||
|
const suggestedPath = `blog/posts/${opts.date}-${slug}.md`;
|
||||||
|
|
||||||
|
return { content, suggestedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.substring(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blogService = {
|
||||||
|
parseFrontmatter,
|
||||||
|
serializeFrontmatter,
|
||||||
|
readAuthorsFile,
|
||||||
|
writeAuthorsFile,
|
||||||
|
extractCategories,
|
||||||
|
scaffoldBlogPost,
|
||||||
|
};
|
||||||
297
api/src/modules/docs/docs-access.routes.ts
Normal file
297
api/src/modules/docs/docs-access.routes.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
|
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
|
import { prisma } from '../../config/database';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { docsAccessService, ShareLinkError } from './docs-access.service';
|
||||||
|
import { docsFilesService } from './docs-files.service';
|
||||||
|
import { upsertPolicySchema, createShareLinkSchema } from './docs-access.schemas';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// --- Access Policy Routes (authenticated, CONTENT_ROLES) ---
|
||||||
|
|
||||||
|
// GET /api/docs-access/policy?path=... — get effective policy for a file
|
||||||
|
router.get(
|
||||||
|
'/policy',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const path = String(req.query['path'] ?? '').trim();
|
||||||
|
if (!path) {
|
||||||
|
res.status(400).json({ error: { message: 'Path query parameter required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const policy = await docsAccessService.getEffectivePolicy(path);
|
||||||
|
res.json(policy);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get doc access policy', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/docs-access/policy — create or update policy for a path
|
||||||
|
router.put(
|
||||||
|
'/policy',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = upsertPolicySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid policy data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { documentPath, isDirectory, allowedEditors } = parsed.data;
|
||||||
|
|
||||||
|
const policy = await prisma.docAccessPolicy.upsert({
|
||||||
|
where: { documentPath },
|
||||||
|
create: {
|
||||||
|
documentPath,
|
||||||
|
isDirectory,
|
||||||
|
allowedEditors: allowedEditors as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||||
|
createdById: req.user!.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
isDirectory,
|
||||||
|
allowedEditors: allowedEditors as unknown as import('@prisma/client').Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(policy);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to upsert doc access policy', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/docs-access/policy?path=... — remove policy (revert to default)
|
||||||
|
router.delete(
|
||||||
|
'/policy',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const path = String(req.query['path'] ?? '').trim();
|
||||||
|
if (!path) {
|
||||||
|
res.status(400).json({ error: { message: 'Path query parameter required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.docAccessPolicy.deleteMany({
|
||||||
|
where: { documentPath: path },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to delete doc access policy', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/docs-access/policies — list all policies
|
||||||
|
router.get(
|
||||||
|
'/policies',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const policies = await prisma.docAccessPolicy.findMany({
|
||||||
|
orderBy: { documentPath: 'asc' },
|
||||||
|
include: { createdBy: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
res.json({ policies });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to list doc access policies', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Share Link Routes ---
|
||||||
|
|
||||||
|
// POST /api/docs-access/share/create — generate share link (authenticated, CONTENT_ROLES)
|
||||||
|
router.post(
|
||||||
|
'/share/create',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = createShareLinkSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid share link data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file exists
|
||||||
|
try {
|
||||||
|
docsFilesService.safeResolve(parsed.data.documentPath);
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: { message: 'Document not found', code: 'NOT_FOUND' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = await docsAccessService.generateShareLink(
|
||||||
|
req.user!.id,
|
||||||
|
parsed.data.documentPath,
|
||||||
|
{
|
||||||
|
canEdit: parsed.data.canEdit,
|
||||||
|
expiresInHours: parsed.data.expiresInHours,
|
||||||
|
maxUses: parsed.data.maxUses,
|
||||||
|
guestName: parsed.data.guestName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(link);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to create share link', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/docs-access/share/links?path=... — list share links for a document
|
||||||
|
router.get(
|
||||||
|
'/share/links',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const path = String(req.query['path'] ?? '').trim();
|
||||||
|
const where = path ? { documentPath: path } : {};
|
||||||
|
|
||||||
|
const links = await prisma.docShareLink.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { createdBy: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ links });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to list share links', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /api/docs-access/share/:id/revoke — revoke a share link
|
||||||
|
router.patch(
|
||||||
|
'/share/:id/revoke',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
const link = await prisma.docShareLink.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'REVOKED' },
|
||||||
|
});
|
||||||
|
res.json(link);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to revoke share link', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/docs-access/share/:id — delete a share link record
|
||||||
|
router.delete(
|
||||||
|
'/share/:id',
|
||||||
|
authenticate,
|
||||||
|
requireNonTemp,
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const id = req.params.id as string;
|
||||||
|
await prisma.docShareLink.delete({ where: { id } });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to delete share link', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Public Share Redemption (no auth required) ---
|
||||||
|
|
||||||
|
// GET /api/docs-access/share/public/:shareToken — validate token, return doc metadata + collab JWT
|
||||||
|
router.get(
|
||||||
|
'/share/public/:shareToken',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const shareToken = req.params.shareToken as string;
|
||||||
|
if (!shareToken || shareToken.length < 32) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid share token', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareLink = await docsAccessService.validateShareToken(shareToken);
|
||||||
|
|
||||||
|
// Read document content to get the filename
|
||||||
|
let documentName = shareLink.documentPath.split('/').pop() || 'Document';
|
||||||
|
try {
|
||||||
|
// Check file still exists
|
||||||
|
docsFilesService.safeResolve(shareLink.documentPath);
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: { message: 'Shared document no longer exists', code: 'NOT_FOUND' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a short-lived collab JWT for WebSocket access
|
||||||
|
const collabToken = docsAccessService.generateShareCollabToken({
|
||||||
|
shareToken,
|
||||||
|
documentPath: shareLink.documentPath,
|
||||||
|
canEdit: shareLink.canEdit,
|
||||||
|
guestName: shareLink.guestName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deterministic color for guest
|
||||||
|
const colorPalette = [
|
||||||
|
'#E57373', '#81C784', '#64B5F6', '#FFB74D', '#BA68C8',
|
||||||
|
'#4DD0E1', '#FF8A65', '#AED581', '#9575CD', '#4DB6AC',
|
||||||
|
'#F06292', '#FFD54F', '#7986CB', '#A1887F', '#90A4AE',
|
||||||
|
];
|
||||||
|
const colorIndex = shareToken.charCodeAt(0) % colorPalette.length;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
documentPath: shareLink.documentPath,
|
||||||
|
documentName,
|
||||||
|
canEdit: shareLink.canEdit,
|
||||||
|
collabToken,
|
||||||
|
guestIdentity: {
|
||||||
|
id: `share:${shareToken.substring(0, 8)}`,
|
||||||
|
name: shareLink.guestName || 'Guest',
|
||||||
|
color: colorPalette[colorIndex],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ShareLinkError) {
|
||||||
|
const statusMap: Record<string, number> = {
|
||||||
|
SHARE_LINK_NOT_FOUND: 404,
|
||||||
|
SHARE_LINK_REVOKED: 410,
|
||||||
|
SHARE_LINK_EXPIRED: 410,
|
||||||
|
SHARE_LINK_MAX_USES: 429,
|
||||||
|
};
|
||||||
|
res.status(statusMap[err.code] || 400).json({ error: { message: err.message, code: err.code } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Failed to validate share link', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const docsAccessRouter = router;
|
||||||
23
api/src/modules/docs/docs-access.schemas.ts
Normal file
23
api/src/modules/docs/docs-access.schemas.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// --- Access policy ---
|
||||||
|
|
||||||
|
export const upsertPolicySchema = z.object({
|
||||||
|
documentPath: z.string().min(1).max(500),
|
||||||
|
isDirectory: z.boolean().optional().default(false),
|
||||||
|
allowedEditors: z.array(z.string().max(200)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpsertPolicyInput = z.infer<typeof upsertPolicySchema>;
|
||||||
|
|
||||||
|
// --- Share link ---
|
||||||
|
|
||||||
|
export const createShareLinkSchema = z.object({
|
||||||
|
documentPath: z.string().min(1).max(500),
|
||||||
|
canEdit: z.boolean().optional().default(true),
|
||||||
|
expiresInHours: z.number().int().positive().max(720).optional(), // max 30 days
|
||||||
|
maxUses: z.number().int().positive().max(1000).optional(),
|
||||||
|
guestName: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateShareLinkInput = z.infer<typeof createShareLinkSchema>;
|
||||||
286
api/src/modules/docs/docs-access.service.ts
Normal file
286
api/src/modules/docs/docs-access.service.ts
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { prisma } from '../../config/database';
|
||||||
|
import { env } from '../../config/env';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { docsFilesService } from './docs-files.service';
|
||||||
|
import type { CreateShareLinkInput } from './docs-access.schemas';
|
||||||
|
|
||||||
|
// --- Access Policy Resolution ---
|
||||||
|
|
||||||
|
interface EffectivePolicy {
|
||||||
|
id: string | null;
|
||||||
|
documentPath: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
allowedEditors: string[];
|
||||||
|
isDefault: boolean; // true if no policy found (all content editors)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the most specific applicable policy for a document path.
|
||||||
|
* Walks from exact path up through parent directories.
|
||||||
|
* Returns default "all content editors" if no policy found.
|
||||||
|
*/
|
||||||
|
async function getEffectivePolicy(documentPath: string): Promise<EffectivePolicy> {
|
||||||
|
// Normalize path: remove leading/trailing slashes
|
||||||
|
const normalized = documentPath.replace(/^\/+|\/+$/g, '');
|
||||||
|
|
||||||
|
// 1. Check for exact file match
|
||||||
|
const exactPolicy = await prisma.docAccessPolicy.findUnique({
|
||||||
|
where: { documentPath: normalized },
|
||||||
|
});
|
||||||
|
if (exactPolicy) {
|
||||||
|
return {
|
||||||
|
id: exactPolicy.id,
|
||||||
|
documentPath: exactPolicy.documentPath,
|
||||||
|
isDirectory: exactPolicy.isDirectory,
|
||||||
|
allowedEditors: exactPolicy.allowedEditors as string[],
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up directory hierarchy looking for directory policies
|
||||||
|
const segments = normalized.split('/');
|
||||||
|
for (let i = segments.length - 1; i >= 1; i--) {
|
||||||
|
const dirPath = segments.slice(0, i).join('/');
|
||||||
|
const dirPolicy = await prisma.docAccessPolicy.findUnique({
|
||||||
|
where: { documentPath: dirPath },
|
||||||
|
});
|
||||||
|
if (dirPolicy && dirPolicy.isDirectory) {
|
||||||
|
return {
|
||||||
|
id: dirPolicy.id,
|
||||||
|
documentPath: dirPolicy.documentPath,
|
||||||
|
isDirectory: true,
|
||||||
|
allowedEditors: dirPolicy.allowedEditors as string[],
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No policy found — default to all content editors
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
documentPath: normalized,
|
||||||
|
isDirectory: false,
|
||||||
|
allowedEditors: ['all_content_editors'],
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user can edit a specific document.
|
||||||
|
* SUPER_ADMIN always passes.
|
||||||
|
*/
|
||||||
|
async function canUserEdit(
|
||||||
|
userId: string,
|
||||||
|
userRoles: UserRole[],
|
||||||
|
documentPath: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// SUPER_ADMIN always passes
|
||||||
|
if (userRoles.includes(UserRole.SUPER_ADMIN)) return true;
|
||||||
|
|
||||||
|
const policy = await getEffectivePolicy(documentPath);
|
||||||
|
|
||||||
|
for (const editor of policy.allowedEditors) {
|
||||||
|
if (editor === 'all_content_editors') {
|
||||||
|
// Check if user has any CONTENT_ROLES
|
||||||
|
if (userRoles.includes(UserRole.CONTENT_ADMIN) || userRoles.includes(UserRole.SUPER_ADMIN)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (editor.startsWith('user:')) {
|
||||||
|
if (editor === `user:${userId}`) return true;
|
||||||
|
} else if (editor.startsWith('role:')) {
|
||||||
|
const role = editor.substring(5) as UserRole;
|
||||||
|
if (userRoles.includes(role)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Share Link Management ---
|
||||||
|
|
||||||
|
interface ShareLinkOptions {
|
||||||
|
canEdit?: boolean;
|
||||||
|
expiresInHours?: number;
|
||||||
|
maxUses?: number;
|
||||||
|
guestName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a share link for a document.
|
||||||
|
*/
|
||||||
|
async function generateShareLink(
|
||||||
|
createdById: string,
|
||||||
|
documentPath: string,
|
||||||
|
options: ShareLinkOptions = {},
|
||||||
|
): Promise<{ id: string; shareToken: string; documentPath: string }> {
|
||||||
|
// Validate the file exists
|
||||||
|
docsFilesService.safeResolve(documentPath);
|
||||||
|
|
||||||
|
const shareToken = crypto.randomBytes(24).toString('hex');
|
||||||
|
const expiresAt = options.expiresInHours
|
||||||
|
? new Date(Date.now() + options.expiresInHours * 60 * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const link = await prisma.docShareLink.create({
|
||||||
|
data: {
|
||||||
|
documentPath: documentPath.replace(/^\/+|\/+$/g, ''),
|
||||||
|
shareToken,
|
||||||
|
canEdit: options.canEdit ?? true,
|
||||||
|
expiresAt,
|
||||||
|
maxUses: options.maxUses ?? null,
|
||||||
|
guestName: options.guestName ?? null,
|
||||||
|
createdById,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: link.id, shareToken: link.shareToken, documentPath: link.documentPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a share token. Returns the share link or throws.
|
||||||
|
* Increments use count on successful validation.
|
||||||
|
*/
|
||||||
|
async function validateShareToken(shareToken: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
documentPath: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
guestName: string | null;
|
||||||
|
}> {
|
||||||
|
const link = await prisma.docShareLink.findUnique({
|
||||||
|
where: { shareToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!link) throw new ShareLinkError('Share link not found', 'SHARE_LINK_NOT_FOUND');
|
||||||
|
if (link.status !== 'ACTIVE') throw new ShareLinkError('Share link has been revoked', 'SHARE_LINK_REVOKED');
|
||||||
|
if (link.expiresAt && link.expiresAt < new Date()) {
|
||||||
|
// Auto-expire
|
||||||
|
await prisma.docShareLink.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: { status: 'EXPIRED' },
|
||||||
|
});
|
||||||
|
throw new ShareLinkError('Share link has expired', 'SHARE_LINK_EXPIRED');
|
||||||
|
}
|
||||||
|
if (link.maxUses && link.useCount >= link.maxUses) {
|
||||||
|
throw new ShareLinkError('Share link has reached maximum uses', 'SHARE_LINK_MAX_USES');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment use count
|
||||||
|
await prisma.docShareLink.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: { useCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: link.id,
|
||||||
|
documentPath: link.documentPath,
|
||||||
|
canEdit: link.canEdit,
|
||||||
|
guestName: link.guestName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a short-lived JWT for a share-link guest to use with the collab WebSocket.
|
||||||
|
*/
|
||||||
|
function generateShareCollabToken(shareLink: {
|
||||||
|
shareToken: string;
|
||||||
|
documentPath: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
guestName: string | null;
|
||||||
|
}): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
type: 'doc_share',
|
||||||
|
shareToken: shareLink.shareToken,
|
||||||
|
documentPath: shareLink.documentPath,
|
||||||
|
canEdit: shareLink.canEdit,
|
||||||
|
guestName: shareLink.guestName || 'Guest',
|
||||||
|
},
|
||||||
|
env.JWT_INVITE_SECRET,
|
||||||
|
{ expiresIn: '4h' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cascade Operations ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update access policies and share links when a file is renamed.
|
||||||
|
*/
|
||||||
|
async function cascadeRename(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
const normalizedOld = oldPath.replace(/^\/+|\/+$/g, '');
|
||||||
|
const normalizedNew = newPath.replace(/^\/+|\/+$/g, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update exact match policy
|
||||||
|
await prisma.docAccessPolicy.updateMany({
|
||||||
|
where: { documentPath: normalizedOld },
|
||||||
|
data: { documentPath: normalizedNew },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update share links
|
||||||
|
await prisma.docShareLink.updateMany({
|
||||||
|
where: { documentPath: normalizedOld },
|
||||||
|
data: { documentPath: normalizedNew },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update doc watches
|
||||||
|
await prisma.docWatch.updateMany({
|
||||||
|
where: { filePath: normalizedOld },
|
||||||
|
data: { filePath: normalizedNew },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Docs access: cascaded rename ${normalizedOld} → ${normalizedNew}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to cascade rename for docs access:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke share links and delete policy when a file is deleted.
|
||||||
|
*/
|
||||||
|
async function cascadeDelete(path: string): Promise<void> {
|
||||||
|
const normalized = path.replace(/^\/+|\/+$/g, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Revoke active share links
|
||||||
|
await prisma.docShareLink.updateMany({
|
||||||
|
where: { documentPath: normalized, status: 'ACTIVE' },
|
||||||
|
data: { status: 'REVOKED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete access policy
|
||||||
|
await prisma.docAccessPolicy.deleteMany({
|
||||||
|
where: { documentPath: normalized },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete watches
|
||||||
|
await prisma.docWatch.deleteMany({
|
||||||
|
where: { filePath: normalized },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Docs access: cascaded delete for ${normalized}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to cascade delete for docs access:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShareLinkError extends Error {
|
||||||
|
code: string;
|
||||||
|
constructor(message: string, code: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ShareLinkError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsAccessService = {
|
||||||
|
getEffectivePolicy,
|
||||||
|
canUserEdit,
|
||||||
|
generateShareLink,
|
||||||
|
validateShareToken,
|
||||||
|
generateShareCollabToken,
|
||||||
|
cascadeRename,
|
||||||
|
cascadeDelete,
|
||||||
|
};
|
||||||
@ -10,8 +10,9 @@ import { env } from '../../config/env';
|
|||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { redis } from '../../config/redis';
|
import { redis } from '../../config/redis';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { CONTENT_ROLES } from '../../utils/roles';
|
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
|
||||||
import { docsFilesService } from './docs-files.service';
|
import { docsFilesService } from './docs-files.service';
|
||||||
|
import { docsAccessService } from './docs-access.service';
|
||||||
|
|
||||||
// --- Metrics ---
|
// --- Metrics ---
|
||||||
import { Gauge } from 'prom-client';
|
import { Gauge } from 'prom-client';
|
||||||
@ -39,6 +40,15 @@ interface TokenPayload {
|
|||||||
roles?: UserRole[];
|
roles?: UserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Share-link collab JWT payload (signed with JWT_INVITE_SECRET)
|
||||||
|
interface ShareTokenPayload {
|
||||||
|
type: 'doc_share';
|
||||||
|
shareToken: string;
|
||||||
|
documentPath: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
guestName: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Deterministic color from user ID ---
|
// --- Deterministic color from user ID ---
|
||||||
const COLLAB_COLORS = [
|
const COLLAB_COLORS = [
|
||||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||||||
@ -71,26 +81,6 @@ const docsExtension: Extension = {
|
|||||||
throw new Error('Authentication required');
|
throw new Error('Authentication required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify JWT
|
|
||||||
let payload: TokenPayload;
|
|
||||||
try {
|
|
||||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid or expired token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const roles = payload.roles || [payload.role];
|
|
||||||
|
|
||||||
// Check CONTENT_ROLES for write access
|
|
||||||
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
|
|
||||||
if (!hasWriteAccess) {
|
|
||||||
// Allow read-only for any authenticated non-TEMP user
|
|
||||||
if (roles.includes(UserRole.TEMP)) {
|
|
||||||
throw new Error('TEMP users cannot access collaboration');
|
|
||||||
}
|
|
||||||
data.connectionConfig.readOnly = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate document path (prevent path traversal)
|
// Validate document path (prevent path traversal)
|
||||||
try {
|
try {
|
||||||
docsFilesService.safeResolve(documentName);
|
docsFilesService.safeResolve(documentName);
|
||||||
@ -98,45 +88,129 @@ const docsExtension: Extension = {
|
|||||||
throw new Error('Invalid document path');
|
throw new Error('Invalid document path');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit: max connections per user
|
// Try standard JWT (access token) first
|
||||||
const currentCount = connectionsPerUser.get(payload.id) || 0;
|
let payload: TokenPayload | null = null;
|
||||||
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
|
try {
|
||||||
|
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
|
} catch {
|
||||||
|
// Standard JWT failed — try share-link JWT below
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
// --- Standard authenticated user flow ---
|
||||||
|
const roles = payload.roles || [payload.role];
|
||||||
|
|
||||||
|
// Check CONTENT_ROLES for write access
|
||||||
|
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
|
||||||
|
if (!hasWriteAccess) {
|
||||||
|
if (roles.includes(UserRole.TEMP)) {
|
||||||
|
throw new Error('TEMP users cannot access collaboration');
|
||||||
|
}
|
||||||
|
data.connectionConfig.readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-file access policy check
|
||||||
|
if (hasWriteAccess) {
|
||||||
|
try {
|
||||||
|
const canEdit = await docsAccessService.canUserEdit(payload.id, roles as UserRole[], documentName);
|
||||||
|
if (!canEdit) {
|
||||||
|
data.connectionConfig.readOnly = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If policy check fails, default to read-only
|
||||||
|
data.connectionConfig.readOnly = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: max connections per user
|
||||||
|
const currentCount = connectionsPerUser.get(payload.id) || 0;
|
||||||
|
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
|
||||||
|
throw new Error('Too many concurrent connections');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: max concurrent documents
|
||||||
|
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
|
||||||
|
if (!hocuspocus.documents.has(documentName)) {
|
||||||
|
throw new Error('Too many concurrent documents');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track connection
|
||||||
|
connectionsPerUser.set(payload.id, currentCount + 1);
|
||||||
|
|
||||||
|
// Look up user name from DB
|
||||||
|
let userName = payload.email.split('@')[0];
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.id },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
if (user?.name) userName = user.name;
|
||||||
|
} catch {
|
||||||
|
// Fall back to email prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
data.context.user = {
|
||||||
|
id: payload.id,
|
||||||
|
email: payload.email,
|
||||||
|
name: userName,
|
||||||
|
color: getUserColor(payload.id),
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Share-link guest flow ---
|
||||||
|
let sharePayload: ShareTokenPayload;
|
||||||
|
try {
|
||||||
|
sharePayload = jwt.verify(token, env.JWT_INVITE_SECRET, { algorithms: ['HS256'] }) as ShareTokenPayload;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid or expired token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a doc_share token
|
||||||
|
if (sharePayload.type !== 'doc_share') {
|
||||||
|
throw new Error('Invalid token type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the document matches
|
||||||
|
if (sharePayload.documentPath !== documentName) {
|
||||||
|
throw new Error('Token does not match this document');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validate share token against DB (could have been revoked since page load)
|
||||||
|
try {
|
||||||
|
await docsAccessService.validateShareToken(sharePayload.shareToken);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Share link has been revoked or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set read-only if share link doesn't grant edit
|
||||||
|
if (!sharePayload.canEdit) {
|
||||||
|
data.connectionConfig.readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit for share guests (keyed on share token prefix)
|
||||||
|
const guestKey = `share:${sharePayload.shareToken.substring(0, 8)}`;
|
||||||
|
const guestCount = connectionsPerUser.get(guestKey) || 0;
|
||||||
|
if (guestCount >= MAX_CONNECTIONS_PER_USER) {
|
||||||
throw new Error('Too many concurrent connections');
|
throw new Error('Too many concurrent connections');
|
||||||
}
|
}
|
||||||
|
connectionsPerUser.set(guestKey, guestCount + 1);
|
||||||
|
|
||||||
// Rate limit: max concurrent documents
|
// Set guest context
|
||||||
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
|
|
||||||
// Only block if this is a NEW document (not joining existing)
|
|
||||||
if (!hocuspocus.documents.has(documentName)) {
|
|
||||||
throw new Error('Too many concurrent documents');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track connection
|
|
||||||
connectionsPerUser.set(payload.id, currentCount + 1);
|
|
||||||
|
|
||||||
// Look up user name from DB
|
|
||||||
let userName = payload.email.split('@')[0];
|
|
||||||
try {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: payload.id },
|
|
||||||
select: { name: true },
|
|
||||||
});
|
|
||||||
if (user?.name) userName = user.name;
|
|
||||||
} catch {
|
|
||||||
// Fall back to email prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set context for use in other hooks
|
|
||||||
data.context.user = {
|
data.context.user = {
|
||||||
id: payload.id,
|
id: guestKey,
|
||||||
email: payload.email,
|
email: '',
|
||||||
name: userName,
|
name: sharePayload.guestName || 'Guest',
|
||||||
color: getUserColor(payload.id),
|
color: getUserColor(sharePayload.shareToken),
|
||||||
roles,
|
roles: [],
|
||||||
|
isShareGuest: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
|
logger.info(`Docs collab: guest "${sharePayload.guestName || 'Guest'}" connected to ${documentName} via share link`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async onLoadDocument(data) {
|
async onLoadDocument(data) {
|
||||||
|
|||||||
@ -317,6 +317,54 @@ async function searchFiles(
|
|||||||
return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
|
return matches.slice(0, limit).map(({ name, path }) => ({ name, path }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search within file contents for a query string.
|
||||||
|
* Returns matching files with line numbers and context.
|
||||||
|
*/
|
||||||
|
async function searchContent(
|
||||||
|
query: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<{ path: string; name: string; matches: { line: number; text: string; context: string }[] }[]> {
|
||||||
|
if (!query || query.length < 2) return [];
|
||||||
|
|
||||||
|
const tree = await listTree();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const results: { path: string; name: string; matches: { line: number; text: string; context: string }[] }[] = [];
|
||||||
|
|
||||||
|
async function walk(nodes: FileNode[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (results.length >= limit) return;
|
||||||
|
if (node.isDirectory) {
|
||||||
|
if (node.children) await walk(node.children);
|
||||||
|
} else if (node.name.endsWith('.md') || node.name.endsWith('.txt') || node.name.endsWith('.yml') || node.name.endsWith('.yaml')) {
|
||||||
|
try {
|
||||||
|
const content = await readFileContent(node.path);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const matches: { line: number; text: string; context: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length && matches.length < 5; i++) {
|
||||||
|
if (lines[i].toLowerCase().includes(q)) {
|
||||||
|
const contextStart = Math.max(0, i - 1);
|
||||||
|
const contextEnd = Math.min(lines.length - 1, i + 1);
|
||||||
|
const context = lines.slice(contextStart, contextEnd + 1).join('\n');
|
||||||
|
matches.push({ line: i + 1, text: lines[i].trim(), context });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
results.push({ path: node.path, name: node.name, matches });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip files that can't be read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(tree);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export const docsFilesService = {
|
export const docsFilesService = {
|
||||||
listTree,
|
listTree,
|
||||||
readFileContent,
|
readFileContent,
|
||||||
@ -329,4 +377,5 @@ export const docsFilesService = {
|
|||||||
isEditableFile,
|
isEditableFile,
|
||||||
invalidateTreeCache,
|
invalidateTreeCache,
|
||||||
searchFiles,
|
searchFiles,
|
||||||
|
searchContent,
|
||||||
};
|
};
|
||||||
|
|||||||
267
api/src/modules/docs/docs-history.service.ts
Normal file
267
api/src/modules/docs/docs-history.service.ts
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { env } from '../../config/env';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docs version history via Gitea API.
|
||||||
|
* Auto-commits file saves and provides history/restore.
|
||||||
|
*
|
||||||
|
* Uses the main project repo (not the docs-comments repo).
|
||||||
|
* Files are stored at mkdocs/docs/{path} in the repo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GITEA_TIMEOUT = 15000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a file path for Gitea API.
|
||||||
|
* Gitea expects each path segment to be individually encoded.
|
||||||
|
*/
|
||||||
|
function encodeRepoPath(filePath: string): string {
|
||||||
|
return filePath.split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaCommit {
|
||||||
|
sha: string;
|
||||||
|
commit: {
|
||||||
|
message: string;
|
||||||
|
author: { name: string; email: string; date: string };
|
||||||
|
committer: { name: string; email: string; date: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaFileContent {
|
||||||
|
content: string; // base64 encoded
|
||||||
|
sha: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return env.GITEA_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiToken(): string {
|
||||||
|
return env.GITEA_API_TOKEN || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRepoPath(): string {
|
||||||
|
// The main project repo — matches repo_url in mkdocs.yml
|
||||||
|
return env.GITEA_DOCS_REPO || 'admin/changemaker.lite';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocsPrefix(): string {
|
||||||
|
// Relative path within repo where docs live
|
||||||
|
return env.GITEA_DOCS_PREFIX || 'mkdocs/docs';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRepoBranch(): string {
|
||||||
|
return env.GITEA_DOCS_BRANCH || 'v2';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated Gitea API request.
|
||||||
|
*/
|
||||||
|
async function giteaRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const token = getApiToken();
|
||||||
|
if (!token) throw new Error('Gitea API token not configured');
|
||||||
|
|
||||||
|
const url = `${getBaseUrl()}/api/v1${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), GITEA_TIMEOUT);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchBody: string | undefined;
|
||||||
|
if (body) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchBody = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
return {} as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current SHA of a file in the repo (needed for updates).
|
||||||
|
*/
|
||||||
|
async function getFileSha(repoFilePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await giteaRequest<GiteaFileContent>(
|
||||||
|
'GET',
|
||||||
|
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}?ref=${getRepoBranch()}`,
|
||||||
|
);
|
||||||
|
return data.sha;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit a file change to Gitea.
|
||||||
|
* Fire-and-forget — errors are logged but don't propagate.
|
||||||
|
*/
|
||||||
|
async function commitFile(
|
||||||
|
docsRelativePath: string,
|
||||||
|
content: string,
|
||||||
|
authorName: string,
|
||||||
|
authorEmail: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const token = getApiToken();
|
||||||
|
if (!token) return; // Silently skip if not configured
|
||||||
|
|
||||||
|
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingSha = await getFileSha(repoFilePath);
|
||||||
|
const base64Content = Buffer.from(content, 'utf-8').toString('base64');
|
||||||
|
|
||||||
|
if (existingSha) {
|
||||||
|
// Update existing file
|
||||||
|
await giteaRequest(
|
||||||
|
'PUT',
|
||||||
|
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
|
||||||
|
{
|
||||||
|
message: `Update ${docsRelativePath} by ${authorName}`,
|
||||||
|
content: base64Content,
|
||||||
|
sha: existingSha,
|
||||||
|
branch: getRepoBranch(),
|
||||||
|
author: { name: authorName, email: authorEmail },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new file
|
||||||
|
await giteaRequest(
|
||||||
|
'POST',
|
||||||
|
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
|
||||||
|
{
|
||||||
|
message: `Create ${docsRelativePath} by ${authorName}`,
|
||||||
|
content: base64Content,
|
||||||
|
branch: getRepoBranch(),
|
||||||
|
author: { name: authorName, email: authorEmail },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Docs history: committed ${docsRelativePath} by ${authorName}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Docs history: failed to commit ${docsRelativePath}:`, err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commit history for a documentation file.
|
||||||
|
*/
|
||||||
|
async function getFileHistory(docsRelativePath: string, limit = 20): Promise<GiteaCommit[]> {
|
||||||
|
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await giteaRequest<GiteaCommit[]>(
|
||||||
|
'GET',
|
||||||
|
`/repos/${getRepoPath()}/commits?sha=${getRepoBranch()}&path=${encodeURIComponent(repoFilePath)}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Docs history: failed to get history for ${docsRelativePath}:`, err instanceof Error ? err.message : err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file content at a specific commit.
|
||||||
|
*/
|
||||||
|
async function getFileAtCommit(docsRelativePath: string, commitSha: string): Promise<string | null> {
|
||||||
|
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await giteaRequest<GiteaFileContent>(
|
||||||
|
'GET',
|
||||||
|
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}?ref=${commitSha}`,
|
||||||
|
);
|
||||||
|
return Buffer.from(data.content, 'base64').toString('utf-8');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Docs history: failed to get file at commit ${commitSha}:`, err instanceof Error ? err.message : err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a file to a previous version by reading old content and creating a new commit.
|
||||||
|
*/
|
||||||
|
async function restoreRevision(
|
||||||
|
docsRelativePath: string,
|
||||||
|
commitSha: string,
|
||||||
|
authorName: string,
|
||||||
|
authorEmail: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const oldContent = await getFileAtCommit(docsRelativePath, commitSha);
|
||||||
|
if (!oldContent) return null;
|
||||||
|
|
||||||
|
const repoFilePath = `${getDocsPrefix()}/${docsRelativePath}`;
|
||||||
|
const currentSha = await getFileSha(repoFilePath);
|
||||||
|
if (!currentSha) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await giteaRequest(
|
||||||
|
'PUT',
|
||||||
|
`/repos/${getRepoPath()}/contents/${encodeRepoPath(repoFilePath)}`,
|
||||||
|
{
|
||||||
|
message: `Restore ${docsRelativePath} to revision ${commitSha.substring(0, 7)} by ${authorName}`,
|
||||||
|
content: Buffer.from(oldContent, 'utf-8').toString('base64'),
|
||||||
|
sha: currentSha,
|
||||||
|
branch: getRepoBranch(),
|
||||||
|
author: { name: authorName, email: authorEmail },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return oldContent;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Docs history: failed to restore ${docsRelativePath}:`, err instanceof Error ? err.message : err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the Gitea history feature is available (token configured and Gitea reachable).
|
||||||
|
*/
|
||||||
|
async function isAvailable(): Promise<boolean> {
|
||||||
|
const token = getApiToken();
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getBaseUrl()}/api/v1/version`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsHistoryService = {
|
||||||
|
commitFile,
|
||||||
|
getFileHistory,
|
||||||
|
getFileAtCommit,
|
||||||
|
restoreRevision,
|
||||||
|
isAvailable,
|
||||||
|
};
|
||||||
173
api/src/modules/docs/docs-metadata.service.ts
Normal file
173
api/src/modules/docs/docs-metadata.service.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { stat } from 'fs/promises';
|
||||||
|
import { resolve as pathResolve } from 'path';
|
||||||
|
import { parse as yamlParse } from 'yaml';
|
||||||
|
import { env } from '../../config/env';
|
||||||
|
import { redis } from '../../config/redis';
|
||||||
|
import { prisma } from '../../config/database';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { docsFilesService, type FileNode } from './docs-files.service';
|
||||||
|
|
||||||
|
const METADATA_CACHE_KEY = 'DOCS_CACHE:metadata';
|
||||||
|
const METADATA_CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||||
|
|
||||||
|
export interface DocPageMetadata {
|
||||||
|
path: string;
|
||||||
|
title: string | null;
|
||||||
|
tags: string[];
|
||||||
|
description: string | null;
|
||||||
|
status: string | null;
|
||||||
|
lastModified: string | null;
|
||||||
|
wordCount: number;
|
||||||
|
hasAccessPolicy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocMetadataWarning {
|
||||||
|
type: 'no-tags' | 'no-description' | 'orphaned' | 'stale';
|
||||||
|
paths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocMetadataResult {
|
||||||
|
totalPages: number;
|
||||||
|
pages: DocPageMetadata[];
|
||||||
|
warnings: DocMetadataWarning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive metadata for all documentation pages.
|
||||||
|
* Walks the file tree, parses frontmatter, cross-references with analytics and nav.
|
||||||
|
*/
|
||||||
|
async function getMetadata(): Promise<DocMetadataResult> {
|
||||||
|
// Try cache
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(METADATA_CACHE_KEY);
|
||||||
|
if (cached) return JSON.parse(cached) as DocMetadataResult;
|
||||||
|
} catch {
|
||||||
|
// Ignore cache errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = await docsFilesService.listTree();
|
||||||
|
const docsRoot = pathResolve(env.MKDOCS_DOCS_PATH);
|
||||||
|
|
||||||
|
// Collect all .md files
|
||||||
|
const mdFiles: string[] = [];
|
||||||
|
function walk(nodes: FileNode[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.isDirectory && node.children) {
|
||||||
|
walk(node.children);
|
||||||
|
} else if (node.name.endsWith('.md')) {
|
||||||
|
mdFiles.push(node.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree);
|
||||||
|
|
||||||
|
// Get access policies in one query
|
||||||
|
const policies = await prisma.docAccessPolicy.findMany({
|
||||||
|
select: { documentPath: true },
|
||||||
|
});
|
||||||
|
const policyPaths = new Set(policies.map(p => p.documentPath));
|
||||||
|
|
||||||
|
// Parse each file
|
||||||
|
const pages: DocPageMetadata[] = await Promise.all(
|
||||||
|
mdFiles.map(async (filePath) => {
|
||||||
|
try {
|
||||||
|
const content = await docsFilesService.readFileContent(filePath);
|
||||||
|
const fullPath = pathResolve(docsRoot, filePath);
|
||||||
|
|
||||||
|
// Get file mod time
|
||||||
|
let lastModified: string | null = null;
|
||||||
|
try {
|
||||||
|
const fileStats = await stat(fullPath);
|
||||||
|
lastModified = fileStats.mtime.toISOString();
|
||||||
|
} catch {
|
||||||
|
// Ignore stat errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse frontmatter
|
||||||
|
let title: string | null = null;
|
||||||
|
let tags: string[] = [];
|
||||||
|
let description: string | null = null;
|
||||||
|
let status: string | null = null;
|
||||||
|
|
||||||
|
const match = content.match(FRONTMATTER_REGEX);
|
||||||
|
if (match) {
|
||||||
|
try {
|
||||||
|
const fm = yamlParse(match[1]) as Record<string, unknown>;
|
||||||
|
if (typeof fm?.title === 'string') title = fm.title;
|
||||||
|
if (Array.isArray(fm?.tags)) tags = fm.tags.filter((t): t is string => typeof t === 'string');
|
||||||
|
if (typeof fm?.description === 'string') description = fm.description;
|
||||||
|
if (typeof fm?.status === 'string') status = fm.status;
|
||||||
|
} catch {
|
||||||
|
// Invalid frontmatter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title from first # heading if not in frontmatter
|
||||||
|
if (!title) {
|
||||||
|
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||||
|
if (headingMatch) title = headingMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word count (body only, rough estimate)
|
||||||
|
const bodyText = match ? match[2] : content;
|
||||||
|
const wordCount = bodyText.split(/\s+/).filter(w => w.length > 0).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
title,
|
||||||
|
tags,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
lastModified,
|
||||||
|
wordCount,
|
||||||
|
hasAccessPolicy: policyPaths.has(filePath),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
path: filePath,
|
||||||
|
title: null,
|
||||||
|
tags: [],
|
||||||
|
description: null,
|
||||||
|
status: null,
|
||||||
|
lastModified: null,
|
||||||
|
wordCount: 0,
|
||||||
|
hasAccessPolicy: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build warnings
|
||||||
|
const warnings: DocMetadataWarning[] = [];
|
||||||
|
|
||||||
|
const noTags = pages.filter(p => p.tags.length === 0 && !p.path.startsWith('blog/')).map(p => p.path);
|
||||||
|
if (noTags.length > 0) warnings.push({ type: 'no-tags', paths: noTags });
|
||||||
|
|
||||||
|
const noDescription = pages.filter(p => !p.description && !p.path.startsWith('blog/')).map(p => p.path);
|
||||||
|
if (noDescription.length > 0) warnings.push({ type: 'no-description', paths: noDescription });
|
||||||
|
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const stale = pages.filter(p => p.lastModified && p.lastModified < thirtyDaysAgo).map(p => p.path);
|
||||||
|
if (stale.length > 0) warnings.push({ type: 'stale', paths: stale });
|
||||||
|
|
||||||
|
const result: DocMetadataResult = {
|
||||||
|
totalPages: pages.length,
|
||||||
|
pages,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
try {
|
||||||
|
await redis.setex(METADATA_CACHE_KEY, METADATA_CACHE_TTL, JSON.stringify(result));
|
||||||
|
} catch {
|
||||||
|
// Ignore cache errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsMetadataService = {
|
||||||
|
getMetadata,
|
||||||
|
};
|
||||||
404
api/src/modules/docs/docs-templates.service.ts
Normal file
404
api/src/modules/docs/docs-templates.service.ts
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
export interface DocTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: 'blog' | 'guide' | 'reference' | 'planning' | 'general';
|
||||||
|
icon: string; // Material icon name
|
||||||
|
filenamePattern: string; // e.g. "{{slug}}.md" or "blog/posts/{{date}}-{{slug}}.md"
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = () => new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const BUILT_IN_TEMPLATES: DocTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'blog-post',
|
||||||
|
name: 'Blog Post',
|
||||||
|
description: 'A blog post with MkDocs Material frontmatter',
|
||||||
|
category: 'blog',
|
||||||
|
icon: 'article',
|
||||||
|
filenamePattern: 'blog/posts/{{date}}-{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
date: {{date}}
|
||||||
|
authors:
|
||||||
|
- admin
|
||||||
|
categories:
|
||||||
|
- General
|
||||||
|
draft: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{title}}
|
||||||
|
|
||||||
|
Write your intro paragraph here. This appears on the blog index page.
|
||||||
|
|
||||||
|
<!-- more -->
|
||||||
|
|
||||||
|
## Main Content
|
||||||
|
|
||||||
|
Continue writing below the fold...
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'how-to-guide',
|
||||||
|
name: 'How-To Guide',
|
||||||
|
description: 'Step-by-step guide for a specific task',
|
||||||
|
category: 'guide',
|
||||||
|
icon: 'menu_book',
|
||||||
|
filenamePattern: 'docs/{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- guide
|
||||||
|
---
|
||||||
|
|
||||||
|
# How to {{title}}
|
||||||
|
|
||||||
|
Brief description of what this guide covers and who it's for.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Prerequisite 1
|
||||||
|
- Prerequisite 2
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 1: Getting Started
|
||||||
|
|
||||||
|
Description of the first step.
|
||||||
|
|
||||||
|
### Step 2: Configuration
|
||||||
|
|
||||||
|
Description of the second step.
|
||||||
|
|
||||||
|
### Step 3: Verification
|
||||||
|
|
||||||
|
How to verify everything is working.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
Common issues and their solutions.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
What to do after completing this guide.
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'api-reference',
|
||||||
|
name: 'API Reference',
|
||||||
|
description: 'API endpoint documentation',
|
||||||
|
category: 'reference',
|
||||||
|
icon: 'api',
|
||||||
|
filenamePattern: 'docs/api/{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- api
|
||||||
|
- reference
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{title}} API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Brief description of this API group.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /api/endpoint
|
||||||
|
|
||||||
|
Description of what this endpoint does.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| id | string | Yes | Resource ID |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"id": "abc123",
|
||||||
|
"name": "Example"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### POST /api/endpoint
|
||||||
|
|
||||||
|
Description of what this endpoint does.
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"name": "Example"
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 400 | Bad Request |
|
||||||
|
| 401 | Unauthorized |
|
||||||
|
| 404 | Not Found |
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'adr',
|
||||||
|
name: 'Architecture Decision Record',
|
||||||
|
description: 'Document an architecture decision',
|
||||||
|
category: 'planning',
|
||||||
|
icon: 'architecture',
|
||||||
|
filenamePattern: 'docs/architecture/adr-{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- architecture
|
||||||
|
- decision
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR: {{title}}
|
||||||
|
|
||||||
|
**Date:** {{date}}
|
||||||
|
**Status:** Proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
What is the issue that we're seeing that is motivating this decision or change?
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
What is the change that we're proposing and/or doing?
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
What becomes easier or more difficult to do because of this change?
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Benefit 1
|
||||||
|
- Benefit 2
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Drawback 1
|
||||||
|
- Drawback 2
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- Side effect 1
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'faq',
|
||||||
|
name: 'FAQ Page',
|
||||||
|
description: 'Frequently asked questions with collapsible answers',
|
||||||
|
category: 'general',
|
||||||
|
icon: 'quiz',
|
||||||
|
filenamePattern: 'docs/{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- faq
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{title}}
|
||||||
|
|
||||||
|
Frequently asked questions about this topic.
|
||||||
|
|
||||||
|
??? question "Question 1?"
|
||||||
|
|
||||||
|
Answer to question 1. You can include **formatting**, links, and code blocks.
|
||||||
|
|
||||||
|
??? question "Question 2?"
|
||||||
|
|
||||||
|
Answer to question 2.
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# Example command
|
||||||
|
echo "hello world"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
??? question "Question 3?"
|
||||||
|
|
||||||
|
Answer to question 3.
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'release-notes',
|
||||||
|
name: 'Release Notes',
|
||||||
|
description: 'Release notes for a version',
|
||||||
|
category: 'planning',
|
||||||
|
icon: 'new_releases',
|
||||||
|
filenamePattern: 'docs/{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- release
|
||||||
|
---
|
||||||
|
|
||||||
|
# Release Notes — {{title}}
|
||||||
|
|
||||||
|
**Release Date:** {{date}}
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
Brief summary of the most important changes.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Feature Name** — Description of the new feature
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Improvement** — Description of the improvement
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- **Fix** — Description of what was fixed
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
!!! warning "Breaking Changes"
|
||||||
|
List any breaking changes that require user action.
|
||||||
|
|
||||||
|
## Upgrade Instructions
|
||||||
|
|
||||||
|
Steps needed to upgrade from the previous version.
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tutorial',
|
||||||
|
name: 'Tutorial',
|
||||||
|
description: 'In-depth tutorial with learning objectives',
|
||||||
|
category: 'guide',
|
||||||
|
icon: 'school',
|
||||||
|
filenamePattern: 'docs/{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- tutorial
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{title}}
|
||||||
|
|
||||||
|
## What You'll Learn
|
||||||
|
|
||||||
|
By the end of this tutorial, you will be able to:
|
||||||
|
|
||||||
|
- Learning objective 1
|
||||||
|
- Learning objective 2
|
||||||
|
- Learning objective 3
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
!!! info "Before you begin"
|
||||||
|
- Prerequisite 1
|
||||||
|
- Prerequisite 2
|
||||||
|
|
||||||
|
## Part 1: Setup
|
||||||
|
|
||||||
|
Content for part 1...
|
||||||
|
|
||||||
|
## Part 2: Implementation
|
||||||
|
|
||||||
|
Content for part 2...
|
||||||
|
|
||||||
|
## Part 3: Testing
|
||||||
|
|
||||||
|
Content for part 3...
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Recap of what was covered and next steps.
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- [Related Guide 1](link)
|
||||||
|
- [Related Guide 2](link)
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meeting-notes',
|
||||||
|
name: 'Meeting Notes',
|
||||||
|
description: 'Meeting notes template with agenda and action items',
|
||||||
|
category: 'general',
|
||||||
|
icon: 'groups',
|
||||||
|
filenamePattern: 'docs/{{slug}}.md',
|
||||||
|
content: `---
|
||||||
|
tags:
|
||||||
|
- meeting
|
||||||
|
---
|
||||||
|
|
||||||
|
# {{title}}
|
||||||
|
|
||||||
|
**Date:** {{date}}
|
||||||
|
**Attendees:**
|
||||||
|
|
||||||
|
## Agenda
|
||||||
|
|
||||||
|
1. Item 1
|
||||||
|
2. Item 2
|
||||||
|
3. Item 3
|
||||||
|
|
||||||
|
## Discussion Notes
|
||||||
|
|
||||||
|
### Topic 1
|
||||||
|
|
||||||
|
Notes...
|
||||||
|
|
||||||
|
### Topic 2
|
||||||
|
|
||||||
|
Notes...
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
- [ ] Action item 1 — @assignee — due date
|
||||||
|
- [ ] Action item 2 — @assignee — due date
|
||||||
|
|
||||||
|
## Next Meeting
|
||||||
|
|
||||||
|
Date and topics for next meeting.
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getTemplates(): DocTemplate[] {
|
||||||
|
return BUILT_IN_TEMPLATES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(
|
||||||
|
templateId: string,
|
||||||
|
variables: { title: string; date?: string; slug?: string; author?: string },
|
||||||
|
): { content: string; suggestedPath: string } | null {
|
||||||
|
const template = BUILT_IN_TEMPLATES.find(t => t.id === templateId);
|
||||||
|
if (!template) return null;
|
||||||
|
|
||||||
|
const date = variables.date || today();
|
||||||
|
const slug = variables.slug || slugify(variables.title);
|
||||||
|
const author = variables.author || 'admin';
|
||||||
|
|
||||||
|
let content = template.content
|
||||||
|
.replace(/\{\{title\}\}/g, variables.title)
|
||||||
|
.replace(/\{\{date\}\}/g, date)
|
||||||
|
.replace(/\{\{slug\}\}/g, slug)
|
||||||
|
.replace(/\{\{author\}\}/g, author);
|
||||||
|
|
||||||
|
let suggestedPath = template.filenamePattern
|
||||||
|
.replace(/\{\{title\}\}/g, variables.title)
|
||||||
|
.replace(/\{\{date\}\}/g, date)
|
||||||
|
.replace(/\{\{slug\}\}/g, slug);
|
||||||
|
|
||||||
|
return { content, suggestedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.substring(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const docsTemplatesService = {
|
||||||
|
getTemplates,
|
||||||
|
applyTemplate,
|
||||||
|
};
|
||||||
@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { rm } from 'fs/promises';
|
import { rm } from 'fs/promises';
|
||||||
import { extname, basename } from 'path';
|
import { extname, basename } from 'path';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { CONTENT_ROLES } from '../../utils/roles';
|
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { isServiceOnline } from '../../utils/health-check';
|
import { isServiceOnline } from '../../utils/health-check';
|
||||||
import { cm_docs_operations } from '../../utils/metrics';
|
import { cm_docs_operations } from '../../utils/metrics';
|
||||||
@ -15,6 +16,12 @@ import { mkdocsConfigService } from './mkdocs-config.service';
|
|||||||
import { headerBuilderService } from './header-builder.service';
|
import { headerBuilderService } from './header-builder.service';
|
||||||
import { headerConfigSchema } from './header-builder.schemas';
|
import { headerConfigSchema } from './header-builder.schemas';
|
||||||
import { docsResetService } from './docs-reset.service';
|
import { docsResetService } from './docs-reset.service';
|
||||||
|
import { blogService } from './blog.service';
|
||||||
|
import { newBlogPostSchema, authorsFileSchema } from './blog.schemas';
|
||||||
|
import { docsAccessService } from './docs-access.service';
|
||||||
|
import { docsHistoryService } from './docs-history.service';
|
||||||
|
import { docsTemplatesService } from './docs-templates.service';
|
||||||
|
import { docsMetadataService } from './docs-metadata.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
@ -265,6 +272,27 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/docs/files/search-content — search within file contents
|
||||||
|
router.get(
|
||||||
|
'/files/search-content',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const query = String(req.query['q'] ?? '').trim();
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
res.json({ results: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const limit = Math.min(Math.max(Number(req.query['limit']) || 10, 1), 30);
|
||||||
|
const results = await docsFilesService.searchContent(query, limit);
|
||||||
|
res.json({ results });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to search docs content', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/docs/files/rename — rename/move file
|
// POST /api/docs/files/rename — rename/move file
|
||||||
router.post(
|
router.post(
|
||||||
'/files/rename',
|
'/files/rename',
|
||||||
@ -277,7 +305,18 @@ router.post(
|
|||||||
res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } });
|
res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check access on both source and destination
|
||||||
|
const userRoles = getUserRoles(req.user!);
|
||||||
|
const canEditFrom = await docsAccessService.canUserEdit(req.user!.id, userRoles, from);
|
||||||
|
if (!canEditFrom) {
|
||||||
|
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await docsFilesService.renameFile(from, to);
|
await docsFilesService.renameFile(from, to);
|
||||||
|
// Cascade rename for access policies and share links
|
||||||
|
docsAccessService.cascadeRename(from, to).catch(() => {});
|
||||||
// Invalidate old path's collaboration state
|
// Invalidate old path's collaboration state
|
||||||
docsCollabService.invalidateDocument(from).catch(() => {});
|
docsCollabService.invalidateDocument(from).catch(() => {});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@ -319,6 +358,15 @@ router.put(
|
|||||||
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-file access check
|
||||||
|
const userRoles = getUserRoles(req.user!);
|
||||||
|
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
|
||||||
|
if (!canEdit) {
|
||||||
|
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { content } = req.body as { content?: string };
|
const { content } = req.body as { content?: string };
|
||||||
if (typeof content !== 'string') {
|
if (typeof content !== 'string') {
|
||||||
res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } });
|
res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } });
|
||||||
@ -327,6 +375,9 @@ router.put(
|
|||||||
await docsFilesService.writeFileContent(filePath, content);
|
await docsFilesService.writeFileContent(filePath, content);
|
||||||
// Invalidate collaboration state so next session starts fresh from disk
|
// Invalidate collaboration state so next session starts fresh from disk
|
||||||
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||||
|
// Fire-and-forget: commit to Gitea for version history
|
||||||
|
const userName = req.user!.email;
|
||||||
|
docsHistoryService.commitFile(filePath, content, userName, req.user!.email).catch(() => {});
|
||||||
res.json({ success: true, path: filePath });
|
res.json({ success: true, path: filePath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleFileError(err, res, next);
|
handleFileError(err, res, next);
|
||||||
@ -367,7 +418,18 @@ router.delete(
|
|||||||
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-file access check
|
||||||
|
const userRoles = getUserRoles(req.user!);
|
||||||
|
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
|
||||||
|
if (!canEdit) {
|
||||||
|
res.status(403).json({ error: { message: 'You do not have edit access to this document', code: 'DOC_ACCESS_DENIED' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await docsFilesService.deleteFile(filePath);
|
await docsFilesService.deleteFile(filePath);
|
||||||
|
// Cascade delete for access policies and share links
|
||||||
|
docsAccessService.cascadeDelete(filePath).catch(() => {});
|
||||||
// Invalidate collaboration state for deleted file
|
// Invalidate collaboration state for deleted file
|
||||||
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@ -410,4 +472,200 @@ function handleFileError(err: unknown, res: Response, next: NextFunction): void
|
|||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Blog Endpoints ---
|
||||||
|
|
||||||
|
// GET /api/docs/blog/authors — read .authors.yml
|
||||||
|
router.get(
|
||||||
|
'/blog/authors',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const authors = await blogService.readAuthorsFile();
|
||||||
|
res.json({ authors });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to read blog authors', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/docs/blog/authors — update .authors.yml
|
||||||
|
router.put(
|
||||||
|
'/blog/authors',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { authors } = req.body as { authors?: Record<string, unknown> };
|
||||||
|
if (!authors || typeof authors !== 'object') {
|
||||||
|
res.status(400).json({ error: { message: 'Authors object required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = authorsFileSchema.safeParse({ authors });
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid authors data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await blogService.writeAuthorsFile(parsed.data.authors);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to update blog authors', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/docs/blog/categories — deduplicated categories from posts
|
||||||
|
router.get(
|
||||||
|
'/blog/categories',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const categories = await blogService.extractCategories();
|
||||||
|
res.json({ categories });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to extract blog categories', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/docs/blog/posts — create new blog post from wizard
|
||||||
|
router.post(
|
||||||
|
'/blog/posts',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = newBlogPostSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid blog post data', code: 'VALIDATION_ERROR', details: parsed.error.flatten().fieldErrors } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { content, suggestedPath } = blogService.scaffoldBlogPost(parsed.data);
|
||||||
|
await docsFilesService.createFile(suggestedPath, content);
|
||||||
|
// Fire-and-forget: commit to Gitea
|
||||||
|
const userName = req.user!.email;
|
||||||
|
docsHistoryService.commitFile(suggestedPath, content, userName, req.user!.email).catch(() => {});
|
||||||
|
res.status(201).json({ success: true, path: suggestedPath });
|
||||||
|
} catch (err) {
|
||||||
|
handleFileError(err, res, next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Version History Endpoints ---
|
||||||
|
|
||||||
|
// GET /api/docs/history/* — get commit history for a file
|
||||||
|
router.get(
|
||||||
|
'/history/*',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const filePath = extractWildcardPath(req);
|
||||||
|
if (!filePath) {
|
||||||
|
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const limit = Math.min(Math.max(Number(req.query['limit']) || 20, 1), 50);
|
||||||
|
const commits = await docsHistoryService.getFileHistory(filePath, limit);
|
||||||
|
res.json({ commits });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get docs history', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/docs/revision/:sha/* — get file content at specific commit
|
||||||
|
router.get(
|
||||||
|
'/revision/:sha/*',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const sha = req.params.sha as string;
|
||||||
|
// Extract path after /revision/:sha/
|
||||||
|
const params = req.params as Record<string, string | string[]>;
|
||||||
|
const wildcardParam = params[0] || params['0'];
|
||||||
|
const filePath = Array.isArray(wildcardParam) ? wildcardParam.join('/') : (wildcardParam || '');
|
||||||
|
|
||||||
|
if (!filePath || !sha) {
|
||||||
|
res.status(400).json({ error: { message: 'SHA and file path required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = await docsHistoryService.getFileAtCommit(filePath, sha);
|
||||||
|
if (content === null) {
|
||||||
|
res.status(404).json({ error: { message: 'Revision not found', code: 'NOT_FOUND' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ sha, path: filePath, content });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get docs revision', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/docs/restore/:sha/* — restore file to a previous version
|
||||||
|
router.post(
|
||||||
|
'/restore/:sha/*',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const sha = req.params.sha as string;
|
||||||
|
const params = req.params as Record<string, string | string[]>;
|
||||||
|
const wildcardParam = params[0] || params['0'];
|
||||||
|
const filePath = Array.isArray(wildcardParam) ? wildcardParam.join('/') : (wildcardParam || '');
|
||||||
|
|
||||||
|
if (!filePath || !sha) {
|
||||||
|
res.status(400).json({ error: { message: 'SHA and file path required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName = req.user!.email;
|
||||||
|
const content = await docsHistoryService.restoreRevision(filePath, sha, userName, req.user!.email);
|
||||||
|
if (content === null) {
|
||||||
|
res.status(404).json({ error: { message: 'Could not restore revision', code: 'RESTORE_FAILED' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update the disk file
|
||||||
|
await docsFilesService.writeFileContent(filePath, content);
|
||||||
|
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||||
|
|
||||||
|
res.json({ success: true, path: filePath });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to restore docs revision', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Templates ---
|
||||||
|
|
||||||
|
// GET /api/docs/templates — list available templates
|
||||||
|
router.get(
|
||||||
|
'/templates',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (_req: Request, res: Response) => {
|
||||||
|
const templates = docsTemplatesService.getTemplates();
|
||||||
|
res.json({ templates });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Metadata Dashboard ---
|
||||||
|
|
||||||
|
// GET /api/docs/metadata — full metadata for all docs pages
|
||||||
|
router.get(
|
||||||
|
'/metadata',
|
||||||
|
requireRole(...CONTENT_ROLES),
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const metadata = await docsMetadataService.getMetadata();
|
||||||
|
res.json(metadata);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to get docs metadata', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const docsRouter = router;
|
export const docsRouter = router;
|
||||||
|
|||||||
70
api/src/modules/gitea-setup/gitea-setup.routes.ts
Normal file
70
api/src/modules/gitea-setup/gitea-setup.routes.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
|
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { giteaSetupService } from './gitea-setup.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(requireNonTemp);
|
||||||
|
router.use(requireRole('SUPER_ADMIN'));
|
||||||
|
|
||||||
|
const credentialsSchema = z.object({
|
||||||
|
username: z.string().min(1).max(100),
|
||||||
|
password: z.string().min(1).max(200),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/gitea/setup/status — check current setup state
|
||||||
|
router.get(
|
||||||
|
'/status',
|
||||||
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const status = await giteaSetupService.checkStatus();
|
||||||
|
res.json(status);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to check Gitea setup status', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/gitea/setup/test-connection — test basic auth credentials
|
||||||
|
router.post(
|
||||||
|
'/test-connection',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = credentialsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Username and password required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await giteaSetupService.testConnection(parsed.data.username, parsed.data.password);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to test Gitea connection', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/gitea/setup/run — run full setup
|
||||||
|
router.post(
|
||||||
|
'/run',
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = credentialsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: { message: 'Username and password required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await giteaSetupService.runFullSetup(parsed.data.username, parsed.data.password);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to run Gitea setup', err);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const giteaSetupRouter = router;
|
||||||
447
api/src/modules/gitea-setup/gitea-setup.service.ts
Normal file
447
api/src/modules/gitea-setup/gitea-setup.service.ts
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
import { env } from '../../config/env';
|
||||||
|
import { prisma } from '../../config/database';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { encrypt } from '../../utils/crypto';
|
||||||
|
import { giteaClient } from '../../services/gitea.client';
|
||||||
|
|
||||||
|
const SETUP_TIMEOUT = 15000;
|
||||||
|
|
||||||
|
interface StepResult {
|
||||||
|
step: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupResult {
|
||||||
|
success: boolean;
|
||||||
|
steps: StepResult[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Gitea API request with Basic Auth (for bootstrapping before we have a token).
|
||||||
|
*/
|
||||||
|
async function giteaBasicRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
body?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${env.GITEA_URL}/api/v1${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchBody: string | undefined;
|
||||||
|
if (body) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchBody = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
return {} as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a Gitea API request with token auth.
|
||||||
|
*/
|
||||||
|
async function giteaTokenRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
token: string,
|
||||||
|
body?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${env.GITEA_URL}/api/v1${path}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), SETUP_TIMEOUT);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchBody: string | undefined;
|
||||||
|
if (body) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchBody = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Gitea ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
return {} as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Gitea is reachable and the install page has been completed.
|
||||||
|
*/
|
||||||
|
async function checkStatus(): Promise<{
|
||||||
|
giteaOnline: boolean;
|
||||||
|
installComplete: boolean;
|
||||||
|
tokenConfigured: boolean;
|
||||||
|
reposCreated: boolean;
|
||||||
|
oauthConfigured: boolean;
|
||||||
|
setupComplete: boolean;
|
||||||
|
}> {
|
||||||
|
let giteaOnline = false;
|
||||||
|
let installComplete = false;
|
||||||
|
let tokenConfigured = false;
|
||||||
|
let reposCreated = false;
|
||||||
|
let oauthConfigured = false;
|
||||||
|
|
||||||
|
// Check Gitea online
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
giteaOnline = res.ok;
|
||||||
|
installComplete = res.ok; // If API responds, install is complete
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DB settings
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSettings.findFirst({
|
||||||
|
select: {
|
||||||
|
giteaApiToken: true,
|
||||||
|
giteaOauthClientId: true,
|
||||||
|
giteaOauthClientSecret: true,
|
||||||
|
giteaSetupComplete: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
tokenConfigured = !!settings.giteaApiToken;
|
||||||
|
oauthConfigured = !!settings.giteaOauthClientId && !!settings.giteaOauthClientSecret;
|
||||||
|
|
||||||
|
if (settings.giteaSetupComplete) {
|
||||||
|
return {
|
||||||
|
giteaOnline,
|
||||||
|
installComplete,
|
||||||
|
tokenConfigured,
|
||||||
|
reposCreated: true, // Trust the flag
|
||||||
|
oauthConfigured,
|
||||||
|
setupComplete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB not available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check repos if we have a token
|
||||||
|
if (tokenConfigured && giteaOnline) {
|
||||||
|
try {
|
||||||
|
const config = await giteaClient.getConfig();
|
||||||
|
if (config.apiToken) {
|
||||||
|
await giteaTokenRequest('GET', `/repos/admin/docs-comments`, config.apiToken);
|
||||||
|
await giteaTokenRequest('GET', `/repos/admin/changemaker.lite`, config.apiToken);
|
||||||
|
reposCreated = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Repos don't exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
giteaOnline,
|
||||||
|
installComplete,
|
||||||
|
tokenConfigured,
|
||||||
|
reposCreated,
|
||||||
|
oauthConfigured,
|
||||||
|
setupComplete: tokenConfigured && reposCreated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection with basic auth credentials.
|
||||||
|
*/
|
||||||
|
async function testConnection(username: string, password: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
giteaVersion?: string;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const data = await giteaBasicRequest<{ version: string }>(
|
||||||
|
'GET', '/version', username, password,
|
||||||
|
);
|
||||||
|
// Also verify admin access
|
||||||
|
await giteaBasicRequest<{ login: string; is_admin: boolean }>(
|
||||||
|
'GET', '/user', username, password,
|
||||||
|
);
|
||||||
|
return { success: true, giteaVersion: data.version };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes('401')) {
|
||||||
|
return { success: false, error: 'Invalid username or password' };
|
||||||
|
}
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full Gitea setup: create token, repos, labels, OAuth app.
|
||||||
|
*/
|
||||||
|
async function runFullSetup(username: string, password: string): Promise<SetupResult> {
|
||||||
|
const steps: StepResult[] = [];
|
||||||
|
|
||||||
|
// Step 1: Check Gitea is reachable
|
||||||
|
try {
|
||||||
|
await giteaBasicRequest<{ version: string }>('GET', '/version', username, password);
|
||||||
|
steps.push({ step: 'check_connection', success: true });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
steps.push({ step: 'check_connection', success: false, error: msg });
|
||||||
|
return { success: false, steps, error: 'Cannot connect to Gitea' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create API token
|
||||||
|
let apiToken = '';
|
||||||
|
try {
|
||||||
|
// First try to delete any existing token with the same name
|
||||||
|
try {
|
||||||
|
await giteaBasicRequest('DELETE', `/users/${username}/tokens/changemaker-auto`, username, password);
|
||||||
|
} catch {
|
||||||
|
// Token doesn't exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await giteaBasicRequest<{ sha1: string }>(
|
||||||
|
'POST',
|
||||||
|
`/users/${username}/tokens`,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
{ name: 'changemaker-auto', scopes: ['all'] },
|
||||||
|
);
|
||||||
|
apiToken = tokenData.sha1;
|
||||||
|
steps.push({ step: 'create_token', success: true, data: { tokenName: 'changemaker-auto' } });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
steps.push({ step: 'create_token', success: false, error: msg });
|
||||||
|
return { success: false, steps, error: 'Failed to create API token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Create docs-comments repo
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await giteaTokenRequest('GET', `/repos/${username}/docs-comments`, apiToken);
|
||||||
|
steps.push({ step: 'create_comments_repo', success: true, data: { note: 'Already exists' } });
|
||||||
|
} catch {
|
||||||
|
await giteaTokenRequest('POST', '/user/repos', apiToken, {
|
||||||
|
name: 'docs-comments',
|
||||||
|
description: 'Documentation page comments — managed by Changemaker Lite',
|
||||||
|
private: false,
|
||||||
|
auto_init: true,
|
||||||
|
});
|
||||||
|
steps.push({ step: 'create_comments_repo', success: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
steps.push({ step: 'create_comments_repo', success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Create changemaker.lite repo (for version history)
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await giteaTokenRequest('GET', `/repos/${username}/changemaker.lite`, apiToken);
|
||||||
|
steps.push({ step: 'create_history_repo', success: true, data: { note: 'Already exists' } });
|
||||||
|
} catch {
|
||||||
|
await giteaTokenRequest('POST', '/user/repos', apiToken, {
|
||||||
|
name: 'changemaker.lite',
|
||||||
|
description: 'Documentation version history — managed by Changemaker Lite',
|
||||||
|
private: true,
|
||||||
|
auto_init: true,
|
||||||
|
default_branch: 'v2',
|
||||||
|
});
|
||||||
|
steps.push({ step: 'create_history_repo', success: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
steps.push({ step: 'create_history_repo', success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Create labels on docs-comments repo
|
||||||
|
try {
|
||||||
|
const labels = [
|
||||||
|
{ name: 'docs-page', color: '#0075ca' },
|
||||||
|
{ name: 'anonymous', color: '#e4e669' },
|
||||||
|
{ name: 'moderated', color: '#0e8a16' },
|
||||||
|
];
|
||||||
|
for (const label of labels) {
|
||||||
|
try {
|
||||||
|
await giteaTokenRequest('POST', `/repos/${username}/docs-comments/labels`, apiToken, label);
|
||||||
|
} catch {
|
||||||
|
// Label may already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps.push({ step: 'create_labels', success: true });
|
||||||
|
} catch (err) {
|
||||||
|
steps.push({ step: 'create_labels', success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Create OAuth2 app
|
||||||
|
let oauthClientId = '';
|
||||||
|
let oauthClientSecret = '';
|
||||||
|
try {
|
||||||
|
// Check if OAuth app already exists
|
||||||
|
const existingApps = await giteaTokenRequest<Array<{ id: number; name: string; client_id: string; client_secret: string }>>(
|
||||||
|
'GET', '/user/applications/oauth2', apiToken,
|
||||||
|
);
|
||||||
|
const existing = existingApps.find(a => a.name === 'changemaker-docs');
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
oauthClientId = existing.client_id;
|
||||||
|
// Can't retrieve secret for existing app; leave blank if already set in DB
|
||||||
|
steps.push({ step: 'create_oauth_app', success: true, data: { note: 'Already exists', clientId: oauthClientId } });
|
||||||
|
} else {
|
||||||
|
const domain = env.DOMAIN || 'cmlite.org';
|
||||||
|
const oauthData = await giteaTokenRequest<{ client_id: string; client_secret: string }>(
|
||||||
|
'POST', '/user/applications/oauth2', apiToken,
|
||||||
|
{
|
||||||
|
name: 'changemaker-docs',
|
||||||
|
redirect_uris: [
|
||||||
|
`https://${domain}/comments/callback/`,
|
||||||
|
`https://docs.${domain}/comments/callback/`,
|
||||||
|
`http://localhost:${env.MKDOCS_PORT || 4003}/comments/callback/`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
oauthClientId = oauthData.client_id;
|
||||||
|
oauthClientSecret = oauthData.client_secret;
|
||||||
|
steps.push({ step: 'create_oauth_app', success: true, data: { clientId: oauthClientId } });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
steps.push({ step: 'create_oauth_app', success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Save to database
|
||||||
|
try {
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
enableDocsComments: true,
|
||||||
|
giteaApiToken: encrypt(apiToken),
|
||||||
|
giteaCommentsRepoOwner: username,
|
||||||
|
giteaCommentsRepoName: 'docs-comments',
|
||||||
|
giteaSetupComplete: true,
|
||||||
|
};
|
||||||
|
if (oauthClientId) updateData.giteaOauthClientId = oauthClientId;
|
||||||
|
if (oauthClientSecret) updateData.giteaOauthClientSecret = encrypt(oauthClientSecret);
|
||||||
|
|
||||||
|
await prisma.siteSettings.updateMany({ data: updateData });
|
||||||
|
|
||||||
|
// Clear config cache so the running system picks up new credentials
|
||||||
|
giteaClient.clearConfigCache();
|
||||||
|
|
||||||
|
steps.push({ step: 'save_config', success: true });
|
||||||
|
} catch (err) {
|
||||||
|
steps.push({ step: 'save_config', success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
return { success: false, steps, error: 'Failed to save configuration' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStepsSucceeded = steps.every(s => s.success);
|
||||||
|
return { success: allStepsSucceeded, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-setup if GITEA_ADMIN_PASSWORD is set and setup hasn't been completed.
|
||||||
|
* Called on API startup. Fire-and-forget with retries.
|
||||||
|
*/
|
||||||
|
async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success: boolean; error?: string }> {
|
||||||
|
const password = env.GITEA_ADMIN_PASSWORD;
|
||||||
|
if (!password) return { alreadyComplete: true, success: true };
|
||||||
|
|
||||||
|
// Check if already complete
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSettings.findFirst({
|
||||||
|
select: { giteaSetupComplete: true },
|
||||||
|
});
|
||||||
|
if (settings?.giteaSetupComplete) {
|
||||||
|
return { alreadyComplete: true, success: true };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB might not be ready yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Gitea to be available (up to 3 retries, 15s apart)
|
||||||
|
let giteaReady = false;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${env.GITEA_URL}/api/v1/version`, { signal: controller.signal });
|
||||||
|
if (res.ok) {
|
||||||
|
giteaReady = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not ready yet
|
||||||
|
}
|
||||||
|
if (i < 2) {
|
||||||
|
logger.info(`Gitea auto-setup: waiting for Gitea to be ready (attempt ${i + 1}/3)...`);
|
||||||
|
await new Promise(r => setTimeout(r, 15000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!giteaReady) {
|
||||||
|
return { alreadyComplete: false, success: false, error: 'Gitea not reachable after 3 attempts' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run setup
|
||||||
|
logger.info('Gitea auto-setup: running initial setup...');
|
||||||
|
const result = await runFullSetup('admin', password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info('Gitea auto-setup: completed successfully');
|
||||||
|
for (const step of result.steps) {
|
||||||
|
logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`Gitea auto-setup: failed — ${result.error}`);
|
||||||
|
for (const step of result.steps) {
|
||||||
|
if (!step.success) logger.warn(` ${step.step}: ${step.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { alreadyComplete: false, success: result.success, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const giteaSetupService = {
|
||||||
|
checkStatus,
|
||||||
|
testConnection,
|
||||||
|
runFullSetup,
|
||||||
|
autoSetupIfNeeded,
|
||||||
|
};
|
||||||
@ -77,6 +77,7 @@ export const updateSiteSettingsSchema = z.object({
|
|||||||
giteaCommentsRepoName: z.string().max(100).optional(),
|
giteaCommentsRepoName: z.string().max(100).optional(),
|
||||||
giteaOauthClientId: z.string().max(500).optional(),
|
giteaOauthClientId: z.string().max(500).optional(),
|
||||||
giteaOauthClientSecret: z.string().max(500).optional(),
|
giteaOauthClientSecret: z.string().max(500).optional(),
|
||||||
|
giteaSetupComplete: z.boolean().optional(),
|
||||||
|
|
||||||
// User Provisioning
|
// User Provisioning
|
||||||
enableUserProvisioning: z.boolean().optional(),
|
enableUserProvisioning: z.boolean().optional(),
|
||||||
|
|||||||
@ -38,6 +38,9 @@ import { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
|||||||
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
||||||
import { blocksRouter } from './modules/pages/blocks.routes';
|
import { blocksRouter } from './modules/pages/blocks.routes';
|
||||||
import { docsRouter } from './modules/docs/docs.routes';
|
import { docsRouter } from './modules/docs/docs.routes';
|
||||||
|
import { docsAccessRouter } from './modules/docs/docs-access.routes';
|
||||||
|
import { giteaSetupRouter } from './modules/gitea-setup/gitea-setup.routes';
|
||||||
|
import { giteaSetupService } from './modules/gitea-setup/gitea-setup.service';
|
||||||
import { servicesRouter } from './modules/services/services.routes';
|
import { servicesRouter } from './modules/services/services.routes';
|
||||||
import { siteSettingsRouter } from './modules/settings/settings.routes';
|
import { siteSettingsRouter } from './modules/settings/settings.routes';
|
||||||
import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes';
|
import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes';
|
||||||
@ -308,6 +311,8 @@ app.use('/api/pages', pagesPublicRouter); // Public landing pages
|
|||||||
app.use('/api/pages', pagesAdminRouter); // Admin landing page CRUD (auth required)
|
app.use('/api/pages', pagesAdminRouter); // Admin landing page CRUD (auth required)
|
||||||
app.use('/api/page-blocks', blocksRouter); // Admin page block library (auth required)
|
app.use('/api/page-blocks', blocksRouter); // Admin page block library (auth required)
|
||||||
app.use('/api/docs', docsRouter); // Docs status + config (auth required)
|
app.use('/api/docs', docsRouter); // Docs status + config (auth required)
|
||||||
|
app.use('/api/docs-access', docsAccessRouter); // Docs access policies + share links
|
||||||
|
app.use('/api/gitea/setup', giteaSetupRouter); // Gitea auto-setup (SUPER_ADMIN)
|
||||||
app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN)
|
app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN)
|
||||||
app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
|
app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
|
||||||
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
|
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
|
||||||
@ -421,6 +426,9 @@ async function start() {
|
|||||||
reengagementService.scan().catch(() => {});
|
reengagementService.scan().catch(() => {});
|
||||||
socialDigestService.scan().catch(() => {});
|
socialDigestService.scan().catch(() => {});
|
||||||
|
|
||||||
|
// Gitea auto-setup (if admin password is provided, auto-configure token + repos)
|
||||||
|
giteaSetupService.autoSetupIfNeeded().catch(() => {});
|
||||||
|
|
||||||
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
|
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
|
||||||
presenceService.markAllOffline().catch(() => {});
|
presenceService.markAllOffline().catch(() => {});
|
||||||
sseService.startHeartbeat();
|
sseService.startHeartbeat();
|
||||||
|
|||||||
21
config.sh
21
config.sh
@ -641,12 +641,25 @@ configure_features() {
|
|||||||
SMS_ENABLED="no"
|
SMS_ENABLED="no"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if prompt_yes_no "Enable Docs Comments (Gitea-backed page comments)?"; then
|
if prompt_yes_no "Enable Docs Comments & Version History (Gitea-backed)?"; then
|
||||||
update_env_var "GITEA_COMMENTS_ENABLED" "true"
|
update_env_var "GITEA_COMMENTS_ENABLED" "true"
|
||||||
success "Docs Comments enabled"
|
success "Docs Comments & Version History enabled"
|
||||||
DOCS_COMMENTS_ENABLED="yes"
|
DOCS_COMMENTS_ENABLED="yes"
|
||||||
info "After Gitea is running, create a Personal Access Token and OAuth2 app,"
|
|
||||||
info "then set GITEA_API_TOKEN, GITEA_OAUTH_CLIENT_ID/SECRET in .env."
|
echo ""
|
||||||
|
info "Gitea auto-setup will create the API token, repos, and OAuth app automatically."
|
||||||
|
info "You need to provide the Gitea admin password (set during Gitea's first-run install)."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -srp " Gitea admin password [leave blank to set up later via admin GUI]: " gitea_admin_pw
|
||||||
|
echo ""
|
||||||
|
if [[ -n "$gitea_admin_pw" ]]; then
|
||||||
|
update_env_var "GITEA_ADMIN_PASSWORD" "$gitea_admin_pw"
|
||||||
|
update_env_var "GITEA_COMMENTS_REPO_OWNER" "admin"
|
||||||
|
success "Gitea admin password saved — auto-setup will run on next start"
|
||||||
|
else
|
||||||
|
info "No password provided. Run Gitea Setup from the admin GUI after first start."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
DOCS_COMMENTS_ENABLED="no"
|
DOCS_COMMENTS_ENABLED="no"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -116,6 +116,13 @@ services:
|
|||||||
- GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin}
|
- GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin}
|
||||||
- GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-}
|
- GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-}
|
||||||
- GITEA_REGISTRY_PASS=${GITEA_REGISTRY_PASS:-}
|
- GITEA_REGISTRY_PASS=${GITEA_REGISTRY_PASS:-}
|
||||||
|
# Gitea (docs comments, version history, auto-setup)
|
||||||
|
- GITEA_URL=${GITEA_URL:-http://gitea-changemaker:3000}
|
||||||
|
- GITEA_API_TOKEN=${GITEA_API_TOKEN:-}
|
||||||
|
- GITEA_ADMIN_PASSWORD=${GITEA_ADMIN_PASSWORD:-}
|
||||||
|
- GITEA_DOCS_REPO=${GITEA_DOCS_REPO:-admin/changemaker.lite}
|
||||||
|
- GITEA_DOCS_PREFIX=${GITEA_DOCS_PREFIX:-mkdocs/docs}
|
||||||
|
- GITEA_DOCS_BRANCH=${GITEA_DOCS_BRANCH:-v2}
|
||||||
volumes:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user