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 N8nPage from '@/pages/N8nPage';
|
||||
import GiteaPage from '@/pages/GiteaPage';
|
||||
import GiteaSetupPage from '@/pages/GiteaSetupPage';
|
||||
import MailHogPage from '@/pages/MailHogPage';
|
||||
import MiniQRPage from '@/pages/MiniQRPage';
|
||||
import ExcalidrawPage from '@/pages/ExcalidrawPage';
|
||||
@ -45,6 +46,7 @@ import PangolinPage from '@/pages/PangolinPage';
|
||||
import ObservabilityPage from '@/pages/ObservabilityPage';
|
||||
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
|
||||
import DocsCommentsPage from '@/pages/DocsCommentsPage';
|
||||
import DocsMetadataPage from '@/pages/DocsMetadataPage';
|
||||
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
|
||||
import SubscribersPage from '@/pages/payments/SubscribersPage';
|
||||
import PaymentProductsPage from '@/pages/payments/ProductsPage';
|
||||
@ -153,6 +155,7 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
|
||||
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
|
||||
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
|
||||
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
|
||||
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||
|
||||
@ -313,6 +316,9 @@ export default function App() {
|
||||
<Route index element={<ContactProfilePage />} />
|
||||
</Route>
|
||||
|
||||
{/* Shared doc editor (no auth, token-based access) */}
|
||||
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
|
||||
|
||||
{/* Public Media Gallery (purple theme) — feature-gated */}
|
||||
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
|
||||
<Route index element={<MediaGalleryPage />} />
|
||||
@ -604,6 +610,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="docs/metadata"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||
<DocsMetadataPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="navigation"
|
||||
element={
|
||||
@ -644,6 +658,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="services/gitea/setup"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||
<GiteaSetupPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="services/mailhog"
|
||||
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/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/metadata', icon: <DatabaseOutlined />, label: 'Metadata' });
|
||||
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
||||
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
||||
items.push({
|
||||
@ -338,6 +339,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
||||
{ type: 'group', label: 'Tools', children: [
|
||||
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
||||
{ 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/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,
|
||||
CalendarOutlined,
|
||||
ClearOutlined,
|
||||
FormOutlined,
|
||||
ShareAltOutlined,
|
||||
LockOutlined,
|
||||
HistoryOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Editor 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 { registerWikiLinkCompletion } from '@/utils/wikiLinkCompletion';
|
||||
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 PreviewMode = 'desktop' | 'mobile';
|
||||
@ -651,6 +667,15 @@ export default function DocsPage() {
|
||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||
const [pollInsertOpen, setPollInsertOpen] = 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 dragCounter = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -678,6 +703,11 @@ export default function DocsPage() {
|
||||
collabEnabled,
|
||||
);
|
||||
|
||||
// Blog hooks
|
||||
const blogFrontmatter = useBlogFrontmatter(selectedFile, fileContent);
|
||||
const blogAuthors = useBlogAuthors();
|
||||
const blogCategories = useBlogCategories();
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
// Keep fileTreeRef in sync for Monaco autocomplete callback
|
||||
@ -1900,6 +1930,9 @@ export default function DocsPage() {
|
||||
<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 }} />
|
||||
</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}>
|
||||
<Button type="text" size="small" icon={<UploadOutlined />} onClick={handleUploadButtonClick} aria-label="Upload file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
||||
</Tooltip>
|
||||
@ -2076,6 +2109,16 @@ export default function DocsPage() {
|
||||
)}
|
||||
{dirty && !collab.active && <span style={{ color: token.colorWarning, fontWeight: 600 }}>Modified</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>
|
||||
@ -2156,8 +2199,31 @@ export default function DocsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor / Image Viewer */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{/* Editor / Image Viewer + Blog Panel */}
|
||||
<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 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Spin />
|
||||
@ -2224,6 +2290,7 @@ export default function DocsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -2403,6 +2470,52 @@ export default function DocsPage() {
|
||||
/>
|
||||
</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")
|
||||
calendarExportTokens CalendarExportToken[] @relation("CalendarExportTokenOwner")
|
||||
|
||||
// Docs access & sharing
|
||||
docAccessPoliciesCreated DocAccessPolicy[] @relation("DocAccessPolicyCreator")
|
||||
docShareLinksCreated DocShareLink[] @relation("DocShareLinkCreator")
|
||||
docWatches DocWatch[] @relation("DocWatcher")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@ -975,6 +980,7 @@ model SiteSettings {
|
||||
giteaCommentsRepoName String @default("docs-comments")
|
||||
giteaOauthClientId String @default("")
|
||||
giteaOauthClientSecret String @default("") // Encrypted at rest
|
||||
giteaSetupComplete Boolean @default(false) @map("gitea_setup_complete")
|
||||
|
||||
// Notification settings
|
||||
notifyAdminShiftSignup Boolean @default(true)
|
||||
@ -5169,6 +5175,60 @@ model DocCollabState {
|
||||
@@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
|
||||
// ============================================================================
|
||||
|
||||
@ -191,6 +191,14 @@ const envSchema = z.object({
|
||||
GITEA_OAUTH_CLIENT_ID: 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)
|
||||
ENABLE_SMS: z.string().default('false'),
|
||||
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 { redis } from '../../config/redis';
|
||||
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 { docsAccessService } from './docs-access.service';
|
||||
|
||||
// --- Metrics ---
|
||||
import { Gauge } from 'prom-client';
|
||||
@ -39,6 +40,15 @@ interface TokenPayload {
|
||||
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 ---
|
||||
const COLLAB_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||||
@ -71,26 +81,6 @@ const docsExtension: Extension = {
|
||||
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)
|
||||
try {
|
||||
docsFilesService.safeResolve(documentName);
|
||||
@ -98,45 +88,129 @@ const docsExtension: Extension = {
|
||||
throw new Error('Invalid document path');
|
||||
}
|
||||
|
||||
// Rate limit: max connections per user
|
||||
const currentCount = connectionsPerUser.get(payload.id) || 0;
|
||||
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
|
||||
// Try standard JWT (access token) first
|
||||
let payload: TokenPayload | null = null;
|
||||
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');
|
||||
}
|
||||
connectionsPerUser.set(guestKey, guestCount + 1);
|
||||
|
||||
// Rate limit: max concurrent documents
|
||||
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
|
||||
// Set guest context
|
||||
data.context.user = {
|
||||
id: payload.id,
|
||||
email: payload.email,
|
||||
name: userName,
|
||||
color: getUserColor(payload.id),
|
||||
roles,
|
||||
id: guestKey,
|
||||
email: '',
|
||||
name: sharePayload.guestName || 'Guest',
|
||||
color: getUserColor(sharePayload.shareToken),
|
||||
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) {
|
||||
|
||||
@ -317,6 +317,54 @@ async function searchFiles(
|
||||
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 = {
|
||||
listTree,
|
||||
readFileContent,
|
||||
@ -329,4 +377,5 @@ export const docsFilesService = {
|
||||
isEditableFile,
|
||||
invalidateTreeCache,
|
||||
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 { rm } from 'fs/promises';
|
||||
import { extname, basename } from 'path';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||
import { env } from '../../config/env';
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { isServiceOnline } from '../../utils/health-check';
|
||||
import { cm_docs_operations } from '../../utils/metrics';
|
||||
@ -15,6 +16,12 @@ import { mkdocsConfigService } from './mkdocs-config.service';
|
||||
import { headerBuilderService } from './header-builder.service';
|
||||
import { headerConfigSchema } from './header-builder.schemas';
|
||||
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();
|
||||
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
|
||||
router.post(
|
||||
'/files/rename',
|
||||
@ -277,7 +305,18 @@ router.post(
|
||||
res.status(400).json({ error: { message: 'Both "from" and "to" paths are required', code: 'VALIDATION_ERROR' } });
|
||||
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);
|
||||
// Cascade rename for access policies and share links
|
||||
docsAccessService.cascadeRename(from, to).catch(() => {});
|
||||
// Invalidate old path's collaboration state
|
||||
docsCollabService.invalidateDocument(from).catch(() => {});
|
||||
res.json({ success: true });
|
||||
@ -319,6 +358,15 @@ router.put(
|
||||
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||
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 };
|
||||
if (typeof content !== 'string') {
|
||||
res.status(400).json({ error: { message: 'Content string required', code: 'VALIDATION_ERROR' } });
|
||||
@ -327,6 +375,9 @@ router.put(
|
||||
await docsFilesService.writeFileContent(filePath, content);
|
||||
// Invalidate collaboration state so next session starts fresh from disk
|
||||
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 });
|
||||
} catch (err) {
|
||||
handleFileError(err, res, next);
|
||||
@ -367,7 +418,18 @@ router.delete(
|
||||
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||
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);
|
||||
// Cascade delete for access policies and share links
|
||||
docsAccessService.cascadeDelete(filePath).catch(() => {});
|
||||
// Invalidate collaboration state for deleted file
|
||||
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||
res.json({ success: true });
|
||||
@ -410,4 +472,200 @@ function handleFileError(err: unknown, res: Response, next: NextFunction): void
|
||||
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;
|
||||
|
||||
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(),
|
||||
giteaOauthClientId: z.string().max(500).optional(),
|
||||
giteaOauthClientSecret: z.string().max(500).optional(),
|
||||
giteaSetupComplete: z.boolean().optional(),
|
||||
|
||||
// User Provisioning
|
||||
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 { blocksRouter } from './modules/pages/blocks.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 { siteSettingsRouter } from './modules/settings/settings.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/page-blocks', blocksRouter); // Admin page block library (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/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
|
||||
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
|
||||
@ -421,6 +426,9 @@ async function start() {
|
||||
reengagementService.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
|
||||
presenceService.markAllOffline().catch(() => {});
|
||||
sseService.startHeartbeat();
|
||||
|
||||
21
config.sh
21
config.sh
@ -641,12 +641,25 @@ configure_features() {
|
||||
SMS_ENABLED="no"
|
||||
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"
|
||||
success "Docs Comments enabled"
|
||||
success "Docs Comments & Version History enabled"
|
||||
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
|
||||
DOCS_COMMENTS_ENABLED="no"
|
||||
fi
|
||||
|
||||
@ -116,6 +116,13 @@ services:
|
||||
- GITEA_REGISTRY=${GITEA_REGISTRY:-gitea.bnkops.com/admin}
|
||||
- GITEA_REGISTRY_USER=${GITEA_REGISTRY_USER:-}
|
||||
- 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:
|
||||
- ./api:/app
|
||||
- /app/node_modules
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user