Skip to content
Clone

feat(admin): implement admin module with authentication, user management, and audit logging

ZhenYi committed
parent 0d3c94c commit 98e2c80 Browse files

83 files changed

+16498 −24

File tree 83
Added admin/src/app/index.tsx View admin/src/app/index.tsx
@@ -0,0 +1,17 @@
1import { StrictMode } from 'react'
2import { createRoot } from 'react-dom/client'
3import { RouterProvider } from 'react-router-dom'
4import { SessionProvider } from '@/app/session'
5import { ToastProvider } from '@/shared/ui'
6import { router } from './routes'
7import './styles.css'
8
9createRoot(document.getElementById('root')!).render(
10 <StrictMode>
11 <ToastProvider>
12 <SessionProvider>
13 <RouterProvider router={router} />
14 </SessionProvider>
15 </ToastProvider>
16 </StrictMode>,
17)
No newline at end of file
Added admin/src/app/layout.tsx View admin/src/app/layout.tsx
@@ -0,0 +1,119 @@
1import {
2 Activity,
3 Database,
4 HardDrive,
5 LayoutDashboard,
6 ListChecks,
7 LogOut,
8 Moon,
9 ScrollText,
10 ShieldCheck,
11 Sun,
12 Users,
13} from 'lucide-react'
14import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'
15import { useSession } from '@/app/session'
16import { useTheme } from '@/app/theme'
17import { Badge, Button, Loading } from '@/shared/ui'
18
19const NAV_ITEMS = [
20 { to: '/', label: '系统状态', icon: LayoutDashboard, end: true },
21 { to: '/users', label: '用户', icon: Users },
22 { to: '/spaces', label: 'Space', icon: Database },
23 { to: '/storage', label: '存储', icon: HardDrive },
24 { to: '/queues', label: '队列', icon: Activity },
25 { to: '/tasks', label: '任务', icon: ListChecks },
26 { to: '/logs', label: '日志', icon: ScrollText },
27 { to: '/admins', label: '管理员', icon: ShieldCheck },
28]
29
30function roleLabel(role: string): string {
31 switch (role) {
32 case 'super_admin':
33 return '超级管理员'
34 case 'operator':
35 return '运维'
36 default:
37 return '只读'
38 }
39}
40
41export function AdminShell() {
42 const { me, loading, logout } = useSession()
43 const { dark, toggle } = useTheme()
44 const navigate = useNavigate()
45
46 if (loading) {
47 return <Loading label="正在校验管理会话…" />
48 }
49 if (!me) {
50 return <Navigate to="/login" replace />
51 }
52 if (me.must_change_password) {
53 return <Navigate to="/password" replace />
54 }
55
56 return (
57 <div className="flex min-h-full">
58 <aside className="hidden w-56 shrink-0 flex-col border-r border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-950 md:flex">
59 <div className="mb-4 px-2">
60 <div className="text-sm font-semibold text-slate-900 dark:text-slate-100">GitData</div>
61 <div className="text-xs text-slate-500">管理后台</div>
62 </div>
63 <nav className="flex flex-1 flex-col gap-1">
64 {NAV_ITEMS.map((item) => (
65 <NavLink
66 key={item.to}
67 to={item.to}
68 end={item.end}
69 className={({ isActive }) =>
70 `flex items-center gap-2 rounded-md px-2 py-1.5 text-sm ${
71 isActive
72 ? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
73 : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
74 }`
75 }
76 >
77 <item.icon size={16} />
78 {item.label}
79 </NavLink>
80 ))}
81 </nav>
82 <div className="mt-4 border-t border-slate-200 pt-3 text-xs text-slate-500 dark:border-slate-800">
83 adminhost
84 </div>
85 </aside>
86
87 <div className="flex min-w-0 flex-1 flex-col">
88 <header className="flex items-center justify-between gap-3 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-950">
89 <div className="flex items-center gap-2">
90 <span className="font-medium text-slate-800 dark:text-slate-100">{me.display_name}</span>
91 <Badge tone={me.role === 'super_admin' ? 'danger' : 'info'}>{roleLabel(me.role)}</Badge>
92 </div>
93 <div className="flex items-center gap-2">
94 <Button variant="ghost" size="sm" title="切换主题" onClick={toggle}>
95 {dark ? <Sun size={16} /> : <Moon size={16} />}
96 </Button>
97 <Button variant="secondary" size="sm" onClick={() => navigate('/password')}>
98 修改口令
99 </Button>
100 <Button
101 variant="secondary"
102 size="sm"
103 onClick={async () => {
104 await logout()
105 navigate('/login')
106 }}
107 >
108 <LogOut size={14} />
109 退出
110 </Button>
111 </div>
112 </header>
113 <main className="min-w-0 flex-1 overflow-y-auto p-4">
114 <Outlet />
115 </main>
116 </div>
117 </div>
118 )
119}
No newline at end of file
Added admin/src/app/routes.tsx View admin/src/app/routes.tsx
@@ -0,0 +1,38 @@
1import { createBrowserRouter, Navigate } from 'react-router-dom'
2import { AdminShell } from '@/app/layout'
3import { AdminsPage } from '@/pages/admins'
4import { DashboardPage } from '@/pages/dashboard'
5import { LoginPage } from '@/pages/login'
6import { LogsPage } from '@/pages/logs'
7import { NotFoundPage } from '@/pages/not-found'
8import { PasswordPage } from '@/pages/password'
9import { QueuesPage } from '@/pages/queues'
10import { SpaceDetailPage } from '@/pages/space-detail'
11import { SpacesPage } from '@/pages/spaces'
12import { StoragePage } from '@/pages/storage'
13import { TasksPage } from '@/pages/tasks'
14import { UserDetailPage } from '@/pages/user-detail'
15import { UsersPage } from '@/pages/users'
16
17export const router = createBrowserRouter([
18 { path: '/login', element: <LoginPage /> },
19 { path: '/password', element: <PasswordPage /> },
20 {
21 path: '/',
22 element: <AdminShell />,
23 children: [
24 { index: true, element: <DashboardPage /> },
25 { path: 'users', element: <UsersPage /> },
26 { path: 'users/:uid', element: <UserDetailPage /> },
27 { path: 'spaces', element: <SpacesPage /> },
28 { path: 'spaces/:id', element: <SpaceDetailPage /> },
29 { path: 'storage', element: <StoragePage /> },
30 { path: 'queues', element: <QueuesPage /> },
31 { path: 'tasks', element: <TasksPage /> },
32 { path: 'logs', element: <LogsPage /> },
33 { path: 'admins', element: <AdminsPage /> },
34 { path: '*', element: <NotFoundPage /> },
35 ],
36 },
37 { path: '*', element: <Navigate to="/" replace /> },
38])
No newline at end of file
Added admin/src/app/session.tsx View admin/src/app/session.tsx
@@ -0,0 +1,53 @@
1import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
2import type { ReactNode } from 'react'
3import { adminAuth } from '@/shared/api'
4import type { AdminDto } from '@/shared/api'
5
6interface SessionValue {
7 me: AdminDto | null
8 loading: boolean
9 refresh: () => Promise<void>
10 logout: () => Promise<void>
11}
12
13const SessionContext = createContext<SessionValue>({
14 me: null,
15 loading: true,
16 refresh: async () => {},
17 logout: async () => {},
18})
19
20export function SessionProvider({ children }: { children: ReactNode }) {
21 const [me, setMe] = useState<AdminDto | null>(null)
22 const [loading, setLoading] = useState(true)
23
24 const refresh = useCallback(async () => {
25 try {
26 const value = await adminAuth.me()
27 setMe(value)
28 } catch {
29 setMe(null)
30 } finally {
31 setLoading(false)
32 }
33 }, [])
34
35 useEffect(() => {
36 void refresh()
37 }, [refresh])
38
39 const logout = useCallback(async () => {
40 try {
41 await adminAuth.logout()
42 } catch {
43 }
44 setMe(null)
45 }, [])
46
47 const value = useMemo(() => ({ me, loading, refresh, logout }), [me, loading, refresh, logout])
48 return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
49}
50
51export function useSession() {
52 return useContext(SessionContext)
53}
No newline at end of file
Added admin/src/app/styles.css View admin/src/app/styles.css
@@ -0,0 +1,21 @@
1@import "tailwindcss";
2
3@custom-variant dark (&:where(.dark, .dark *));
4
5html,
6body,
7#root {
8 height: 100%;
9}
10
11body {
12 margin: 0;
13 font-family: "Geist", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
14 background-color: #f8fafc;
15 color: #0f172a;
16}
17
18.dark body {
19 background-color: #020617;
20 color: #e2e8f0;
21}
No newline at end of file
Added admin/src/app/theme.ts View admin/src/app/theme.ts
@@ -0,0 +1,21 @@
1import { useCallback, useEffect, useState } from 'react'
2
3const THEME_KEY = 'gitdata-admin-theme'
4
5export function useTheme() {
6 const [dark, setDark] = useState(() => {
7 const stored = window.localStorage.getItem(THEME_KEY)
8 if (stored) {
9 return stored === 'dark'
10 }
11 return window.matchMedia('(prefers-color-scheme: dark)').matches
12 })
13
14 useEffect(() => {
15 document.documentElement.classList.toggle('dark', dark)
16 window.localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light')
17 }, [dark])
18
19 const toggle = useCallback(() => setDark((value) => !value), [])
20 return { dark, toggle }
21}
No newline at end of file
Added admin/src/pages/admins.tsx View admin/src/pages/admins.tsx
@@ -0,0 +1,321 @@
1import { useCallback, useEffect, useState } from 'react'
2import { useSession } from '@/app/session'
3import { adminAccounts } from '@/shared/api'
4import type { AdminDto, Page } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatDateTime } from '@/shared/format'
7import {
8 Badge,
9 Button,
10 Card,
11 ConfirmDialog,
12 DataTable,
13 ErrorBanner,
14 Field,
15 Loading,
16 Modal,
17 Pagination,
18 Select,
19 TextInput,
20 useToast,
21} from '@/shared/ui'
22
23const PAGE_SIZE = 20
24
25const ROLE_OPTIONS = [
26 { value: 'super_admin', label: '超级管理员' },
27 { value: 'operator', label: '运维' },
28 { value: 'viewer', label: '只读' },
29]
30
31export function AdminsPage() {
32 const { me } = useSession()
33 const toast = useToast()
34 const [data, setData] = useState<Page<AdminDto> | null>(null)
35 const [offset, setOffset] = useState(0)
36 const [error, setError] = useState<string | null>(null)
37 const [createOpen, setCreateOpen] = useState(false)
38 const [username, setUsername] = useState('')
39 const [displayName, setDisplayName] = useState('')
40 const [password, setPassword] = useState('')
41 const [role, setRole] = useState('operator')
42 const [editTarget, setEditTarget] = useState<AdminDto | null>(null)
43 const [editDisplayName, setEditDisplayName] = useState('')
44 const [editRole, setEditRole] = useState('operator')
45 const [editActive, setEditActive] = useState(true)
46 const [passwordTarget, setPasswordTarget] = useState<AdminDto | null>(null)
47 const [newPassword, setNewPassword] = useState('')
48 const [deleteTarget, setDeleteTarget] = useState<AdminDto | null>(null)
49
50 const load = useCallback(async () => {
51 if (me?.role !== 'super_admin') {
52 return
53 }
54 setError(null)
55 try {
56 setData(await adminAccounts.list({ limit: PAGE_SIZE, offset }))
57 } catch (err) {
58 setError(err instanceof ApiError ? err.message : '管理员列表加载失败')
59 }
60 }, [me, offset])
61
62 useEffect(() => {
63 void load()
64 }, [load])
65
66 if (me?.role !== 'super_admin') {
67 return (
68 <Card title="管理员">
69 <p className="text-sm text-slate-500">仅超级管理员可以管理管理员账号。</p>
70 </Card>
71 )
72 }
73
74 const run = async (action: () => Promise<unknown>, message: string) => {
75 try {
76 await action()
77 toast(message)
78 setCreateOpen(false)
79 setEditTarget(null)
80 setPasswordTarget(null)
81 setDeleteTarget(null)
82 await load()
83 } catch (err) {
84 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
85 }
86 }
87
88 return (
89 <div className="space-y-4">
90 <div className="flex items-center justify-between">
91 <div>
92 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">管理员</h1>
93 <p className="text-xs text-slate-500">管理账号、角色与口令重置</p>
94 </div>
95 <Button
96 size="sm"
97 onClick={() => {
98 setUsername('')
99 setDisplayName('')
100 setPassword('')
101 setRole('operator')
102 setCreateOpen(true)
103 }}
104 >
105 新建管理员
106 </Button>
107 </div>
108
109 <ErrorBanner message={error} />
110
111 <Card title="管理员账号">
112 {data === null ? (
113 <Loading />
114 ) : (
115 <>
116 <DataTable
117 columns={[
118 { key: 'username', title: '账号', render: (row) => row.username },
119 { key: 'display_name', title: '显示名', render: (row) => row.display_name },
120 {
121 key: 'role',
122 title: '角色',
123 render: (row) => (
124 <Badge tone={row.role === 'super_admin' ? 'danger' : row.role === 'operator' ? 'info' : 'neutral'}>
125 {ROLE_OPTIONS.find((option) => option.value === row.role)?.label ?? row.role}
126 </Badge>
127 ),
128 },
129 {
130 key: 'active',
131 title: '状态',
132 render: (row) => (
133 <Badge tone={row.is_active ? 'success' : 'danger'}>{row.is_active ? '启用' : '停用'}</Badge>
134 ),
135 },
136 {
137 key: 'last_login',
138 title: '最后登录',
139 render: (row) => (
140 <span className="text-xs text-slate-500">{formatDateTime(row.last_login_at)}</span>
141 ),
142 },
143 {
144 key: 'actions',
145 title: '操作',
146 render: (row) => (
147 <div className="flex flex-wrap gap-1">
148 <Button
149 size="sm"
150 variant="secondary"
151 onClick={() => {
152 setEditTarget(row)
153 setEditDisplayName(row.display_name)
154 setEditRole(row.role)
155 setEditActive(row.is_active)
156 }}
157 >
158 编辑
159 </Button>
160 <Button
161 size="sm"
162 variant="secondary"
163 onClick={() => {
164 setPasswordTarget(row)
165 setNewPassword('')
166 }}
167 >
168 重置口令
169 </Button>
170 <Button size="sm" variant="danger" onClick={() => setDeleteTarget(row)}>
171 删除
172 </Button>
173 </div>
174 ),
175 },
176 ]}
177 rows={data.items}
178 rowKey={(row) => row.id}
179 />
180 <Pagination total={data.total} limit={data.limit} offset={data.offset} onChange={setOffset} />
181 </>
182 )}
183 </Card>
184
185 <Modal
186 open={createOpen}
187 title="新建管理员"
188 onClose={() => setCreateOpen(false)}
189 footer={
190 <>
191 <Button variant="secondary" onClick={() => setCreateOpen(false)}>
192 取消
193 </Button>
194 <Button
195 onClick={() =>
196 void run(
197 () =>
198 adminAccounts.create({
199 username,
200 display_name: displayName || undefined,
201 password,
202 role,
203 }),
204 '管理员已创建',
205 )
206 }
207 >
208 创建
209 </Button>
210 </>
211 }
212 >
213 <Field label="账号" hint="3-32 位,字母/数字/_/-/.">
214 <TextInput value={username} onChange={setUsername} />
215 </Field>
216 <Field label="显示名">
217 <TextInput value={displayName} onChange={setDisplayName} />
218 </Field>
219 <Field label="初始口令">
220 <TextInput value={password} onChange={setPassword} type="password" />
221 </Field>
222 <Field label="角色">
223 <Select value={role} onChange={setRole} options={ROLE_OPTIONS} />
224 </Field>
225 </Modal>
226
227 <Modal
228 open={editTarget !== null}
229 title={`编辑管理员 ${editTarget?.username ?? ''}`}
230 onClose={() => setEditTarget(null)}
231 footer={
232 <>
233 <Button variant="secondary" onClick={() => setEditTarget(null)}>
234 取消
235 </Button>
236 <Button
237 onClick={() => {
238 if (!editTarget) {
239 return
240 }
241 void run(
242 () =>
243 adminAccounts.update(editTarget.id, {
244 display_name: editDisplayName,
245 role: editRole,
246 is_active: editActive,
247 }),
248 '管理员已更新',
249 )
250 }}
251 >
252 保存
253 </Button>
254 </>
255 }
256 >
257 <Field label="显示名">
258 <TextInput value={editDisplayName} onChange={setEditDisplayName} />
259 </Field>
260 <Field label="角色">
261 <Select value={editRole} onChange={setEditRole} options={ROLE_OPTIONS} />
262 </Field>
263 <Field label="状态">
264 <Select
265 value={editActive ? 'active' : 'inactive'}
266 onChange={(value) => setEditActive(value === 'active')}
267 options={[
268 { value: 'active', label: '启用' },
269 { value: 'inactive', label: '停用' },
270 ]}
271 />
272 </Field>
273 </Modal>
274
275 <Modal
276 open={passwordTarget !== null}
277 title={`重置口令 ${passwordTarget?.username ?? ''}`}
278 onClose={() => setPasswordTarget(null)}
279 footer={
280 <>
281 <Button variant="secondary" onClick={() => setPasswordTarget(null)}>
282 取消
283 </Button>
284 <Button
285 onClick={() => {
286 if (!passwordTarget || !newPassword) {
287 return
288 }
289 void run(
290 () => adminAccounts.resetPassword(passwordTarget.id, newPassword),
291 '口令已重置,下次登录需改密',
292 )
293 }}
294 >
295 确认重置
296 </Button>
297 </>
298 }
299 >
300 <Field label="新口令">
301 <TextInput value={newPassword} onChange={setNewPassword} type="password" />
302 </Field>
303 </Modal>
304
305 <ConfirmDialog
306 open={deleteTarget !== null}
307 title="删除管理员"
308 description="删除后该账号立即失效;系统会阻止删除自己与最后一个超级管理员。"
309 confirmLabel="删除管理员"
310 requireText={deleteTarget?.username}
311 onClose={() => setDeleteTarget(null)}
312 onConfirm={() => {
313 if (!deleteTarget) {
314 return
315 }
316 void run(() => adminAccounts.remove(deleteTarget.id), '管理员已删除')
317 }}
318 />
319 </div>
320 )
321}
No newline at end of file
Added admin/src/pages/dashboard.tsx View admin/src/pages/dashboard.tsx
@@ -0,0 +1,172 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminSystem } from '@/shared/api'
3import type { SystemStatus } from '@/shared/api'
4import { formatDateTime, formatUptime } from '@/shared/format'
5import { Badge, Button, Card, DataTable, ErrorBanner, Loading, statusTone } from '@/shared/ui'
6
7function DependencyCard({ name, dependency }: { name: string; dependency: { ok: boolean; latency_ms: number | null; detail: string | null } }) {
8 return (
9 <Card title={name}>
10 <div className="flex items-center justify-between">
11 <Badge tone={dependency.ok ? 'success' : 'danger'}>{dependency.ok ? '正常' : '异常'}</Badge>
12 <span className="text-xs text-slate-500">
13 {dependency.latency_ms === null ? '-' : `${dependency.latency_ms} ms`}
14 </span>
15 </div>
16 {dependency.detail && (
17 <p className="mt-2 break-all text-xs text-slate-500">{dependency.detail}</p>
18 )}
19 </Card>
20 )
21}
22
23export function DashboardPage() {
24 const [status, setStatus] = useState<SystemStatus | null>(null)
25 const [error, setError] = useState<string | null>(null)
26 const [busy, setBusy] = useState(false)
27
28 const load = useCallback(async () => {
29 setBusy(true)
30 setError(null)
31 try {
32 setStatus(await adminSystem.status())
33 } catch {
34 setError('系统状态加载失败')
35 } finally {
36 setBusy(false)
37 }
38 }, [])
39
40 useEffect(() => {
41 void load()
42 const timer = window.setInterval(() => {
43 void load()
44 }, 30000)
45 return () => window.clearInterval(timer)
46 }, [load])
47
48 if (!status) {
49 return (
50 <div className="space-y-3">
51 <ErrorBanner message={error} />
52 <Loading />
53 </div>
54 )
55 }
56
57 const counts = status.counts
58
59 return (
60 <div className="space-y-4">
61 <div className="flex items-center justify-between">
62 <div>
63 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">系统状态</h1>
64 <p className="text-xs text-slate-500">
65 adminhost v{status.process.version} · 运行 {formatUptime(status.process.uptime_secs)} · 更新于{' '}
66 {formatDateTime(status.generated_at)}
67 </p>
68 </div>
69 <Button variant="secondary" size="sm" disabled={busy} onClick={() => void load()}>
70 刷新
71 </Button>
72 </div>
73
74 <ErrorBanner message={error} />
75
76 <div className="grid gap-3 md:grid-cols-4">
77 <DependencyCard name="PostgreSQL" dependency={status.dependencies.database} />
78 <DependencyCard name="Redis" dependency={status.dependencies.redis} />
79 <DependencyCard name="S3 存储" dependency={status.dependencies.storage} />
80 <DependencyCard name="搜索" dependency={status.dependencies.search} />
81 </div>
82
83 <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-5">
84 {(
85 [
86 ['用户', counts.users],
87 ['活跃用户', counts.active_users],
88 ['Space', counts.spaces],
89 ['仓库', counts.repos],
90 ['房间', counts.rooms],
91 ['工单', counts.issues],
92 ['消息', counts.messages],
93 ['管理员', counts.admin_users],
94 ['活跃 Space', counts.active_spaces],
95 ] as [string, number][]
96 ).map(([label, value]) => (
97 <Card key={label} title={label}>
98 <div className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
99 </Card>
100 ))}
101 </div>
102
103 <div className="grid gap-3 lg:grid-cols-2">
104 <Card title="队列概览">
105 <DataTable
106 columns={[
107 { key: 'name', title: '队列', render: (row) => row.name },
108 {
109 key: 'depth',
110 title: '深度',
111 render: (row) => (row.depth === null ? '-' : row.depth),
112 },
113 { key: 'failed', title: '失败', render: (row) => row.failed },
114 {
115 key: 'enabled',
116 title: '状态',
117 render: (row) => (
118 <Badge tone={row.enabled ? 'success' : 'neutral'}>
119 {row.enabled ? '启用' : '停用'}
120 </Badge>
121 ),
122 },
123 ]}
124 rows={status.queues}
125 rowKey={(row) => row.name}
126 />
127 </Card>
128
129 <Card title="最近失败">
130 <DataTable
131 columns={[
132 { key: 'source', title: '来源', render: (row) => row.source },
133 {
134 key: 'state',
135 title: '状态',
136 render: (row) => <Badge tone={statusTone(row.state)}>{row.state}</Badge>,
137 },
138 {
139 key: 'error',
140 title: '错误',
141 render: (row) => (
142 <span className="line-clamp-2 max-w-[280px] break-all text-xs text-slate-500">
143 {row.error ?? '-'}
144 </span>
145 ),
146 },
147 {
148 key: 'created_at',
149 title: '时间',
150 render: (row) => (
151 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
152 ),
153 },
154 ]}
155 rows={status.recent_failures}
156 rowKey={(row) => `${row.source}-${row.id}`}
157 empty="没有失败记录"
158 />
159 </Card>
160 </div>
161
162 <Card title="存储">
163 <p className="text-xs text-slate-500">
164 存储用量与对象浏览请前往「存储」页面;当前功能开关:搜索{' '}
165 {status.features.search_enabled ? '开启' : '关闭'},导入{' '}
166 {status.features.import_enabled ? '开启' : '关闭'},邮件{' '}
167 {status.features.email_enabled ? '开启' : '关闭'}。
168 </p>
169 </Card>
170 </div>
171 )
172}
Added admin/src/pages/login.tsx View admin/src/pages/login.tsx
@@ -0,0 +1,65 @@
1import { useEffect, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { useSession } from '@/app/session'
4import { adminAuth } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { Button, Card, ErrorBanner, Field, TextInput, useToast } from '@/shared/ui'
7
8export function LoginPage() {
9 const { me, loading, refresh } = useSession()
10 const navigate = useNavigate()
11 const toast = useToast()
12 const [username, setUsername] = useState('')
13 const [password, setPassword] = useState('')
14 const [error, setError] = useState<string | null>(null)
15 const [busy, setBusy] = useState(false)
16
17 useEffect(() => {
18 if (!loading && me) {
19 navigate(me.must_change_password ? '/password' : '/', { replace: true })
20 }
21 }, [loading, me, navigate])
22
23 const submit = async () => {
24 setBusy(true)
25 setError(null)
26 try {
27 const resp = await adminAuth.login(username, password)
28 await refresh()
29 toast('登录成功')
30 navigate(resp.must_change_password ? '/password' : '/', { replace: true })
31 } catch (err) {
32 setError(err instanceof ApiError ? err.message : '登录失败,请稍后再试')
33 } finally {
34 setBusy(false)
35 }
36 }
37
38 return (
39 <div className="flex min-h-full items-center justify-center p-4">
40 <Card title="GitData 管理后台" className="w-full max-w-sm">
41 <form
42 className="space-y-3"
43 onSubmit={(event) => {
44 event.preventDefault()
45 void submit()
46 }}
47 >
48 <ErrorBanner message={error} />
49 <Field label="管理员账号">
50 <TextInput value={username} onChange={setUsername} placeholder="username" />
51 </Field>
52 <Field label="口令">
53 <TextInput value={password} onChange={setPassword} type="password" />
54 </Field>
55 <Button type="submit" disabled={busy || !username || !password}>
56 {busy ? '登录中…' : '登录'}
57 </Button>
58 <p className="text-xs text-slate-500">
59 管理端使用独立会话 Cookie,与普通用户登录互不影响。
60 </p>
61 </form>
62 </Card>
63 </div>
64 )
65}
No newline at end of file
Added admin/src/pages/logs.tsx View admin/src/pages/logs.tsx
@@ -0,0 +1,227 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminLogs } from '@/shared/api'
3import type { AuditLogPage, JobLogPage } from '@/shared/api'
4import { ApiError } from '@/shared/api'
5import { formatDateTime, prettyJson } from '@/shared/format'
6import {
7 Badge,
8 Button,
9 Card,
10 DataTable,
11 ErrorBanner,
12 Field,
13 Loading,
14 Modal,
15 Pagination,
16 Select,
17 TextInput,
18 statusTone,
19} from '@/shared/ui'
20
21const PAGE_SIZE = 20
22
23export function LogsPage() {
24 const [tab, setTab] = useState<'audit' | 'jobs'>('audit')
25 const [action, setAction] = useState('')
26 const [targetType, setTargetType] = useState('')
27 const [source, setSource] = useState('')
28 const [offset, setOffset] = useState(0)
29 const [audit, setAudit] = useState<AuditLogPage | null>(null)
30 const [jobs, setJobs] = useState<JobLogPage | null>(null)
31 const [detail, setDetail] = useState<unknown>(null)
32 const [error, setError] = useState<string | null>(null)
33
34 const load = useCallback(async () => {
35 setError(null)
36 try {
37 if (tab === 'audit') {
38 setAudit(
39 await adminLogs.audit({
40 action: action || undefined,
41 target_type: targetType || undefined,
42 limit: PAGE_SIZE,
43 offset,
44 }),
45 )
46 } else {
47 setJobs(await adminLogs.jobs({ source: source || undefined, limit: PAGE_SIZE, offset }))
48 }
49 } catch (err) {
50 setError(err instanceof ApiError ? err.message : '日志加载失败')
51 }
52 }, [tab, action, targetType, source, offset])
53
54 useEffect(() => {
55 void load()
56 }, [load])
57
58 return (
59 <div className="space-y-4">
60 <div className="flex items-center justify-between">
61 <div>
62 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">日志</h1>
63 <p className="text-xs text-slate-500">管理员操作审计与作业错误日志</p>
64 </div>
65 <div className="flex gap-2">
66 <Button
67 size="sm"
68 variant={tab === 'audit' ? 'primary' : 'secondary'}
69 onClick={() => {
70 setTab('audit')
71 setOffset(0)
72 }}
73 >
74 审计日志
75 </Button>
76 <Button
77 size="sm"
78 variant={tab === 'jobs' ? 'primary' : 'secondary'}
79 onClick={() => {
80 setTab('jobs')
81 setOffset(0)
82 }}
83 >
84 作业日志
85 </Button>
86 </div>
87 </div>
88
89 <ErrorBanner message={error} />
90
91 {tab === 'audit' ? (
92 <Card
93 title="操作审计"
94 action={
95 <form
96 className="flex items-end gap-2"
97 onSubmit={(event) => {
98 event.preventDefault()
99 setOffset(0)
100 void load()
101 }}
102 >
103 <Field label="动作">
104 <TextInput value={action} onChange={setAction} placeholder="user.suspended …" />
105 </Field>
106 <Field label="目标类型">
107 <TextInput value={targetType} onChange={setTargetType} placeholder="user / space …" />
108 </Field>
109 <Button type="submit" size="sm" variant="secondary">
110 过滤
111 </Button>
112 </form>
113 }
114 >
115 {audit === null ? (
116 <Loading />
117 ) : (
118 <>
119 <DataTable
120 columns={[
121 {
122 key: 'time',
123 title: '时间',
124 render: (row) => (
125 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
126 ),
127 },
128 { key: 'admin', title: '管理员', render: (row) => row.admin_username },
129 { key: 'action', title: '动作', render: (row) => <Badge tone="info">{row.action}</Badge> },
130 {
131 key: 'target',
132 title: '目标',
133 render: (row) => (
134 <span className="text-xs text-slate-500">
135 {row.target_type ?? '-'} {row.target_id ? `(${row.target_id.slice(0, 8)}…)` : ''}
136 </span>
137 ),
138 },
139 {
140 key: 'ip',
141 title: 'IP',
142 render: (row) => <span className="text-xs text-slate-500">{row.ip ?? '-'}</span>,
143 },
144 {
145 key: 'actions',
146 title: '详情',
147 render: (row) => (
148 <Button size="sm" variant="secondary" onClick={() => setDetail(row.detail)}>
149 查看
150 </Button>
151 ),
152 },
153 ]}
154 rows={audit.items}
155 rowKey={(row) => row.id}
156 />
157 <Pagination total={audit.total} limit={audit.limit} offset={audit.offset} onChange={setOffset} />
158 </>
159 )}
160 </Card>
161 ) : (
162 <Card
163 title="作业错误"
164 action={
165 <Select
166 value={source}
167 onChange={(value) => {
168 setSource(value)
169 setOffset(0)
170 }}
171 options={[
172 { value: '', label: '全部来源' },
173 { value: 'import', label: 'import' },
174 { value: 'search_index', label: 'search_index' },
175 { value: 'chat_webhook', label: 'chat_webhook' },
176 { value: 'pipeline', label: 'pipeline' },
177 { value: 'system_tasks', label: 'system_tasks' },
178 ]}
179 />
180 }
181 >
182 {jobs === null ? (
183 <Loading />
184 ) : (
185 <>
186 <DataTable
187 columns={[
188 {
189 key: 'time',
190 title: '时间',
191 render: (row) => (
192 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
193 ),
194 },
195 { key: 'source', title: '来源', render: (row) => row.source },
196 {
197 key: 'state',
198 title: '状态',
199 render: (row) => <Badge tone={statusTone(row.state)}>{row.state}</Badge>,
200 },
201 {
202 key: 'error',
203 title: '错误',
204 render: (row) => (
205 <span className="line-clamp-2 max-w-[420px] break-all text-xs text-slate-500">
206 {row.error ?? '-'}
207 </span>
208 ),
209 },
210 ]}
211 rows={jobs.items}
212 rowKey={(row) => `${row.source}-${row.id}`}
213 />
214 <Pagination total={jobs.total} limit={jobs.limit} offset={jobs.offset} onChange={setOffset} />
215 </>
216 )}
217 </Card>
218 )}
219
220 <Modal open={detail !== null} title="审计详情" onClose={() => setDetail(null)}>
221 <pre className="max-h-80 overflow-auto rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
222 {prettyJson(detail)}
223 </pre>
224 </Modal>
225 </div>
226 )
227}
No newline at end of file
Added admin/src/pages/not-found.tsx View admin/src/pages/not-found.tsx
@@ -0,0 +1,13 @@
1import { Link } from 'react-router-dom'
2import { Card } from '@/shared/ui'
3
4export function NotFoundPage() {
5 return (
6 <Card title="页面不存在">
7 <p className="text-sm text-slate-500">没有找到对应的管理页面。</p>
8 <Link className="mt-3 inline-block text-sm text-sky-600 hover:underline dark:text-sky-400" to="/">
9 返回系统状态
10 </Link>
11 </Card>
12 )
13}
No newline at end of file
Added admin/src/pages/password.tsx View admin/src/pages/password.tsx
@@ -0,0 +1,76 @@
1import { useState } from 'react'
2import { Navigate, useNavigate } from 'react-router-dom'
3import { useSession } from '@/app/session'
4import { adminAuth } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { Button, Card, ErrorBanner, Field, TextInput, useToast } from '@/shared/ui'
7
8export function PasswordPage() {
9 const { me, loading, refresh } = useSession()
10 const navigate = useNavigate()
11 const toast = useToast()
12 const [current, setCurrent] = useState('')
13 const [next, setNext] = useState('')
14 const [confirm, setConfirm] = useState('')
15 const [error, setError] = useState<string | null>(null)
16 const [busy, setBusy] = useState(false)
17
18 if (loading) {
19 return null
20 }
21 if (!me) {
22 return <Navigate to="/login" replace />
23 }
24
25 const submit = async () => {
26 if (next !== confirm) {
27 setError('两次输入的新口令不一致')
28 return
29 }
30 setBusy(true)
31 setError(null)
32 try {
33 await adminAuth.changePassword(current, next)
34 await refresh()
35 toast('口令已更新')
36 navigate('/', { replace: true })
37 } catch (err) {
38 setError(err instanceof ApiError ? err.message : '修改失败,请稍后再试')
39 } finally {
40 setBusy(false)
41 }
42 }
43
44 return (
45 <div className="flex min-h-full items-center justify-center p-4">
46 <Card title="修改管理口令" className="w-full max-w-sm">
47 <form
48 className="space-y-3"
49 onSubmit={(event) => {
50 event.preventDefault()
51 void submit()
52 }}
53 >
54 <ErrorBanner message={error} />
55 {me.must_change_password && (
56 <div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-950 dark:text-amber-300">
57 首次登录必须修改口令后才能访问其他管理功能。
58 </div>
59 )}
60 <Field label="当前口令">
61 <TextInput value={current} onChange={setCurrent} type="password" />
62 </Field>
63 <Field label="新口令">
64 <TextInput value={next} onChange={setNext} type="password" />
65 </Field>
66 <Field label="确认新口令">
67 <TextInput value={confirm} onChange={setConfirm} type="password" />
68 </Field>
69 <Button type="submit" disabled={busy || !current || !next}>
70 {busy ? '提交中…' : '保存'}
71 </Button>
72 </form>
73 </Card>
74 </div>
75 )
76}
No newline at end of file
Added admin/src/pages/queues.tsx View admin/src/pages/queues.tsx
@@ -0,0 +1,151 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminQueues } from '@/shared/api'
3import type { QueueSnapshot } from '@/shared/api'
4import { ApiError } from '@/shared/api'
5import {
6 Badge,
7 Button,
8 Card,
9 ConfirmDialog,
10 DataTable,
11 ErrorBanner,
12 Loading,
13 useToast,
14} from '@/shared/ui'
15
16export function QueuesPage() {
17 const toast = useToast()
18 const [queues, setQueues] = useState<QueueSnapshot[] | null>(null)
19 const [error, setError] = useState<string | null>(null)
20 const [purgeTarget, setPurgeTarget] = useState<QueueSnapshot | null>(null)
21
22 const load = useCallback(async () => {
23 setError(null)
24 try {
25 setQueues(await adminQueues.list())
26 } catch (err) {
27 setError(err instanceof ApiError ? err.message : '队列快照加载失败')
28 }
29 }, [])
30
31 useEffect(() => {
32 void load()
33 const timer = window.setInterval(() => {
34 void load()
35 }, 30000)
36 return () => window.clearInterval(timer)
37 }, [load])
38
39 const run = async (action: () => Promise<{ queue: string; affected: number }>, message: string) => {
40 try {
41 const result = await action()
42 toast(`${message}(影响 ${result.affected} 条)`)
43 setPurgeTarget(null)
44 await load()
45 } catch (err) {
46 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
47 }
48 }
49
50 return (
51 <div className="space-y-4">
52 <div className="flex items-center justify-between">
53 <div>
54 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">队列</h1>
55 <p className="text-xs text-slate-500">
56 Redis 同步/邮件队列与数据库作业队列的深度、失败与重排
57 </p>
58 </div>
59 <Button variant="secondary" size="sm" onClick={() => void load()}>
60 刷新
61 </Button>
62 </div>
63
64 <ErrorBanner message={error} />
65
66 <Card title="队列快照">
67 {queues === null ? (
68 <Loading />
69 ) : (
70 <DataTable
71 columns={[
72 {
73 key: 'name',
74 title: '队列',
75 render: (row) => (
76 <div>
77 <div className="font-medium">{row.name}</div>
78 <div className="text-xs text-slate-400">{row.kind}</div>
79 </div>
80 ),
81 },
82 {
83 key: 'enabled',
84 title: '开关',
85 render: (row) => (
86 <Badge tone={row.enabled ? 'success' : 'neutral'}>{row.enabled ? '启用' : '停用'}</Badge>
87 ),
88 },
89 { key: 'depth', title: '深度', render: (row) => (row.depth === null ? '-' : row.depth) },
90 { key: 'queued', title: '排队', render: (row) => row.queued },
91 { key: 'running', title: '进行', render: (row) => row.running },
92 {
93 key: 'failed',
94 title: '失败',
95 render: (row) => (row.failed > 0 ? <Badge tone="danger">{row.failed}</Badge> : 0),
96 },
97 { key: 'succeeded', title: '成功', render: (row) => row.succeeded },
98 {
99 key: 'note',
100 title: '备注',
101 render: (row) => <span className="text-xs text-slate-500">{row.note ?? '-'}</span>,
102 },
103 {
104 key: 'actions',
105 title: '操作',
106 render: (row) => (
107 <div className="flex flex-wrap gap-1">
108 <Button
109 size="sm"
110 variant="secondary"
111 onClick={() => void run(() => adminQueues.requeueFailed(row.name), '失败作业已重排')}
112 >
113 重排失败
114 </Button>
115 <Button size="sm" variant="danger" onClick={() => setPurgeTarget(row)}>
116 清空
117 </Button>
118 </div>
119 ),
120 },
121 ]}
122 rows={queues}
123 rowKey={(row) => row.name}
124 />
125 )}
126 </Card>
127
128 <ConfirmDialog
129 open={purgeTarget !== null}
130 title={`清空队列 ${purgeTarget?.name ?? ''}`}
131 description={
132 <>
133 <p>将清空该队列:Redis 同步队列直接删除 list,数据库队列删除终态记录。</p>
134 <p className="mt-2 text-xs text-slate-500">
135 该操作不会删除排队中/进行中的作业,但会永久移除历史终态记录。
136 </p>
137 </>
138 }
139 confirmLabel="清空队列"
140 requireText={purgeTarget?.name}
141 onClose={() => setPurgeTarget(null)}
142 onConfirm={() => {
143 if (!purgeTarget) {
144 return
145 }
146 void run(() => adminQueues.purge(purgeTarget.name), '队列已清理')
147 }}
148 />
149 </div>
150 )
151}
No newline at end of file
Added admin/src/pages/space-detail.tsx View admin/src/pages/space-detail.tsx
@@ -0,0 +1,173 @@
1import { useCallback, useEffect, useState } from 'react'
2import { useNavigate, useParams } from 'react-router-dom'
3import { adminSpaces } from '@/shared/api'
4import type { AdminSpaceDetail, AdminSpaceMember, Page } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatBytes, formatDateTime } from '@/shared/format'
7import { Badge, Button, Card, DataTable, ErrorBanner, Loading, Pagination, useToast } from '@/shared/ui'
8
9export function SpaceDetailPage() {
10 const { id = '' } = useParams()
11 const navigate = useNavigate()
12 const toast = useToast()
13 const [detail, setDetail] = useState<AdminSpaceDetail | null>(null)
14 const [members, setMembers] = useState<Page<AdminSpaceMember> | null>(null)
15 const [offset, setOffset] = useState(0)
16 const [error, setError] = useState<string | null>(null)
17 const [name, setName] = useState('')
18 const [description, setDescription] = useState('')
19
20 const load = useCallback(async () => {
21 setError(null)
22 try {
23 const value = await adminSpaces.get(id)
24 setDetail(value)
25 setName(value.space.name)
26 setDescription(value.space.description ?? '')
27 setMembers(await adminSpaces.members(id, { limit: 20, offset }))
28 } catch (err) {
29 setError(err instanceof ApiError ? err.message : 'Space 详情加载失败')
30 }
31 }, [id, offset])
32
33 useEffect(() => {
34 void load()
35 }, [load])
36
37 const run = async (action: () => Promise<unknown>, message: string) => {
38 try {
39 await action()
40 toast(message)
41 await load()
42 } catch (err) {
43 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
44 }
45 }
46
47 if (!detail) {
48 return (
49 <div className="space-y-3">
50 <ErrorBanner message={error} />
51 <Loading />
52 </div>
53 )
54 }
55
56 const space = detail.space
57
58 return (
59 <div className="space-y-4">
60 <div className="flex items-center justify-between">
61 <div>
62 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{space.name}</h1>
63 <p className="text-xs text-slate-500">{space.id}</p>
64 </div>
65 <Button variant="secondary" size="sm" onClick={() => navigate(-1)}>
66 返回
67 </Button>
68 </div>
69
70 <ErrorBanner message={error} />
71
72 <div className="grid gap-3 md:grid-cols-4">
73 <Card title="成员"><div className="text-2xl font-semibold">{space.nums_members}</div></Card>
74 <Card title="仓库"><div className="text-2xl font-semibold">{detail.repos_count}</div></Card>
75 <Card title="仓库体积"><div className="text-2xl font-semibold">{formatBytes(detail.repos_size)}</div></Card>
76 <Card title="房间 / 目录">
77 <div className="text-2xl font-semibold">
78 {detail.rooms_count} / {detail.dirs_count}
79 </div>
80 </Card>
81 </div>
82
83 <Card title="基础设置">
84 <div className="grid gap-3 md:grid-cols-3">
85 <label className="flex flex-col gap-1 text-sm">
86 <span className="font-medium text-slate-700 dark:text-slate-200">名称</span>
87 <input
88 className="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
89 value={name}
90 onChange={(event) => setName(event.target.value)}
91 />
92 </label>
93 <label className="flex flex-col gap-1 text-sm md:col-span-2">
94 <span className="font-medium text-slate-700 dark:text-slate-200">描述</span>
95 <input
96 className="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm dark:border-slate-600 dark:bg-slate-800"
97 value={description}
98 onChange={(event) => setDescription(event.target.value)}
99 />
100 </label>
101 </div>
102 <div className="mt-3 flex flex-wrap gap-2">
103 <Button
104 size="sm"
105 onClick={() =>
106 void run(() => adminSpaces.update(id, { name, description: description || null }), 'Space 已更新')
107 }
108 >
109 保存
110 </Button>
111 <Button
112 size="sm"
113 variant="secondary"
114 onClick={() => void run(() => adminSpaces.update(id, { is_active: !space.is_active }), '状态已更新')}
115 >
116 {space.is_active ? '停用 Space' : '启用 Space'}
117 </Button>
118 </div>
119 <p className="mt-2 text-xs text-slate-500">
120 Owner:{space.owner_username ?? space.owner_id} · 创建于 {formatDateTime(space.created_at)}
121 </p>
122 </Card>
123
124 <Card title="成员">
125 {members === null ? (
126 <Loading />
127 ) : (
128 <>
129 <DataTable
130 columns={[
131 { key: 'user', title: '用户', render: (row) => row.username ?? row.user_id },
132 { key: 'alias', title: '别名', render: (row) => row.alias_name ?? '-' },
133 {
134 key: 'status',
135 title: '状态',
136 render: (row) => (
137 <Badge tone={row.status === 'active' ? 'success' : 'neutral'}>{row.status}</Badge>
138 ),
139 },
140 {
141 key: 'joined',
142 title: '加入时间',
143 render: (row) => (
144 <span className="text-xs text-slate-500">{formatDateTime(row.joined_at)}</span>
145 ),
146 },
147 {
148 key: 'actions',
149 title: '操作',
150 render: (row) =>
151 row.status === 'removed' ? (
152 <Badge tone="neutral">已移除</Badge>
153 ) : (
154 <Button
155 size="sm"
156 variant="danger"
157 onClick={() => void run(() => adminSpaces.removeMember(id, row.user_id), '成员已移除')}
158 >
159 移除
160 </Button>
161 ),
162 },
163 ]}
164 rows={members.items}
165 rowKey={(row) => row.user_id}
166 />
167 <Pagination total={members.total} limit={members.limit} offset={members.offset} onChange={setOffset} />
168 </>
169 )}
170 </Card>
171 </div>
172 )
173}
No newline at end of file
Added admin/src/pages/spaces.tsx View admin/src/pages/spaces.tsx
@@ -0,0 +1,247 @@
1import { useCallback, useEffect, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { adminSpaces } from '@/shared/api'
4import type { AdminSpaceSummary, Page } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatDateTime } from '@/shared/format'
7import {
8 Badge,
9 Button,
10 Card,
11 ConfirmDialog,
12 DataTable,
13 ErrorBanner,
14 Field,
15 Loading,
16 Modal,
17 Pagination,
18 Select,
19 TextInput,
20 useToast,
21} from '@/shared/ui'
22
23const PAGE_SIZE = 20
24
25export function SpacesPage() {
26 const navigate = useNavigate()
27 const toast = useToast()
28 const [query, setQuery] = useState('')
29 const [status, setStatus] = useState('')
30 const [applied, setApplied] = useState({ query: '', status: '' })
31 const [offset, setOffset] = useState(0)
32 const [data, setData] = useState<Page<AdminSpaceSummary> | null>(null)
33 const [error, setError] = useState<string | null>(null)
34 const [busy, setBusy] = useState(false)
35 const [target, setTarget] = useState<AdminSpaceSummary | null>(null)
36 const [transferOpen, setTransferOpen] = useState(false)
37 const [newOwner, setNewOwner] = useState('')
38 const [deleteOpen, setDeleteOpen] = useState(false)
39
40 const load = useCallback(async () => {
41 setBusy(true)
42 setError(null)
43 try {
44 setData(
45 await adminSpaces.list({
46 q: applied.query || undefined,
47 status: applied.status || undefined,
48 limit: PAGE_SIZE,
49 offset,
50 }),
51 )
52 } catch (err) {
53 setError(err instanceof ApiError ? err.message : 'Space 列表加载失败')
54 } finally {
55 setBusy(false)
56 }
57 }, [applied, offset])
58
59 useEffect(() => {
60 void load()
61 }, [load])
62
63 const run = async (action: () => Promise<unknown>, message: string) => {
64 try {
65 await action()
66 toast(message)
67 setTransferOpen(false)
68 setDeleteOpen(false)
69 setTarget(null)
70 await load()
71 } catch (err) {
72 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
73 }
74 }
75
76 return (
77 <div className="space-y-4">
78 <div className="flex flex-wrap items-end justify-between gap-3">
79 <div>
80 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Space</h1>
81 <p className="text-xs text-slate-500">租户检索、启停、所有权转移与成员管理</p>
82 </div>
83 <form
84 className="flex flex-wrap items-end gap-2"
85 onSubmit={(event) => {
86 event.preventDefault()
87 setOffset(0)
88 setApplied({ query, status })
89 }}
90 >
91 <Field label="关键字">
92 <TextInput value={query} onChange={setQuery} placeholder="名称 / 标识 / UUID" />
93 </Field>
94 <Field label="状态">
95 <Select
96 value={status}
97 onChange={setStatus}
98 options={[
99 { value: '', label: '全部' },
100 { value: 'active', label: '启用' },
101 { value: 'disabled', label: '停用' },
102 ]}
103 />
104 </Field>
105 <Button type="submit" disabled={busy}>
106 查询
107 </Button>
108 </form>
109 </div>
110
111 <ErrorBanner message={error} />
112
113 <Card title="Space 列表">
114 {data === null ? (
115 <Loading />
116 ) : (
117 <>
118 <DataTable
119 columns={[
120 {
121 key: 'name',
122 title: '名称',
123 render: (row) => (
124 <button
125 className="font-medium text-sky-600 hover:underline dark:text-sky-400"
126 onClick={() => navigate(`/spaces/${row.id}`)}
127 >
128 {row.name}
129 </button>
130 ),
131 },
132 { key: 'lower_name', title: '标识', render: (row) => row.lower_name },
133 { key: 'owner', title: 'Owner', render: (row) => row.owner_username ?? row.owner_id },
134 { key: 'members', title: '成员', render: (row) => row.nums_members },
135 {
136 key: 'state',
137 title: '状态',
138 render: (row) => (
139 <Badge tone={row.is_active ? 'success' : 'danger'}>
140 {row.is_active ? '启用' : '停用'}
141 </Badge>
142 ),
143 },
144 {
145 key: 'created_at',
146 title: '创建时间',
147 render: (row) => (
148 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
149 ),
150 },
151 {
152 key: 'actions',
153 title: '操作',
154 render: (row) => (
155 <div className="flex flex-wrap gap-1">
156 <Button size="sm" variant="secondary" onClick={() => navigate(`/spaces/${row.id}`)}>
157 详情
158 </Button>
159 <Button
160 size="sm"
161 variant="secondary"
162 onClick={() =>
163 void run(
164 () => adminSpaces.update(row.id, { is_active: !row.is_active }),
165 'Space 状态已更新',
166 )
167 }
168 >
169 {row.is_active ? '停用' : '启用'}
170 </Button>
171 <Button
172 size="sm"
173 variant="secondary"
174 onClick={() => {
175 setTarget(row)
176 setNewOwner('')
177 setTransferOpen(true)
178 }}
179 >
180 转移
181 </Button>
182 <Button
183 size="sm"
184 variant="danger"
185 onClick={() => {
186 setTarget(row)
187 setDeleteOpen(true)
188 }}
189 >
190 停用删除
191 </Button>
192 </div>
193 ),
194 },
195 ]}
196 rows={data.items}
197 rowKey={(row) => row.id}
198 />
199 <Pagination total={data.total} limit={data.limit} offset={data.offset} onChange={setOffset} />
200 </>
201 )}
202 </Card>
203
204 <Modal
205 open={transferOpen}
206 title={`转移 Space ${target?.name ?? ''}`}
207 onClose={() => setTransferOpen(false)}
208 footer={
209 <>
210 <Button variant="secondary" onClick={() => setTransferOpen(false)}>
211 取消
212 </Button>
213 <Button
214 onClick={() => {
215 if (!target || !newOwner) {
216 return
217 }
218 void run(() => adminSpaces.transfer(target.id, newOwner), '所有权已转移')
219 }}
220 >
221 确认转移
222 </Button>
223 </>
224 }
225 >
226 <Field label="新 Owner 用户 UUID" hint="目标用户必须处于启用状态">
227 <TextInput value={newOwner} onChange={setNewOwner} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
228 </Field>
229 </Modal>
230
231 <ConfirmDialog
232 open={deleteOpen}
233 title="停用 Space"
234 description="将立即停用该 Space(软删除),空间成员与内容保留。"
235 confirmLabel="停用 Space"
236 requireText={target?.lower_name}
237 onClose={() => setDeleteOpen(false)}
238 onConfirm={() => {
239 if (!target) {
240 return
241 }
242 void run(() => adminSpaces.remove(target.id), 'Space 已停用')
243 }}
244 />
245 </div>
246 )
247}
No newline at end of file
Added admin/src/pages/storage.tsx View admin/src/pages/storage.tsx
@@ -0,0 +1,207 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminStorage } from '@/shared/api'
3import type { StorageObjectPage, StorageOverview } from '@/shared/api'
4import { ApiError } from '@/shared/api'
5import { formatBytes, formatDateTime } from '@/shared/format'
6import {
7 Button,
8 Card,
9 ConfirmDialog,
10 DataTable,
11 ErrorBanner,
12 Field,
13 Loading,
14 TextInput,
15 useToast,
16} from '@/shared/ui'
17
18export function StoragePage() {
19 const toast = useToast()
20 const [overview, setOverview] = useState<StorageOverview | null>(null)
21 const [objects, setObjects] = useState<StorageObjectPage | null>(null)
22 const [prefix, setPrefix] = useState('')
23 const [appliedPrefix, setAppliedPrefix] = useState('')
24 const [token, setToken] = useState<string | undefined>(undefined)
25 const [error, setError] = useState<string | null>(null)
26 const [busy, setBusy] = useState(false)
27 const [deleteKey, setDeleteKey] = useState<string | null>(null)
28
29 const loadOverview = useCallback(async (refresh: boolean) => {
30 try {
31 setOverview(await adminStorage.overview(refresh))
32 } catch (err) {
33 setError(err instanceof ApiError ? err.message : '存储用量加载失败')
34 }
35 }, [])
36
37 const loadObjects = useCallback(async () => {
38 setBusy(true)
39 try {
40 setObjects(await adminStorage.objects({ prefix: appliedPrefix || undefined, token, limit: 50 }))
41 } catch (err) {
42 setError(err instanceof ApiError ? err.message : '对象列表加载失败')
43 } finally {
44 setBusy(false)
45 }
46 }, [appliedPrefix, token])
47
48 useEffect(() => {
49 void loadOverview(false)
50 }, [loadOverview])
51
52 useEffect(() => {
53 void loadObjects()
54 }, [loadObjects])
55
56 return (
57 <div className="space-y-4">
58 <div className="flex items-center justify-between">
59 <div>
60 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">存储</h1>
61 <p className="text-xs text-slate-500">
62 单桶对象浏览与用量({overview?.bucket ?? '-'})
63 </p>
64 </div>
65 <Button variant="secondary" size="sm" onClick={() => void loadOverview(true)}>
66 强制刷新用量
67 </Button>
68 </div>
69
70 <ErrorBanner message={error} />
71
72 {overview === null ? (
73 <Loading />
74 ) : (
75 <>
76 <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
77 <Card title="总用量">
78 <div className="text-xl font-semibold">{formatBytes(overview.total_bytes)}</div>
79 <div className="text-xs text-slate-500">{overview.total_objects} 个对象</div>
80 </Card>
81 {overview.categories.map((category) => (
82 <Card key={category.name} title={category.name}>
83 <div className="text-xl font-semibold">{formatBytes(category.bytes)}</div>
84 <div className="text-xs text-slate-500">{category.objects} 个对象</div>
85 </Card>
86 ))}
87 </div>
88 <p className="text-xs text-slate-400">
89 统计时间 {formatDateTime(overview.generated_at)} {overview.cached ? '(缓存)' : ''}
90 </p>
91 </>
92 )}
93
94 <Card title="对象浏览">
95 <form
96 className="mb-3 flex flex-wrap items-end gap-2"
97 onSubmit={(event) => {
98 event.preventDefault()
99 setToken(undefined)
100 setAppliedPrefix(prefix)
101 }}
102 >
103 <Field label="前缀">
104 <TextInput value={prefix} onChange={setPrefix} placeholder="avatars/ space-images/ chat/ lfs/ git/" />
105 </Field>
106 <Button type="submit" disabled={busy}>
107 浏览
108 </Button>
109 </form>
110
111 {objects === null ? (
112 <Loading />
113 ) : (
114 <>
115 <DataTable
116 columns={[
117 { key: 'key', title: 'Key', render: (row) => <span className="break-all text-xs">{row.key}</span> },
118 { key: 'size', title: '大小', render: (row) => formatBytes(row.size) },
119 {
120 key: 'etag',
121 title: 'ETag',
122 render: (row) => (
123 <span className="text-xs text-slate-500">{row.etag ?? '-'}</span>
124 ),
125 },
126 {
127 key: 'actions',
128 title: '操作',
129 render: (row) => (
130 <div className="flex gap-1">
131 <Button
132 size="sm"
133 variant="secondary"
134 onClick={async () => {
135 try {
136 const info = await adminStorage.downloadUrl(row.key)
137 window.open(info.url, '_blank', 'noopener')
138 } catch (err) {
139 toast(err instanceof ApiError ? err.message : '生成下载链接失败', 'danger')
140 }
141 }}
142 >
143 下载
144 </Button>
145 <Button size="sm" variant="danger" onClick={() => setDeleteKey(row.key)}>
146 删除
147 </Button>
148 </div>
149 ),
150 },
151 ]}
152 rows={objects.items}
153 rowKey={(row) => row.key}
154 />
155 <div className="mt-3 flex items-center justify-between text-xs text-slate-500">
156 <span>本页 {objects.items.length} 个对象</span>
157 <div className="flex gap-2">
158 <Button
159 size="sm"
160 variant="secondary"
161 disabled={!token}
162 onClick={() => setToken(undefined)}
163 >
164 回到首页
165 </Button>
166 <Button
167 size="sm"
168 variant="secondary"
169 disabled={!objects.next_token}
170 onClick={() => setToken(objects.next_token ?? undefined)}
171 >
172 下一页
173 </Button>
174 </div>
175 </div>
176 </>
177 )}
178 </Card>
179
180 <ConfirmDialog
181 open={deleteKey !== null}
182 title="删除存储对象"
183 description={`将永久删除对象 ${deleteKey ?? ''},引用该对象的业务数据可能失效。`}
184 confirmLabel="删除对象"
185 requireText={deleteKey ?? undefined}
186 onClose={() => setDeleteKey(null)}
187 onConfirm={() => {
188 if (!deleteKey) {
189 return
190 }
191 const key = deleteKey
192 void (async () => {
193 try {
194 await adminStorage.remove(key)
195 toast('对象已删除')
196 setDeleteKey(null)
197 await loadOverview(true)
198 await loadObjects()
199 } catch (err) {
200 toast(err instanceof ApiError ? err.message : '删除失败', 'danger')
201 }
202 })()
203 }}
204 />
205 </div>
206 )
207}
No newline at end of file
Added admin/src/pages/tasks.tsx View admin/src/pages/tasks.tsx
@@ -0,0 +1,241 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminTasks } from '@/shared/api'
3import type { TaskDetail, TaskPage } from '@/shared/api'
4import { ApiError } from '@/shared/api'
5import { formatDateTime, prettyJson } from '@/shared/format'
6import {
7 Badge,
8 Button,
9 Card,
10 ConfirmDialog,
11 DataTable,
12 ErrorBanner,
13 Field,
14 Loading,
15 Modal,
16 Pagination,
17 Select,
18 TextInput,
19 statusTone,
20 useToast,
21} from '@/shared/ui'
22
23const PAGE_SIZE = 20
24
25const SOURCES = [
26 { value: '', label: '全部来源' },
27 { value: 'import', label: 'import' },
28 { value: 'search_index', label: 'search_index' },
29 { value: 'chat_webhook', label: 'chat_webhook' },
30 { value: 'pipeline', label: 'pipeline' },
31 { value: 'deletion', label: 'deletion' },
32 { value: 'system_tasks', label: 'system_tasks' },
33]
34
35export function TasksPage() {
36 const toast = useToast()
37 const [source, setSource] = useState('')
38 const [state, setState] = useState('')
39 const [query, setQuery] = useState('')
40 const [applied, setApplied] = useState({ source: '', state: '', query: '' })
41 const [offset, setOffset] = useState(0)
42 const [data, setData] = useState<TaskPage | null>(null)
43 const [detail, setDetail] = useState<TaskDetail | null>(null)
44 const [error, setError] = useState<string | null>(null)
45 const [busy, setBusy] = useState(false)
46 const [deleteTarget, setDeleteTarget] = useState<{ source: string; id: string } | null>(null)
47
48 const load = useCallback(async () => {
49 setBusy(true)
50 setError(null)
51 try {
52 setData(
53 await adminTasks.list({
54 source: applied.source || undefined,
55 state: applied.state || undefined,
56 q: applied.query || undefined,
57 limit: PAGE_SIZE,
58 offset,
59 }),
60 )
61 } catch (err) {
62 setError(err instanceof ApiError ? err.message : '任务列表加载失败')
63 } finally {
64 setBusy(false)
65 }
66 }, [applied, offset])
67
68 useEffect(() => {
69 void load()
70 }, [load])
71
72 const run = async (action: () => Promise<unknown>, message: string) => {
73 try {
74 await action()
75 toast(message)
76 setDeleteTarget(null)
77 await load()
78 } catch (err) {
79 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
80 }
81 }
82
83 return (
84 <div className="space-y-4">
85 <div className="flex flex-wrap items-end justify-between gap-3">
86 <div>
87 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">任务</h1>
88 <p className="text-xs text-slate-500">导入、索引、Webhook、流水线、注销与系统任务的统一视图</p>
89 </div>
90 <form
91 className="flex flex-wrap items-end gap-2"
92 onSubmit={(event) => {
93 event.preventDefault()
94 setOffset(0)
95 setApplied({ source, state, query })
96 }}
97 >
98 <Field label="来源">
99 <Select value={source} onChange={setSource} options={SOURCES} />
100 </Field>
101 <Field label="状态">
102 <TextInput value={state} onChange={setState} placeholder="failed / queued …" />
103 </Field>
104 <Field label="关键字">
105 <TextInput value={query} onChange={setQuery} placeholder="标题 / ID / 错误" />
106 </Field>
107 <Button type="submit" disabled={busy}>
108 查询
109 </Button>
110 </form>
111 </div>
112
113 <ErrorBanner message={error} />
114
115 <Card title="任务列表">
116 {data === null ? (
117 <Loading />
118 ) : (
119 <>
120 <DataTable
121 columns={[
122 {
123 key: 'source',
124 title: '来源',
125 render: (row) => <span className="text-xs text-slate-500">{row.source}</span>,
126 },
127 { key: 'title', title: '任务', render: (row) => row.title },
128 {
129 key: 'state',
130 title: '状态',
131 render: (row) => <Badge tone={statusTone(row.state)}>{row.state}</Badge>,
132 },
133 {
134 key: 'created',
135 title: '创建时间',
136 render: (row) => (
137 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
138 ),
139 },
140 {
141 key: 'error',
142 title: '错误',
143 render: (row) => (
144 <span className="line-clamp-2 max-w-[220px] break-all text-xs text-slate-500">
145 {row.last_error ?? '-'}
146 </span>
147 ),
148 },
149 {
150 key: 'actions',
151 title: '操作',
152 render: (row) => (
153 <div className="flex flex-wrap gap-1">
154 <Button
155 size="sm"
156 variant="secondary"
157 onClick={async () => {
158 try {
159 setDetail(await adminTasks.get(row.source, row.id))
160 } catch (err) {
161 toast(err instanceof ApiError ? err.message : '详情加载失败', 'danger')
162 }
163 }}
164 >
165 详情
166 </Button>
167 <Button
168 size="sm"
169 variant="secondary"
170 onClick={() => void run(() => adminTasks.retry(row.source, row.id), '任务已重试')}
171 >
172 重试
173 </Button>
174 <Button
175 size="sm"
176 variant="secondary"
177 onClick={() => void run(() => adminTasks.cancel(row.source, row.id), '任务已取消')}
178 >
179 取消
180 </Button>
181 <Button
182 size="sm"
183 variant="danger"
184 onClick={() => setDeleteTarget({ source: row.source, id: row.id })}
185 >
186 删除
187 </Button>
188 </div>
189 ),
190 },
191 ]}
192 rows={data.items}
193 rowKey={(row) => `${row.source}-${row.id}`}
194 />
195 <Pagination total={data.total} limit={data.limit} offset={data.offset} onChange={setOffset} />
196 </>
197 )}
198 </Card>
199
200 <Modal
201 open={detail !== null}
202 title="任务详情"
203 onClose={() => setDetail(null)}
204 >
205 {detail && (
206 <div className="space-y-2">
207 <div className="flex items-center gap-2">
208 <Badge tone={statusTone(detail.task.state)}>{detail.task.state}</Badge>
209 <span className="text-xs text-slate-500">
210 {detail.task.source} · {detail.task.id}
211 </span>
212 </div>
213 {detail.task.last_error && (
214 <div className="rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-300">
215 {detail.task.last_error}
216 </div>
217 )}
218 <pre className="max-h-80 overflow-auto rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
219 {prettyJson(detail.detail)}
220 </pre>
221 </div>
222 )}
223 </Modal>
224
225 <ConfirmDialog
226 open={deleteTarget !== null}
227 title="删除任务记录"
228 description="仅终态任务可删除,删除后不可恢复。"
229 confirmLabel="删除记录"
230 requireText={deleteTarget?.id}
231 onClose={() => setDeleteTarget(null)}
232 onConfirm={() => {
233 if (!deleteTarget) {
234 return
235 }
236 void run(() => adminTasks.remove(deleteTarget.source, deleteTarget.id), '任务记录已删除')
237 }}
238 />
239 </div>
240 )
241}
No newline at end of file
Added admin/src/pages/user-detail.tsx View admin/src/pages/user-detail.tsx
@@ -0,0 +1,345 @@
1import { useCallback, useEffect, useState } from 'react'
2import { Link, useNavigate, useParams } from 'react-router-dom'
3import { adminUsers } from '@/shared/api'
4import type { AdminUserDetail } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatDateTime } from '@/shared/format'
7import {
8 Badge,
9 Button,
10 Card,
11 ConfirmDialog,
12 DataTable,
13 ErrorBanner,
14 Field,
15 Loading,
16 Modal,
17 TextInput,
18 useToast,
19} from '@/shared/ui'
20
21export function UserDetailPage() {
22 const { uid = '' } = useParams()
23 const navigate = useNavigate()
24 const toast = useToast()
25 const [detail, setDetail] = useState<AdminUserDetail | null>(null)
26 const [error, setError] = useState<string | null>(null)
27 const [displayName, setDisplayName] = useState('')
28 const [busy, setBusy] = useState(false)
29 const [passwordOpen, setPasswordOpen] = useState(false)
30 const [newPassword, setNewPassword] = useState('')
31 const [deleteOpen, setDeleteOpen] = useState(false)
32
33 const load = useCallback(async () => {
34 setBusy(true)
35 setError(null)
36 try {
37 const value = await adminUsers.get(uid)
38 setDetail(value)
39 setDisplayName(value.user.display_name)
40 } catch (err) {
41 setError(err instanceof ApiError ? err.message : '用户详情加载失败')
42 } finally {
43 setBusy(false)
44 }
45 }, [uid])
46
47 useEffect(() => {
48 void load()
49 }, [load])
50
51 const run = async (action: () => Promise<unknown>, message: string) => {
52 try {
53 await action()
54 toast(message)
55 setPasswordOpen(false)
56 setDeleteOpen(false)
57 await load()
58 } catch (err) {
59 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
60 }
61 }
62
63 if (!detail) {
64 return (
65 <div className="space-y-3">
66 <ErrorBanner message={error} />
67 <Loading />
68 </div>
69 )
70 }
71
72 const user = detail.user
73
74 return (
75 <div className="space-y-4">
76 <div className="flex items-center justify-between">
77 <div>
78 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{user.username}</h1>
79 <p className="text-xs text-slate-500">{user.uid}</p>
80 </div>
81 <Button variant="secondary" size="sm" onClick={() => navigate(-1)}>
82 返回
83 </Button>
84 </div>
85
86 <ErrorBanner message={error} />
87
88 <div className="grid gap-3 lg:grid-cols-3">
89 <Card title="账号状态">
90 <div className="flex flex-wrap gap-1">
91 <Badge tone={user.is_active ? 'success' : 'danger'}>{user.is_active ? '启用' : '停用'}</Badge>
92 {user.is_restricted && <Badge tone="warning">受限</Badge>}
93 {user.prohibit_login && <Badge tone="danger">禁止登录</Badge>}
94 {user.suspended && <Badge tone="danger">封禁中</Badge>}
95 </div>
96 <dl className="mt-3 space-y-1 text-xs text-slate-600 dark:text-slate-300">
97 <div>注册:{formatDateTime(user.created_at)}</div>
98 <div>最后登录:{formatDateTime(user.last_login_at)}</div>
99 </dl>
100 <div className="mt-3 flex flex-wrap gap-2">
101 <Button
102 size="sm"
103 variant="secondary"
104 onClick={() =>
105 void run(() => adminUsers.update(user.uid, { is_active: !user.is_active }), '状态已更新')
106 }
107 >
108 {user.is_active ? '停用账号' : '启用账号'}
109 </Button>
110 <Button
111 size="sm"
112 variant="secondary"
113 onClick={() =>
114 void run(
115 () => adminUsers.update(user.uid, { is_restricted: !user.is_restricted }),
116 '受限状态已更新',
117 )
118 }
119 >
120 {user.is_restricted ? '解除受限' : '标记受限'}
121 </Button>
122 <Button
123 size="sm"
124 variant="secondary"
125 onClick={() =>
126 void run(
127 () => adminUsers.update(user.uid, { prohibit_login: !user.prohibit_login }),
128 '登录权限已更新',
129 )
130 }
131 >
132 {user.prohibit_login ? '允许登录' : '禁止登录'}
133 </Button>
134 </div>
135 </Card>
136
137 <Card title="基础资料">
138 <Field label="显示名">
139 <TextInput value={displayName} onChange={setDisplayName} />
140 </Field>
141 <div className="mt-3 flex gap-2">
142 <Button
143 size="sm"
144 onClick={() => void run(() => adminUsers.update(user.uid, { display_name: displayName }), '显示名已更新')}
145 >
146 保存
147 </Button>
148 <Button
149 size="sm"
150 variant="secondary"
151 onClick={() => void run(() => adminUsers.revokeSessions(user.uid), '会话已吊销')}
152 >
153 吊销全部会话
154 </Button>
155 <Button
156 size="sm"
157 variant="danger"
158 onClick={() => {
159 setNewPassword('')
160 setPasswordOpen(true)
161 }}
162 >
163 重置口令
164 </Button>
165 </div>
166 </Card>
167
168 <Card title="危险操作">
169 <p className="text-xs text-slate-500">强制注销将立即匿名化账号,仅超级管理员可执行。</p>
170 <div className="mt-3">
171 <Button variant="danger" size="sm" onClick={() => setDeleteOpen(true)}>
172 强制注销用户
173 </Button>
174 </div>
175 </Card>
176 </div>
177
178 <div className="grid gap-3 lg:grid-cols-2">
179 <Card title="邮箱">
180 <DataTable
181 columns={[
182 { key: 'email', title: '邮箱', render: (row) => row.email },
183 {
184 key: 'primary',
185 title: '主邮箱',
186 render: (row) => (row.is_primary ? <Badge tone="info">主</Badge> : '-'),
187 },
188 {
189 key: 'activated',
190 title: '已验证',
191 render: (row) => (
192 <Badge tone={row.is_activated ? 'success' : 'warning'}>
193 {row.is_activated ? '是' : '否'}
194 </Badge>
195 ),
196 },
197 ]}
198 rows={detail.emails}
199 rowKey={(row) => row.id}
200 />
201 </Card>
202
203 <Card title="所属 Space">
204 <DataTable
205 columns={[
206 {
207 key: 'name',
208 title: '名称',
209 render: (row) => (
210 <Link className="text-sky-600 hover:underline dark:text-sky-400" to={`/spaces/${row.id}`}>
211 {row.name}
212 </Link>
213 ),
214 },
215 { key: 'lower_name', title: '标识', render: (row) => row.lower_name },
216 {
217 key: 'active',
218 title: '状态',
219 render: (row) => (
220 <Badge tone={row.is_active ? 'success' : 'danger'}>
221 {row.is_active ? '启用' : '停用'}
222 </Badge>
223 ),
224 },
225 ]}
226 rows={detail.spaces}
227 rowKey={(row) => row.id}
228 empty="该用户不是任何 Space 的 Owner"
229 />
230 </Card>
231
232 <Card title="会话">
233 <DataTable
234 columns={[
235 { key: 'kind', title: '类型', render: (row) => row.kind },
236 {
237 key: 'last_seen',
238 title: '最后活跃',
239 render: (row) => (
240 <span className="text-xs text-slate-500">{formatDateTime(row.last_seen_at)}</span>
241 ),
242 },
243 {
244 key: 'expires',
245 title: '过期',
246 render: (row) => (
247 <span className="text-xs text-slate-500">{formatDateTime(row.expires_at)}</span>
248 ),
249 },
250 {
251 key: 'revoked',
252 title: '状态',
253 render: (row) => (
254 <Badge tone={row.is_revoked ? 'danger' : 'success'}>
255 {row.is_revoked ? '已吊销' : '有效'}
256 </Badge>
257 ),
258 },
259 ]}
260 rows={detail.sessions}
261 rowKey={(row) => row.id}
262 />
263 </Card>
264
265 <Card title="封禁记录">
266 <DataTable
267 columns={[
268 { key: 'type', title: '类型', render: (row) => row.suspension_type },
269 { key: 'reason', title: '原因', render: (row) => row.reason ?? '-' },
270 {
271 key: 'range',
272 title: '时间',
273 render: (row) => (
274 <span className="text-xs text-slate-500">
275 {formatDateTime(row.starts_at)} → {row.ends_at ? formatDateTime(row.ends_at) : '永久'}
276 </span>
277 ),
278 },
279 {
280 key: 'actions',
281 title: '操作',
282 render: (row) =>
283 row.revoked_at ? (
284 <Badge tone="neutral">已撤销</Badge>
285 ) : (
286 <Button
287 size="sm"
288 variant="secondary"
289 onClick={() =>
290 void run(() => adminUsers.revokeSuspension(user.uid, row.id), '封禁已撤销')
291 }
292 >
293 撤销
294 </Button>
295 ),
296 },
297 ]}
298 rows={detail.suspensions}
299 rowKey={(row) => row.id}
300 empty="没有封禁记录"
301 />
302 </Card>
303 </div>
304
305 <Modal
306 open={passwordOpen}
307 title="重置用户口令"
308 onClose={() => setPasswordOpen(false)}
309 footer={
310 <>
311 <Button variant="secondary" onClick={() => setPasswordOpen(false)}>
312 取消
313 </Button>
314 <Button
315 onClick={() => {
316 if (!newPassword) {
317 return
318 }
319 void run(() => adminUsers.resetPassword(user.uid, newPassword), '口令已重置')
320 }}
321 >
322 确认重置
323 </Button>
324 </>
325 }
326 >
327 <Field label="新口令">
328 <TextInput value={newPassword} onChange={setNewPassword} type="password" />
329 </Field>
330 </Modal>
331
332 <ConfirmDialog
333 open={deleteOpen}
334 title="强制注销用户"
335 description="将立即匿名化该用户、吊销会话并禁用账号,操作不可恢复。"
336 confirmLabel="强制注销"
337 requireText={user.username}
338 onClose={() => setDeleteOpen(false)}
339 onConfirm={() => void run(() => adminUsers.remove(user.uid), '用户已注销')}
340 />
341
342 {busy && <Loading />}
343 </div>
344 )
345}
No newline at end of file
Added admin/src/pages/users.tsx View admin/src/pages/users.tsx
@@ -0,0 +1,328 @@
1import { useCallback, useEffect, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { adminUsers } from '@/shared/api'
4import type { AdminUserSummary, Page } from '@/shared/api'
5import { ApiError } from '@/shared/api'
6import { formatDateTime } from '@/shared/format'
7import {
8 Badge,
9 Button,
10 Card,
11 ConfirmDialog,
12 DataTable,
13 ErrorBanner,
14 Field,
15 Loading,
16 Modal,
17 Pagination,
18 Select,
19 TextInput,
20 useToast,
21} from '@/shared/ui'
22
23const PAGE_SIZE = 20
24
25export function UsersPage() {
26 const navigate = useNavigate()
27 const toast = useToast()
28 const [query, setQuery] = useState('')
29 const [status, setStatus] = useState('')
30 const [applied, setApplied] = useState({ query: '', status: '' })
31 const [offset, setOffset] = useState(0)
32 const [data, setData] = useState<Page<AdminUserSummary> | null>(null)
33 const [error, setError] = useState<string | null>(null)
34 const [busy, setBusy] = useState(false)
35 const [target, setTarget] = useState<AdminUserSummary | null>(null)
36 const [suspendOpen, setSuspendOpen] = useState(false)
37 const [suspendType, setSuspendType] = useState('temporary')
38 const [suspendReason, setSuspendReason] = useState('')
39 const [suspendDays, setSuspendDays] = useState('7')
40 const [passwordOpen, setPasswordOpen] = useState(false)
41 const [newPassword, setNewPassword] = useState('')
42 const [deleteOpen, setDeleteOpen] = useState(false)
43
44 const load = useCallback(async () => {
45 setBusy(true)
46 setError(null)
47 try {
48 setData(
49 await adminUsers.list({
50 q: applied.query || undefined,
51 status: applied.status || undefined,
52 limit: PAGE_SIZE,
53 offset,
54 }),
55 )
56 } catch (err) {
57 setError(err instanceof ApiError ? err.message : '用户列表加载失败')
58 } finally {
59 setBusy(false)
60 }
61 }, [applied, offset])
62
63 useEffect(() => {
64 void load()
65 }, [load])
66
67 const run = async (action: () => Promise<unknown>, message: string) => {
68 try {
69 await action()
70 toast(message)
71 setSuspendOpen(false)
72 setPasswordOpen(false)
73 setDeleteOpen(false)
74 setTarget(null)
75 await load()
76 } catch (err) {
77 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
78 }
79 }
80
81 return (
82 <div className="space-y-4">
83 <div className="flex flex-wrap items-end justify-between gap-3">
84 <div>
85 <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">用户</h1>
86 <p className="text-xs text-slate-500">检索、封禁、重置口令与会话管理</p>
87 </div>
88 <form
89 className="flex flex-wrap items-end gap-2"
90 onSubmit={(event) => {
91 event.preventDefault()
92 setOffset(0)
93 setApplied({ query, status })
94 }}
95 >
96 <Field label="关键字">
97 <TextInput value={query} onChange={setQuery} placeholder="用户名 / 显示名 / 邮箱 / UUID" />
98 </Field>
99 <Field label="状态">
100 <Select
101 value={status}
102 onChange={setStatus}
103 options={[
104 { value: '', label: '全部' },
105 { value: 'active', label: '启用' },
106 { value: 'inactive', label: '停用' },
107 { value: 'restricted', label: '受限' },
108 { value: 'prohibited', label: '禁止登录' },
109 ]}
110 />
111 </Field>
112 <Button type="submit" disabled={busy}>
113 查询
114 </Button>
115 </form>
116 </div>
117
118 <ErrorBanner message={error} />
119
120 <Card title="用户列表" action={busy ? <span className="text-xs text-slate-400">加载中…</span> : null}>
121 {data === null ? (
122 <Loading />
123 ) : (
124 <>
125 <DataTable
126 columns={[
127 {
128 key: 'username',
129 title: '用户名',
130 render: (row) => (
131 <button
132 className="font-medium text-sky-600 hover:underline dark:text-sky-400"
133 onClick={() => navigate(`/users/${row.uid}`)}
134 >
135 {row.username}
136 </button>
137 ),
138 },
139 { key: 'display_name', title: '显示名', render: (row) => row.display_name },
140 {
141 key: 'state',
142 title: '状态',
143 render: (row) => (
144 <div className="flex flex-wrap gap-1">
145 {!row.is_active && <Badge tone="danger">停用</Badge>}
146 {row.is_restricted && <Badge tone="warning">受限</Badge>}
147 {row.prohibit_login && <Badge tone="danger">禁登</Badge>}
148 {row.suspended && <Badge tone="danger">封禁中</Badge>}
149 {row.bot && <Badge tone="info">Bot</Badge>}
150 {row.is_active && !row.is_restricted && !row.prohibit_login && (
151 <Badge tone="success">正常</Badge>
152 )}
153 </div>
154 ),
155 },
156 {
157 key: 'created_at',
158 title: '注册时间',
159 render: (row) => (
160 <span className="text-xs text-slate-500">{formatDateTime(row.created_at)}</span>
161 ),
162 },
163 {
164 key: 'last_login_at',
165 title: '最后登录',
166 render: (row) => (
167 <span className="text-xs text-slate-500">{formatDateTime(row.last_login_at)}</span>
168 ),
169 },
170 {
171 key: 'actions',
172 title: '操作',
173 render: (row) => (
174 <div className="flex flex-wrap gap-1">
175 <Button size="sm" variant="secondary" onClick={() => navigate(`/users/${row.uid}`)}>
176 详情
177 </Button>
178 <Button
179 size="sm"
180 variant="secondary"
181 onClick={() => {
182 setTarget(row)
183 setSuspendReason('')
184 setSuspendType('temporary')
185 setSuspendOpen(true)
186 }}
187 >
188 封禁
189 </Button>
190 <Button
191 size="sm"
192 variant="secondary"
193 onClick={() => {
194 setTarget(row)
195 setNewPassword('')
196 setPasswordOpen(true)
197 }}
198 >
199 重置口令
200 </Button>
201 <Button
202 size="sm"
203 variant="secondary"
204 onClick={() => void run(() => adminUsers.revokeSessions(row.uid), '已吊销全部会话')}
205 >
206 踢下线
207 </Button>
208 <Button
209 size="sm"
210 variant="danger"
211 onClick={() => {
212 setTarget(row)
213 setDeleteOpen(true)
214 }}
215 >
216 删除
217 </Button>
218 </div>
219 ),
220 },
221 ]}
222 rows={data.items}
223 rowKey={(row) => row.uid}
224 />
225 <Pagination total={data.total} limit={data.limit} offset={data.offset} onChange={setOffset} />
226 </>
227 )}
228 </Card>
229
230 <Modal
231 open={suspendOpen}
232 title={`封禁用户 ${target?.username ?? ''}`}
233 onClose={() => setSuspendOpen(false)}
234 footer={
235 <>
236 <Button variant="secondary" onClick={() => setSuspendOpen(false)}>
237 取消
238 </Button>
239 <Button
240 variant="danger"
241 onClick={() => {
242 if (!target) {
243 return
244 }
245 const endsAt =
246 suspendType === 'temporary'
247 ? new Date(Date.now() + Number(suspendDays) * 86400000).toISOString()
248 : undefined
249 void run(
250 () =>
251 adminUsers.suspend(target.uid, {
252 suspension_type: suspendType,
253 reason: suspendReason || undefined,
254 ends_at: endsAt,
255 }),
256 '已封禁用户并吊销会话',
257 )
258 }}
259 >
260 执行封禁
261 </Button>
262 </>
263 }
264 >
265 <Field label="封禁类型">
266 <Select
267 value={suspendType}
268 onChange={setSuspendType}
269 options={[
270 { value: 'temporary', label: '临时封禁' },
271 { value: 'permanent', label: '永久封禁' },
272 ]}
273 />
274 </Field>
275 {suspendType === 'temporary' && (
276 <Field label="持续天数">
277 <TextInput value={suspendDays} onChange={setSuspendDays} type="number" />
278 </Field>
279 )}
280 <Field label="原因">
281 <TextInput value={suspendReason} onChange={setSuspendReason} placeholder="可选" />
282 </Field>
283 </Modal>
284
285 <Modal
286 open={passwordOpen}
287 title={`重置口令 ${target?.username ?? ''}`}
288 onClose={() => setPasswordOpen(false)}
289 footer={
290 <>
291 <Button variant="secondary" onClick={() => setPasswordOpen(false)}>
292 取消
293 </Button>
294 <Button
295 onClick={() => {
296 if (!target || !newPassword) {
297 return
298 }
299 void run(() => adminUsers.resetPassword(target.uid, newPassword), '口令已重置,会话已吊销')
300 }}
301 >
302 确认重置
303 </Button>
304 </>
305 }
306 >
307 <Field label="新口令" hint="需满足站点口令策略">
308 <TextInput value={newPassword} onChange={setNewPassword} type="password" />
309 </Field>
310 </Modal>
311
312 <ConfirmDialog
313 open={deleteOpen}
314 title="强制注销用户"
315 description="将立即匿名化该用户、吊销会话并禁用账号,操作不可恢复。"
316 confirmLabel="强制注销"
317 requireText={target?.username}
318 onClose={() => setDeleteOpen(false)}
319 onConfirm={() => {
320 if (!target) {
321 return
322 }
323 void run(() => adminUsers.remove(target.uid), '用户已注销')
324 }}
325 />
326 </div>
327 )
328}
No newline at end of file
Added admin/src/shared/api-types.ts View admin/src/shared/api-types.ts
@@ -0,0 +1,274 @@
1export type AdminRole = 'super_admin' | 'operator' | 'viewer'
2
3export interface Page<T> {
4 items: T[]
5 total: number
6 limit: number
7 offset: number
8}
9
10export interface AdminDto {
11 id: string
12 username: string
13 display_name: string
14 role: AdminRole
15 is_active: boolean
16 must_change_password: boolean
17 last_login_at: string | null
18 last_login_ip: string | null
19 created_at: string
20 updated_at: string
21}
22
23export interface AdminLoginResp {
24 admin: AdminDto
25 must_change_password: boolean
26}
27
28export interface AdminUserSummary {
29 uid: string
30 username: string
31 display_name: string
32 avatar_url: string
33 is_active: boolean
34 is_restricted: boolean
35 prohibit_login: boolean
36 bot: boolean
37 system: boolean
38 suspended: boolean
39 created_at: string
40 last_login_at: string | null
41}
42
43export interface AdminUserEmail {
44 id: string
45 email: string
46 is_primary: boolean
47 is_activated: boolean
48 created_at: string
49}
50
51export interface AdminUserSession {
52 id: string
53 kind: string
54 last_seen_at: string
55 created_at: string
56 expires_at: string
57 is_revoked: boolean
58}
59
60export interface AdminUserSpace {
61 id: string
62 name: string
63 lower_name: string
64 is_active: boolean
65}
66
67export interface AdminSuspension {
68 id: string
69 suspension_type: string
70 reason: string | null
71 starts_at: string
72 ends_at: string | null
73 revoked_at: string | null
74 suspended_by: string | null
75}
76
77export interface AdminUserDetail {
78 user: AdminUserSummary
79 emails: AdminUserEmail[]
80 sessions: AdminUserSession[]
81 spaces: AdminUserSpace[]
82 suspensions: AdminSuspension[]
83}
84
85export interface AdminSpaceSummary {
86 id: string
87 name: string
88 lower_name: string
89 description: string | null
90 owner_id: string
91 owner_username: string | null
92 is_private: boolean
93 is_active: boolean
94 nums_members: number
95 created_at: string
96 updated_at: string
97}
98
99export interface AdminSpaceDetail {
100 space: AdminSpaceSummary
101 repos_count: number
102 repos_size: number
103 rooms_count: number
104 dirs_count: number
105}
106
107export interface AdminSpaceMember {
108 user_id: string
109 username: string | null
110 display_name: string | null
111 alias_name: string | null
112 status: string
113 joined_at: string
114 last_active_at: string | null
115}
116
117export interface StorageCategory {
118 name: string
119 prefix: string
120 bytes: number
121 objects: number
122}
123
124export interface StorageOverview {
125 bucket: string
126 total_bytes: number
127 total_objects: number
128 categories: StorageCategory[]
129 generated_at: string
130 cached: boolean
131}
132
133export interface StorageObject {
134 key: string
135 size: number
136 last_modified: unknown
137 etag: string | null
138}
139
140export interface StorageObjectPage {
141 prefix: string
142 items: StorageObject[]
143 next_token: string | null
144}
145
146export interface QueueSnapshot {
147 name: string
148 kind: string
149 enabled: boolean
150 depth: number | null
151 queued: number
152 running: number
153 failed: number
154 succeeded: number
155 note: string | null
156}
157
158export interface QueueActionResp {
159 queue: string
160 affected: number
161}
162
163export interface TaskDto {
164 id: string
165 source: string
166 title: string
167 state: string
168 created_at: string
169 updated_at: string | null
170 finished_at: string | null
171 last_error: string | null
172}
173
174export interface TaskPage {
175 items: TaskDto[]
176 total: number
177 limit: number
178 offset: number
179}
180
181export interface TaskDetail {
182 task: TaskDto
183 detail: unknown
184}
185
186export interface TaskActionResp {
187 source: string
188 id: string
189 state: string
190}
191
192export interface AuditLog {
193 id: string
194 admin_id: string | null
195 admin_username: string
196 action: string
197 target_type: string | null
198 target_id: string | null
199 detail: unknown
200 ip: string | null
201 user_agent: string | null
202 request_id: string | null
203 created_at: string
204}
205
206export interface AuditLogPage {
207 items: AuditLog[]
208 total: number
209 limit: number
210 offset: number
211}
212
213export interface JobLog {
214 source: string
215 id: string
216 state: string
217 error: string | null
218 created_at: string
219 updated_at: string | null
220}
221
222export interface JobLogPage {
223 items: JobLog[]
224 total: number
225 limit: number
226 offset: number
227}
228
229export interface Dependency {
230 ok: boolean
231 latency_ms: number | null
232 detail: string | null
233}
234
235export interface SystemStatus {
236 process: { version: string; uptime_secs: number }
237 dependencies: {
238 database: Dependency
239 redis: Dependency
240 storage: Dependency
241 search: Dependency
242 }
243 features: {
244 search_enabled: boolean
245 import_enabled: boolean
246 email_enabled: boolean
247 email_queue_enabled: boolean
248 }
249 counts: {
250 users: number
251 active_users: number
252 spaces: number
253 active_spaces: number
254 repos: number
255 rooms: number
256 issues: number
257 messages: number
258 admin_users: number
259 }
260 queues: QueueSnapshot[]
261 recent_failures: JobLog[]
262 generated_at: string
263}
264
265export class ApiError extends Error {
266 status: number
267 errorCode: string | null
268
269 constructor(status: number, message: string, errorCode: string | null) {
270 super(message)
271 this.status = status
272 this.errorCode = errorCode
273 }
274}
Added admin/src/shared/api.ts View admin/src/shared/api.ts
@@ -0,0 +1,226 @@
1import type {
2 AdminDto,
3 AdminLoginResp,
4 AdminSpaceDetail,
5 AdminSpaceMember,
6 AdminSpaceSummary,
7 AdminSuspension,
8 AdminUserDetail,
9 AdminUserSummary,
10 AuditLogPage,
11 JobLogPage,
12 Page,
13 QueueActionResp,
14 QueueSnapshot,
15 StorageObjectPage,
16 StorageOverview,
17 SystemStatus,
18 TaskActionResp,
19 TaskDetail,
20 TaskPage,
21} from './api-types'
22import { ApiError } from './api-types'
23
24export * from './api-types'
25
26const BASE = '/api/v1'
27
28interface Envelope<T> {
29 code: number
30 message: string
31 data: T | null
32 error: string | null
33 error_code: string | null
34}
35
36function qs(params?: Record<string, unknown>): string {
37 if (!params) {
38 return ''
39 }
40 const search = new URLSearchParams()
41 for (const [key, value] of Object.entries(params)) {
42 if (value === undefined || value === null || value === '') {
43 continue
44 }
45 search.set(key, String(value))
46 }
47 const query = search.toString()
48 return query ? `?${query}` : ''
49}
50
51async function request<T>(
52 method: string,
53 path: string,
54 options?: { params?: Record<string, unknown>; body?: unknown },
55): Promise<T> {
56 const response = await fetch(`${BASE}${path}${qs(options?.params)}`, {
57 method,
58 credentials: 'include',
59 headers: options?.body === undefined ? undefined : { 'content-type': 'application/json' },
60 body: options?.body === undefined ? undefined : JSON.stringify(options.body),
61 })
62 const text = await response.text()
63 let envelope: Envelope<T> | null = null
64 if (text) {
65 try {
66 envelope = JSON.parse(text) as Envelope<T>
67 } catch {
68 envelope = null
69 }
70 }
71 if (!response.ok) {
72 throw new ApiError(
73 response.status,
74 envelope?.message ?? response.statusText,
75 envelope?.error_code ?? null,
76 )
77 }
78 return (envelope?.data as T) ?? (undefined as T)
79}
80
81export const adminAuth = {
82 login: (username: string, password: string) =>
83 request<AdminLoginResp>('POST', '/admin/auth/login', { body: { username, password } }),
84 logout: () => request<void>('POST', '/admin/auth/logout'),
85 me: () => request<AdminDto>('GET', '/admin/me'),
86 changePassword: (currentPassword: string, newPassword: string) =>
87 request<void>('PATCH', '/admin/me/password', {
88 body: { current_password: currentPassword, new_password: newPassword },
89 }),
90}
91
92export const adminAccounts = {
93 list: (params: { limit?: number; offset?: number }) =>
94 request<Page<AdminDto>>('GET', '/admin/admins', { params }),
95 create: (body: {
96 username: string
97 display_name?: string
98 password: string
99 role: string
100 }) => request<AdminDto>('POST', '/admin/admins', { body }),
101 update: (
102 id: string,
103 body: { display_name?: string; role?: string; is_active?: boolean },
104 ) => request<AdminDto>('PATCH', `/admin/admins/${id}`, { body }),
105 remove: (id: string) => request<void>('DELETE', `/admin/admins/${id}`),
106 resetPassword: (id: string, newPassword: string) =>
107 request<void>('POST', `/admin/admins/${id}/reset-password`, {
108 body: { new_password: newPassword },
109 }),
110}
111export const adminUsers = {
112 list: (params: {
113 q?: string
114 status?: string
115 limit?: number
116 offset?: number
117 sort?: string
118 }) => request<Page<AdminUserSummary>>('GET', '/admin/users', { params }),
119 get: (uid: string) => request<AdminUserDetail>('GET', `/admin/users/${uid}`),
120 update: (
121 uid: string,
122 body: {
123 display_name?: string
124 is_active?: boolean
125 is_restricted?: boolean
126 prohibit_login?: boolean
127 },
128 ) => request<AdminUserSummary>('PATCH', `/admin/users/${uid}`, { body }),
129 suspend: (
130 uid: string,
131 body: { suspension_type: string; reason?: string; ends_at?: string },
132 ) => request<AdminSuspension>('POST', `/admin/users/${uid}/suspend`, { body }),
133 revokeSuspension: (uid: string, suspensionId: string) =>
134 request<void>('POST', `/admin/users/${uid}/suspensions/${suspensionId}/revoke`),
135 resetPassword: (uid: string, newPassword: string) =>
136 request<void>('POST', `/admin/users/${uid}/reset-password`, {
137 body: { new_password: newPassword },
138 }),
139 revokeSessions: (uid: string) =>
140 request<{ revoked: number }>('POST', `/admin/users/${uid}/revoke-sessions`),
141 remove: (uid: string) =>
142 request<{ user_id: string; anonymized: boolean }>('DELETE', `/admin/users/${uid}`),
143}
144
145export const adminSpaces = {
146 list: (params: {
147 q?: string
148 status?: string
149 owner_id?: string
150 limit?: number
151 offset?: number
152 }) => request<Page<AdminSpaceSummary>>('GET', '/admin/spaces', { params }),
153 get: (id: string) => request<AdminSpaceDetail>('GET', `/admin/spaces/${id}`),
154 update: (
155 id: string,
156 body: { name?: string; description?: string | null; is_private?: boolean; is_active?: boolean },
157 ) => request<AdminSpaceDetail>('PATCH', `/admin/spaces/${id}`, { body }),
158 transfer: (id: string, newOwnerId: string) =>
159 request<AdminSpaceDetail>('POST', `/admin/spaces/${id}/transfer`, {
160 body: { new_owner_id: newOwnerId },
161 }),
162 remove: (id: string) => request<void>('DELETE', `/admin/spaces/${id}`),
163 members: (id: string, params: { status?: string; limit?: number; offset?: number }) =>
164 request<Page<AdminSpaceMember>>('GET', `/admin/spaces/${id}/members`, { params }),
165 removeMember: (id: string, uid: string) =>
166 request<{ space_id: string; user_id: string; removed: boolean }>(
167 'DELETE',
168 `/admin/spaces/${id}/members/${uid}`,
169 ),
170}
171
172export const adminStorage = {
173 overview: (refresh?: boolean) =>
174 request<StorageOverview>('GET', '/admin/storage/overview', { params: { refresh } }),
175 objects: (params: { prefix?: string; token?: string; limit?: number }) =>
176 request<StorageObjectPage>('GET', '/admin/storage/objects', { params }),
177 downloadUrl: (key: string) =>
178 request<{ key: string; url: string; expires_in_secs: number }>(
179 'GET',
180 '/admin/storage/download-url',
181 { params: { key } },
182 ),
183 remove: (key: string) =>
184 request<{ key: string; deleted: boolean }>('DELETE', '/admin/storage/objects', {
185 params: { key },
186 }),
187}
188
189export const adminQueues = {
190 list: () => request<QueueSnapshot[]>('GET', '/admin/queues'),
191 requeueFailed: (name: string) =>
192 request<QueueActionResp>('POST', `/admin/queues/${name}/requeue-failed`),
193 purge: (name: string) => request<QueueActionResp>('POST', `/admin/queues/${name}/purge`),
194}
195
196export const adminTasks = {
197 list: (params: {
198 source?: string
199 state?: string
200 q?: string
201 limit?: number
202 offset?: number
203 }) => request<TaskPage>('GET', '/admin/tasks', { params }),
204 get: (source: string, id: string) => request<TaskDetail>('GET', `/admin/tasks/${source}/${id}`),
205 retry: (source: string, id: string) =>
206 request<TaskActionResp>('POST', `/admin/tasks/${source}/${id}/retry`),
207 cancel: (source: string, id: string) =>
208 request<TaskActionResp>('POST', `/admin/tasks/${source}/${id}/cancel`),
209 remove: (source: string, id: string) => request<void>('DELETE', `/admin/tasks/${source}/${id}`),
210}
211
212export const adminLogs = {
213 audit: (params: {
214 action?: string
215 target_type?: string
216 admin_id?: string
217 limit?: number
218 offset?: number
219 }) => request<AuditLogPage>('GET', '/admin/logs/audit', { params }),
220 jobs: (params: { source?: string; limit?: number; offset?: number }) =>
221 request<JobLogPage>('GET', '/admin/logs/jobs', { params }),
222}
223
224export const adminSystem = {
225 status: () => request<SystemStatus>('GET', '/admin/system/status'),
226}
No newline at end of file
Added admin/src/shared/format.ts View admin/src/shared/format.ts
@@ -0,0 +1,56 @@
1export function formatDateTime(value?: string | null): string {
2 if (!value) {
3 return '-'
4 }
5 const date = new Date(value)
6 if (Number.isNaN(date.getTime())) {
7 return value
8 }
9 return date.toLocaleString('zh-CN', {
10 year: 'numeric',
11 month: '2-digit',
12 day: '2-digit',
13 hour: '2-digit',
14 minute: '2-digit',
15 second: '2-digit',
16 })
17}
18
19export function formatBytes(value: number): string {
20 if (!Number.isFinite(value) || value <= 0) {
21 return '0 B'
22 }
23 const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
24 let size = value
25 let unit = 0
26 while (size >= 1024 && unit < units.length - 1) {
27 size /= 1024
28 unit += 1
29 }
30 return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`
31}
32
33export function formatUptime(seconds: number): string {
34 const days = Math.floor(seconds / 86400)
35 const hours = Math.floor((seconds % 86400) / 3600)
36 const minutes = Math.floor((seconds % 3600) / 60)
37 if (days > 0) {
38 return `${days}d ${hours}h ${minutes}m`
39 }
40 if (hours > 0) {
41 return `${hours}h ${minutes}m`
42 }
43 return `${minutes}m`
44}
45
46export function shortId(id: string): string {
47 return id.length > 10 ? `${id.slice(0, 8)}…` : id
48}
49
50export function prettyJson(value: unknown): string {
51 try {
52 return JSON.stringify(value, null, 2)
53 } catch {
54 return String(value)
55 }
56}
No newline at end of file
Added admin/src/shared/ui.tsx View admin/src/shared/ui.tsx
@@ -0,0 +1,442 @@
1import { createContext, useCallback, useContext, useMemo, useState } from 'react'
2import type { ReactNode } from 'react'
3
4export function Button({
5 children,
6 onClick,
7 variant = 'primary',
8 size = 'md',
9 disabled,
10 type = 'button',
11 title,
12}: {
13 children: ReactNode
14 onClick?: () => void
15 variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
16 size?: 'sm' | 'md'
17 disabled?: boolean
18 type?: 'button' | 'submit'
19 title?: string
20}) {
21 const base =
22 'inline-flex items-center justify-center gap-1 rounded-md font-medium transition disabled:cursor-not-allowed disabled:opacity-50'
23 const sizes = size === 'sm' ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'
24 const variants: Record<string, string> = {
25 primary: 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white',
26 secondary:
27 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700',
28 danger: 'bg-red-600 text-white hover:bg-red-500',
29 ghost: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800',
30 }
31 return (
32 <button
33 type={type}
34 title={title}
35 disabled={disabled}
36 onClick={onClick}
37 className={`${base} ${sizes} ${variants[variant]}`}
38 >
39 {children}
40 </button>
41 )
42}
43
44export function Card({
45 title,
46 action,
47 children,
48 className,
49}: {
50 title?: ReactNode
51 action?: ReactNode
52 children: ReactNode
53 className?: string
54}) {
55 return (
56 <section
57 className={`rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900 ${className ?? ''}`}
58 >
59 {(title || action) && (
60 <header className="mb-3 flex items-center justify-between gap-3">
61 <h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100">{title}</h2>
62 {action}
63 </header>
64 )}
65 {children}
66 </section>
67 )
68}
69
70type Tone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
71
72export function Badge({ tone = 'neutral', children }: { tone?: Tone; children: ReactNode }) {
73 const tones: Record<Tone, string> = {
74 neutral: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
75 success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
76 warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
77 danger: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
78 info: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
79 }
80 return (
81 <span className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium ${tones[tone]}`}>
82 {children}
83 </span>
84 )
85}
86
87export function statusTone(state: string): Tone {
88 switch (state) {
89 case 'success':
90 case 'succeeded':
91 case 'done':
92 case 'completed':
93 case 'active':
94 return 'success'
95 case 'failed':
96 case 'failure':
97 case 'cancelled':
98 case 'canceled':
99 case 'removed':
100 return 'danger'
101 case 'running':
102 case 'processing':
103 case 'delivering':
104 case 'scheduled':
105 case 'cancel_requested':
106 return 'info'
107 case 'pending':
108 case 'queued':
109 return 'warning'
110 default:
111 return 'neutral'
112 }
113}
114
115export function Field({
116 label,
117 hint,
118 children,
119}: {
120 label: string
121 hint?: string
122 children: ReactNode
123}) {
124 return (
125 <label className="flex flex-col gap-1 text-sm">
126 <span className="font-medium text-slate-700 dark:text-slate-200">{label}</span>
127 {children}
128 {hint && <span className="text-xs text-slate-500 dark:text-slate-400">{hint}</span>}
129 </label>
130 )
131}
132
133const inputClass =
134 'w-full rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-900 outline-none focus:border-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100'
135
136export function TextInput({
137 value,
138 onChange,
139 placeholder,
140 type = 'text',
141 disabled,
142}: {
143 value: string
144 onChange: (value: string) => void
145 placeholder?: string
146 type?: string
147 disabled?: boolean
148}) {
149 return (
150 <input
151 className={inputClass}
152 value={value}
153 type={type}
154 disabled={disabled}
155 placeholder={placeholder}
156 onChange={(event) => onChange(event.target.value)}
157 />
158 )
159}
160
161export function Select({
162 value,
163 onChange,
164 options,
165}: {
166 value: string
167 onChange: (value: string) => void
168 options: { value: string; label: string }[]
169}) {
170 return (
171 <select className={inputClass} value={value} onChange={(event) => onChange(event.target.value)}>
172 {options.map((option) => (
173 <option key={option.value} value={option.value}>
174 {option.label}
175 </option>
176 ))}
177 </select>
178 )
179}
180
181export function Checkbox({
182 checked,
183 onChange,
184 label,
185}: {
186 checked: boolean
187 onChange: (value: boolean) => void
188 label: string
189}) {
190 return (
191 <label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
192 <input
193 type="checkbox"
194 className="h-4 w-4 rounded border-slate-300"
195 checked={checked}
196 onChange={(event) => onChange(event.target.checked)}
197 />
198 {label}
199 </label>
200 )
201}
202export interface Column<T> {
203 key: string
204 title: ReactNode
205 render: (row: T) => ReactNode
206 className?: string
207}
208
209export function DataTable<T>({
210 columns,
211 rows,
212 rowKey,
213 onRowClick,
214 empty = '暂无数据',
215}: {
216 columns: Column<T>[]
217 rows: T[]
218 rowKey: (row: T) => string
219 onRowClick?: (row: T) => void
220 empty?: string
221}) {
222 return (
223 <div className="overflow-x-auto">
224 <table className="w-full min-w-[640px] border-collapse text-sm">
225 <thead>
226 <tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-700 dark:text-slate-400">
227 {columns.map((column) => (
228 <th key={column.key} className={`px-2 py-2 font-medium ${column.className ?? ''}`}>
229 {column.title}
230 </th>
231 ))}
232 </tr>
233 </thead>
234 <tbody>
235 {rows.length === 0 && (
236 <tr>
237 <td colSpan={columns.length} className="px-2 py-6 text-center text-slate-400">
238 {empty}
239 </td>
240 </tr>
241 )}
242 {rows.map((row) => (
243 <tr
244 key={rowKey(row)}
245 onClick={onRowClick ? () => onRowClick(row) : undefined}
246 className={`border-b border-slate-100 last:border-0 dark:border-slate-800 ${
247 onRowClick ? 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/60' : ''
248 }`}
249 >
250 {columns.map((column) => (
251 <td key={column.key} className={`px-2 py-2 align-middle ${column.className ?? ''}`}>
252 {column.render(row)}
253 </td>
254 ))}
255 </tr>
256 ))}
257 </tbody>
258 </table>
259 </div>
260 )
261}
262
263export function Pagination({
264 total,
265 limit,
266 offset,
267 onChange,
268}: {
269 total: number
270 limit: number
271 offset: number
272 onChange: (offset: number) => void
273}) {
274 const page = Math.floor(offset / limit) + 1
275 const pages = Math.max(1, Math.ceil(total / limit))
276 return (
277 <div className="mt-3 flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
278 <span>
279 共 {total} 条 · 第 {page}/{pages} 页
280 </span>
281 <div className="flex gap-2">
282 <Button
283 size="sm"
284 variant="secondary"
285 disabled={offset <= 0}
286 onClick={() => onChange(Math.max(0, offset - limit))}
287 >
288 上一页
289 </Button>
290 <Button
291 size="sm"
292 variant="secondary"
293 disabled={offset + limit >= total}
294 onClick={() => onChange(offset + limit)}
295 >
296 下一页
297 </Button>
298 </div>
299 </div>
300 )
301}
302
303export function Modal({
304 open,
305 title,
306 onClose,
307 children,
308 footer,
309}: {
310 open: boolean
311 title: ReactNode
312 onClose: () => void
313 children: ReactNode
314 footer?: ReactNode
315}) {
316 if (!open) {
317 return null
318 }
319 return (
320 <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4">
321 <div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg bg-white p-4 shadow-xl dark:bg-slate-900">
322 <header className="mb-3 flex items-center justify-between">
323 <h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
324 <Button variant="ghost" size="sm" onClick={onClose}>
325 关闭
326 </Button>
327 </header>
328 <div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">{children}</div>
329 {footer && <footer className="mt-4 flex justify-end gap-2">{footer}</footer>}
330 </div>
331 </div>
332 )
333}
334
335export function ConfirmDialog({
336 open,
337 title,
338 description,
339 confirmLabel = '确认执行',
340 requireText,
341 busy,
342 onConfirm,
343 onClose,
344}: {
345 open: boolean
346 title: string
347 description: ReactNode
348 confirmLabel?: string
349 requireText?: string
350 busy?: boolean
351 onConfirm: () => void
352 onClose: () => void
353}) {
354 const [text, setText] = useState('')
355 const canConfirm = !requireText || text === requireText
356 return (
357 <Modal
358 open={open}
359 title={title}
360 onClose={onClose}
361 footer={
362 <>
363 <Button variant="secondary" onClick={onClose}>
364 取消
365 </Button>
366 <Button variant="danger" disabled={!canConfirm || busy} onClick={onConfirm}>
367 {busy ? '执行中…' : confirmLabel}
368 </Button>
369 </>
370 }
371 >
372 <div className="space-y-3">
373 <div>{description}</div>
374 {requireText && (
375 <Field label={`输入 ${requireText} 以确认`}>
376 <TextInput value={text} onChange={setText} placeholder={requireText} />
377 </Field>
378 )}
379 </div>
380 </Modal>
381 )
382}
383
384export function ErrorBanner({ message }: { message: string | null }) {
385 if (!message) {
386 return null
387 }
388 return (
389 <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
390 {message}
391 </div>
392 )
393}
394
395export function Loading({ label = '加载中…' }: { label?: string }) {
396 return <div className="py-6 text-center text-sm text-slate-400">{label}</div>
397}
398
399export function EmptyState({ label }: { label: string }) {
400 return <div className="py-6 text-center text-sm text-slate-400">{label}</div>
401}
402
403interface ToastItem {
404 id: number
405 message: string
406 tone: 'success' | 'danger'
407}
408
409const ToastContext = createContext<(message: string, tone?: 'success' | 'danger') => void>(() => {})
410
411export function ToastProvider({ children }: { children: ReactNode }) {
412 const [items, setItems] = useState<ToastItem[]>([])
413 const push = useCallback((message: string, tone: 'success' | 'danger' = 'success') => {
414 const id = Date.now() + Math.random()
415 setItems((current) => [...current, { id, message, tone }])
416 window.setTimeout(() => {
417 setItems((current) => current.filter((item) => item.id !== id))
418 }, 4000)
419 }, [])
420 const value = useMemo(() => push, [push])
421 return (
422 <ToastContext.Provider value={value}>
423 {children}
424 <div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex flex-col gap-2">
425 {items.map((item) => (
426 <div
427 key={item.id}
428 className={`pointer-events-auto rounded-md px-3 py-2 text-sm text-white shadow-lg ${
429 item.tone === 'success' ? 'bg-emerald-600' : 'bg-red-600'
430 }`}
431 >
432 {item.message}
433 </div>
434 ))}
435 </div>
436 </ToastContext.Provider>
437 )
438}
439
440export function useToast() {
441 return useContext(ToastContext)
442}
No newline at end of file
Added admin/build.rs View admin/build.rs
@@ -0,0 +1,128 @@
1use std::collections::BTreeMap;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7fn main() {
8 println!("cargo:rerun-if-changed=build.rs");
9 for watched in [
10 "src",
11 "index.html",
12 "vite.config.ts",
13 "package.json",
14 "bun.lock",
15 "tsconfig.json",
16 "tsconfig.app.json",
17 "tsconfig.node.json",
18 ] {
19 println!("cargo:rerun-if-changed={watched}");
20 }
21
22 let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
23 let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
24
25 if env::var_os("ADMIN_UI_SKIP_FRONTEND_BUILD").is_none() && !build_frontend(&root) {
26 println!(
27 "cargo:warning=admin frontend build via bun/npm unavailable or failed; \
28 embedding existing dist/ as-is"
29 );
30 }
31
32 let dist = root.join("dist");
33 let index = dist.join("index.html");
34 if !index.is_file() {
35 panic!(
36 "dist/index.html not found — run `bun install && bun run build` \
37 (or `npm run build`) inside admin/ first"
38 );
39 }
40
41 let mut files: BTreeMap<String, PathBuf> = BTreeMap::new();
42 collect(&dist, &dist, &mut files);
43
44 let mut src = String::from("// @generated by build.rs — embedded admin frontend (dist/)\n\n");
45 src.push_str(&format!("pub const INDEX: &[u8] = include_bytes!({:?});\n\n", index));
46 src.push_str("pub const FILES: &[(&str, &[u8])] = &[\n");
47 for (rel, abs) in &files {
48 if rel == "index.html" {
49 continue;
50 }
51 src.push_str(&format!(" ({:?}, include_bytes!({:?})),\n", rel, abs));
52 }
53 src.push_str("];\n\npub const PATHS: &[&str] = &[\n");
54 for rel in files.keys() {
55 src.push_str(&format!(" {:?},\n", rel));
56 }
57 src.push_str("];\n");
58
59 fs::write(out_dir.join("dist_embed.rs"), src).expect("write dist_embed.rs");
60}
61
62fn build_frontend(root: &Path) -> bool {
63 if !root.join("package.json").is_file() {
64 return false;
65 }
66 let tool = if command_ok("bun") {
67 "bun"
68 } else if command_ok("npm") {
69 npm_cmd()
70 } else {
71 return false;
72 };
73
74 let mut steps: Vec<(&str, &[&str])> = Vec::new();
75 if !root.join("node_modules").is_dir() {
76 steps.push((tool, &["install"]));
77 }
78 steps.push((tool, &["run", "build"]));
79
80 for (t, args) in steps {
81 eprintln!("cargo:warning=running `{t} {}` in {}", args.join(" "), root.display());
82 let ok = Command::new(t)
83 .args(args)
84 .current_dir(root)
85 .status()
86 .map(|s| s.success())
87 .unwrap_or(false);
88 if !ok {
89 return false;
90 }
91 }
92 true
93}
94
95#[cfg(windows)]
96fn npm_cmd() -> &'static str {
97 "npm.cmd"
98}
99#[cfg(not(windows))]
100fn npm_cmd() -> &'static str {
101 "npm"
102}
103
104fn command_ok(name: &str) -> bool {
105 Command::new(name)
106 .arg("--version")
107 .stdin(Stdio::null())
108 .stdout(Stdio::null())
109 .stderr(Stdio::null())
110 .status()
111 .is_ok_and(|s| s.success())
112}
113
114fn collect(base: &Path, dir: &Path, out: &mut BTreeMap<String, PathBuf>) {
115 for entry in fs::read_dir(dir).expect("read dist dir") {
116 let path = entry.expect("dist entry").path();
117 if path.is_dir() {
118 collect(base, &path, out);
119 } else {
120 let rel = path
121 .strip_prefix(base)
122 .unwrap()
123 .to_string_lossy()
124 .replace('\\', "/");
125 out.insert(rel, path);
126 }
127 }
128}
No newline at end of file
Added admin/bun.lock View admin/bun.lock
@@ -0,0 +1,224 @@
1{
2 "lockfileVersion": 1,
3 "configVersion": 1,
4 "workspaces": {
5 "": {
6 "name": "gitdata-admin",
7 "dependencies": {
8 "@tailwindcss/vite": "^4.3.3",
9 "lucide-react": "^1.39.0",
10 "react": "^19.2.8",
11 "react-dom": "^19.2.8",
12 "react-router-dom": "^7.18.3",
13 "tailwindcss": "^4.3.3",
14 },
15 "devDependencies": {
16 "@types/node": "^24.13.3",
17 "@types/react": "^19.2.18",
18 "@types/react-dom": "^19.2.4",
19 "@vitejs/plugin-react": "^6.1.0",
20 "typescript": "~6.0.2",
21 "vite": "^8.2.2",
22 },
23 },
24 },
25 "packages": {
26 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
27
28 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
29
30 "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
31
32 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
33
34 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
35
36 "@oxc-project/types": ["@oxc-project/types@0.149.0", "", {}, "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA=="],
37
38 "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.8", "", { "os": "android", "cpu": "arm" }, "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw=="],
39
40 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.8", "", { "os": "android", "cpu": "arm64" }, "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ=="],
41
42 "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA=="],
43
44 "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA=="],
45
46 "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ=="],
47
48 "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.8", "", { "os": "linux", "cpu": "arm" }, "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q=="],
49
50 "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew=="],
51
52 "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA=="],
53
54 "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA=="],
55
56 "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ=="],
57
58 "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g=="],
59
60 "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA=="],
61
62 "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.8", "", { "os": "none", "cpu": "arm64" }, "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg=="],
63
64 "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A=="],
65
66 "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng=="],
67
68 "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
69
70 "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
71
72 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
73
74 "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
75
76 "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
77
78 "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
79
80 "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
81
82 "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
83
84 "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
85
86 "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
87
88 "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
89
90 "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
91
92 "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
93
94 "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
95
96 "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
97
98 "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
99
100 "@types/node": ["@types/node@24.13.4", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw=="],
101
102 "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
103
104 "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
105
106 "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
107
108 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
109
110 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
111
112 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
113
114 "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
115
116 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
117
118 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
119
120 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
121
122 "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
123
124 "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
125
126 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
127
128 "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
129
130 "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
131
132 "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
133
134 "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
135
136 "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
137
138 "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
139
140 "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
141
142 "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
143
144 "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
145
146 "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
147
148 "lucide-react": ["lucide-react@1.46.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA=="],
149
150 "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
151
152 "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
153
154 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
155
156 "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
157
158 "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
159
160 "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
161
162 "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
163
164 "react-router": ["react-router@7.18.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA=="],
165
166 "react-router-dom": ["react-router-dom@7.18.3", "", { "dependencies": { "react-router": "7.18.3" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg=="],
167
168 "rolldown": ["rolldown@1.2.8", "", { "dependencies": { "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.8", "@rolldown/binding-android-arm64": "1.2.8", "@rolldown/binding-darwin-arm64": "1.2.8", "@rolldown/binding-darwin-x64": "1.2.8", "@rolldown/binding-freebsd-x64": "1.2.8", "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", "@rolldown/binding-linux-arm64-gnu": "1.2.8", "@rolldown/binding-linux-arm64-musl": "1.2.8", "@rolldown/binding-linux-ppc64-gnu": "1.2.8", "@rolldown/binding-linux-s390x-gnu": "1.2.8", "@rolldown/binding-linux-x64-gnu": "1.2.8", "@rolldown/binding-linux-x64-musl": "1.2.8", "@rolldown/binding-openharmony-arm64": "1.2.8", "@rolldown/binding-win32-arm64-msvc": "1.2.8", "@rolldown/binding-win32-x64-msvc": "1.2.8" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ=="],
169
170 "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
171
172 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
173
174 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
175
176 "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
177
178 "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
179
180 "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
181
182 "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
183
184 "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
185
186 "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
187
188 "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
189
190 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
191
192 "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
193
194 "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
195
196 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="],
197
198 "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
199
200 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
201
202 "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
203
204 "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
205
206 "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
207
208 "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
209
210 "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
211
212 "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
213
214 "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
215
216 "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
217
218 "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
219
220 "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
221
222 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
223 }
224}
Added admin/Cargo.toml View admin/Cargo.toml
@@ -0,0 +1,7 @@
1[package]
2name = "admin-web"
3version.workspace = true
4edition.workspace = true
5
6[lib]
7path = "lib.rs"
No newline at end of file
Added admin/index.html View admin/index.html
@@ -0,0 +1,13 @@
1<!doctype html>
2<html lang="zh-CN">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <meta name="robots" content="noindex,nofollow" />
7 <title>GitData 管理后台</title>
8 </head>
9 <body>
10 <div id="root"></div>
11 <script type="module" src="/src/app/index.tsx"></script>
12 </body>
13</html>
No newline at end of file
Added admin/lib.rs View admin/lib.rs
@@ -0,0 +1,92 @@
1mod dist_embed {
2 include!(concat!(env!("OUT_DIR"), "/dist_embed.rs"));
3}
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct Asset {
7 pub path: &'static str,
8 pub mime: &'static str,
9 pub content: &'static [u8],
10}
11
12pub fn index() -> Asset {
13 Asset {
14 path: "index.html",
15 mime: "text/html; charset=utf-8",
16 content: dist_embed::INDEX,
17 }
18}
19
20pub fn file(path: &str) -> Option<Asset> {
21 let key = normalize(path)?;
22 if key.is_empty() || key == "index.html" {
23 return Some(index());
24 }
25 dist_embed::FILES
26 .iter()
27 .find(|(p, _)| *p == key.as_str())
28 .map(|(p, content)| Asset {
29 path: p,
30 mime: mime_of(p),
31 content,
32 })
33}
34
35pub fn exists(path: &str) -> bool {
36 match normalize(path) {
37 None => false,
38 Some(key) => {
39 key.is_empty()
40 || key == "index.html"
41 || dist_embed::FILES.iter().any(|(p, _)| *p == key.as_str())
42 }
43 }
44}
45
46pub fn paths() -> &'static [&'static str] {
47 dist_embed::PATHS
48}
49
50fn normalize(path: &str) -> Option<String> {
51 let path = path.split(['?', '#']).next().unwrap_or(path);
52 let mut segs: Vec<&str> = Vec::new();
53 for seg in path.split('/') {
54 match seg {
55 "" | "." => {}
56 ".." => return None,
57 s => segs.push(s),
58 }
59 }
60 Some(segs.join("/"))
61}
62
63fn mime_of(path: &str) -> &'static str {
64 let ext = path
65 .rsplit_once('.')
66 .map_or("", |(_, e)| e)
67 .to_ascii_lowercase();
68 match ext.as_str() {
69 "html" | "htm" => "text/html; charset=utf-8",
70 "js" | "mjs" => "text/javascript; charset=utf-8",
71 "css" => "text/css; charset=utf-8",
72 "json" | "map" => "application/json",
73 "webmanifest" => "application/manifest+json",
74 "svg" => "image/svg+xml",
75 "png" => "image/png",
76 "jpg" | "jpeg" => "image/jpeg",
77 "gif" => "image/gif",
78 "webp" => "image/webp",
79 "avif" => "image/avif",
80 "ico" => "image/x-icon",
81 "wasm" => "application/wasm",
82 "ttf" => "font/ttf",
83 "otf" => "font/otf",
84 "woff" => "font/woff",
85 "woff2" => "font/woff2",
86 "eot" => "application/vnd.ms-fontobject",
87 "txt" => "text/plain; charset=utf-8",
88 "xml" => "application/xml",
89 "pdf" => "application/pdf",
90 _ => "application/octet-stream",
91 }
92}
No newline at end of file
Added admin/openapi.json View admin/openapi.json
@@ -0,0 +1,5611 @@
1{
2 "openapi": "3.1.0",
3 "info": {
4 "title": "gitdata admin api",
5 "description": "GitData 管理后台 API。独立进程(adminhost)、独立会话 Cookie(gitdata_admin_session)、固定角色 super_admin/operator/viewer。",
6 "license": {
7 "name": ""
8 },
9 "version": "1.0"
10 },
11 "servers": [
12 {
13 "url": "/api/v1"
14 }
15 ],
16 "paths": {
17 "/admin/admins": {
18 "get": {
19 "tags": [
20 "admin-admins"
21 ],
22 "description": "管理员账号列表;仅超级管理员可见。",
23 "operationId": "list_admins",
24 "parameters": [
25 {
26 "name": "limit",
27 "in": "query",
28 "required": false,
29 "schema": {
30 "type": [
31 "integer",
32 "null"
33 ],
34 "format": "int64",
35 "minimum": 0
36 }
37 },
38 {
39 "name": "offset",
40 "in": "query",
41 "required": false,
42 "schema": {
43 "type": [
44 "integer",
45 "null"
46 ],
47 "format": "int64",
48 "minimum": 0
49 }
50 },
51 {
52 "name": "sort",
53 "in": "query",
54 "required": false,
55 "schema": {
56 "type": [
57 "string",
58 "null"
59 ]
60 }
61 }
62 ],
63 "responses": {
64 "200": {
65 "description": "admin page",
66 "content": {
67 "application/json": {
68 "schema": {
69 "$ref": "#/components/schemas/AppResponseData_Page_AdminDto"
70 }
71 }
72 }
73 },
74 "401": {
75 "description": "authentication required",
76 "content": {
77 "application/json": {
78 "schema": {
79 "$ref": "#/components/schemas/AppResponseData_NoData"
80 }
81 }
82 }
83 },
84 "403": {
85 "description": "operation not allowed",
86 "content": {
87 "application/json": {
88 "schema": {
89 "$ref": "#/components/schemas/AppResponseData_NoData"
90 }
91 }
92 }
93 }
94 }
95 },
96 "post": {
97 "tags": [
98 "admin-admins"
99 ],
100 "description": "创建管理员账号;仅超级管理员可操作。",
101 "operationId": "create_admin",
102 "requestBody": {
103 "content": {
104 "application/json": {
105 "schema": {
106 "$ref": "#/components/schemas/CreateAdminCmd"
107 }
108 }
109 },
110 "required": true
111 },
112 "responses": {
113 "200": {
114 "description": "admin created",
115 "content": {
116 "application/json": {
117 "schema": {
118 "$ref": "#/components/schemas/AppResponseData_AdminDto"
119 }
120 }
121 }
122 },
123 "400": {
124 "description": "request failed validation",
125 "content": {
126 "application/json": {
127 "schema": {
128 "$ref": "#/components/schemas/AppResponseData_NoData"
129 }
130 }
131 }
132 },
133 "409": {
134 "description": "resource conflict",
135 "content": {
136 "application/json": {
137 "schema": {
138 "$ref": "#/components/schemas/AppResponseData_NoData"
139 }
140 }
141 }
142 }
143 }
144 }
145 },
146 "/admin/admins/{id}": {
147 "get": {
148 "tags": [
149 "admin-admins"
150 ],
151 "description": "管理员账号详情。",
152 "operationId": "get_admin",
153 "parameters": [
154 {
155 "name": "id",
156 "in": "path",
157 "description": "admin id",
158 "required": true,
159 "schema": {
160 "type": "string",
161 "format": "uuid"
162 }
163 }
164 ],
165 "responses": {
166 "200": {
167 "description": "admin detail",
168 "content": {
169 "application/json": {
170 "schema": {
171 "$ref": "#/components/schemas/AppResponseData_AdminDto"
172 }
173 }
174 }
175 },
176 "404": {
177 "description": "resource not found",
178 "content": {
179 "application/json": {
180 "schema": {
181 "$ref": "#/components/schemas/AppResponseData_NoData"
182 }
183 }
184 }
185 }
186 }
187 },
188 "delete": {
189 "tags": [
190 "admin-admins"
191 ],
192 "description": "删除管理员账号;禁止删除自己与最后一个超级管理员。",
193 "operationId": "delete_admin",
194 "parameters": [
195 {
196 "name": "id",
197 "in": "path",
198 "description": "admin id",
199 "required": true,
200 "schema": {
201 "type": "string",
202 "format": "uuid"
203 }
204 }
205 ],
206 "responses": {
207 "200": {
208 "description": "admin deleted",
209 "content": {
210 "application/json": {
211 "schema": {
212 "$ref": "#/components/schemas/AppResponseData_NoData"
213 }
214 }
215 }
216 },
217 "404": {
218 "description": "resource not found",
219 "content": {
220 "application/json": {
221 "schema": {
222 "$ref": "#/components/schemas/AppResponseData_NoData"
223 }
224 }
225 }
226 },
227 "409": {
228 "description": "resource conflict",
229 "content": {
230 "application/json": {
231 "schema": {
232 "$ref": "#/components/schemas/AppResponseData_NoData"
233 }
234 }
235 }
236 }
237 }
238 },
239 "patch": {
240 "tags": [
241 "admin-admins"
242 ],
243 "description": "更新管理员显示名/角色/启用状态;禁止操作最后一个超级管理员。",
244 "operationId": "update_admin",
245 "parameters": [
246 {
247 "name": "id",
248 "in": "path",
249 "description": "admin id",
250 "required": true,
251 "schema": {
252 "type": "string",
253 "format": "uuid"
254 }
255 }
256 ],
257 "requestBody": {
258 "content": {
259 "application/json": {
260 "schema": {
261 "$ref": "#/components/schemas/UpdateAdminCmd"
262 }
263 }
264 },
265 "required": true
266 },
267 "responses": {
268 "200": {
269 "description": "admin updated",
270 "content": {
271 "application/json": {
272 "schema": {
273 "$ref": "#/components/schemas/AppResponseData_AdminDto"
274 }
275 }
276 }
277 },
278 "409": {
279 "description": "resource conflict",
280 "content": {
281 "application/json": {
282 "schema": {
283 "$ref": "#/components/schemas/AppResponseData_NoData"
284 }
285 }
286 }
287 }
288 }
289 }
290 },
291 "/admin/admins/{id}/reset-password": {
292 "post": {
293 "tags": [
294 "admin-admins"
295 ],
296 "description": "重置管理员口令并要求下次登录强制改密。",
297 "operationId": "reset_admin_password",
298 "parameters": [
299 {
300 "name": "id",
301 "in": "path",
302 "description": "admin id",
303 "required": true,
304 "schema": {
305 "type": "string",
306 "format": "uuid"
307 }
308 }
309 ],
310 "requestBody": {
311 "content": {
312 "application/json": {
313 "schema": {
314 "$ref": "#/components/schemas/ResetAdminPasswordCmd"
315 }
316 }
317 },
318 "required": true
319 },
320 "responses": {
321 "200": {
322 "description": "password reset",
323 "content": {
324 "application/json": {
325 "schema": {
326 "$ref": "#/components/schemas/AppResponseData_NoData"
327 }
328 }
329 }
330 },
331 "400": {
332 "description": "request failed validation",
333 "content": {
334 "application/json": {
335 "schema": {
336 "$ref": "#/components/schemas/AppResponseData_NoData"
337 }
338 }
339 }
340 },
341 "404": {
342 "description": "resource not found",
343 "content": {
344 "application/json": {
345 "schema": {
346 "$ref": "#/components/schemas/AppResponseData_NoData"
347 }
348 }
349 }
350 }
351 }
352 }
353 },
354 "/admin/auth/login": {
355 "post": {
356 "tags": [
357 "admin-auth"
358 ],
359 "description": "管理员用户名口令登录;会话使用独立的 gitdata_admin_session Cookie。",
360 "operationId": "login",
361 "requestBody": {
362 "content": {
363 "application/json": {
364 "schema": {
365 "$ref": "#/components/schemas/AdminLoginCmd"
366 }
367 }
368 },
369 "required": true
370 },
371 "responses": {
372 "200": {
373 "description": "login accepted",
374 "content": {
375 "application/json": {
376 "schema": {
377 "$ref": "#/components/schemas/AppResponseData_AdminLoginResp"
378 }
379 }
380 }
381 },
382 "400": {
383 "description": "request failed validation",
384 "content": {
385 "application/json": {
386 "schema": {
387 "$ref": "#/components/schemas/AppResponseData_NoData"
388 }
389 }
390 }
391 },
392 "401": {
393 "description": "invalid credentials",
394 "content": {
395 "application/json": {
396 "schema": {
397 "$ref": "#/components/schemas/AppResponseData_NoData"
398 }
399 }
400 }
401 },
402 "429": {
403 "description": "rate limit exceeded",
404 "content": {
405 "application/json": {
406 "schema": {
407 "$ref": "#/components/schemas/AppResponseData_NoData"
408 }
409 }
410 }
411 }
412 }
413 }
414 },
415 "/admin/auth/logout": {
416 "post": {
417 "tags": [
418 "admin-auth"
419 ],
420 "description": "退出管理端会话;重复调用始终返回成功。",
421 "operationId": "logout",
422 "responses": {
423 "200": {
424 "description": "logged out",
425 "content": {
426 "application/json": {
427 "schema": {
428 "$ref": "#/components/schemas/AppResponseData_NoData"
429 }
430 }
431 }
432 }
433 }
434 }
435 },
436 "/admin/logs/audit": {
437 "get": {
438 "tags": [
439 "admin-logs"
440 ],
441 "description": "管理操作审计日志(登录事件与成功变更)。",
442 "operationId": "list_audit_logs",
443 "parameters": [
444 {
445 "name": "admin_id",
446 "in": "query",
447 "required": false,
448 "schema": {
449 "type": [
450 "string",
451 "null"
452 ],
453 "format": "uuid"
454 }
455 },
456 {
457 "name": "action",
458 "in": "query",
459 "required": false,
460 "schema": {
461 "type": [
462 "string",
463 "null"
464 ]
465 }
466 },
467 {
468 "name": "target_type",
469 "in": "query",
470 "required": false,
471 "schema": {
472 "type": [
473 "string",
474 "null"
475 ]
476 }
477 },
478 {
479 "name": "target_id",
480 "in": "query",
481 "required": false,
482 "schema": {
483 "type": [
484 "string",
485 "null"
486 ]
487 }
488 },
489 {
490 "name": "from",
491 "in": "query",
492 "required": false,
493 "schema": {
494 "type": [
495 "string",
496 "null"
497 ],
498 "format": "date-time"
499 }
500 },
501 {
502 "name": "to",
503 "in": "query",
504 "required": false,
505 "schema": {
506 "type": [
507 "string",
508 "null"
509 ],
510 "format": "date-time"
511 }
512 },
513 {
514 "name": "limit",
515 "in": "query",
516 "required": false,
517 "schema": {
518 "type": [
519 "integer",
520 "null"
521 ],
522 "format": "int64",
523 "minimum": 0
524 }
525 },
526 {
527 "name": "offset",
528 "in": "query",
529 "required": false,
530 "schema": {
531 "type": [
532 "integer",
533 "null"
534 ],
535 "format": "int64",
536 "minimum": 0
537 }
538 }
539 ],
540 "responses": {
541 "200": {
542 "description": "audit page",
543 "content": {
544 "application/json": {
545 "schema": {
546 "$ref": "#/components/schemas/AppResponseData_AuditLogPageDto"
547 }
548 }
549 }
550 },
551 "401": {
552 "description": "authentication required",
553 "content": {
554 "application/json": {
555 "schema": {
556 "$ref": "#/components/schemas/AppResponseData_NoData"
557 }
558 }
559 }
560 }
561 }
562 }
563 },
564 "/admin/logs/jobs": {
565 "get": {
566 "tags": [
567 "admin-logs"
568 ],
569 "description": "作业错误日志:聚合 import/search/system_tasks/webhook/pipeline 的失败信息。",
570 "operationId": "list_job_logs",
571 "parameters": [
572 {
573 "name": "source",
574 "in": "query",
575 "required": false,
576 "schema": {
577 "type": [
578 "string",
579 "null"
580 ]
581 }
582 },
583 {
584 "name": "from",
585 "in": "query",
586 "required": false,
587 "schema": {
588 "type": [
589 "string",
590 "null"
591 ],
592 "format": "date-time"
593 }
594 },
595 {
596 "name": "to",
597 "in": "query",
598 "required": false,
599 "schema": {
600 "type": [
601 "string",
602 "null"
603 ],
604 "format": "date-time"
605 }
606 },
607 {
608 "name": "limit",
609 "in": "query",
610 "required": false,
611 "schema": {
612 "type": [
613 "integer",
614 "null"
615 ],
616 "format": "int64",
617 "minimum": 0
618 }
619 },
620 {
621 "name": "offset",
622 "in": "query",
623 "required": false,
624 "schema": {
625 "type": [
626 "integer",
627 "null"
628 ],
629 "format": "int64",
630 "minimum": 0
631 }
632 }
633 ],
634 "responses": {
635 "200": {
636 "description": "job log page",
637 "content": {
638 "application/json": {
639 "schema": {
640 "$ref": "#/components/schemas/AppResponseData_JobLogPageDto"
641 }
642 }
643 }
644 },
645 "401": {
646 "description": "authentication required",
647 "content": {
648 "application/json": {
649 "schema": {
650 "$ref": "#/components/schemas/AppResponseData_NoData"
651 }
652 }
653 }
654 }
655 }
656 }
657 },
658 "/admin/me": {
659 "get": {
660 "tags": [
661 "admin-auth"
662 ],
663 "description": "当前管理员资料。",
664 "operationId": "me",
665 "responses": {
666 "200": {
667 "description": "current admin",
668 "content": {
669 "application/json": {
670 "schema": {
671 "$ref": "#/components/schemas/AppResponseData_AdminDto"
672 }
673 }
674 }
675 },
676 "401": {
677 "description": "authentication required",
678 "content": {
679 "application/json": {
680 "schema": {
681 "$ref": "#/components/schemas/AppResponseData_NoData"
682 }
683 }
684 }
685 }
686 }
687 }
688 },
689 "/admin/me/password": {
690 "patch": {
691 "tags": [
692 "admin-auth"
693 ],
694 "description": "修改当前管理员口令;口令版本变更会失效其他管理端会话。",
695 "operationId": "change_password",
696 "requestBody": {
697 "content": {
698 "application/json": {
699 "schema": {
700 "$ref": "#/components/schemas/ChangePasswordCmd"
701 }
702 }
703 },
704 "required": true
705 },
706 "responses": {
707 "200": {
708 "description": "password changed",
709 "content": {
710 "application/json": {
711 "schema": {
712 "$ref": "#/components/schemas/AppResponseData_NoData"
713 }
714 }
715 }
716 },
717 "400": {
718 "description": "request failed validation",
719 "content": {
720 "application/json": {
721 "schema": {
722 "$ref": "#/components/schemas/AppResponseData_NoData"
723 }
724 }
725 }
726 },
727 "401": {
728 "description": "authentication required",
729 "content": {
730 "application/json": {
731 "schema": {
732 "$ref": "#/components/schemas/AppResponseData_NoData"
733 }
734 }
735 }
736 }
737 }
738 }
739 },
740 "/admin/queues": {
741 "get": {
742 "tags": [
743 "admin-queues"
744 ],
745 "description": "队列快照:Redis git 同步队列、邮件 pub/sub 与各数据库作业队列深度/状态。",
746 "operationId": "list_queues",
747 "responses": {
748 "200": {
749 "description": "queue snapshots",
750 "content": {
751 "application/json": {
752 "schema": {
753 "$ref": "#/components/schemas/AppResponseData_Vec_QueueSnapshotDto"
754 }
755 }
756 }
757 },
758 "401": {
759 "description": "authentication required",
760 "content": {
761 "application/json": {
762 "schema": {
763 "$ref": "#/components/schemas/AppResponseData_NoData"
764 }
765 }
766 }
767 }
768 }
769 }
770 },
771 "/admin/queues/{name}/purge": {
772 "post": {
773 "tags": [
774 "admin-queues"
775 ],
776 "description": "清空队列:git 同步队列直接删除 Redis list,数据库队列删除终态记录;仅超级管理员可操作。",
777 "operationId": "purge",
778 "parameters": [
779 {
780 "name": "name",
781 "in": "path",
782 "description": "queue name",
783 "required": true,
784 "schema": {
785 "type": "string"
786 }
787 }
788 ],
789 "responses": {
790 "200": {
791 "description": "queue purged",
792 "content": {
793 "application/json": {
794 "schema": {
795 "$ref": "#/components/schemas/AppResponseData_QueueActionResp"
796 }
797 }
798 }
799 },
800 "400": {
801 "description": "unsupported queue",
802 "content": {
803 "application/json": {
804 "schema": {
805 "$ref": "#/components/schemas/AppResponseData_NoData"
806 }
807 }
808 }
809 }
810 }
811 }
812 },
813 "/admin/queues/{name}/requeue-failed": {
814 "post": {
815 "tags": [
816 "admin-queues"
817 ],
818 "description": "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks)。",
819 "operationId": "requeue_failed",
820 "parameters": [
821 {
822 "name": "name",
823 "in": "path",
824 "description": "queue name",
825 "required": true,
826 "schema": {
827 "type": "string"
828 }
829 }
830 ],
831 "responses": {
832 "200": {
833 "description": "failed items requeued",
834 "content": {
835 "application/json": {
836 "schema": {
837 "$ref": "#/components/schemas/AppResponseData_QueueActionResp"
838 }
839 }
840 }
841 },
842 "400": {
843 "description": "unsupported queue",
844 "content": {
845 "application/json": {
846 "schema": {
847 "$ref": "#/components/schemas/AppResponseData_NoData"
848 }
849 }
850 }
851 }
852 }
853 }
854 },
855 "/admin/spaces": {
856 "get": {
857 "tags": [
858 "admin-spaces"
859 ],
860 "description": "Space 列表:关键字、状态、Owner 与分页。",
861 "operationId": "list_spaces",
862 "parameters": [
863 {
864 "name": "q",
865 "in": "query",
866 "required": false,
867 "schema": {
868 "type": [
869 "string",
870 "null"
871 ]
872 }
873 },
874 {
875 "name": "status",
876 "in": "query",
877 "required": false,
878 "schema": {
879 "type": [
880 "string",
881 "null"
882 ]
883 }
884 },
885 {
886 "name": "owner_id",
887 "in": "query",
888 "required": false,
889 "schema": {
890 "type": [
891 "string",
892 "null"
893 ],
894 "format": "uuid"
895 }
896 },
897 {
898 "name": "limit",
899 "in": "query",
900 "required": false,
901 "schema": {
902 "type": [
903 "integer",
904 "null"
905 ],
906 "format": "int64",
907 "minimum": 0
908 }
909 },
910 {
911 "name": "offset",
912 "in": "query",
913 "required": false,
914 "schema": {
915 "type": [
916 "integer",
917 "null"
918 ],
919 "format": "int64",
920 "minimum": 0
921 }
922 }
923 ],
924 "responses": {
925 "200": {
926 "description": "space page",
927 "content": {
928 "application/json": {
929 "schema": {
930 "$ref": "#/components/schemas/AppResponseData_Page_AdminSpaceSummary"
931 }
932 }
933 }
934 },
935 "401": {
936 "description": "authentication required",
937 "content": {
938 "application/json": {
939 "schema": {
940 "$ref": "#/components/schemas/AppResponseData_NoData"
941 }
942 }
943 }
944 }
945 }
946 }
947 },
948 "/admin/spaces/{id}": {
949 "get": {
950 "tags": [
951 "admin-spaces"
952 ],
953 "description": "Space 详情:Owner、成员数、仓库/房间/目录统计。",
954 "operationId": "get_space",
955 "parameters": [
956 {
957 "name": "id",
958 "in": "path",
959 "description": "space id",
960 "required": true,
961 "schema": {
962 "type": "string",
963 "format": "uuid"
964 }
965 }
966 ],
967 "responses": {
968 "200": {
969 "description": "space detail",
970 "content": {
971 "application/json": {
972 "schema": {
973 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
974 }
975 }
976 }
977 },
978 "404": {
979 "description": "resource not found",
980 "content": {
981 "application/json": {
982 "schema": {
983 "$ref": "#/components/schemas/AppResponseData_NoData"
984 }
985 }
986 }
987 }
988 }
989 },
990 "delete": {
991 "tags": [
992 "admin-spaces"
993 ],
994 "description": "停用 Space(软删除);仅超级管理员可操作。",
995 "operationId": "delete_space",
996 "parameters": [
997 {
998 "name": "id",
999 "in": "path",
1000 "description": "space id",
1001 "required": true,
1002 "schema": {
1003 "type": "string",
1004 "format": "uuid"
1005 }
1006 }
1007 ],
1008 "responses": {
1009 "200": {
1010 "description": "space deleted",
1011 "content": {
1012 "application/json": {
1013 "schema": {
1014 "$ref": "#/components/schemas/AppResponseData_NoData"
1015 }
1016 }
1017 }
1018 },
1019 "404": {
1020 "description": "resource not found",
1021 "content": {
1022 "application/json": {
1023 "schema": {
1024 "$ref": "#/components/schemas/AppResponseData_NoData"
1025 }
1026 }
1027 }
1028 }
1029 }
1030 },
1031 "patch": {
1032 "tags": [
1033 "admin-spaces"
1034 ],
1035 "description": "更新 Space:名称、描述、私有与启用状态。",
1036 "operationId": "update_space",
1037 "parameters": [
1038 {
1039 "name": "id",
1040 "in": "path",
1041 "description": "space id",
1042 "required": true,
1043 "schema": {
1044 "type": "string",
1045 "format": "uuid"
1046 }
1047 }
1048 ],
1049 "requestBody": {
1050 "content": {
1051 "application/json": {
1052 "schema": {
1053 "$ref": "#/components/schemas/UpdateSpaceCmd"
1054 }
1055 }
1056 },
1057 "required": true
1058 },
1059 "responses": {
1060 "200": {
1061 "description": "space updated",
1062 "content": {
1063 "application/json": {
1064 "schema": {
1065 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
1066 }
1067 }
1068 }
1069 },
1070 "404": {
1071 "description": "resource not found",
1072 "content": {
1073 "application/json": {
1074 "schema": {
1075 "$ref": "#/components/schemas/AppResponseData_NoData"
1076 }
1077 }
1078 }
1079 }
1080 }
1081 }
1082 },
1083 "/admin/spaces/{id}/members": {
1084 "get": {
1085 "tags": [
1086 "admin-spaces"
1087 ],
1088 "description": "Space 成员列表。",
1089 "operationId": "list_space_members",
1090 "parameters": [
1091 {
1092 "name": "id",
1093 "in": "path",
1094 "description": "space id",
1095 "required": true,
1096 "schema": {
1097 "type": "string",
1098 "format": "uuid"
1099 }
1100 },
1101 {
1102 "name": "status",
1103 "in": "query",
1104 "required": false,
1105 "schema": {
1106 "type": [
1107 "string",
1108 "null"
1109 ]
1110 }
1111 },
1112 {
1113 "name": "limit",
1114 "in": "query",
1115 "required": false,
1116 "schema": {
1117 "type": [
1118 "integer",
1119 "null"
1120 ],
1121 "format": "int64",
1122 "minimum": 0
1123 }
1124 },
1125 {
1126 "name": "offset",
1127 "in": "query",
1128 "required": false,
1129 "schema": {
1130 "type": [
1131 "integer",
1132 "null"
1133 ],
1134 "format": "int64",
1135 "minimum": 0
1136 }
1137 }
1138 ],
1139 "responses": {
1140 "200": {
1141 "description": "member page",
1142 "content": {
1143 "application/json": {
1144 "schema": {
1145 "$ref": "#/components/schemas/AppResponseData_Page_AdminSpaceMemberDto"
1146 }
1147 }
1148 }
1149 },
1150 "404": {
1151 "description": "resource not found",
1152 "content": {
1153 "application/json": {
1154 "schema": {
1155 "$ref": "#/components/schemas/AppResponseData_NoData"
1156 }
1157 }
1158 }
1159 }
1160 }
1161 }
1162 },
1163 "/admin/spaces/{id}/members/{uid}": {
1164 "delete": {
1165 "tags": [
1166 "admin-spaces"
1167 ],
1168 "description": "将成员移出 Space。",
1169 "operationId": "remove_space_member",
1170 "parameters": [
1171 {
1172 "name": "id",
1173 "in": "path",
1174 "description": "space id",
1175 "required": true,
1176 "schema": {
1177 "type": "string",
1178 "format": "uuid"
1179 }
1180 },
1181 {
1182 "name": "uid",
1183 "in": "path",
1184 "description": "user id",
1185 "required": true,
1186 "schema": {
1187 "type": "string",
1188 "format": "uuid"
1189 }
1190 }
1191 ],
1192 "responses": {
1193 "200": {
1194 "description": "member removed",
1195 "content": {
1196 "application/json": {
1197 "schema": {
1198 "$ref": "#/components/schemas/AppResponseData_RemoveMemberResp"
1199 }
1200 }
1201 }
1202 },
1203 "404": {
1204 "description": "resource not found",
1205 "content": {
1206 "application/json": {
1207 "schema": {
1208 "$ref": "#/components/schemas/AppResponseData_NoData"
1209 }
1210 }
1211 }
1212 }
1213 }
1214 }
1215 },
1216 "/admin/spaces/{id}/transfer": {
1217 "post": {
1218 "tags": [
1219 "admin-spaces"
1220 ],
1221 "description": "转移 Space 所有权;仅超级管理员可操作。",
1222 "operationId": "transfer_space",
1223 "parameters": [
1224 {
1225 "name": "id",
1226 "in": "path",
1227 "description": "space id",
1228 "required": true,
1229 "schema": {
1230 "type": "string",
1231 "format": "uuid"
1232 }
1233 }
1234 ],
1235 "requestBody": {
1236 "content": {
1237 "application/json": {
1238 "schema": {
1239 "$ref": "#/components/schemas/TransferSpaceCmd"
1240 }
1241 }
1242 },
1243 "required": true
1244 },
1245 "responses": {
1246 "200": {
1247 "description": "space transferred",
1248 "content": {
1249 "application/json": {
1250 "schema": {
1251 "$ref": "#/components/schemas/AppResponseData_AdminSpaceDetail"
1252 }
1253 }
1254 }
1255 },
1256 "400": {
1257 "description": "request failed validation",
1258 "content": {
1259 "application/json": {
1260 "schema": {
1261 "$ref": "#/components/schemas/AppResponseData_NoData"
1262 }
1263 }
1264 }
1265 },
1266 "409": {
1267 "description": "resource conflict",
1268 "content": {
1269 "application/json": {
1270 "schema": {
1271 "$ref": "#/components/schemas/AppResponseData_NoData"
1272 }
1273 }
1274 }
1275 }
1276 }
1277 }
1278 },
1279 "/admin/storage/download-url": {
1280 "get": {
1281 "tags": [
1282 "admin-storage"
1283 ],
1284 "description": "生成对象预签名下载地址(最长 1 小时)。",
1285 "operationId": "download_url",
1286 "parameters": [
1287 {
1288 "name": "key",
1289 "in": "query",
1290 "required": true,
1291 "schema": {
1292 "type": "string"
1293 }
1294 }
1295 ],
1296 "responses": {
1297 "200": {
1298 "description": "presigned download url",
1299 "content": {
1300 "application/json": {
1301 "schema": {
1302 "$ref": "#/components/schemas/AppResponseData_DownloadUrlDto"
1303 }
1304 }
1305 }
1306 },
1307 "404": {
1308 "description": "resource not found",
1309 "content": {
1310 "application/json": {
1311 "schema": {
1312 "$ref": "#/components/schemas/AppResponseData_NoData"
1313 }
1314 }
1315 }
1316 }
1317 }
1318 }
1319 },
1320 "/admin/storage/objects": {
1321 "get": {
1322 "tags": [
1323 "admin-storage"
1324 ],
1325 "description": "对象浏览:前缀过滤与 token 分页。",
1326 "operationId": "list_objects",
1327 "parameters": [
1328 {
1329 "name": "prefix",
1330 "in": "query",
1331 "required": false,
1332 "schema": {
1333 "type": [
1334 "string",
1335 "null"
1336 ]
1337 }
1338 },
1339 {
1340 "name": "token",
1341 "in": "query",
1342 "required": false,
1343 "schema": {
1344 "type": [
1345 "string",
1346 "null"
1347 ]
1348 }
1349 },
1350 {
1351 "name": "limit",
1352 "in": "query",
1353 "required": false,
1354 "schema": {
1355 "type": [
1356 "integer",
1357 "null"
1358 ],
1359 "minimum": 0
1360 }
1361 }
1362 ],
1363 "responses": {
1364 "200": {
1365 "description": "object page",
1366 "content": {
1367 "application/json": {
1368 "schema": {
1369 "$ref": "#/components/schemas/AppResponseData_StorageObjectPageDto"
1370 }
1371 }
1372 }
1373 },
1374 "401": {
1375 "description": "authentication required",
1376 "content": {
1377 "application/json": {
1378 "schema": {
1379 "$ref": "#/components/schemas/AppResponseData_NoData"
1380 }
1381 }
1382 }
1383 }
1384 }
1385 },
1386 "delete": {
1387 "tags": [
1388 "admin-storage"
1389 ],
1390 "description": "删除存储对象;仅超级管理员可操作。",
1391 "operationId": "delete_object",
1392 "parameters": [
1393 {
1394 "name": "key",
1395 "in": "query",
1396 "required": true,
1397 "schema": {
1398 "type": "string"
1399 }
1400 }
1401 ],
1402 "responses": {
1403 "200": {
1404 "description": "object deleted",
1405 "content": {
1406 "application/json": {
1407 "schema": {
1408 "$ref": "#/components/schemas/AppResponseData_DeleteObjectResp"
1409 }
1410 }
1411 }
1412 },
1413 "404": {
1414 "description": "resource not found",
1415 "content": {
1416 "application/json": {
1417 "schema": {
1418 "$ref": "#/components/schemas/AppResponseData_NoData"
1419 }
1420 }
1421 }
1422 }
1423 }
1424 }
1425 },
1426 "/admin/storage/overview": {
1427 "get": {
1428 "tags": [
1429 "admin-storage"
1430 ],
1431 "description": "存储总览:单桶按前缀聚合的用量与对象数(60s 缓存,refresh=true 强制重算)。",
1432 "operationId": "overview",
1433 "parameters": [
1434 {
1435 "name": "refresh",
1436 "in": "query",
1437 "required": false,
1438 "schema": {
1439 "type": [
1440 "boolean",
1441 "null"
1442 ]
1443 }
1444 }
1445 ],
1446 "responses": {
1447 "200": {
1448 "description": "storage overview",
1449 "content": {
1450 "application/json": {
1451 "schema": {
1452 "$ref": "#/components/schemas/AppResponseData_StorageOverviewDto"
1453 }
1454 }
1455 }
1456 },
1457 "401": {
1458 "description": "authentication required",
1459 "content": {
1460 "application/json": {
1461 "schema": {
1462 "$ref": "#/components/schemas/AppResponseData_NoData"
1463 }
1464 }
1465 }
1466 }
1467 }
1468 }
1469 },
1470 "/admin/system/status": {
1471 "get": {
1472 "tags": [
1473 "admin-system"
1474 ],
1475 "description": "系统状态:进程信息、DB/Redis/S3/搜索健康、核心计数、队列汇总与最近失败。",
1476 "operationId": "status",
1477 "responses": {
1478 "200": {
1479 "description": "system status",
1480 "content": {
1481 "application/json": {
1482 "schema": {
1483 "$ref": "#/components/schemas/AppResponseData_SystemStatusDto"
1484 }
1485 }
1486 }
1487 },
1488 "401": {
1489 "description": "authentication required",
1490 "content": {
1491 "application/json": {
1492 "schema": {
1493 "$ref": "#/components/schemas/AppResponseData_NoData"
1494 }
1495 }
1496 }
1497 }
1498 }
1499 }
1500 },
1501 "/admin/tasks": {
1502 "get": {
1503 "tags": [
1504 "admin-tasks"
1505 ],
1506 "description": "统一任务视图:import/search_index/chat_webhook/pipeline/deletion/system_tasks。",
1507 "operationId": "list_tasks",
1508 "parameters": [
1509 {
1510 "name": "source",
1511 "in": "query",
1512 "required": false,
1513 "schema": {
1514 "type": [
1515 "string",
1516 "null"
1517 ]
1518 }
1519 },
1520 {
1521 "name": "state",
1522 "in": "query",
1523 "required": false,
1524 "schema": {
1525 "type": [
1526 "string",
1527 "null"
1528 ]
1529 }
1530 },
1531 {
1532 "name": "q",
1533 "in": "query",
1534 "required": false,
1535 "schema": {
1536 "type": [
1537 "string",
1538 "null"
1539 ]
1540 }
1541 },
1542 {
1543 "name": "from",
1544 "in": "query",
1545 "required": false,
1546 "schema": {
1547 "type": [
1548 "string",
1549 "null"
1550 ],
1551 "format": "date-time"
1552 }
1553 },
1554 {
1555 "name": "to",
1556 "in": "query",
1557 "required": false,
1558 "schema": {
1559 "type": [
1560 "string",
1561 "null"
1562 ],
1563 "format": "date-time"
1564 }
1565 },
1566 {
1567 "name": "limit",
1568 "in": "query",
1569 "required": false,
1570 "schema": {
1571 "type": [
1572 "integer",
1573 "null"
1574 ],
1575 "format": "int64",
1576 "minimum": 0
1577 }
1578 },
1579 {
1580 "name": "offset",
1581 "in": "query",
1582 "required": false,
1583 "schema": {
1584 "type": [
1585 "integer",
1586 "null"
1587 ],
1588 "format": "int64",
1589 "minimum": 0
1590 }
1591 }
1592 ],
1593 "responses": {
1594 "200": {
1595 "description": "task page",
1596 "content": {
1597 "application/json": {
1598 "schema": {
1599 "$ref": "#/components/schemas/AppResponseData_TaskPageDto"
1600 }
1601 }
1602 }
1603 },
1604 "400": {
1605 "description": "request failed validation",
1606 "content": {
1607 "application/json": {
1608 "schema": {
1609 "$ref": "#/components/schemas/AppResponseData_NoData"
1610 }
1611 }
1612 }
1613 }
1614 }
1615 }
1616 },
1617 "/admin/tasks/{source}/{id}": {
1618 "get": {
1619 "tags": [
1620 "admin-tasks"
1621 ],
1622 "description": "任务详情;import token 与 webhook payload 已脱敏。",
1623 "operationId": "get_task",
1624 "parameters": [
1625 {
1626 "name": "source",
1627 "in": "path",
1628 "description": "task source",
1629 "required": true,
1630 "schema": {
1631 "type": "string"
1632 }
1633 },
1634 {
1635 "name": "id",
1636 "in": "path",
1637 "description": "task id",
1638 "required": true,
1639 "schema": {
1640 "type": "string",
1641 "format": "uuid"
1642 }
1643 }
1644 ],
1645 "responses": {
1646 "200": {
1647 "description": "task detail",
1648 "content": {
1649 "application/json": {
1650 "schema": {
1651 "$ref": "#/components/schemas/AppResponseData_TaskDetailDto"
1652 }
1653 }
1654 }
1655 },
1656 "404": {
1657 "description": "resource not found",
1658 "content": {
1659 "application/json": {
1660 "schema": {
1661 "$ref": "#/components/schemas/AppResponseData_NoData"
1662 }
1663 }
1664 }
1665 }
1666 }
1667 },
1668 "delete": {
1669 "tags": [
1670 "admin-tasks"
1671 ],
1672 "description": "删除终态任务记录;仅超级管理员可操作。",
1673 "operationId": "delete_task",
1674 "parameters": [
1675 {
1676 "name": "source",
1677 "in": "path",
1678 "description": "task source",
1679 "required": true,
1680 "schema": {
1681 "type": "string"
1682 }
1683 },
1684 {
1685 "name": "id",
1686 "in": "path",
1687 "description": "task id",
1688 "required": true,
1689 "schema": {
1690 "type": "string",
1691 "format": "uuid"
1692 }
1693 }
1694 ],
1695 "responses": {
1696 "200": {
1697 "description": "task deleted",
1698 "content": {
1699 "application/json": {
1700 "schema": {
1701 "$ref": "#/components/schemas/AppResponseData_NoData"
1702 }
1703 }
1704 }
1705 },
1706 "409": {
1707 "description": "resource conflict",
1708 "content": {
1709 "application/json": {
1710 "schema": {
1711 "$ref": "#/components/schemas/AppResponseData_NoData"
1712 }
1713 }
1714 }
1715 }
1716 }
1717 }
1718 },
1719 "/admin/tasks/{source}/{id}/cancel": {
1720 "post": {
1721 "tags": [
1722 "admin-tasks"
1723 ],
1724 "description": "取消任务:队列中/进行中的作业按来源置取消态。",
1725 "operationId": "cancel_task",
1726 "parameters": [
1727 {
1728 "name": "source",
1729 "in": "path",
1730 "description": "task source",
1731 "required": true,
1732 "schema": {
1733 "type": "string"
1734 }
1735 },
1736 {
1737 "name": "id",
1738 "in": "path",
1739 "description": "task id",
1740 "required": true,
1741 "schema": {
1742 "type": "string",
1743 "format": "uuid"
1744 }
1745 }
1746 ],
1747 "responses": {
1748 "200": {
1749 "description": "task cancelled",
1750 "content": {
1751 "application/json": {
1752 "schema": {
1753 "$ref": "#/components/schemas/AppResponseData_TaskActionResp"
1754 }
1755 }
1756 }
1757 },
1758 "409": {
1759 "description": "resource conflict",
1760 "content": {
1761 "application/json": {
1762 "schema": {
1763 "$ref": "#/components/schemas/AppResponseData_NoData"
1764 }
1765 }
1766 }
1767 }
1768 }
1769 }
1770 },
1771 "/admin/tasks/{source}/{id}/retry": {
1772 "post": {
1773 "tags": [
1774 "admin-tasks"
1775 ],
1776 "description": "重试任务:仅失败/取消态可重试。",
1777 "operationId": "retry_task",
1778 "parameters": [
1779 {
1780 "name": "source",
1781 "in": "path",
1782 "description": "task source",
1783 "required": true,
1784 "schema": {
1785 "type": "string"
1786 }
1787 },
1788 {
1789 "name": "id",
1790 "in": "path",
1791 "description": "task id",
1792 "required": true,
1793 "schema": {
1794 "type": "string",
1795 "format": "uuid"
1796 }
1797 }
1798 ],
1799 "responses": {
1800 "200": {
1801 "description": "task retried",
1802 "content": {
1803 "application/json": {
1804 "schema": {
1805 "$ref": "#/components/schemas/AppResponseData_TaskActionResp"
1806 }
1807 }
1808 }
1809 },
1810 "409": {
1811 "description": "resource conflict",
1812 "content": {
1813 "application/json": {
1814 "schema": {
1815 "$ref": "#/components/schemas/AppResponseData_NoData"
1816 }
1817 }
1818 }
1819 }
1820 }
1821 }
1822 },
1823 "/admin/users": {
1824 "get": {
1825 "tags": [
1826 "admin-users"
1827 ],
1828 "description": "用户列表:关键字(用户名/显示名/邮箱)、状态筛选与分页。",
1829 "operationId": "list_users",
1830 "parameters": [
1831 {
1832 "name": "q",
1833 "in": "query",
1834 "required": false,
1835 "schema": {
1836 "type": [
1837 "string",
1838 "null"
1839 ]
1840 }
1841 },
1842 {
1843 "name": "status",
1844 "in": "query",
1845 "required": false,
1846 "schema": {
1847 "type": [
1848 "string",
1849 "null"
1850 ]
1851 }
1852 },
1853 {
1854 "name": "limit",
1855 "in": "query",
1856 "required": false,
1857 "schema": {
1858 "type": [
1859 "integer",
1860 "null"
1861 ],
1862 "format": "int64",
1863 "minimum": 0
1864 }
1865 },
1866 {
1867 "name": "offset",
1868 "in": "query",
1869 "required": false,
1870 "schema": {
1871 "type": [
1872 "integer",
1873 "null"
1874 ],
1875 "format": "int64",
1876 "minimum": 0
1877 }
1878 },
1879 {
1880 "name": "sort",
1881 "in": "query",
1882 "required": false,
1883 "schema": {
1884 "type": [
1885 "string",
1886 "null"
1887 ]
1888 }
1889 }
1890 ],
1891 "responses": {
1892 "200": {
1893 "description": "user page",
1894 "content": {
1895 "application/json": {
1896 "schema": {
1897 "$ref": "#/components/schemas/AppResponseData_Page_AdminUserSummary"
1898 }
1899 }
1900 }
1901 },
1902 "401": {
1903 "description": "authentication required",
1904 "content": {
1905 "application/json": {
1906 "schema": {
1907 "$ref": "#/components/schemas/AppResponseData_NoData"
1908 }
1909 }
1910 }
1911 }
1912 }
1913 }
1914 },
1915 "/admin/users/{uid}": {
1916 "get": {
1917 "tags": [
1918 "admin-users"
1919 ],
1920 "description": "用户详情:账号状态、邮箱、会话、所属 Space 与封禁记录。",
1921 "operationId": "get_user",
1922 "parameters": [
1923 {
1924 "name": "uid",
1925 "in": "path",
1926 "description": "user id",
1927 "required": true,
1928 "schema": {
1929 "type": "string",
1930 "format": "uuid"
1931 }
1932 }
1933 ],
1934 "responses": {
1935 "200": {
1936 "description": "user detail",
1937 "content": {
1938 "application/json": {
1939 "schema": {
1940 "$ref": "#/components/schemas/AppResponseData_AdminUserDetail"
1941 }
1942 }
1943 }
1944 },
1945 "404": {
1946 "description": "resource not found",
1947 "content": {
1948 "application/json": {
1949 "schema": {
1950 "$ref": "#/components/schemas/AppResponseData_NoData"
1951 }
1952 }
1953 }
1954 }
1955 }
1956 },
1957 "delete": {
1958 "tags": [
1959 "admin-users"
1960 ],
1961 "description": "强制注销用户:立即执行匿名化并禁用账号;仅超级管理员可操作。",
1962 "operationId": "delete_user",
1963 "parameters": [
1964 {
1965 "name": "uid",
1966 "in": "path",
1967 "description": "user id",
1968 "required": true,
1969 "schema": {
1970 "type": "string",
1971 "format": "uuid"
1972 }
1973 }
1974 ],
1975 "responses": {
1976 "200": {
1977 "description": "user deleted",
1978 "content": {
1979 "application/json": {
1980 "schema": {
1981 "$ref": "#/components/schemas/AppResponseData_DeleteUserResp"
1982 }
1983 }
1984 }
1985 },
1986 "403": {
1987 "description": "operation not allowed",
1988 "content": {
1989 "application/json": {
1990 "schema": {
1991 "$ref": "#/components/schemas/AppResponseData_NoData"
1992 }
1993 }
1994 }
1995 }
1996 }
1997 },
1998 "patch": {
1999 "tags": [
2000 "admin-users"
2001 ],
2002 "description": "更新用户账号标志:显示名、启用、受限、禁止登录。",
2003 "operationId": "update_user",
2004 "parameters": [
2005 {
2006 "name": "uid",
2007 "in": "path",
2008 "description": "user id",
2009 "required": true,
2010 "schema": {
2011 "type": "string",
2012 "format": "uuid"
2013 }
2014 }
2015 ],
2016 "requestBody": {
2017 "content": {
2018 "application/json": {
2019 "schema": {
2020 "$ref": "#/components/schemas/UpdateUserCmd"
2021 }
2022 }
2023 },
2024 "required": true
2025 },
2026 "responses": {
2027 "200": {
2028 "description": "user updated",
2029 "content": {
2030 "application/json": {
2031 "schema": {
2032 "$ref": "#/components/schemas/AppResponseData_AdminUserSummary"
2033 }
2034 }
2035 }
2036 },
2037 "404": {
2038 "description": "resource not found",
2039 "content": {
2040 "application/json": {
2041 "schema": {
2042 "$ref": "#/components/schemas/AppResponseData_NoData"
2043 }
2044 }
2045 }
2046 }
2047 }
2048 }
2049 },
2050 "/admin/users/{uid}/reset-password": {
2051 "post": {
2052 "tags": [
2053 "admin-users"
2054 ],
2055 "description": "重置用户口令并吊销全部会话。",
2056 "operationId": "reset_user_password",
2057 "parameters": [
2058 {
2059 "name": "uid",
2060 "in": "path",
2061 "description": "user id",
2062 "required": true,
2063 "schema": {
2064 "type": "string",
2065 "format": "uuid"
2066 }
2067 }
2068 ],
2069 "requestBody": {
2070 "content": {
2071 "application/json": {
2072 "schema": {
2073 "$ref": "#/components/schemas/ResetUserPasswordCmd"
2074 }
2075 }
2076 },
2077 "required": true
2078 },
2079 "responses": {
2080 "200": {
2081 "description": "password reset",
2082 "content": {
2083 "application/json": {
2084 "schema": {
2085 "$ref": "#/components/schemas/AppResponseData_NoData"
2086 }
2087 }
2088 }
2089 },
2090 "400": {
2091 "description": "request failed validation",
2092 "content": {
2093 "application/json": {
2094 "schema": {
2095 "$ref": "#/components/schemas/AppResponseData_NoData"
2096 }
2097 }
2098 }
2099 },
2100 "404": {
2101 "description": "resource not found",
2102 "content": {
2103 "application/json": {
2104 "schema": {
2105 "$ref": "#/components/schemas/AppResponseData_NoData"
2106 }
2107 }
2108 }
2109 }
2110 }
2111 }
2112 },
2113 "/admin/users/{uid}/revoke-sessions": {
2114 "post": {
2115 "tags": [
2116 "admin-users"
2117 ],
2118 "description": "吊销用户全部有效会话。",
2119 "operationId": "revoke_user_sessions",
2120 "parameters": [
2121 {
2122 "name": "uid",
2123 "in": "path",
2124 "description": "user id",
2125 "required": true,
2126 "schema": {
2127 "type": "string",
2128 "format": "uuid"
2129 }
2130 }
2131 ],
2132 "responses": {
2133 "200": {
2134 "description": "sessions revoked",
2135 "content": {
2136 "application/json": {
2137 "schema": {
2138 "$ref": "#/components/schemas/AppResponseData_RevokeSessionsResp"
2139 }
2140 }
2141 }
2142 },
2143 "404": {
2144 "description": "resource not found",
2145 "content": {
2146 "application/json": {
2147 "schema": {
2148 "$ref": "#/components/schemas/AppResponseData_NoData"
2149 }
2150 }
2151 }
2152 }
2153 }
2154 }
2155 },
2156 "/admin/users/{uid}/suspend": {
2157 "post": {
2158 "tags": [
2159 "admin-users"
2160 ],
2161 "description": "封禁用户:写入封禁记录、标记受限并吊销全部会话。",
2162 "operationId": "suspend_user",
2163 "parameters": [
2164 {
2165 "name": "uid",
2166 "in": "path",
2167 "description": "user id",
2168 "required": true,
2169 "schema": {
2170 "type": "string",
2171 "format": "uuid"
2172 }
2173 }
2174 ],
2175 "requestBody": {
2176 "content": {
2177 "application/json": {
2178 "schema": {
2179 "$ref": "#/components/schemas/SuspendUserCmd"
2180 }
2181 }
2182 },
2183 "required": true
2184 },
2185 "responses": {
2186 "200": {
2187 "description": "user suspended",
2188 "content": {
2189 "application/json": {
2190 "schema": {
2191 "$ref": "#/components/schemas/AppResponseData_AdminSuspensionDto"
2192 }
2193 }
2194 }
2195 },
2196 "400": {
2197 "description": "request failed validation",
2198 "content": {
2199 "application/json": {
2200 "schema": {
2201 "$ref": "#/components/schemas/AppResponseData_NoData"
2202 }
2203 }
2204 }
2205 },
2206 "404": {
2207 "description": "resource not found",
2208 "content": {
2209 "application/json": {
2210 "schema": {
2211 "$ref": "#/components/schemas/AppResponseData_NoData"
2212 }
2213 }
2214 }
2215 }
2216 }
2217 }
2218 },
2219 "/admin/users/{uid}/suspensions/{suspension_id}/revoke": {
2220 "post": {
2221 "tags": [
2222 "admin-users"
2223 ],
2224 "description": "撤销用户封禁记录;无剩余生效封禁时解除受限标志。",
2225 "operationId": "revoke_suspension",
2226 "parameters": [
2227 {
2228 "name": "uid",
2229 "in": "path",
2230 "description": "user id",
2231 "required": true,
2232 "schema": {
2233 "type": "string",
2234 "format": "uuid"
2235 }
2236 },
2237 {
2238 "name": "suspension_id",
2239 "in": "path",
2240 "description": "suspension id",
2241 "required": true,
2242 "schema": {
2243 "type": "string",
2244 "format": "uuid"
2245 }
2246 }
2247 ],
2248 "responses": {
2249 "200": {
2250 "description": "suspension revoked",
2251 "content": {
2252 "application/json": {
2253 "schema": {
2254 "$ref": "#/components/schemas/AppResponseData_NoData"
2255 }
2256 }
2257 }
2258 },
2259 "404": {
2260 "description": "resource not found",
2261 "content": {
2262 "application/json": {
2263 "schema": {
2264 "$ref": "#/components/schemas/AppResponseData_NoData"
2265 }
2266 }
2267 }
2268 }
2269 }
2270 }
2271 }
2272 },
2273 "components": {
2274 "schemas": {
2275 "AdminDto": {
2276 "type": "object",
2277 "required": [
2278 "id",
2279 "username",
2280 "display_name",
2281 "role",
2282 "is_active",
2283 "must_change_password",
2284 "created_at",
2285 "updated_at"
2286 ],
2287 "properties": {
2288 "created_at": {
2289 "type": "string",
2290 "format": "date-time"
2291 },
2292 "display_name": {
2293 "type": "string"
2294 },
2295 "id": {
2296 "type": "string",
2297 "format": "uuid"
2298 },
2299 "is_active": {
2300 "type": "boolean"
2301 },
2302 "last_login_at": {
2303 "type": [
2304 "string",
2305 "null"
2306 ],
2307 "format": "date-time"
2308 },
2309 "last_login_ip": {
2310 "type": [
2311 "string",
2312 "null"
2313 ]
2314 },
2315 "must_change_password": {
2316 "type": "boolean"
2317 },
2318 "role": {
2319 "$ref": "#/components/schemas/AdminRole"
2320 },
2321 "updated_at": {
2322 "type": "string",
2323 "format": "date-time"
2324 },
2325 "username": {
2326 "type": "string"
2327 }
2328 }
2329 },
2330 "AdminLoginCmd": {
2331 "type": "object",
2332 "required": [
2333 "username",
2334 "password"
2335 ],
2336 "properties": {
2337 "password": {
2338 "type": "string"
2339 },
2340 "username": {
2341 "type": "string"
2342 }
2343 }
2344 },
2345 "AdminLoginResp": {
2346 "type": "object",
2347 "required": [
2348 "admin",
2349 "must_change_password"
2350 ],
2351 "properties": {
2352 "admin": {
2353 "$ref": "#/components/schemas/AdminDto"
2354 },
2355 "must_change_password": {
2356 "type": "boolean"
2357 }
2358 }
2359 },
2360 "AdminRole": {
2361 "type": "string",
2362 "enum": [
2363 "super_admin",
2364 "operator",
2365 "viewer"
2366 ]
2367 },
2368 "AdminSpaceDetail": {
2369 "type": "object",
2370 "required": [
2371 "space",
2372 "repos_count",
2373 "repos_size",
2374 "rooms_count",
2375 "dirs_count"
2376 ],
2377 "properties": {
2378 "dirs_count": {
2379 "type": "integer",
2380 "format": "int64"
2381 },
2382 "repos_count": {
2383 "type": "integer",
2384 "format": "int64"
2385 },
2386 "repos_size": {
2387 "type": "integer",
2388 "format": "int64"
2389 },
2390 "rooms_count": {
2391 "type": "integer",
2392 "format": "int64"
2393 },
2394 "space": {
2395 "$ref": "#/components/schemas/AdminSpaceSummary"
2396 }
2397 }
2398 },
2399 "AdminSpaceMemberDto": {
2400 "type": "object",
2401 "required": [
2402 "user_id",
2403 "status",
2404 "joined_at"
2405 ],
2406 "properties": {
2407 "alias_name": {
2408 "type": [
2409 "string",
2410 "null"
2411 ]
2412 },
2413 "display_name": {
2414 "type": [
2415 "string",
2416 "null"
2417 ]
2418 },
2419 "joined_at": {
2420 "type": "string",
2421 "format": "date-time"
2422 },
2423 "last_active_at": {
2424 "type": [
2425 "string",
2426 "null"
2427 ],
2428 "format": "date-time"
2429 },
2430 "status": {
2431 "type": "string"
2432 },
2433 "user_id": {
2434 "type": "string",
2435 "format": "uuid"
2436 },
2437 "username": {
2438 "type": [
2439 "string",
2440 "null"
2441 ]
2442 }
2443 }
2444 },
2445 "AdminSpaceSummary": {
2446 "type": "object",
2447 "required": [
2448 "id",
2449 "name",
2450 "lower_name",
2451 "owner_id",
2452 "is_private",
2453 "is_active",
2454 "nums_members",
2455 "created_at",
2456 "updated_at"
2457 ],
2458 "properties": {
2459 "created_at": {
2460 "type": "string",
2461 "format": "date-time"
2462 },
2463 "description": {
2464 "type": [
2465 "string",
2466 "null"
2467 ]
2468 },
2469 "id": {
2470 "type": "string",
2471 "format": "uuid"
2472 },
2473 "is_active": {
2474 "type": "boolean"
2475 },
2476 "is_private": {
2477 "type": "boolean"
2478 },
2479 "lower_name": {
2480 "type": "string"
2481 },
2482 "name": {
2483 "type": "string"
2484 },
2485 "nums_members": {
2486 "type": "integer",
2487 "format": "int64"
2488 },
2489 "owner_id": {
2490 "type": "string",
2491 "format": "uuid"
2492 },
2493 "owner_username": {
2494 "type": [
2495 "string",
2496 "null"
2497 ]
2498 },
2499 "updated_at": {
2500 "type": "string",
2501 "format": "date-time"
2502 }
2503 }
2504 },
2505 "AdminSuspensionDto": {
2506 "type": "object",
2507 "required": [
2508 "id",
2509 "suspension_type",
2510 "starts_at"
2511 ],
2512 "properties": {
2513 "ends_at": {
2514 "type": [
2515 "string",
2516 "null"
2517 ],
2518 "format": "date-time"
2519 },
2520 "id": {
2521 "type": "string",
2522 "format": "uuid"
2523 },
2524 "reason": {
2525 "type": [
2526 "string",
2527 "null"
2528 ]
2529 },
2530 "revoked_at": {
2531 "type": [
2532 "string",
2533 "null"
2534 ],
2535 "format": "date-time"
2536 },
2537 "starts_at": {
2538 "type": "string",
2539 "format": "date-time"
2540 },
2541 "suspended_by": {
2542 "type": [
2543 "string",
2544 "null"
2545 ],
2546 "format": "uuid"
2547 },
2548 "suspension_type": {
2549 "type": "string"
2550 }
2551 }
2552 },
2553 "AdminUserDetail": {
2554 "type": "object",
2555 "required": [
2556 "user",
2557 "emails",
2558 "sessions",
2559 "spaces",
2560 "suspensions"
2561 ],
2562 "properties": {
2563 "emails": {
2564 "type": "array",
2565 "items": {
2566 "$ref": "#/components/schemas/AdminUserEmailDto"
2567 }
2568 },
2569 "sessions": {
2570 "type": "array",
2571 "items": {
2572 "$ref": "#/components/schemas/AdminUserSessionDto"
2573 }
2574 },
2575 "spaces": {
2576 "type": "array",
2577 "items": {
2578 "$ref": "#/components/schemas/AdminUserSpaceDto"
2579 }
2580 },
2581 "suspensions": {
2582 "type": "array",
2583 "items": {
2584 "$ref": "#/components/schemas/AdminSuspensionDto"
2585 }
2586 },
2587 "user": {
2588 "$ref": "#/components/schemas/AdminUserSummary"
2589 }
2590 }
2591 },
2592 "AdminUserEmailDto": {
2593 "type": "object",
2594 "required": [
2595 "id",
2596 "email",
2597 "is_primary",
2598 "is_activated",
2599 "created_at"
2600 ],
2601 "properties": {
2602 "created_at": {
2603 "type": "string",
2604 "format": "date-time"
2605 },
2606 "email": {
2607 "type": "string"
2608 },
2609 "id": {
2610 "type": "string",
2611 "format": "uuid"
2612 },
2613 "is_activated": {
2614 "type": "boolean"
2615 },
2616 "is_primary": {
2617 "type": "boolean"
2618 }
2619 }
2620 },
2621 "AdminUserSessionDto": {
2622 "type": "object",
2623 "required": [
2624 "id",
2625 "kind",
2626 "last_seen_at",
2627 "created_at",
2628 "expires_at",
2629 "is_revoked"
2630 ],
2631 "properties": {
2632 "created_at": {
2633 "type": "string",
2634 "format": "date-time"
2635 },
2636 "expires_at": {
2637 "type": "string",
2638 "format": "date-time"
2639 },
2640 "id": {
2641 "type": "string",
2642 "format": "uuid"
2643 },
2644 "is_revoked": {
2645 "type": "boolean"
2646 },
2647 "kind": {
2648 "type": "string"
2649 },
2650 "last_seen_at": {
2651 "type": "string",
2652 "format": "date-time"
2653 }
2654 }
2655 },
2656 "AdminUserSpaceDto": {
2657 "type": "object",
2658 "required": [
2659 "id",
2660 "name",
2661 "lower_name",
2662 "is_active"
2663 ],
2664 "properties": {
2665 "id": {
2666 "type": "string",
2667 "format": "uuid"
2668 },
2669 "is_active": {
2670 "type": "boolean"
2671 },
2672 "lower_name": {
2673 "type": "string"
2674 },
2675 "name": {
2676 "type": "string"
2677 }
2678 }
2679 },
2680 "AdminUserSummary": {
2681 "type": "object",
2682 "required": [
2683 "uid",
2684 "username",
2685 "display_name",
2686 "avatar_url",
2687 "is_active",
2688 "is_restricted",
2689 "prohibit_login",
2690 "bot",
2691 "system",
2692 "suspended",
2693 "created_at"
2694 ],
2695 "properties": {
2696 "avatar_url": {
2697 "type": "string"
2698 },
2699 "bot": {
2700 "type": "boolean"
2701 },
2702 "created_at": {
2703 "type": "string",
2704 "format": "date-time"
2705 },
2706 "display_name": {
2707 "type": "string"
2708 },
2709 "is_active": {
2710 "type": "boolean"
2711 },
2712 "is_restricted": {
2713 "type": "boolean"
2714 },
2715 "last_login_at": {
2716 "type": [
2717 "string",
2718 "null"
2719 ],
2720 "format": "date-time"
2721 },
2722 "prohibit_login": {
2723 "type": "boolean"
2724 },
2725 "suspended": {
2726 "type": "boolean"
2727 },
2728 "system": {
2729 "type": "boolean"
2730 },
2731 "uid": {
2732 "type": "string",
2733 "format": "uuid"
2734 },
2735 "username": {
2736 "type": "string"
2737 }
2738 }
2739 },
2740 "AppResponseData_AdminDto": {
2741 "type": "object",
2742 "required": [
2743 "code",
2744 "message",
2745 "request_id",
2746 "log_id"
2747 ],
2748 "properties": {
2749 "code": {
2750 "type": "integer",
2751 "format": "int32"
2752 },
2753 "data": {
2754 "type": "object",
2755 "required": [
2756 "id",
2757 "username",
2758 "display_name",
2759 "role",
2760 "is_active",
2761 "must_change_password",
2762 "created_at",
2763 "updated_at"
2764 ],
2765 "properties": {
2766 "created_at": {
2767 "type": "string",
2768 "format": "date-time"
2769 },
2770 "display_name": {
2771 "type": "string"
2772 },
2773 "id": {
2774 "type": "string",
2775 "format": "uuid"
2776 },
2777 "is_active": {
2778 "type": "boolean"
2779 },
2780 "last_login_at": {
2781 "type": [
2782 "string",
2783 "null"
2784 ],
2785 "format": "date-time"
2786 },
2787 "last_login_ip": {
2788 "type": [
2789 "string",
2790 "null"
2791 ]
2792 },
2793 "must_change_password": {
2794 "type": "boolean"
2795 },
2796 "role": {
2797 "$ref": "#/components/schemas/AdminRole"
2798 },
2799 "updated_at": {
2800 "type": "string",
2801 "format": "date-time"
2802 },
2803 "username": {
2804 "type": "string"
2805 }
2806 }
2807 },
2808 "error": {
2809 "type": [
2810 "string",
2811 "null"
2812 ]
2813 },
2814 "error_code": {
2815 "type": [
2816 "string",
2817 "null"
2818 ]
2819 },
2820 "log_id": {
2821 "type": "string"
2822 },
2823 "message": {
2824 "type": "string"
2825 },
2826 "request_id": {
2827 "type": "string"
2828 }
2829 }
2830 },
2831 "AppResponseData_AdminLoginResp": {
2832 "type": "object",
2833 "required": [
2834 "code",
2835 "message",
2836 "request_id",
2837 "log_id"
2838 ],
2839 "properties": {
2840 "code": {
2841 "type": "integer",
2842 "format": "int32"
2843 },
2844 "data": {
2845 "type": "object",
2846 "required": [
2847 "admin",
2848 "must_change_password"
2849 ],
2850 "properties": {
2851 "admin": {
2852 "$ref": "#/components/schemas/AdminDto"
2853 },
2854 "must_change_password": {
2855 "type": "boolean"
2856 }
2857 }
2858 },
2859 "error": {
2860 "type": [
2861 "string",
2862 "null"
2863 ]
2864 },
2865 "error_code": {
2866 "type": [
2867 "string",
2868 "null"
2869 ]
2870 },
2871 "log_id": {
2872 "type": "string"
2873 },
2874 "message": {
2875 "type": "string"
2876 },
2877 "request_id": {
2878 "type": "string"
2879 }
2880 }
2881 },
2882 "AppResponseData_AdminSpaceDetail": {
2883 "type": "object",
2884 "required": [
2885 "code",
2886 "message",
2887 "request_id",
2888 "log_id"
2889 ],
2890 "properties": {
2891 "code": {
2892 "type": "integer",
2893 "format": "int32"
2894 },
2895 "data": {
2896 "type": "object",
2897 "required": [
2898 "space",
2899 "repos_count",
2900 "repos_size",
2901 "rooms_count",
2902 "dirs_count"
2903 ],
2904 "properties": {
2905 "dirs_count": {
2906 "type": "integer",
2907 "format": "int64"
2908 },
2909 "repos_count": {
2910 "type": "integer",
2911 "format": "int64"
2912 },
2913 "repos_size": {
2914 "type": "integer",
2915 "format": "int64"
2916 },
2917 "rooms_count": {
2918 "type": "integer",
2919 "format": "int64"
2920 },
2921 "space": {
2922 "$ref": "#/components/schemas/AdminSpaceSummary"
2923 }
2924 }
2925 },
2926 "error": {
2927 "type": [
2928 "string",
2929 "null"
2930 ]
2931 },
2932 "error_code": {
2933 "type": [
2934 "string",
2935 "null"
2936 ]
2937 },
2938 "log_id": {
2939 "type": "string"
2940 },
2941 "message": {
2942 "type": "string"
2943 },
2944 "request_id": {
2945 "type": "string"
2946 }
2947 }
2948 },
2949 "AppResponseData_AdminSuspensionDto": {
2950 "type": "object",
2951 "required": [
2952 "code",
2953 "message",
2954 "request_id",
2955 "log_id"
2956 ],
2957 "properties": {
2958 "code": {
2959 "type": "integer",
2960 "format": "int32"
2961 },
2962 "data": {
2963 "type": "object",
2964 "required": [
2965 "id",
2966 "suspension_type",
2967 "starts_at"
2968 ],
2969 "properties": {
2970 "ends_at": {
2971 "type": [
2972 "string",
2973 "null"
2974 ],
2975 "format": "date-time"
2976 },
2977 "id": {
2978 "type": "string",
2979 "format": "uuid"
2980 },
2981 "reason": {
2982 "type": [
2983 "string",
2984 "null"
2985 ]
2986 },
2987 "revoked_at": {
2988 "type": [
2989 "string",
2990 "null"
2991 ],
2992 "format": "date-time"
2993 },
2994 "starts_at": {
2995 "type": "string",
2996 "format": "date-time"
2997 },
2998 "suspended_by": {
2999 "type": [
3000 "string",
3001 "null"
3002 ],
3003 "format": "uuid"
3004 },
3005 "suspension_type": {
3006 "type": "string"
3007 }
3008 }
3009 },
3010 "error": {
3011 "type": [
3012 "string",
3013 "null"
3014 ]
3015 },
3016 "error_code": {
3017 "type": [
3018 "string",
3019 "null"
3020 ]
3021 },
3022 "log_id": {
3023 "type": "string"
3024 },
3025 "message": {
3026 "type": "string"
3027 },
3028 "request_id": {
3029 "type": "string"
3030 }
3031 }
3032 },
3033 "AppResponseData_AdminUserDetail": {
3034 "type": "object",
3035 "required": [
3036 "code",
3037 "message",
3038 "request_id",
3039 "log_id"
3040 ],
3041 "properties": {
3042 "code": {
3043 "type": "integer",
3044 "format": "int32"
3045 },
3046 "data": {
3047 "type": "object",
3048 "required": [
3049 "user",
3050 "emails",
3051 "sessions",
3052 "spaces",
3053 "suspensions"
3054 ],
3055 "properties": {
3056 "emails": {
3057 "type": "array",
3058 "items": {
3059 "$ref": "#/components/schemas/AdminUserEmailDto"
3060 }
3061 },
3062 "sessions": {
3063 "type": "array",
3064 "items": {
3065 "$ref": "#/components/schemas/AdminUserSessionDto"
3066 }
3067 },
3068 "spaces": {
3069 "type": "array",
3070 "items": {
3071 "$ref": "#/components/schemas/AdminUserSpaceDto"
3072 }
3073 },
3074 "suspensions": {
3075 "type": "array",
3076 "items": {
3077 "$ref": "#/components/schemas/AdminSuspensionDto"
3078 }
3079 },
3080 "user": {
3081 "$ref": "#/components/schemas/AdminUserSummary"
3082 }
3083 }
3084 },
3085 "error": {
3086 "type": [
3087 "string",
3088 "null"
3089 ]
3090 },
3091 "error_code": {
3092 "type": [
3093 "string",
3094 "null"
3095 ]
3096 },
3097 "log_id": {
3098 "type": "string"
3099 },
3100 "message": {
3101 "type": "string"
3102 },
3103 "request_id": {
3104 "type": "string"
3105 }
3106 }
3107 },
3108 "AppResponseData_AdminUserSummary": {
3109 "type": "object",
3110 "required": [
3111 "code",
3112 "message",
3113 "request_id",
3114 "log_id"
3115 ],
3116 "properties": {
3117 "code": {
3118 "type": "integer",
3119 "format": "int32"
3120 },
3121 "data": {
3122 "type": "object",
3123 "required": [
3124 "uid",
3125 "username",
3126 "display_name",
3127 "avatar_url",
3128 "is_active",
3129 "is_restricted",
3130 "prohibit_login",
3131 "bot",
3132 "system",
3133 "suspended",
3134 "created_at"
3135 ],
3136 "properties": {
3137 "avatar_url": {
3138 "type": "string"
3139 },
3140 "bot": {
3141 "type": "boolean"
3142 },
3143 "created_at": {
3144 "type": "string",
3145 "format": "date-time"
3146 },
3147 "display_name": {
3148 "type": "string"
3149 },
3150 "is_active": {
3151 "type": "boolean"
3152 },
3153 "is_restricted": {
3154 "type": "boolean"
3155 },
3156 "last_login_at": {
3157 "type": [
3158 "string",
3159 "null"
3160 ],
3161 "format": "date-time"
3162 },
3163 "prohibit_login": {
3164 "type": "boolean"
3165 },
3166 "suspended": {
3167 "type": "boolean"
3168 },
3169 "system": {
3170 "type": "boolean"
3171 },
3172 "uid": {
3173 "type": "string",
3174 "format": "uuid"
3175 },
3176 "username": {
3177 "type": "string"
3178 }
3179 }
3180 },
3181 "error": {
3182 "type": [
3183 "string",
3184 "null"
3185 ]
3186 },
3187 "error_code": {
3188 "type": [
3189 "string",
3190 "null"
3191 ]
3192 },
3193 "log_id": {
3194 "type": "string"
3195 },
3196 "message": {
3197 "type": "string"
3198 },
3199 "request_id": {
3200 "type": "string"
3201 }
3202 }
3203 },
3204 "AppResponseData_AuditLogPageDto": {
3205 "type": "object",
3206 "required": [
3207 "code",
3208 "message",
3209 "request_id",
3210 "log_id"
3211 ],
3212 "properties": {
3213 "code": {
3214 "type": "integer",
3215 "format": "int32"
3216 },
3217 "data": {
3218 "type": "object",
3219 "required": [
3220 "items",
3221 "total",
3222 "limit",
3223 "offset"
3224 ],
3225 "properties": {
3226 "items": {
3227 "type": "array",
3228 "items": {
3229 "$ref": "#/components/schemas/AuditLogDto"
3230 }
3231 },
3232 "limit": {
3233 "type": "integer",
3234 "format": "int64",
3235 "minimum": 0
3236 },
3237 "offset": {
3238 "type": "integer",
3239 "format": "int64",
3240 "minimum": 0
3241 },
3242 "total": {
3243 "type": "integer",
3244 "format": "int64"
3245 }
3246 }
3247 },
3248 "error": {
3249 "type": [
3250 "string",
3251 "null"
3252 ]
3253 },
3254 "error_code": {
3255 "type": [
3256 "string",
3257 "null"
3258 ]
3259 },
3260 "log_id": {
3261 "type": "string"
3262 },
3263 "message": {
3264 "type": "string"
3265 },
3266 "request_id": {
3267 "type": "string"
3268 }
3269 }
3270 },
3271 "AppResponseData_DeleteObjectResp": {
3272 "type": "object",
3273 "required": [
3274 "code",
3275 "message",
3276 "request_id",
3277 "log_id"
3278 ],
3279 "properties": {
3280 "code": {
3281 "type": "integer",
3282 "format": "int32"
3283 },
3284 "data": {
3285 "type": "object",
3286 "required": [
3287 "key",
3288 "deleted"
3289 ],
3290 "properties": {
3291 "deleted": {
3292 "type": "boolean"
3293 },
3294 "key": {
3295 "type": "string"
3296 }
3297 }
3298 },
3299 "error": {
3300 "type": [
3301 "string",
3302 "null"
3303 ]
3304 },
3305 "error_code": {
3306 "type": [
3307 "string",
3308 "null"
3309 ]
3310 },
3311 "log_id": {
3312 "type": "string"
3313 },
3314 "message": {
3315 "type": "string"
3316 },
3317 "request_id": {
3318 "type": "string"
3319 }
3320 }
3321 },
3322 "AppResponseData_DeleteUserResp": {
3323 "type": "object",
3324 "required": [
3325 "code",
3326 "message",
3327 "request_id",
3328 "log_id"
3329 ],
3330 "properties": {
3331 "code": {
3332 "type": "integer",
3333 "format": "int32"
3334 },
3335 "data": {
3336 "type": "object",
3337 "required": [
3338 "user_id",
3339 "anonymized"
3340 ],
3341 "properties": {
3342 "anonymized": {
3343 "type": "boolean"
3344 },
3345 "user_id": {
3346 "type": "string",
3347 "format": "uuid"
3348 }
3349 }
3350 },
3351 "error": {
3352 "type": [
3353 "string",
3354 "null"
3355 ]
3356 },
3357 "error_code": {
3358 "type": [
3359 "string",
3360 "null"
3361 ]
3362 },
3363 "log_id": {
3364 "type": "string"
3365 },
3366 "message": {
3367 "type": "string"
3368 },
3369 "request_id": {
3370 "type": "string"
3371 }
3372 }
3373 },
3374 "AppResponseData_DownloadUrlDto": {
3375 "type": "object",
3376 "required": [
3377 "code",
3378 "message",
3379 "request_id",
3380 "log_id"
3381 ],
3382 "properties": {
3383 "code": {
3384 "type": "integer",
3385 "format": "int32"
3386 },
3387 "data": {
3388 "type": "object",
3389 "required": [
3390 "key",
3391 "url",
3392 "expires_in_secs"
3393 ],
3394 "properties": {
3395 "expires_in_secs": {
3396 "type": "integer",
3397 "format": "int32",
3398 "minimum": 0
3399 },
3400 "key": {
3401 "type": "string"
3402 },
3403 "url": {
3404 "type": "string"
3405 }
3406 }
3407 },
3408 "error": {
3409 "type": [
3410 "string",
3411 "null"
3412 ]
3413 },
3414 "error_code": {
3415 "type": [
3416 "string",
3417 "null"
3418 ]
3419 },
3420 "log_id": {
3421 "type": "string"
3422 },
3423 "message": {
3424 "type": "string"
3425 },
3426 "request_id": {
3427 "type": "string"
3428 }
3429 }
3430 },
3431 "AppResponseData_JobLogPageDto": {
3432 "type": "object",
3433 "required": [
3434 "code",
3435 "message",
3436 "request_id",
3437 "log_id"
3438 ],
3439 "properties": {
3440 "code": {
3441 "type": "integer",
3442 "format": "int32"
3443 },
3444 "data": {
3445 "type": "object",
3446 "required": [
3447 "items",
3448 "total",
3449 "limit",
3450 "offset"
3451 ],
3452 "properties": {
3453 "items": {
3454 "type": "array",
3455 "items": {
3456 "$ref": "#/components/schemas/JobLogDto"
3457 }
3458 },
3459 "limit": {
3460 "type": "integer",
3461 "format": "int64",
3462 "minimum": 0
3463 },
3464 "offset": {
3465 "type": "integer",
3466 "format": "int64",
3467 "minimum": 0
3468 },
3469 "total": {
3470 "type": "integer",
3471 "format": "int64"
3472 }
3473 }
3474 },
3475 "error": {
3476 "type": [
3477 "string",
3478 "null"
3479 ]
3480 },
3481 "error_code": {
3482 "type": [
3483 "string",
3484 "null"
3485 ]
3486 },
3487 "log_id": {
3488 "type": "string"
3489 },
3490 "message": {
3491 "type": "string"
3492 },
3493 "request_id": {
3494 "type": "string"
3495 }
3496 }
3497 },
3498 "AppResponseData_NoData": {
3499 "type": "object",
3500 "required": [
3501 "code",
3502 "message",
3503 "request_id",
3504 "log_id"
3505 ],
3506 "properties": {
3507 "code": {
3508 "type": "integer",
3509 "format": "int32"
3510 },
3511 "data": {
3512 "default": null
3513 },
3514 "error": {
3515 "type": [
3516 "string",
3517 "null"
3518 ]
3519 },
3520 "error_code": {
3521 "type": [
3522 "string",
3523 "null"
3524 ]
3525 },
3526 "log_id": {
3527 "type": "string"
3528 },
3529 "message": {
3530 "type": "string"
3531 },
3532 "request_id": {
3533 "type": "string"
3534 }
3535 }
3536 },
3537 "AppResponseData_Page_AdminDto": {
3538 "type": "object",
3539 "required": [
3540 "code",
3541 "message",
3542 "request_id",
3543 "log_id"
3544 ],
3545 "properties": {
3546 "code": {
3547 "type": "integer",
3548 "format": "int32"
3549 },
3550 "data": {
3551 "type": "object",
3552 "required": [
3553 "items",
3554 "total",
3555 "limit",
3556 "offset"
3557 ],
3558 "properties": {
3559 "items": {
3560 "type": "array",
3561 "items": {
3562 "type": "object",
3563 "required": [
3564 "id",
3565 "username",
3566 "display_name",
3567 "role",
3568 "is_active",
3569 "must_change_password",
3570 "created_at",
3571 "updated_at"
3572 ],
3573 "properties": {
3574 "created_at": {
3575 "type": "string",
3576 "format": "date-time"
3577 },
3578 "display_name": {
3579 "type": "string"
3580 },
3581 "id": {
3582 "type": "string",
3583 "format": "uuid"
3584 },
3585 "is_active": {
3586 "type": "boolean"
3587 },
3588 "last_login_at": {
3589 "type": [
3590 "string",
3591 "null"
3592 ],
3593 "format": "date-time"
3594 },
3595 "last_login_ip": {
3596 "type": [
3597 "string",
3598 "null"
3599 ]
3600 },
3601 "must_change_password": {
3602 "type": "boolean"
3603 },
3604 "role": {
3605 "$ref": "#/components/schemas/AdminRole"
3606 },
3607 "updated_at": {
3608 "type": "string",
3609 "format": "date-time"
3610 },
3611 "username": {
3612 "type": "string"
3613 }
3614 }
3615 }
3616 },
3617 "limit": {
3618 "type": "integer",
3619 "format": "int64",
3620 "minimum": 0
3621 },
3622 "offset": {
3623 "type": "integer",
3624 "format": "int64",
3625 "minimum": 0
3626 },
3627 "total": {
3628 "type": "integer",
3629 "format": "int64"
3630 }
3631 }
3632 },
3633 "error": {
3634 "type": [
3635 "string",
3636 "null"
3637 ]
3638 },
3639 "error_code": {
3640 "type": [
3641 "string",
3642 "null"
3643 ]
3644 },
3645 "log_id": {
3646 "type": "string"
3647 },
3648 "message": {
3649 "type": "string"
3650 },
3651 "request_id": {
3652 "type": "string"
3653 }
3654 }
3655 },
3656 "AppResponseData_Page_AdminSpaceMemberDto": {
3657 "type": "object",
3658 "required": [
3659 "code",
3660 "message",
3661 "request_id",
3662 "log_id"
3663 ],
3664 "properties": {
3665 "code": {
3666 "type": "integer",
3667 "format": "int32"
3668 },
3669 "data": {
3670 "type": "object",
3671 "required": [
3672 "items",
3673 "total",
3674 "limit",
3675 "offset"
3676 ],
3677 "properties": {
3678 "items": {
3679 "type": "array",
3680 "items": {
3681 "type": "object",
3682 "required": [
3683 "user_id",
3684 "status",
3685 "joined_at"
3686 ],
3687 "properties": {
3688 "alias_name": {
3689 "type": [
3690 "string",
3691 "null"
3692 ]
3693 },
3694 "display_name": {
3695 "type": [
3696 "string",
3697 "null"
3698 ]
3699 },
3700 "joined_at": {
3701 "type": "string",
3702 "format": "date-time"
3703 },
3704 "last_active_at": {
3705 "type": [
3706 "string",
3707 "null"
3708 ],
3709 "format": "date-time"
3710 },
3711 "status": {
3712 "type": "string"
3713 },
3714 "user_id": {
3715 "type": "string",
3716 "format": "uuid"
3717 },
3718 "username": {
3719 "type": [
3720 "string",
3721 "null"
3722 ]
3723 }
3724 }
3725 }
3726 },
3727 "limit": {
3728 "type": "integer",
3729 "format": "int64",
3730 "minimum": 0
3731 },
3732 "offset": {
3733 "type": "integer",
3734 "format": "int64",
3735 "minimum": 0
3736 },
3737 "total": {
3738 "type": "integer",
3739 "format": "int64"
3740 }
3741 }
3742 },
3743 "error": {
3744 "type": [
3745 "string",
3746 "null"
3747 ]
3748 },
3749 "error_code": {
3750 "type": [
3751 "string",
3752 "null"
3753 ]
3754 },
3755 "log_id": {
3756 "type": "string"
3757 },
3758 "message": {
3759 "type": "string"
3760 },
3761 "request_id": {
3762 "type": "string"
3763 }
3764 }
3765 },
3766 "AppResponseData_Page_AdminSpaceSummary": {
3767 "type": "object",
3768 "required": [
3769 "code",
3770 "message",
3771 "request_id",
3772 "log_id"
3773 ],
3774 "properties": {
3775 "code": {
3776 "type": "integer",
3777 "format": "int32"
3778 },
3779 "data": {
3780 "type": "object",
3781 "required": [
3782 "items",
3783 "total",
3784 "limit",
3785 "offset"
3786 ],
3787 "properties": {
3788 "items": {
3789 "type": "array",
3790 "items": {
3791 "type": "object",
3792 "required": [
3793 "id",
3794 "name",
3795 "lower_name",
3796 "owner_id",
3797 "is_private",
3798 "is_active",
3799 "nums_members",
3800 "created_at",
3801 "updated_at"
3802 ],
3803 "properties": {
3804 "created_at": {
3805 "type": "string",
3806 "format": "date-time"
3807 },
3808 "description": {
3809 "type": [
3810 "string",
3811 "null"
3812 ]
3813 },
3814 "id": {
3815 "type": "string",
3816 "format": "uuid"
3817 },
3818 "is_active": {
3819 "type": "boolean"
3820 },
3821 "is_private": {
3822 "type": "boolean"
3823 },
3824 "lower_name": {
3825 "type": "string"
3826 },
3827 "name": {
3828 "type": "string"
3829 },
3830 "nums_members": {
3831 "type": "integer",
3832 "format": "int64"
3833 },
3834 "owner_id": {
3835 "type": "string",
3836 "format": "uuid"
3837 },
3838 "owner_username": {
3839 "type": [
3840 "string",
3841 "null"
3842 ]
3843 },
3844 "updated_at": {
3845 "type": "string",
3846 "format": "date-time"
3847 }
3848 }
3849 }
3850 },
3851 "limit": {
3852 "type": "integer",
3853 "format": "int64",
3854 "minimum": 0
3855 },
3856 "offset": {
3857 "type": "integer",
3858 "format": "int64",
3859 "minimum": 0
3860 },
3861 "total": {
3862 "type": "integer",
3863 "format": "int64"
3864 }
3865 }
3866 },
3867 "error": {
3868 "type": [
3869 "string",
3870 "null"
3871 ]
3872 },
3873 "error_code": {
3874 "type": [
3875 "string",
3876 "null"
3877 ]
3878 },
3879 "log_id": {
3880 "type": "string"
3881 },
3882 "message": {
3883 "type": "string"
3884 },
3885 "request_id": {
3886 "type": "string"
3887 }
3888 }
3889 },
3890 "AppResponseData_Page_AdminUserSummary": {
3891 "type": "object",
3892 "required": [
3893 "code",
3894 "message",
3895 "request_id",
3896 "log_id"
3897 ],
3898 "properties": {
3899 "code": {
3900 "type": "integer",
3901 "format": "int32"
3902 },
3903 "data": {
3904 "type": "object",
3905 "required": [
3906 "items",
3907 "total",
3908 "limit",
3909 "offset"
3910 ],
3911 "properties": {
3912 "items": {
3913 "type": "array",
3914 "items": {
3915 "type": "object",
3916 "required": [
3917 "uid",
3918 "username",
3919 "display_name",
3920 "avatar_url",
3921 "is_active",
3922 "is_restricted",
3923 "prohibit_login",
3924 "bot",
3925 "system",
3926 "suspended",
3927 "created_at"
3928 ],
3929 "properties": {
3930 "avatar_url": {
3931 "type": "string"
3932 },
3933 "bot": {
3934 "type": "boolean"
3935 },
3936 "created_at": {
3937 "type": "string",
3938 "format": "date-time"
3939 },
3940 "display_name": {
3941 "type": "string"
3942 },
3943 "is_active": {
3944 "type": "boolean"
3945 },
3946 "is_restricted": {
3947 "type": "boolean"
3948 },
3949 "last_login_at": {
3950 "type": [
3951 "string",
3952 "null"
3953 ],
3954 "format": "date-time"
3955 },
3956 "prohibit_login": {
3957 "type": "boolean"
3958 },
3959 "suspended": {
3960 "type": "boolean"
3961 },
3962 "system": {
3963 "type": "boolean"
3964 },
3965 "uid": {
3966 "type": "string",
3967 "format": "uuid"
3968 },
3969 "username": {
3970 "type": "string"
3971 }
3972 }
3973 }
3974 },
3975 "limit": {
3976 "type": "integer",
3977 "format": "int64",
3978 "minimum": 0
3979 },
3980 "offset": {
3981 "type": "integer",
3982 "format": "int64",
3983 "minimum": 0
3984 },
3985 "total": {
3986 "type": "integer",
3987 "format": "int64"
3988 }
3989 }
3990 },
3991 "error": {
3992 "type": [
3993 "string",
3994 "null"
3995 ]
3996 },
3997 "error_code": {
3998 "type": [
3999 "string",
4000 "null"
4001 ]
4002 },
4003 "log_id": {
4004 "type": "string"
4005 },
4006 "message": {
4007 "type": "string"
4008 },
4009 "request_id": {
4010 "type": "string"
4011 }
4012 }
4013 },
4014 "AppResponseData_QueueActionResp": {
4015 "type": "object",
4016 "required": [
4017 "code",
4018 "message",
4019 "request_id",
4020 "log_id"
4021 ],
4022 "properties": {
4023 "code": {
4024 "type": "integer",
4025 "format": "int32"
4026 },
4027 "data": {
4028 "type": "object",
4029 "required": [
4030 "queue",
4031 "affected"
4032 ],
4033 "properties": {
4034 "affected": {
4035 "type": "integer",
4036 "format": "int64",
4037 "minimum": 0
4038 },
4039 "queue": {
4040 "type": "string"
4041 }
4042 }
4043 },
4044 "error": {
4045 "type": [
4046 "string",
4047 "null"
4048 ]
4049 },
4050 "error_code": {
4051 "type": [
4052 "string",
4053 "null"
4054 ]
4055 },
4056 "log_id": {
4057 "type": "string"
4058 },
4059 "message": {
4060 "type": "string"
4061 },
4062 "request_id": {
4063 "type": "string"
4064 }
4065 }
4066 },
4067 "AppResponseData_RemoveMemberResp": {
4068 "type": "object",
4069 "required": [
4070 "code",
4071 "message",
4072 "request_id",
4073 "log_id"
4074 ],
4075 "properties": {
4076 "code": {
4077 "type": "integer",
4078 "format": "int32"
4079 },
4080 "data": {
4081 "type": "object",
4082 "required": [
4083 "space_id",
4084 "user_id",
4085 "removed"
4086 ],
4087 "properties": {
4088 "removed": {
4089 "type": "boolean"
4090 },
4091 "space_id": {
4092 "type": "string",
4093 "format": "uuid"
4094 },
4095 "user_id": {
4096 "type": "string",
4097 "format": "uuid"
4098 }
4099 }
4100 },
4101 "error": {
4102 "type": [
4103 "string",
4104 "null"
4105 ]
4106 },
4107 "error_code": {
4108 "type": [
4109 "string",
4110 "null"
4111 ]
4112 },
4113 "log_id": {
4114 "type": "string"
4115 },
4116 "message": {
4117 "type": "string"
4118 },
4119 "request_id": {
4120 "type": "string"
4121 }
4122 }
4123 },
4124 "AppResponseData_RevokeSessionsResp": {
4125 "type": "object",
4126 "required": [
4127 "code",
4128 "message",
4129 "request_id",
4130 "log_id"
4131 ],
4132 "properties": {
4133 "code": {
4134 "type": "integer",
4135 "format": "int32"
4136 },
4137 "data": {
4138 "type": "object",
4139 "required": [
4140 "revoked"
4141 ],
4142 "properties": {
4143 "revoked": {
4144 "type": "integer",
4145 "format": "int64",
4146 "minimum": 0
4147 }
4148 }
4149 },
4150 "error": {
4151 "type": [
4152 "string",
4153 "null"
4154 ]
4155 },
4156 "error_code": {
4157 "type": [
4158 "string",
4159 "null"
4160 ]
4161 },
4162 "log_id": {
4163 "type": "string"
4164 },
4165 "message": {
4166 "type": "string"
4167 },
4168 "request_id": {
4169 "type": "string"
4170 }
4171 }
4172 },
4173 "AppResponseData_StorageObjectPageDto": {
4174 "type": "object",
4175 "required": [
4176 "code",
4177 "message",
4178 "request_id",
4179 "log_id"
4180 ],
4181 "properties": {
4182 "code": {
4183 "type": "integer",
4184 "format": "int32"
4185 },
4186 "data": {
4187 "type": "object",
4188 "required": [
4189 "prefix",
4190 "items"
4191 ],
4192 "properties": {
4193 "items": {
4194 "type": "array",
4195 "items": {
4196 "$ref": "#/components/schemas/StorageObjectDto"
4197 }
4198 },
4199 "next_token": {
4200 "type": [
4201 "string",
4202 "null"
4203 ]
4204 },
4205 "prefix": {
4206 "type": "string"
4207 }
4208 }
4209 },
4210 "error": {
4211 "type": [
4212 "string",
4213 "null"
4214 ]
4215 },
4216 "error_code": {
4217 "type": [
4218 "string",
4219 "null"
4220 ]
4221 },
4222 "log_id": {
4223 "type": "string"
4224 },
4225 "message": {
4226 "type": "string"
4227 },
4228 "request_id": {
4229 "type": "string"
4230 }
4231 }
4232 },
4233 "AppResponseData_StorageOverviewDto": {
4234 "type": "object",
4235 "required": [
4236 "code",
4237 "message",
4238 "request_id",
4239 "log_id"
4240 ],
4241 "properties": {
4242 "code": {
4243 "type": "integer",
4244 "format": "int32"
4245 },
4246 "data": {
4247 "type": "object",
4248 "required": [
4249 "bucket",
4250 "total_bytes",
4251 "total_objects",
4252 "categories",
4253 "generated_at",
4254 "cached"
4255 ],
4256 "properties": {
4257 "bucket": {
4258 "type": "string"
4259 },
4260 "cached": {
4261 "type": "boolean"
4262 },
4263 "categories": {
4264 "type": "array",
4265 "items": {
4266 "$ref": "#/components/schemas/StorageCategoryDto"
4267 }
4268 },
4269 "generated_at": {
4270 "type": "string",
4271 "format": "date-time"
4272 },
4273 "total_bytes": {
4274 "type": "integer",
4275 "format": "int64",
4276 "minimum": 0
4277 },
4278 "total_objects": {
4279 "type": "integer",
4280 "format": "int64",
4281 "minimum": 0
4282 }
4283 }
4284 },
4285 "error": {
4286 "type": [
4287 "string",
4288 "null"
4289 ]
4290 },
4291 "error_code": {
4292 "type": [
4293 "string",
4294 "null"
4295 ]
4296 },
4297 "log_id": {
4298 "type": "string"
4299 },
4300 "message": {
4301 "type": "string"
4302 },
4303 "request_id": {
4304 "type": "string"
4305 }
4306 }
4307 },
4308 "AppResponseData_SystemStatusDto": {
4309 "type": "object",
4310 "required": [
4311 "code",
4312 "message",
4313 "request_id",
4314 "log_id"
4315 ],
4316 "properties": {
4317 "code": {
4318 "type": "integer",
4319 "format": "int32"
4320 },
4321 "data": {
4322 "type": "object",
4323 "required": [
4324 "process",
4325 "dependencies",
4326 "features",
4327 "counts",
4328 "queues",
4329 "recent_failures",
4330 "generated_at"
4331 ],
4332 "properties": {
4333 "counts": {
4334 "$ref": "#/components/schemas/CountsDto"
4335 },
4336 "dependencies": {
4337 "$ref": "#/components/schemas/DependenciesDto"
4338 },
4339 "features": {
4340 "$ref": "#/components/schemas/FeaturesDto"
4341 },
4342 "generated_at": {
4343 "type": "string",
4344 "format": "date-time"
4345 },
4346 "process": {
4347 "$ref": "#/components/schemas/ProcessInfoDto"
4348 },
4349 "queues": {
4350 "type": "array",
4351 "items": {
4352 "$ref": "#/components/schemas/QueueSnapshotDto"
4353 }
4354 },
4355 "recent_failures": {
4356 "type": "array",
4357 "items": {
4358 "$ref": "#/components/schemas/JobLogDto"
4359 }
4360 }
4361 }
4362 },
4363 "error": {
4364 "type": [
4365 "string",
4366 "null"
4367 ]
4368 },
4369 "error_code": {
4370 "type": [
4371 "string",
4372 "null"
4373 ]
4374 },
4375 "log_id": {
4376 "type": "string"
4377 },
4378 "message": {
4379 "type": "string"
4380 },
4381 "request_id": {
4382 "type": "string"
4383 }
4384 }
4385 },
4386 "AppResponseData_TaskActionResp": {
4387 "type": "object",
4388 "required": [
4389 "code",
4390 "message",
4391 "request_id",
4392 "log_id"
4393 ],
4394 "properties": {
4395 "code": {
4396 "type": "integer",
4397 "format": "int32"
4398 },
4399 "data": {
4400 "type": "object",
4401 "required": [
4402 "source",
4403 "id",
4404 "state"
4405 ],
4406 "properties": {
4407 "id": {
4408 "type": "string",
4409 "format": "uuid"
4410 },
4411 "source": {
4412 "type": "string"
4413 },
4414 "state": {
4415 "type": "string"
4416 }
4417 }
4418 },
4419 "error": {
4420 "type": [
4421 "string",
4422 "null"
4423 ]
4424 },
4425 "error_code": {
4426 "type": [
4427 "string",
4428 "null"
4429 ]
4430 },
4431 "log_id": {
4432 "type": "string"
4433 },
4434 "message": {
4435 "type": "string"
4436 },
4437 "request_id": {
4438 "type": "string"
4439 }
4440 }
4441 },
4442 "AppResponseData_TaskDetailDto": {
4443 "type": "object",
4444 "required": [
4445 "code",
4446 "message",
4447 "request_id",
4448 "log_id"
4449 ],
4450 "properties": {
4451 "code": {
4452 "type": "integer",
4453 "format": "int32"
4454 },
4455 "data": {
4456 "type": "object",
4457 "required": [
4458 "task",
4459 "detail"
4460 ],
4461 "properties": {
4462 "detail": {},
4463 "task": {
4464 "$ref": "#/components/schemas/TaskDto"
4465 }
4466 }
4467 },
4468 "error": {
4469 "type": [
4470 "string",
4471 "null"
4472 ]
4473 },
4474 "error_code": {
4475 "type": [
4476 "string",
4477 "null"
4478 ]
4479 },
4480 "log_id": {
4481 "type": "string"
4482 },
4483 "message": {
4484 "type": "string"
4485 },
4486 "request_id": {
4487 "type": "string"
4488 }
4489 }
4490 },
4491 "AppResponseData_TaskPageDto": {
4492 "type": "object",
4493 "required": [
4494 "code",
4495 "message",
4496 "request_id",
4497 "log_id"
4498 ],
4499 "properties": {
4500 "code": {
4501 "type": "integer",
4502 "format": "int32"
4503 },
4504 "data": {
4505 "type": "object",
4506 "required": [
4507 "items",
4508 "total",
4509 "limit",
4510 "offset"
4511 ],
4512 "properties": {
4513 "items": {
4514 "type": "array",
4515 "items": {
4516 "$ref": "#/components/schemas/TaskDto"
4517 }
4518 },
4519 "limit": {
4520 "type": "integer",
4521 "format": "int64",
4522 "minimum": 0
4523 },
4524 "offset": {
4525 "type": "integer",
4526 "format": "int64",
4527 "minimum": 0
4528 },
4529 "total": {
4530 "type": "integer",
4531 "format": "int64"
4532 }
4533 }
4534 },
4535 "error": {
4536 "type": [
4537 "string",
4538 "null"
4539 ]
4540 },
4541 "error_code": {
4542 "type": [
4543 "string",
4544 "null"
4545 ]
4546 },
4547 "log_id": {
4548 "type": "string"
4549 },
4550 "message": {
4551 "type": "string"
4552 },
4553 "request_id": {
4554 "type": "string"
4555 }
4556 }
4557 },
4558 "AppResponseData_Vec_QueueSnapshotDto": {
4559 "type": "object",
4560 "required": [
4561 "code",
4562 "message",
4563 "request_id",
4564 "log_id"
4565 ],
4566 "properties": {
4567 "code": {
4568 "type": "integer",
4569 "format": "int32"
4570 },
4571 "data": {
4572 "type": "array",
4573 "items": {
4574 "type": "object",
4575 "required": [
4576 "name",
4577 "kind",
4578 "enabled",
4579 "queued",
4580 "running",
4581 "failed",
4582 "succeeded"
4583 ],
4584 "properties": {
4585 "depth": {
4586 "type": [
4587 "integer",
4588 "null"
4589 ],
4590 "format": "int64"
4591 },
4592 "enabled": {
4593 "type": "boolean"
4594 },
4595 "failed": {
4596 "type": "integer",
4597 "format": "int64"
4598 },
4599 "kind": {
4600 "type": "string"
4601 },
4602 "name": {
4603 "type": "string"
4604 },
4605 "note": {
4606 "type": [
4607 "string",
4608 "null"
4609 ]
4610 },
4611 "queued": {
4612 "type": "integer",
4613 "format": "int64"
4614 },
4615 "running": {
4616 "type": "integer",
4617 "format": "int64"
4618 },
4619 "succeeded": {
4620 "type": "integer",
4621 "format": "int64"
4622 }
4623 }
4624 }
4625 },
4626 "error": {
4627 "type": [
4628 "string",
4629 "null"
4630 ]
4631 },
4632 "error_code": {
4633 "type": [
4634 "string",
4635 "null"
4636 ]
4637 },
4638 "log_id": {
4639 "type": "string"
4640 },
4641 "message": {
4642 "type": "string"
4643 },
4644 "request_id": {
4645 "type": "string"
4646 }
4647 }
4648 },
4649 "AuditLogDto": {
4650 "type": "object",
4651 "required": [
4652 "id",
4653 "admin_username",
4654 "action",
4655 "detail",
4656 "created_at"
4657 ],
4658 "properties": {
4659 "action": {
4660 "type": "string"
4661 },
4662 "admin_id": {
4663 "type": [
4664 "string",
4665 "null"
4666 ],
4667 "format": "uuid"
4668 },
4669 "admin_username": {
4670 "type": "string"
4671 },
4672 "created_at": {
4673 "type": "string",
4674 "format": "date-time"
4675 },
4676 "detail": {},
4677 "id": {
4678 "type": "string",
4679 "format": "uuid"
4680 },
4681 "ip": {
4682 "type": [
4683 "string",
4684 "null"
4685 ]
4686 },
4687 "request_id": {
4688 "type": [
4689 "string",
4690 "null"
4691 ]
4692 },
4693 "target_id": {
4694 "type": [
4695 "string",
4696 "null"
4697 ]
4698 },
4699 "target_type": {
4700 "type": [
4701 "string",
4702 "null"
4703 ]
4704 },
4705 "user_agent": {
4706 "type": [
4707 "string",
4708 "null"
4709 ]
4710 }
4711 }
4712 },
4713 "AuditLogPageDto": {
4714 "type": "object",
4715 "required": [
4716 "items",
4717 "total",
4718 "limit",
4719 "offset"
4720 ],
4721 "properties": {
4722 "items": {
4723 "type": "array",
4724 "items": {
4725 "$ref": "#/components/schemas/AuditLogDto"
4726 }
4727 },
4728 "limit": {
4729 "type": "integer",
4730 "format": "int64",
4731 "minimum": 0
4732 },
4733 "offset": {
4734 "type": "integer",
4735 "format": "int64",
4736 "minimum": 0
4737 },
4738 "total": {
4739 "type": "integer",
4740 "format": "int64"
4741 }
4742 }
4743 },
4744 "ChangePasswordCmd": {
4745 "type": "object",
4746 "required": [
4747 "current_password",
4748 "new_password"
4749 ],
4750 "properties": {
4751 "current_password": {
4752 "type": "string"
4753 },
4754 "new_password": {
4755 "type": "string"
4756 }
4757 }
4758 },
4759 "CountsDto": {
4760 "type": "object",
4761 "required": [
4762 "users",
4763 "active_users",
4764 "spaces",
4765 "active_spaces",
4766 "repos",
4767 "rooms",
4768 "issues",
4769 "messages",
4770 "admin_users"
4771 ],
4772 "properties": {
4773 "active_spaces": {
4774 "type": "integer",
4775 "format": "int64"
4776 },
4777 "active_users": {
4778 "type": "integer",
4779 "format": "int64"
4780 },
4781 "admin_users": {
4782 "type": "integer",
4783 "format": "int64"
4784 },
4785 "issues": {
4786 "type": "integer",
4787 "format": "int64"
4788 },
4789 "messages": {
4790 "type": "integer",
4791 "format": "int64"
4792 },
4793 "repos": {
4794 "type": "integer",
4795 "format": "int64"
4796 },
4797 "rooms": {
4798 "type": "integer",
4799 "format": "int64"
4800 },
4801 "spaces": {
4802 "type": "integer",
4803 "format": "int64"
4804 },
4805 "users": {
4806 "type": "integer",
4807 "format": "int64"
4808 }
4809 }
4810 },
4811 "CreateAdminCmd": {
4812 "type": "object",
4813 "required": [
4814 "username",
4815 "password",
4816 "role"
4817 ],
4818 "properties": {
4819 "display_name": {
4820 "type": [
4821 "string",
4822 "null"
4823 ]
4824 },
4825 "password": {
4826 "type": "string"
4827 },
4828 "role": {
4829 "$ref": "#/components/schemas/AdminRole"
4830 },
4831 "username": {
4832 "type": "string"
4833 }
4834 }
4835 },
4836 "DeleteObjectResp": {
4837 "type": "object",
4838 "required": [
4839 "key",
4840 "deleted"
4841 ],
4842 "properties": {
4843 "deleted": {
4844 "type": "boolean"
4845 },
4846 "key": {
4847 "type": "string"
4848 }
4849 }
4850 },
4851 "DeleteUserResp": {
4852 "type": "object",
4853 "required": [
4854 "user_id",
4855 "anonymized"
4856 ],
4857 "properties": {
4858 "anonymized": {
4859 "type": "boolean"
4860 },
4861 "user_id": {
4862 "type": "string",
4863 "format": "uuid"
4864 }
4865 }
4866 },
4867 "DependenciesDto": {
4868 "type": "object",
4869 "required": [
4870 "database",
4871 "redis",
4872 "storage",
4873 "search"
4874 ],
4875 "properties": {
4876 "database": {
4877 "$ref": "#/components/schemas/DependencyDto"
4878 },
4879 "redis": {
4880 "$ref": "#/components/schemas/DependencyDto"
4881 },
4882 "search": {
4883 "$ref": "#/components/schemas/DependencyDto"
4884 },
4885 "storage": {
4886 "$ref": "#/components/schemas/DependencyDto"
4887 }
4888 }
4889 },
4890 "DependencyDto": {
4891 "type": "object",
4892 "required": [
4893 "ok"
4894 ],
4895 "properties": {
4896 "detail": {
4897 "type": [
4898 "string",
4899 "null"
4900 ]
4901 },
4902 "latency_ms": {
4903 "type": [
4904 "integer",
4905 "null"
4906 ],
4907 "format": "int64",
4908 "minimum": 0
4909 },
4910 "ok": {
4911 "type": "boolean"
4912 }
4913 }
4914 },
4915 "DownloadUrlDto": {
4916 "type": "object",
4917 "required": [
4918 "key",
4919 "url",
4920 "expires_in_secs"
4921 ],
4922 "properties": {
4923 "expires_in_secs": {
4924 "type": "integer",
4925 "format": "int32",
4926 "minimum": 0
4927 },
4928 "key": {
4929 "type": "string"
4930 },
4931 "url": {
4932 "type": "string"
4933 }
4934 }
4935 },
4936 "FeaturesDto": {
4937 "type": "object",
4938 "required": [
4939 "search_enabled",
4940 "import_enabled",
4941 "email_enabled",
4942 "email_queue_enabled"
4943 ],
4944 "properties": {
4945 "email_enabled": {
4946 "type": "boolean"
4947 },
4948 "email_queue_enabled": {
4949 "type": "boolean"
4950 },
4951 "import_enabled": {
4952 "type": "boolean"
4953 },
4954 "search_enabled": {
4955 "type": "boolean"
4956 }
4957 }
4958 },
4959 "JobLogDto": {
4960 "type": "object",
4961 "required": [
4962 "source",
4963 "id",
4964 "state",
4965 "created_at"
4966 ],
4967 "properties": {
4968 "created_at": {
4969 "type": "string",
4970 "format": "date-time"
4971 },
4972 "error": {
4973 "type": [
4974 "string",
4975 "null"
4976 ]
4977 },
4978 "id": {
4979 "type": "string",
4980 "format": "uuid"
4981 },
4982 "source": {
4983 "type": "string"
4984 },
4985 "state": {
4986 "type": "string"
4987 },
4988 "updated_at": {
4989 "type": [
4990 "string",
4991 "null"
4992 ],
4993 "format": "date-time"
4994 }
4995 }
4996 },
4997 "JobLogPageDto": {
4998 "type": "object",
4999 "required": [
5000 "items",
5001 "total",
5002 "limit",
5003 "offset"
5004 ],
5005 "properties": {
5006 "items": {
5007 "type": "array",
5008 "items": {
5009 "$ref": "#/components/schemas/JobLogDto"
5010 }
5011 },
5012 "limit": {
5013 "type": "integer",
5014 "format": "int64",
5015 "minimum": 0
5016 },
5017 "offset": {
5018 "type": "integer",
5019 "format": "int64",
5020 "minimum": 0
5021 },
5022 "total": {
5023 "type": "integer",
5024 "format": "int64"
5025 }
5026 }
5027 },
5028 "NoData": {
5029 "default": null
5030 },
5031 "PageQuery": {
5032 "type": "object",
5033 "properties": {
5034 "limit": {
5035 "type": [
5036 "integer",
5037 "null"
5038 ],
5039 "format": "int64",
5040 "minimum": 0
5041 },
5042 "offset": {
5043 "type": [
5044 "integer",
5045 "null"
5046 ],
5047 "format": "int64",
5048 "minimum": 0
5049 },
5050 "sort": {
5051 "type": [
5052 "string",
5053 "null"
5054 ]
5055 }
5056 }
5057 },
5058 "ProcessInfoDto": {
5059 "type": "object",
5060 "required": [
5061 "version",
5062 "uptime_secs"
5063 ],
5064 "properties": {
5065 "uptime_secs": {
5066 "type": "integer",
5067 "format": "int64",
5068 "minimum": 0
5069 },
5070 "version": {
5071 "type": "string"
5072 }
5073 }
5074 },
5075 "QueueActionResp": {
5076 "type": "object",
5077 "required": [
5078 "queue",
5079 "affected"
5080 ],
5081 "properties": {
5082 "affected": {
5083 "type": "integer",
5084 "format": "int64",
5085 "minimum": 0
5086 },
5087 "queue": {
5088 "type": "string"
5089 }
5090 }
5091 },
5092 "QueueSnapshotDto": {
5093 "type": "object",
5094 "required": [
5095 "name",
5096 "kind",
5097 "enabled",
5098 "queued",
5099 "running",
5100 "failed",
5101 "succeeded"
5102 ],
5103 "properties": {
5104 "depth": {
5105 "type": [
5106 "integer",
5107 "null"
5108 ],
5109 "format": "int64"
5110 },
5111 "enabled": {
5112 "type": "boolean"
5113 },
5114 "failed": {
5115 "type": "integer",
5116 "format": "int64"
5117 },
5118 "kind": {
5119 "type": "string"
5120 },
5121 "name": {
5122 "type": "string"
5123 },
5124 "note": {
5125 "type": [
5126 "string",
5127 "null"
5128 ]
5129 },
5130 "queued": {
5131 "type": "integer",
5132 "format": "int64"
5133 },
5134 "running": {
5135 "type": "integer",
5136 "format": "int64"
5137 },
5138 "succeeded": {
5139 "type": "integer",
5140 "format": "int64"
5141 }
5142 }
5143 },
5144 "RemoveMemberResp": {
5145 "type": "object",
5146 "required": [
5147 "space_id",
5148 "user_id",
5149 "removed"
5150 ],
5151 "properties": {
5152 "removed": {
5153 "type": "boolean"
5154 },
5155 "space_id": {
5156 "type": "string",
5157 "format": "uuid"
5158 },
5159 "user_id": {
5160 "type": "string",
5161 "format": "uuid"
5162 }
5163 }
5164 },
5165 "ResetAdminPasswordCmd": {
5166 "type": "object",
5167 "required": [
5168 "new_password"
5169 ],
5170 "properties": {
5171 "new_password": {
5172 "type": "string"
5173 }
5174 }
5175 },
5176 "ResetUserPasswordCmd": {
5177 "type": "object",
5178 "required": [
5179 "new_password"
5180 ],
5181 "properties": {
5182 "new_password": {
5183 "type": "string"
5184 }
5185 }
5186 },
5187 "RevokeSessionsResp": {
5188 "type": "object",
5189 "required": [
5190 "revoked"
5191 ],
5192 "properties": {
5193 "revoked": {
5194 "type": "integer",
5195 "format": "int64",
5196 "minimum": 0
5197 }
5198 }
5199 },
5200 "StorageCategoryDto": {
5201 "type": "object",
5202 "required": [
5203 "name",
5204 "prefix",
5205 "bytes",
5206 "objects"
5207 ],
5208 "properties": {
5209 "bytes": {
5210 "type": "integer",
5211 "format": "int64",
5212 "minimum": 0
5213 },
5214 "name": {
5215 "type": "string"
5216 },
5217 "objects": {
5218 "type": "integer",
5219 "format": "int64",
5220 "minimum": 0
5221 },
5222 "prefix": {
5223 "type": "string"
5224 }
5225 }
5226 },
5227 "StorageObjectDto": {
5228 "type": "object",
5229 "required": [
5230 "key",
5231 "size"
5232 ],
5233 "properties": {
5234 "etag": {
5235 "type": [
5236 "string",
5237 "null"
5238 ]
5239 },
5240 "key": {
5241 "type": "string"
5242 },
5243 "last_modified": {},
5244 "size": {
5245 "type": "integer",
5246 "format": "int64",
5247 "minimum": 0
5248 }
5249 }
5250 },
5251 "StorageObjectPageDto": {
5252 "type": "object",
5253 "required": [
5254 "prefix",
5255 "items"
5256 ],
5257 "properties": {
5258 "items": {
5259 "type": "array",
5260 "items": {
5261 "$ref": "#/components/schemas/StorageObjectDto"
5262 }
5263 },
5264 "next_token": {
5265 "type": [
5266 "string",
5267 "null"
5268 ]
5269 },
5270 "prefix": {
5271 "type": "string"
5272 }
5273 }
5274 },
5275 "StorageOverviewDto": {
5276 "type": "object",
5277 "required": [
5278 "bucket",
5279 "total_bytes",
5280 "total_objects",
5281 "categories",
5282 "generated_at",
5283 "cached"
5284 ],
5285 "properties": {
5286 "bucket": {
5287 "type": "string"
5288 },
5289 "cached": {
5290 "type": "boolean"
5291 },
5292 "categories": {
5293 "type": "array",
5294 "items": {
5295 "$ref": "#/components/schemas/StorageCategoryDto"
5296 }
5297 },
5298 "generated_at": {
5299 "type": "string",
5300 "format": "date-time"
5301 },
5302 "total_bytes": {
5303 "type": "integer",
5304 "format": "int64",
5305 "minimum": 0
5306 },
5307 "total_objects": {
5308 "type": "integer",
5309 "format": "int64",
5310 "minimum": 0
5311 }
5312 }
5313 },
5314 "SuspendUserCmd": {
5315 "type": "object",
5316 "required": [
5317 "suspension_type"
5318 ],
5319 "properties": {
5320 "ends_at": {
5321 "type": [
5322 "string",
5323 "null"
5324 ],
5325 "format": "date-time"
5326 },
5327 "reason": {
5328 "type": [
5329 "string",
5330 "null"
5331 ]
5332 },
5333 "suspension_type": {
5334 "$ref": "#/components/schemas/SuspensionType"
5335 }
5336 }
5337 },
5338 "SuspensionType": {
5339 "type": "string",
5340 "enum": [
5341 "temporary",
5342 "permanent"
5343 ]
5344 },
5345 "SystemStatusDto": {
5346 "type": "object",
5347 "required": [
5348 "process",
5349 "dependencies",
5350 "features",
5351 "counts",
5352 "queues",
5353 "recent_failures",
5354 "generated_at"
5355 ],
5356 "properties": {
5357 "counts": {
5358 "$ref": "#/components/schemas/CountsDto"
5359 },
5360 "dependencies": {
5361 "$ref": "#/components/schemas/DependenciesDto"
5362 },
5363 "features": {
5364 "$ref": "#/components/schemas/FeaturesDto"
5365 },
5366 "generated_at": {
5367 "type": "string",
5368 "format": "date-time"
5369 },
5370 "process": {
5371 "$ref": "#/components/schemas/ProcessInfoDto"
5372 },
5373 "queues": {
5374 "type": "array",
5375 "items": {
5376 "$ref": "#/components/schemas/QueueSnapshotDto"
5377 }
5378 },
5379 "recent_failures": {
5380 "type": "array",
5381 "items": {
5382 "$ref": "#/components/schemas/JobLogDto"
5383 }
5384 }
5385 }
5386 },
5387 "TaskActionResp": {
5388 "type": "object",
5389 "required": [
5390 "source",
5391 "id",
5392 "state"
5393 ],
5394 "properties": {
5395 "id": {
5396 "type": "string",
5397 "format": "uuid"
5398 },
5399 "source": {
5400 "type": "string"
5401 },
5402 "state": {
5403 "type": "string"
5404 }
5405 }
5406 },
5407 "TaskDetailDto": {
5408 "type": "object",
5409 "required": [
5410 "task",
5411 "detail"
5412 ],
5413 "properties": {
5414 "detail": {},
5415 "task": {
5416 "$ref": "#/components/schemas/TaskDto"
5417 }
5418 }
5419 },
5420 "TaskDto": {
5421 "type": "object",
5422 "required": [
5423 "id",
5424 "source",
5425 "title",
5426 "state",
5427 "created_at"
5428 ],
5429 "properties": {
5430 "created_at": {
5431 "type": "string",
5432 "format": "date-time"
5433 },
5434 "finished_at": {
5435 "type": [
5436 "string",
5437 "null"
5438 ],
5439 "format": "date-time"
5440 },
5441 "id": {
5442 "type": "string",
5443 "format": "uuid"
5444 },
5445 "last_error": {
5446 "type": [
5447 "string",
5448 "null"
5449 ]
5450 },
5451 "source": {
5452 "type": "string"
5453 },
5454 "state": {
5455 "type": "string"
5456 },
5457 "title": {
5458 "type": "string"
5459 },
5460 "updated_at": {
5461 "type": [
5462 "string",
5463 "null"
5464 ],
5465 "format": "date-time"
5466 }
5467 }
5468 },
5469 "TaskPageDto": {
5470 "type": "object",
5471 "required": [
5472 "items",
5473 "total",
5474 "limit",
5475 "offset"
5476 ],
5477 "properties": {
5478 "items": {
5479 "type": "array",
5480 "items": {
5481 "$ref": "#/components/schemas/TaskDto"
5482 }
5483 },
5484 "limit": {
5485 "type": "integer",
5486 "format": "int64",
5487 "minimum": 0
5488 },
5489 "offset": {
5490 "type": "integer",
5491 "format": "int64",
5492 "minimum": 0
5493 },
5494 "total": {
5495 "type": "integer",
5496 "format": "int64"
5497 }
5498 }
5499 },
5500 "TransferSpaceCmd": {
5501 "type": "object",
5502 "required": [
5503 "new_owner_id"
5504 ],
5505 "properties": {
5506 "new_owner_id": {
5507 "type": "string",
5508 "format": "uuid"
5509 }
5510 }
5511 },
5512 "UpdateAdminCmd": {
5513 "type": "object",
5514 "properties": {
5515 "display_name": {
5516 "type": [
5517 "string",
5518 "null"
5519 ]
5520 },
5521 "is_active": {
5522 "type": [
5523 "boolean",
5524 "null"
5525 ]
5526 },
5527 "role": {
5528 "oneOf": [
5529 {
5530 "type": "null"
5531 },
5532 {
5533 "$ref": "#/components/schemas/AdminRole"
5534 }
5535 ]
5536 }
5537 }
5538 },
5539 "UpdateSpaceCmd": {
5540 "type": "object",
5541 "properties": {
5542 "description": {
5543 "type": [
5544 "string",
5545 "null"
5546 ]
5547 },
5548 "is_active": {
5549 "type": [
5550 "boolean",
5551 "null"
5552 ]
5553 },
5554 "is_private": {
5555 "type": [
5556 "boolean",
5557 "null"
5558 ]
5559 },
5560 "name": {
5561 "type": [
5562 "string",
5563 "null"
5564 ]
5565 }
5566 }
5567 },
5568 "UpdateUserCmd": {
5569 "type": "object",
5570 "properties": {
5571 "display_name": {
5572 "type": [
5573 "string",
5574 "null"
5575 ]
5576 },
5577 "is_active": {
5578 "type": [
5579 "boolean",
5580 "null"
5581 ]
5582 },
5583 "is_restricted": {
5584 "type": [
5585 "boolean",
5586 "null"
5587 ]
5588 },
5589 "prohibit_login": {
5590 "type": [
5591 "boolean",
5592 "null"
5593 ]
5594 }
5595 }
5596 }
5597 },
5598 "securitySchemes": {
5599 "admin_session_cookie": {
5600 "type": "apiKey",
5601 "in": "cookie",
5602 "name": "gitdata_admin_session"
5603 }
5604 }
5605 },
5606 "security": [
5607 {
5608 "admin_session_cookie": []
5609 }
5610 ]
5611}
No newline at end of file
Added admin/package.json View admin/package.json
@@ -0,0 +1,27 @@
1{
2 "name": "gitdata-admin",
3 "private": true,
4 "version": "0.0.0",
5 "type": "module",
6 "scripts": {
7 "dev": "vite",
8 "build": "tsc -b && vite build",
9 "preview": "vite preview"
10 },
11 "dependencies": {
12 "@tailwindcss/vite": "^4.3.3",
13 "lucide-react": "^1.39.0",
14 "react": "^19.2.8",
15 "react-dom": "^19.2.8",
16 "react-router-dom": "^7.18.3",
17 "tailwindcss": "^4.3.3"
18 },
19 "devDependencies": {
20 "@types/node": "^24.13.3",
21 "@types/react": "^19.2.18",
22 "@types/react-dom": "^19.2.4",
23 "@vitejs/plugin-react": "^6.1.0",
24 "typescript": "~6.0.2",
25 "vite": "^8.2.2"
26 }
27}
No newline at end of file
Added admin/tsconfig.app.json View admin/tsconfig.app.json
@@ -0,0 +1,30 @@
1{
2 "compilerOptions": {
3 "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 "target": "es2023",
5 "lib": ["ES2023", "DOM"],
6 "module": "esnext",
7 "types": ["vite/client"],
8 "allowArbitraryExtensions": true,
9 "skipLibCheck": true,
10
11 "moduleResolution": "bundler",
12 "allowImportingTsExtensions": true,
13 "verbatimModuleSyntax": true,
14 "moduleDetection": "force",
15 "noEmit": true,
16 "jsx": "react-jsx",
17
18 "paths": {
19 "@/app/*": ["./src/app/*"],
20 "@/pages/*": ["./src/pages/*"],
21 "@/shared/*": ["./src/shared/*"]
22 },
23
24 "noUnusedLocals": true,
25 "noUnusedParameters": true,
26 "erasableSyntaxOnly": true,
27 "noFallthroughCasesInSwitch": true
28 },
29 "include": ["src"]
30}
No newline at end of file
Added admin/tsconfig.json View admin/tsconfig.json
@@ -0,0 +1,7 @@
1{
2 "files": [],
3 "references": [
4 { "path": "./tsconfig.app.json" },
5 { "path": "./tsconfig.node.json" }
6 ]
7}
No newline at end of file
Added admin/tsconfig.node.json View admin/tsconfig.node.json
@@ -0,0 +1,21 @@
1{
2 "compilerOptions": {
3 "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 "target": "es2023",
5 "lib": ["ES2023"],
6 "types": ["node"],
7 "skipLibCheck": true,
8
9 "module": "nodenext",
10 "allowImportingTsExtensions": true,
11 "verbatimModuleSyntax": true,
12 "moduleDetection": "force",
13 "noEmit": true,
14
15 "noUnusedLocals": true,
16 "noUnusedParameters": true,
17 "erasableSyntaxOnly": true,
18 "noFallthroughCasesInSwitch": true
19 },
20 "include": ["vite.config.ts"]
21}
No newline at end of file
Added admin/vite.config.ts View admin/vite.config.ts
@@ -0,0 +1,27 @@
1import react from '@vitejs/plugin-react'
2import { fileURLToPath } from 'node:url'
3import { defineConfig } from 'vite'
4import tailwindcss from '@tailwindcss/vite'
5
6const layer = (name: string) => fileURLToPath(new URL(`./src/${name}`, import.meta.url))
7
8export default defineConfig({
9 plugins: [react(), tailwindcss()],
10 resolve: {
11 alias: {
12 '@/app': layer('app'),
13 '@/pages': layer('pages'),
14 '@/shared': layer('shared'),
15 },
16 },
17 server: {
18 host: '0.0.0.0',
19 port: 5174,
20 proxy: {
21 '/api': {
22 target: 'http://127.0.0.1:3004',
23 changeOrigin: true,
24 },
25 },
26 },
27})
No newline at end of file
Added app/adminhost/src/main.rs View app/adminhost/src/main.rs
@@ -0,0 +1,230 @@
1use std::collections::HashMap;
2use std::hash::{Hash, Hasher};
3use std::net::SocketAddr;
4use std::sync::OnceLock;
5
6use admin::AdminService;
7use anyhow::Context;
8use axum::Router;
9use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
10use axum::response::IntoResponse;
11use config::scoped::{AdminhostConfig, ScopedConfig};
12use session::{SessionLayer, store};
13use tokio::net::TcpListener;
14use tower_http::limit::RequestBodyLimitLayer;
15use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
16use tracing::{Level, info, warn};
17
18const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
19const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
20const CACHE_NO_CACHE: &str = "no-cache";
21const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate";
22
23fn build_cors_layer(config: &config::admin::AdminConfig) -> tower_http::cors::CorsLayer {
24 use axum::http::HeaderName;
25 use tower_http::cors::{AllowOrigin, CorsLayer};
26
27 if config.cors_allow_origins.is_empty() {
28 return CorsLayer::new();
29 }
30 let origins: Vec<HeaderValue> = config
31 .cors_allow_origins
32 .iter()
33 .filter_map(|origin| origin.parse().ok())
34 .collect();
35 CorsLayer::new()
36 .allow_origin(AllowOrigin::list(origins))
37 .allow_credentials(config.cors_allow_credentials())
38 .allow_methods([
39 Method::GET,
40 Method::POST,
41 Method::PUT,
42 Method::PATCH,
43 Method::DELETE,
44 Method::OPTIONS,
45 ])
46 .allow_headers([
47 header::CONTENT_TYPE,
48 header::AUTHORIZATION,
49 HeaderName::from_static("x-request-id"),
50 ])
51}
52
53fn admin_session_config(cfg: &AdminhostConfig) -> config::session::SessionConfig {
54 let mut session = cfg.session.clone();
55 session.cookie_name = Some(cfg.admin.session_cookie_name().to_string());
56 session.table_name = Some(cfg.admin.session_table_name().to_string());
57 session
58}
59
60#[tokio::main]
61async fn main() -> anyhow::Result<()> {
62 export::telemetry::init_tracing();
63
64 let cfg = AdminhostConfig::load().context("loading configuration")?;
65 if !cfg.admin.enabled() {
66 info!("adminhost is disabled by config; exiting");
67 return Ok(());
68 }
69
70 info!("connecting to database / redis / storage / email");
71 let state = export::AppState::with_config(cfg.clone().into_app_config())
72 .await
73 .context("building AppState")?;
74 info!("dependencies ready");
75
76 let health = export::health::router_with(Some(state.database.clone()), state.redis.clone());
77 let session_store = store::connect(state.redis.clone(), &admin_session_config(&cfg))
78 .await
79 .context("connecting admin session store")?;
80
81 let service = AdminService::new(state);
82 match service.seed_admin_if_empty().await {
83 Ok(Some(admin_id)) => info!(%admin_id, "initial admin account seeded"),
84 Ok(None) => {}
85 Err(error) => warn!(%error, "admin seed failed"),
86 }
87
88 let api = admin::routes::router(service.clone()).with_state(service);
89 let doc = admin::openapi::api_doc();
90
91 let router: Router = Router::new()
92 .nest("/api/v1", api)
93 .merge(health)
94 .merge(
95 utoipa_swagger_ui::SwaggerUi::new("/api/docs")
96 .url("/api/docs/openapi.json", doc),
97 )
98 .fallback(spa_fallback)
99 .layer(build_cors_layer(&cfg.admin))
100 .layer(SessionLayer::new(session_store))
101 .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
102 .layer(
103 TraceLayer::new_for_http()
104 .make_span_with(
105 DefaultMakeSpan::new()
106 .level(Level::INFO)
107 .include_headers(false),
108 )
109 .on_response(DefaultOnResponse::new().level(Level::INFO)),
110 )
111 .layer(export::middleware::AppTraceLayer::new());
112
113 let bind_addr = cfg.admin.bind_addr().to_string();
114 let port = cfg.admin.port();
115 let addr: SocketAddr = format!("{bind_addr}:{port}")
116 .parse()
117 .with_context(|| format!("parsing bind address {bind_addr}:{port}"))?;
118 let listener = TcpListener::bind(addr)
119 .await
120 .with_context(|| format!("binding {addr}"))?;
121 info!(%addr, "adminhost listening");
122
123 axum::serve(listener, router.into_make_service())
124 .with_graceful_shutdown(export::telemetry::shutdown_signal())
125 .await
126 .context("axum server error")?;
127
128 info!("adminhost shut down");
129 Ok(())
130}
131
132async fn spa_fallback(method: Method, uri: Uri, headers: HeaderMap) -> axum::response::Response {
133 if !matches!(method, Method::GET | Method::HEAD) {
134 return StatusCode::METHOD_NOT_ALLOWED.into_response();
135 }
136 let path = uri.path();
137 if path.starts_with("/api") {
138 return StatusCode::NOT_FOUND.into_response();
139 }
140 let asset = match admin_web::file(path) {
141 Some(asset) => asset,
142 None => {
143 if path.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
144 return StatusCode::NOT_FOUND.into_response();
145 }
146 admin_web::index()
147 }
148 };
149 serve_asset(asset, &headers)
150}
151
152fn serve_asset(asset: admin_web::Asset, headers: &HeaderMap) -> axum::response::Response {
153 let etag = etags()
154 .get(asset.path)
155 .cloned()
156 .unwrap_or_else(|| etag_of(asset.content));
157 let cache_control = HeaderValue::from_static(cache_control(asset.path));
158
159 if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
160 && etag_matches(if_none_match, &etag)
161 {
162 return (
163 StatusCode::NOT_MODIFIED,
164 [
165 (header::CACHE_CONTROL, cache_control),
166 (header::ETAG, etag),
167 ],
168 )
169 .into_response();
170 }
171
172 (
173 [
174 (header::CONTENT_TYPE, HeaderValue::from_static(asset.mime)),
175 (header::CACHE_CONTROL, cache_control),
176 (header::ETAG, etag),
177 ],
178 asset.content,
179 )
180 .into_response()
181}
182
183fn etags() -> &'static HashMap<&'static str, HeaderValue> {
184 static ETAGS: OnceLock<HashMap<&'static str, HeaderValue>> = OnceLock::new();
185 ETAGS.get_or_init(|| {
186 let mut map = HashMap::new();
187 let index = admin_web::index();
188 map.insert(index.path, etag_of(index.content));
189 for path in admin_web::paths() {
190 if let Some(asset) = admin_web::file(path) {
191 map.insert(asset.path, etag_of(asset.content));
192 }
193 }
194 map
195 })
196}
197
198fn etag_of(content: &[u8]) -> HeaderValue {
199 let mut hasher = std::collections::hash_map::DefaultHasher::new();
200 content.hash(&mut hasher);
201 hasher.write_u64(content.len() as u64);
202 HeaderValue::from_maybe_shared(format!("W/\"{:016x}\"", hasher.finish()).into_bytes())
203 .expect("valid etag header")
204}
205
206fn cache_control(path: &str) -> &'static str {
207 if path.starts_with("assets/") {
208 CACHE_IMMUTABLE
209 } else if path == "index.html" {
210 CACHE_NO_CACHE
211 } else {
212 CACHE_REVALIDATE
213 }
214}
215
216fn etag_matches(if_none_match: &HeaderValue, etag: &HeaderValue) -> bool {
217 let Ok(value) = if_none_match.to_str() else {
218 return false;
219 };
220 let Ok(target) = etag.to_str() else {
221 return false;
222 };
223 fn strong(value: &str) -> &str {
224 value.strip_prefix("W/").unwrap_or(value)
225 }
226 value
227 .split(',')
228 .map(str::trim)
229 .any(|candidate| candidate == "*" || strong(candidate) == strong(target))
230}
No newline at end of file
Added app/adminhost/Cargo.toml View app/adminhost/Cargo.toml
@@ -0,0 +1,18 @@
1[package]
2name = "adminhost"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8admin = { workspace = true }
9admin-web = { workspace = true }
10config = { workspace = true }
11export = { workspace = true }
12session = { workspace = true }
13axum = { workspace = true }
14anyhow = { workspace = true }
15tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread"] }
16tower-http = { workspace = true }
17tracing = { workspace = true }
18utoipa-swagger-ui = { workspace = true }
No newline at end of file
Modified app/openapi/src/main.rs View app/openapi/src/main.rs
@@ -1,39 +1,65 @@
1use std::path::PathBuf;
1use std::path::{Path, PathBuf};
22
33use anyhow::{Context, anyhow};
4use webapi::openapi::api_doc;
54
6fn main() -> anyhow::Result<()> {
7 let json = std::thread::Builder::new()
5fn generate<F>(generate: F) -> anyhow::Result<String>
6where
7 F: FnOnce() -> anyhow::Result<String> + Send + 'static,
8{
9 std::thread::Builder::new()
810 .stack_size(256 * 1024 * 1024)
9 .spawn(|| {
10 let doc = api_doc();
11 doc.to_pretty_json().context("serializing openapi doc")
12 })
11 .spawn(generate)
1312 .context("spawn generator thread")?
1413 .join()
15 .map_err(|_| anyhow!("openapi generation panicked (stack overflow?)"))??;
14 .map_err(|_| anyhow!("openapi generation panicked (stack overflow?)"))?
15}
16
17fn check_file(path: &Path, json: &str) -> anyhow::Result<()> {
18 let existing = std::fs::read_to_string(path)
19 .with_context(|| format!("reading {}", path.display()))?;
20 if existing.trim_end() != json.trim_end() {
21 eprintln!(
22 "{} is out of date; run `cargo run -p openapi` to regenerate",
23 path.display()
24 );
25 std::process::exit(1);
26 }
27 Ok(())
28}
29
30fn main() -> anyhow::Result<()> {
31 let web = generate(|| {
32 let doc = webapi::openapi::api_doc();
33 doc.to_pretty_json().context("serializing web openapi doc")
34 })?;
35 let admin = generate(|| {
36 let doc = admin::openapi::api_doc();
37 doc.to_pretty_json().context("serializing admin openapi doc")
38 })?;
1639
1740 let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1841 let root = crate_dir
1942 .ancestors()
2043 .nth(2)
2144 .ok_or_else(|| anyhow!("workspace root not found above {}", crate_dir.display()))?;
22 let output = root.join("openapi.json");
45 let web_path = root.join("openapi.json");
46 let admin_path = root.join("admin").join("openapi.json");
2347
2448 let check = std::env::args().nth(1).as_deref() == Some("--check");
2549 if check {
26 let existing = std::fs::read_to_string(&output)
27 .with_context(|| format!("reading {}", output.display()))?;
28 if existing.trim_end() != json.trim_end() {
29 eprintln!("openapi.json is out of date; run `cargo run -p openapi` to regenerate");
30 std::process::exit(1);
31 }
32 println!("openapi.json is up to date");
50 check_file(&web_path, &web)?;
51 check_file(&admin_path, &admin)?;
52 println!("openapi.json and admin/openapi.json are up to date");
3353 return Ok(());
3454 }
3555
36 std::fs::write(&output, json).with_context(|| format!("writing {}", output.display()))?;
37 println!("wrote {}", output.display());
56 std::fs::write(&web_path, web).with_context(|| format!("writing {}", web_path.display()))?;
57 std::fs::write(&admin_path, admin)
58 .with_context(|| format!("writing {}", admin_path.display()))?;
59 println!(
60 "wrote {} and {}",
61 web_path.display(),
62 admin_path.display()
63 );
3864 Ok(())
39}
65}
No newline at end of file
Modified app/openapi/Cargo.toml View app/openapi/Cargo.toml
@@ -5,5 +5,6 @@
55publish = false
66
77[dependencies]
8admin = { workspace = true }
89webapi = { workspace = true }
910anyhow = { workspace = true }
Added chart/templates/adminhost.yaml View chart/templates/adminhost.yaml
@@ -0,0 +1,127 @@
1{{- if .Values.adminhost.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-adminhost" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: adminhost
9spec:
10 replicas: {{ .Values.adminhost.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: adminhost
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: adminhost
21 {{- with .Values.podLabels }}
22 {{- toYaml . | nindent 8 }}
23 {{- end }}
24 annotations:
25 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
26 {{- with .Values.podAnnotations }}
27 {{- toYaml . | nindent 8 }}
28 {{- end }}
29 spec:
30 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
31 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
32 terminationGracePeriodSeconds: 30
33 securityContext:
34 {{- toYaml .Values.podSecurityContext | nindent 8 }}
35 {{- with .Values.nodeSelector }}
36 nodeSelector:
37 {{- toYaml . | nindent 8 }}
38 {{- end }}
39 {{- with .Values.tolerations }}
40 tolerations:
41 {{- toYaml . | nindent 8 }}
42 {{- end }}
43 containers:
44 - name: adminhost
45 image: {{ include "gitdata.image" . }}
46 imagePullPolicy: {{ .Values.image.pullPolicy }}
47 args: ["adminhost"]
48 {{- include "gitdata.envFrom" . | nindent 10 }}
49 env:
50 {{- include "gitdata.env" . | nindent 12 }}
51 {{- with .Values.adminhost.extraEnv }}
52 {{- toYaml . | nindent 12 }}
53 {{- end }}
54 ports:
55 - name: http
56 containerPort: {{ .Values.adminhost.port }}
57 livenessProbe:
58 httpGet: { path: /healthz, port: http }
59 initialDelaySeconds: 10
60 periodSeconds: 15
61 timeoutSeconds: 3
62 readinessProbe:
63 httpGet: { path: /readyz, port: http }
64 initialDelaySeconds: 5
65 periodSeconds: 10
66 timeoutSeconds: 3
67 failureThreshold: 3
68 securityContext:
69 {{- toYaml .Values.securityContext | nindent 12 }}
70 resources:
71 {{- toYaml .Values.adminhost.resources | nindent 12 }}
72---
73apiVersion: v1
74kind: Service
75metadata:
76 name: {{ printf "%s-adminhost" (include "gitdata.fullname" .) }}
77 labels:
78 {{- include "gitdata.labels" . | nindent 4 }}
79 app.kubernetes.io/component: adminhost
80spec:
81 type: ClusterIP
82 selector:
83 {{- include "gitdata.selectorLabels" . | nindent 4 }}
84 app.kubernetes.io/component: adminhost
85 ports:
86 - name: http
87 port: {{ .Values.adminhost.port }}
88 targetPort: http
89{{- if .Values.adminhost.ingress.enabled }}
90---
91apiVersion: networking.k8s.io/v1
92kind: Ingress
93metadata:
94 name: {{ printf "%s-adminhost" (include "gitdata.fullname" .) }}
95 labels:
96 {{- include "gitdata.labels" . | nindent 4 }}
97 app.kubernetes.io/component: adminhost
98 annotations:
99 {{- with .Values.adminhost.ingress.annotations }}
100 {{- toYaml . | nindent 4 }}
101 {{- end }}
102 {{- if and .Values.adminhost.ingress.tls.enabled .Values.adminhost.ingress.tls.clusterIssuer }}
103 cert-manager.io/cluster-issuer: {{ .Values.adminhost.ingress.tls.clusterIssuer }}
104 {{- end }}
105spec:
106 {{- with .Values.adminhost.ingress.className }}
107 ingressClassName: {{ . }}
108 {{- end }}
109 {{- if .Values.adminhost.ingress.tls.enabled }}
110 tls:
111 - hosts:
112 - {{ .Values.adminhost.ingress.host | quote }}
113 secretName: {{ default (printf "%s-adminhost-tls" (include "gitdata.fullname" .)) .Values.adminhost.ingress.tls.secretName }}
114 {{- end }}
115 rules:
116 - host: {{ .Values.adminhost.ingress.host | quote }}
117 http:
118 paths:
119 - path: /
120 pathType: Prefix
121 backend:
122 service:
123 name: {{ printf "%s-adminhost" (include "gitdata.fullname" .) }}
124 port:
125 name: http
126{{- end }}
127{{- end }}
No newline at end of file
Modified chart/values.yaml View chart/values.yaml
@@ -56,6 +56,8 @@
5656 APP_AUTH__CHAT_JWT_SEC: "" # >=32 字节,chat WS 握手 JWT 密钥
5757 APP_SEARCH__API_KEY: ""
5858 APP_IMPORT__TOKEN_SECRET: "" # >=32 字节,加密导入任务里的源站令牌
59 APP_ADMIN__SEED_USERNAME: "" # 首次启动种子管理员账号(admin_user 为空时创建)
60 APP_ADMIN__SEED_PASSWORD: "" # 种子管理员口令,首次登录强制修改
5961
6062podSecurityContext:
6163 fsGroup: 10001
@@ -104,6 +106,24 @@
104106 enabled: true
105107 minAvailable: 1
106108
109adminhost:
110 enabled: true
111 replicaCount: 1
112 port: 3004
113 resources:
114 requests: { cpu: 100m, memory: 128Mi }
115 limits: { memory: 512Mi }
116 extraEnv: []
117 # 管理端独立域名 Ingress(同源提供 SPA + /api/v1/admin)
118 ingress:
119 enabled: false
120 host: admin.gitdata.example.com
121 className: ""
122 tls:
123 enabled: false
124 secretName: ""
125 clusterIssuer: ""
126
107127githost:
108128 enabled: true
109129 # SSH host key 落在共享 PVC 上;多副本前请预先放入同一把 key,
Modified config/default.toml View config/default.toml
@@ -287,3 +287,13 @@
287287
288288[health.mails]
289289port = 3003
290[admin]
291enabled = true
292bind_addr = "0.0.0.0"
293port = 3004
294seed_username = ""
295seed_password = ""
296session_cookie_name = "gitdata_admin_session"
297session_table_name = "admin_session"
298cors_allow_origins = []
299cors_allow_credentials = false
Added lib/admin/src/handlers/admins.rs View lib/admin/src/handlers/admins.rs
@@ -0,0 +1,158 @@
1use axum::Json;
2use axum::extract::{Extension, Path, Query, State};
3use axum::http::HeaderMap;
4use export::pagination::{Page, PageQuery};
5use export::rest::{AppResponseData, NoData};
6use export::middleware::RequestId;
7use uuid::Uuid;
8
9use crate::errors::AdminApiError;
10use crate::services::auth::{
11 AdminDto, CreateAdminCmd, ResetAdminPasswordCmd, UpdateAdminCmd,
12};
13use crate::services::service::AdminService;
14use crate::share::roles::CurrentAdmin;
15
16use super::audit_ctx;
17
18#[utoipa::path(
19 get,
20 path = "/admin/admins",
21 tag = "admin-admins",
22 description = "管理员账号列表;仅超级管理员可见。",
23 params(PageQuery),
24 responses(
25 (status = 200, description = "admin page", body = AppResponseData<Page<AdminDto>>),
26 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
27 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn list_admins(
31 State(service): State<AdminService>,
32 CurrentAdmin(actor): CurrentAdmin,
33 Query(page): Query<PageQuery>,
34) -> Result<AppResponseData<Page<AdminDto>>, AdminApiError> {
35 let result = service.list_admins(&actor, page).await?;
36 Ok(AppResponseData::ok(result))
37}
38
39#[utoipa::path(
40 post,
41 path = "/admin/admins",
42 tag = "admin-admins",
43 description = "创建管理员账号;仅超级管理员可操作。",
44 request_body = CreateAdminCmd,
45 responses(
46 (status = 200, description = "admin created", body = AppResponseData<AdminDto>),
47 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
48 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
49 )
50)]
51pub async fn create_admin(
52 State(service): State<AdminService>,
53 CurrentAdmin(actor): CurrentAdmin,
54 headers: HeaderMap,
55 request_id: Option<Extension<RequestId>>,
56 Json(cmd): Json<CreateAdminCmd>,
57) -> Result<AppResponseData<AdminDto>, AdminApiError> {
58 let ctx = audit_ctx(&headers, request_id);
59 let admin = service.create_admin(&actor, cmd, &ctx).await?;
60 Ok(AppResponseData::ok(admin))
61}
62
63#[utoipa::path(
64 get,
65 path = "/admin/admins/{id}",
66 tag = "admin-admins",
67 description = "管理员账号详情。",
68 params(("id" = Uuid, Path, description = "admin id")),
69 responses(
70 (status = 200, description = "admin detail", body = AppResponseData<AdminDto>),
71 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
72 )
73)]
74pub async fn get_admin(
75 State(service): State<AdminService>,
76 CurrentAdmin(actor): CurrentAdmin,
77 Path(id): Path<Uuid>,
78) -> Result<AppResponseData<AdminDto>, AdminApiError> {
79 let admin = service.get_admin(&actor, id).await?;
80 Ok(AppResponseData::ok(admin))
81}
82
83#[utoipa::path(
84 patch,
85 path = "/admin/admins/{id}",
86 tag = "admin-admins",
87 description = "更新管理员显示名/角色/启用状态;禁止操作最后一个超级管理员。",
88 params(("id" = Uuid, Path, description = "admin id")),
89 request_body = UpdateAdminCmd,
90 responses(
91 (status = 200, description = "admin updated", body = AppResponseData<AdminDto>),
92 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
93 )
94)]
95pub async fn update_admin(
96 State(service): State<AdminService>,
97 CurrentAdmin(actor): CurrentAdmin,
98 Path(id): Path<Uuid>,
99 headers: HeaderMap,
100 request_id: Option<Extension<RequestId>>,
101 Json(cmd): Json<UpdateAdminCmd>,
102) -> Result<AppResponseData<AdminDto>, AdminApiError> {
103 let ctx = audit_ctx(&headers, request_id);
104 let admin = service.update_admin(&actor, id, cmd, &ctx).await?;
105 Ok(AppResponseData::ok(admin))
106}
107
108#[utoipa::path(
109 delete,
110 path = "/admin/admins/{id}",
111 tag = "admin-admins",
112 description = "删除管理员账号;禁止删除自己与最后一个超级管理员。",
113 params(("id" = Uuid, Path, description = "admin id")),
114 responses(
115 (status = 200, description = "admin deleted", body = AppResponseData<NoData>),
116 (status = 404, description = "resource not found", body = AppResponseData<NoData>),
117 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
118 )
119)]
120pub async fn delete_admin(
121 State(service): State<AdminService>,
122 CurrentAdmin(actor): CurrentAdmin,
123 Path(id): Path<Uuid>,
124 headers: HeaderMap,
125 request_id: Option<Extension<RequestId>>,
126) -> Result<AppResponseData<NoData>, AdminApiError> {
127 let ctx = audit_ctx(&headers, request_id);
128 service.delete_admin(&actor, id, &ctx).await?;
129 Ok(AppResponseData::empty())
130}
131
132#[utoipa::path(
133 post,
134 path = "/admin/admins/{id}/reset-password",
135 tag = "admin-admins",
136 description = "重置管理员口令并要求下次登录强制改密。",
137 params(("id" = Uuid, Path, description = "admin id")),
138 request_body = ResetAdminPasswordCmd,
139 responses(
140 (status = 200, description = "password reset", body = AppResponseData<NoData>),
141 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
142 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
143 )
144)]
145pub async fn reset_admin_password(
146 State(service): State<AdminService>,
147 CurrentAdmin(actor): CurrentAdmin,
148 Path(id): Path<Uuid>,
149 headers: HeaderMap,
150 request_id: Option<Extension<RequestId>>,
151 Json(cmd): Json<ResetAdminPasswordCmd>,
152) -> Result<AppResponseData<NoData>, AdminApiError> {
153 let ctx = audit_ctx(&headers, request_id);
154 service
155 .reset_admin_password(&actor, id, cmd, &ctx)
156 .await?;
157 Ok(AppResponseData::empty())
158}
No newline at end of file
Added lib/admin/src/handlers/auth.rs View lib/admin/src/handlers/auth.rs
@@ -0,0 +1,103 @@
1use axum::Json;
2use axum::extract::{Extension, State};
3use axum::http::HeaderMap;
4use export::rest::{AppResponseData, NoData};
5use export::middleware::RequestId;
6use session::Session;
7
8use crate::errors::AdminApiError;
9use crate::services::auth::{AdminDto, AdminLoginCmd, AdminLoginResp, ChangePasswordCmd};
10use crate::services::service::AdminService;
11use crate::share::roles::CurrentAdmin;
12
13use super::audit_ctx;
14
15#[utoipa::path(
16 post,
17 path = "/admin/auth/login",
18 tag = "admin-auth",
19 description = "管理员用户名口令登录;会话使用独立的 gitdata_admin_session Cookie。",
20 request_body = AdminLoginCmd,
21 responses(
22 (status = 200, description = "login accepted", body = AppResponseData<AdminLoginResp>),
23 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
24 (status = 401, description = "invalid credentials", body = AppResponseData<NoData>),
25 (status = 429, description = "rate limit exceeded", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn login(
29 State(service): State<AdminService>,
30 session: Session,
31 headers: HeaderMap,
32 request_id: Option<Extension<RequestId>>,
33 Json(cmd): Json<AdminLoginCmd>,
34) -> Result<AppResponseData<AdminLoginResp>, AdminApiError> {
35 let ctx = audit_ctx(&headers, request_id);
36 let resp = service.admin_login(&session, cmd, &ctx).await?;
37 Ok(AppResponseData::ok(resp))
38}
39
40#[utoipa::path(
41 post,
42 path = "/admin/auth/logout",
43 tag = "admin-auth",
44 description = "退出管理端会话;重复调用始终返回成功。",
45 responses(
46 (status = 200, description = "logged out", body = AppResponseData<NoData>)
47 )
48)]
49pub async fn logout(
50 State(service): State<AdminService>,
51 session: Session,
52 headers: HeaderMap,
53 request_id: Option<Extension<RequestId>>,
54) -> Result<AppResponseData<NoData>, AdminApiError> {
55 let ctx = audit_ctx(&headers, request_id);
56 service.admin_logout(&session, None, &ctx).await?;
57 Ok(AppResponseData::empty())
58}
59
60#[utoipa::path(
61 get,
62 path = "/admin/me",
63 tag = "admin-auth",
64 description = "当前管理员资料。",
65 responses(
66 (status = 200, description = "current admin", body = AppResponseData<AdminDto>),
67 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
68 )
69)]
70pub async fn me(
71 State(service): State<AdminService>,
72 CurrentAdmin(actor): CurrentAdmin,
73) -> Result<AppResponseData<AdminDto>, AdminApiError> {
74 let admin = service.admin_me(&actor).await?;
75 Ok(AppResponseData::ok(admin))
76}
77
78#[utoipa::path(
79 patch,
80 path = "/admin/me/password",
81 tag = "admin-auth",
82 description = "修改当前管理员口令;口令版本变更会失效其他管理端会话。",
83 request_body = ChangePasswordCmd,
84 responses(
85 (status = 200, description = "password changed", body = AppResponseData<NoData>),
86 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
87 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
88 )
89)]
90pub async fn change_password(
91 State(service): State<AdminService>,
92 session: Session,
93 CurrentAdmin(actor): CurrentAdmin,
94 headers: HeaderMap,
95 request_id: Option<Extension<RequestId>>,
96 Json(cmd): Json<ChangePasswordCmd>,
97) -> Result<AppResponseData<NoData>, AdminApiError> {
98 let ctx = audit_ctx(&headers, request_id);
99 service
100 .change_admin_password(&session, &actor, cmd, &ctx)
101 .await?;
102 Ok(AppResponseData::empty())
103}
No newline at end of file
Added lib/admin/src/handlers/logs.rs View lib/admin/src/handlers/logs.rs
@@ -0,0 +1,44 @@
1use axum::extract::{Query, State};
2use export::rest::{AppResponseData, NoData};
3
4use crate::errors::AdminApiError;
5use crate::services::logs::{AuditLogPageDto, JobLogPageDto, ListAuditQuery, ListJobLogsQuery};
6use crate::services::service::AdminService;
7
8#[utoipa::path(
9 get,
10 path = "/admin/logs/audit",
11 tag = "admin-logs",
12 description = "管理操作审计日志(登录事件与成功变更)。",
13 params(ListAuditQuery),
14 responses(
15 (status = 200, description = "audit page", body = AppResponseData<AuditLogPageDto>),
16 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn list_audit_logs(
20 State(service): State<AdminService>,
21 Query(query): Query<ListAuditQuery>,
22) -> Result<AppResponseData<AuditLogPageDto>, AdminApiError> {
23 let result = service.list_audit_logs(query).await?;
24 Ok(AppResponseData::ok(result))
25}
26
27#[utoipa::path(
28 get,
29 path = "/admin/logs/jobs",
30 tag = "admin-logs",
31 description = "作业错误日志:聚合 import/search/system_tasks/webhook/pipeline 的失败信息。",
32 params(ListJobLogsQuery),
33 responses(
34 (status = 200, description = "job log page", body = AppResponseData<JobLogPageDto>),
35 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
36 )
37)]
38pub async fn list_job_logs(
39 State(service): State<AdminService>,
40 Query(query): Query<ListJobLogsQuery>,
41) -> Result<AppResponseData<JobLogPageDto>, AdminApiError> {
42 let result = service.list_job_logs(query).await?;
43 Ok(AppResponseData::ok(result))
44}
No newline at end of file
Added lib/admin/src/handlers/mod.rs View lib/admin/src/handlers/mod.rs
@@ -0,0 +1,19 @@
1pub mod admins;
2pub mod auth;
3pub mod logs;
4pub mod queues;
5pub mod spaces;
6pub mod storage;
7pub mod system;
8pub mod tasks;
9pub mod users;
10
11use axum::extract::Extension;
12use axum::http::HeaderMap;
13use export::middleware::RequestId;
14
15use crate::services::audit::AuditCtx;
16
17pub(crate) fn audit_ctx(headers: &HeaderMap, request_id: Option<Extension<RequestId>>) -> AuditCtx {
18 AuditCtx::from_headers(headers).with_request_id(request_id.map(|Extension(value)| value.0))
19}
No newline at end of file
Added lib/admin/src/handlers/queues.rs View lib/admin/src/handlers/queues.rs
@@ -0,0 +1,74 @@
1use axum::extract::{Extension, Path, State};
2use axum::http::HeaderMap;
3use export::rest::{AppResponseData, NoData};
4use export::middleware::RequestId;
5
6use crate::errors::AdminApiError;
7use crate::services::queues::{QueueActionResp, QueueSnapshotDto};
8use crate::services::service::AdminService;
9use crate::share::roles::CurrentAdmin;
10
11use super::audit_ctx;
12
13#[utoipa::path(
14 get,
15 path = "/admin/queues",
16 tag = "admin-queues",
17 description = "队列快照:Redis git 同步队列、邮件 pub/sub 与各数据库作业队列深度/状态。",
18 responses(
19 (status = 200, description = "queue snapshots", body = AppResponseData<Vec<QueueSnapshotDto>>),
20 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn list_queues(
24 State(service): State<AdminService>,
25) -> Result<AppResponseData<Vec<QueueSnapshotDto>>, AdminApiError> {
26 let result = service.queue_snapshots().await?;
27 Ok(AppResponseData::ok(result))
28}
29
30#[utoipa::path(
31 post,
32 path = "/admin/queues/{name}/requeue-failed",
33 tag = "admin-queues",
34 description = "重排失败作业(search_index/import/chat_webhook/pipeline/deletion/system_tasks)。",
35 params(("name" = String, Path, description = "queue name")),
36 responses(
37 (status = 200, description = "failed items requeued", body = AppResponseData<QueueActionResp>),
38 (status = 400, description = "unsupported queue", body = AppResponseData<NoData>)
39 )
40)]
41pub async fn requeue_failed(
42 State(service): State<AdminService>,
43 CurrentAdmin(actor): CurrentAdmin,
44 Path(name): Path<String>,
45 headers: HeaderMap,
46 request_id: Option<Extension<RequestId>>,
47) -> Result<AppResponseData<QueueActionResp>, AdminApiError> {
48 let ctx = audit_ctx(&headers, request_id);
49 let result = service.requeue_failed(&actor, &name, &ctx).await?;
50 Ok(AppResponseData::ok(result))
51}
52
53#[utoipa::path(
54 post,
55 path = "/admin/queues/{name}/purge",
56 tag = "admin-queues",
57 description = "清空队列:git 同步队列直接删除 Redis list,数据库队列删除终态记录;仅超级管理员可操作。",
58 params(("name" = String, Path, description = "queue name")),
59 responses(
60 (status = 200, description = "queue purged", body = AppResponseData<QueueActionResp>),
61 (status = 400, description = "unsupported queue", body = AppResponseData<NoData>)
62 )
63)]
64pub async fn purge(
65 State(service): State<AdminService>,
66 CurrentAdmin(actor): CurrentAdmin,
67 Path(name): Path<String>,
68 headers: HeaderMap,
69 request_id: Option<Extension<RequestId>>,
70) -> Result<AppResponseData<QueueActionResp>, AdminApiError> {
71 let ctx = audit_ctx(&headers, request_id);
72 let result = service.purge_queue(&actor, &name, &ctx).await?;
73 Ok(AppResponseData::ok(result))
74}
No newline at end of file
Added lib/admin/src/handlers/spaces.rs View lib/admin/src/handlers/spaces.rs
@@ -0,0 +1,178 @@
1use axum::Json;
2use axum::extract::{Extension, Path, Query, State};
3use axum::http::HeaderMap;
4use export::pagination::Page;
5use export::rest::{AppResponseData, NoData};
6use export::middleware::RequestId;
7use uuid::Uuid;
8
9use crate::errors::AdminApiError;
10use crate::services::service::AdminService;
11use crate::services::spaces::{
12 AdminSpaceDetail, AdminSpaceMemberDto, AdminSpaceSummary, ListMembersQuery, ListSpacesQuery,
13 RemoveMemberResp, TransferSpaceCmd, UpdateSpaceCmd,
14};
15use crate::share::roles::CurrentAdmin;
16
17use super::audit_ctx;
18
19#[utoipa::path(
20 get,
21 path = "/admin/spaces",
22 tag = "admin-spaces",
23 description = "Space 列表:关键字、状态、Owner 与分页。",
24 params(ListSpacesQuery),
25 responses(
26 (status = 200, description = "space page", body = AppResponseData<Page<AdminSpaceSummary>>),
27 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn list_spaces(
31 State(service): State<AdminService>,
32 Query(query): Query<ListSpacesQuery>,
33) -> Result<AppResponseData<Page<AdminSpaceSummary>>, AdminApiError> {
34 let result = service.list_spaces(query).await?;
35 Ok(AppResponseData::ok(result))
36}
37
38#[utoipa::path(
39 get,
40 path = "/admin/spaces/{id}",
41 tag = "admin-spaces",
42 description = "Space 详情:Owner、成员数、仓库/房间/目录统计。",
43 params(("id" = Uuid, Path, description = "space id")),
44 responses(
45 (status = 200, description = "space detail", body = AppResponseData<AdminSpaceDetail>),
46 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
47 )
48)]
49pub async fn get_space(
50 State(service): State<AdminService>,
51 Path(id): Path<Uuid>,
52) -> Result<AppResponseData<AdminSpaceDetail>, AdminApiError> {
53 let detail = service.get_space(id).await?;
54 Ok(AppResponseData::ok(detail))
55}
56
57#[utoipa::path(
58 patch,
59 path = "/admin/spaces/{id}",
60 tag = "admin-spaces",
61 description = "更新 Space:名称、描述、私有与启用状态。",
62 params(("id" = Uuid, Path, description = "space id")),
63 request_body = UpdateSpaceCmd,
64 responses(
65 (status = 200, description = "space updated", body = AppResponseData<AdminSpaceDetail>),
66 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
67 )
68)]
69pub async fn update_space(
70 State(service): State<AdminService>,
71 CurrentAdmin(actor): CurrentAdmin,
72 Path(id): Path<Uuid>,
73 headers: HeaderMap,
74 request_id: Option<Extension<RequestId>>,
75 Json(cmd): Json<UpdateSpaceCmd>,
76) -> Result<AppResponseData<AdminSpaceDetail>, AdminApiError> {
77 let ctx = audit_ctx(&headers, request_id);
78 let result = service.update_space(&actor, id, cmd, &ctx).await?;
79 Ok(AppResponseData::ok(result))
80}
81
82#[utoipa::path(
83 post,
84 path = "/admin/spaces/{id}/transfer",
85 tag = "admin-spaces",
86 description = "转移 Space 所有权;仅超级管理员可操作。",
87 params(("id" = Uuid, Path, description = "space id")),
88 request_body = TransferSpaceCmd,
89 responses(
90 (status = 200, description = "space transferred", body = AppResponseData<AdminSpaceDetail>),
91 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
92 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
93 )
94)]
95pub async fn transfer_space(
96 State(service): State<AdminService>,
97 CurrentAdmin(actor): CurrentAdmin,
98 Path(id): Path<Uuid>,
99 headers: HeaderMap,
100 request_id: Option<Extension<RequestId>>,
101 Json(cmd): Json<TransferSpaceCmd>,
102) -> Result<AppResponseData<AdminSpaceDetail>, AdminApiError> {
103 let ctx = audit_ctx(&headers, request_id);
104 let result = service.transfer_space(&actor, id, cmd, &ctx).await?;
105 Ok(AppResponseData::ok(result))
106}
107
108#[utoipa::path(
109 delete,
110 path = "/admin/spaces/{id}",
111 tag = "admin-spaces",
112 description = "停用 Space(软删除);仅超级管理员可操作。",
113 params(("id" = Uuid, Path, description = "space id")),
114 responses(
115 (status = 200, description = "space deleted", body = AppResponseData<NoData>),
116 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
117 )
118)]
119pub async fn delete_space(
120 State(service): State<AdminService>,
121 CurrentAdmin(actor): CurrentAdmin,
122 Path(id): Path<Uuid>,
123 headers: HeaderMap,
124 request_id: Option<Extension<RequestId>>,
125) -> Result<AppResponseData<NoData>, AdminApiError> {
126 let ctx = audit_ctx(&headers, request_id);
127 service.delete_space(&actor, id, &ctx).await?;
128 Ok(AppResponseData::empty())
129}
130
131#[utoipa::path(
132 get,
133 path = "/admin/spaces/{id}/members",
134 tag = "admin-spaces",
135 description = "Space 成员列表。",
136 params(
137 ("id" = Uuid, Path, description = "space id"),
138 ListMembersQuery
139 ),
140 responses(
141 (status = 200, description = "member page", body = AppResponseData<Page<AdminSpaceMemberDto>>),
142 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
143 )
144)]
145pub async fn list_space_members(
146 State(service): State<AdminService>,
147 Path(id): Path<Uuid>,
148 Query(query): Query<ListMembersQuery>,
149) -> Result<AppResponseData<Page<AdminSpaceMemberDto>>, AdminApiError> {
150 let result = service.list_space_members(id, query).await?;
151 Ok(AppResponseData::ok(result))
152}
153
154#[utoipa::path(
155 delete,
156 path = "/admin/spaces/{id}/members/{uid}",
157 tag = "admin-spaces",
158 description = "将成员移出 Space。",
159 params(
160 ("id" = Uuid, Path, description = "space id"),
161 ("uid" = Uuid, Path, description = "user id")
162 ),
163 responses(
164 (status = 200, description = "member removed", body = AppResponseData<RemoveMemberResp>),
165 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
166 )
167)]
168pub async fn remove_space_member(
169 State(service): State<AdminService>,
170 CurrentAdmin(actor): CurrentAdmin,
171 Path((id, uid)): Path<(Uuid, Uuid)>,
172 headers: HeaderMap,
173 request_id: Option<Extension<RequestId>>,
174) -> Result<AppResponseData<RemoveMemberResp>, AdminApiError> {
175 let ctx = audit_ctx(&headers, request_id);
176 let result = service.remove_space_member(&actor, id, uid, &ctx).await?;
177 Ok(AppResponseData::ok(result))
178}
No newline at end of file
Added lib/admin/src/handlers/storage.rs View lib/admin/src/handlers/storage.rs
@@ -0,0 +1,101 @@
1use axum::extract::{Extension, Query, State};
2use axum::http::HeaderMap;
3use export::rest::{AppResponseData, NoData};
4use export::middleware::RequestId;
5use serde::Deserialize;
6use utoipa::IntoParams;
7
8use crate::errors::AdminApiError;
9use crate::services::service::AdminService;
10use crate::services::storage::{
11 DeleteObjectResp, DownloadUrlDto, ListObjectsQuery, ObjectKeyQuery, StorageObjectPageDto,
12 StorageOverviewDto,
13};
14use crate::share::roles::CurrentAdmin;
15
16use super::audit_ctx;
17
18#[derive(Debug, Clone, Deserialize, IntoParams)]
19pub struct StorageOverviewQuery {
20 pub refresh: Option<bool>,
21}
22
23#[utoipa::path(
24 get,
25 path = "/admin/storage/overview",
26 tag = "admin-storage",
27 description = "存储总览:单桶按前缀聚合的用量与对象数(60s 缓存,refresh=true 强制重算)。",
28 params(StorageOverviewQuery),
29 responses(
30 (status = 200, description = "storage overview", body = AppResponseData<StorageOverviewDto>),
31 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
32 )
33)]
34pub async fn overview(
35 State(service): State<AdminService>,
36 Query(query): Query<StorageOverviewQuery>,
37) -> Result<AppResponseData<StorageOverviewDto>, AdminApiError> {
38 let result = service.storage_overview(query.refresh.unwrap_or(false)).await?;
39 Ok(AppResponseData::ok(result))
40}
41
42#[utoipa::path(
43 get,
44 path = "/admin/storage/objects",
45 tag = "admin-storage",
46 description = "对象浏览:前缀过滤与 token 分页。",
47 params(ListObjectsQuery),
48 responses(
49 (status = 200, description = "object page", body = AppResponseData<StorageObjectPageDto>),
50 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
51 )
52)]
53pub async fn list_objects(
54 State(service): State<AdminService>,
55 Query(query): Query<ListObjectsQuery>,
56) -> Result<AppResponseData<StorageObjectPageDto>, AdminApiError> {
57 let result = service.list_storage_objects(query).await?;
58 Ok(AppResponseData::ok(result))
59}
60
61#[utoipa::path(
62 get,
63 path = "/admin/storage/download-url",
64 tag = "admin-storage",
65 description = "生成对象预签名下载地址(最长 1 小时)。",
66 params(ObjectKeyQuery),
67 responses(
68 (status = 200, description = "presigned download url", body = AppResponseData<DownloadUrlDto>),
69 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
70 )
71)]
72pub async fn download_url(
73 State(service): State<AdminService>,
74 Query(query): Query<ObjectKeyQuery>,
75) -> Result<AppResponseData<DownloadUrlDto>, AdminApiError> {
76 let result = service.storage_download_url(query.key).await?;
77 Ok(AppResponseData::ok(result))
78}
79
80#[utoipa::path(
81 delete,
82 path = "/admin/storage/objects",
83 tag = "admin-storage",
84 description = "删除存储对象;仅超级管理员可操作。",
85 params(ObjectKeyQuery),
86 responses(
87 (status = 200, description = "object deleted", body = AppResponseData<DeleteObjectResp>),
88 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
89 )
90)]
91pub async fn delete_object(
92 State(service): State<AdminService>,
93 CurrentAdmin(actor): CurrentAdmin,
94 Query(query): Query<ObjectKeyQuery>,
95 headers: HeaderMap,
96 request_id: Option<Extension<RequestId>>,
97) -> Result<AppResponseData<DeleteObjectResp>, AdminApiError> {
98 let ctx = audit_ctx(&headers, request_id);
99 let result = service.delete_storage_object(&actor, query.key, &ctx).await?;
100 Ok(AppResponseData::ok(result))
101}
No newline at end of file
Added lib/admin/src/handlers/system.rs View lib/admin/src/handlers/system.rs
@@ -0,0 +1,23 @@
1use axum::extract::State;
2use export::rest::{AppResponseData, NoData};
3
4use crate::errors::AdminApiError;
5use crate::services::service::AdminService;
6use crate::services::system::SystemStatusDto;
7
8#[utoipa::path(
9 get,
10 path = "/admin/system/status",
11 tag = "admin-system",
12 description = "系统状态:进程信息、DB/Redis/S3/搜索健康、核心计数、队列汇总与最近失败。",
13 responses(
14 (status = 200, description = "system status", body = AppResponseData<SystemStatusDto>),
15 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
16 )
17)]
18pub async fn status(
19 State(service): State<AdminService>,
20) -> Result<AppResponseData<SystemStatusDto>, AdminApiError> {
21 let result = service.system_status().await?;
22 Ok(AppResponseData::ok(result))
23}
No newline at end of file
Added lib/admin/src/handlers/tasks.rs View lib/admin/src/handlers/tasks.rs
@@ -0,0 +1,131 @@
1use axum::extract::{Extension, Path, Query, State};
2use axum::http::HeaderMap;
3use export::rest::{AppResponseData, NoData};
4use export::middleware::RequestId;
5use uuid::Uuid;
6
7use crate::errors::AdminApiError;
8use crate::services::service::AdminService;
9use crate::services::tasks::{ListTasksQuery, TaskActionResp, TaskDetailDto, TaskPageDto};
10use crate::share::roles::CurrentAdmin;
11
12use super::audit_ctx;
13
14#[utoipa::path(
15 get,
16 path = "/admin/tasks",
17 tag = "admin-tasks",
18 description = "统一任务视图:import/search_index/chat_webhook/pipeline/deletion/system_tasks。",
19 params(ListTasksQuery),
20 responses(
21 (status = 200, description = "task page", body = AppResponseData<TaskPageDto>),
22 (status = 400, description = "request failed validation", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn list_tasks(
26 State(service): State<AdminService>,
27 Query(query): Query<ListTasksQuery>,
28) -> Result<AppResponseData<TaskPageDto>, AdminApiError> {
29 let result = service.list_tasks(query).await?;
30 Ok(AppResponseData::ok(result))
31}
32
33#[utoipa::path(
34 get,
35 path = "/admin/tasks/{source}/{id}",
36 tag = "admin-tasks",
37 description = "任务详情;import token 与 webhook payload 已脱敏。",
38 params(
39 ("source" = String, Path, description = "task source"),
40 ("id" = Uuid, Path, description = "task id")
41 ),
42 responses(
43 (status = 200, description = "task detail", body = AppResponseData<TaskDetailDto>),
44 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
45 )
46)]
47pub async fn get_task(
48 State(service): State<AdminService>,
49 Path((source, id)): Path<(String, Uuid)>,
50) -> Result<AppResponseData<TaskDetailDto>, AdminApiError> {
51 let result = service.get_task(&source, id).await?;
52 Ok(AppResponseData::ok(result))
53}
54
55#[utoipa::path(
56 post,
57 path = "/admin/tasks/{source}/{id}/retry",
58 tag = "admin-tasks",
59 description = "重试任务:仅失败/取消态可重试。",
60 params(
61 ("source" = String, Path, description = "task source"),
62 ("id" = Uuid, Path, description = "task id")
63 ),
64 responses(
65 (status = 200, description = "task retried", body = AppResponseData<TaskActionResp>),
66 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
67 )
68)]
69pub async fn retry_task(
70 State(service): State<AdminService>,
71 CurrentAdmin(actor): CurrentAdmin,
72 Path((source, id)): Path<(String, Uuid)>,
73 headers: HeaderMap,
74 request_id: Option<Extension<RequestId>>,
75) -> Result<AppResponseData<TaskActionResp>, AdminApiError> {
76 let ctx = audit_ctx(&headers, request_id);
77 let result = service.retry_task(&actor, &source, id, &ctx).await?;
78 Ok(AppResponseData::ok(result))
79}
80
81#[utoipa::path(
82 post,
83 path = "/admin/tasks/{source}/{id}/cancel",
84 tag = "admin-tasks",
85 description = "取消任务:队列中/进行中的作业按来源置取消态。",
86 params(
87 ("source" = String, Path, description = "task source"),
88 ("id" = Uuid, Path, description = "task id")
89 ),
90 responses(
91 (status = 200, description = "task cancelled", body = AppResponseData<TaskActionResp>),
92 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
93 )
94)]
95pub async fn cancel_task(
96 State(service): State<AdminService>,
97 CurrentAdmin(actor): CurrentAdmin,
98 Path((source, id)): Path<(String, Uuid)>,
99 headers: HeaderMap,
100 request_id: Option<Extension<RequestId>>,
101) -> Result<AppResponseData<TaskActionResp>, AdminApiError> {
102 let ctx = audit_ctx(&headers, request_id);
103 let result = service.cancel_task(&actor, &source, id, &ctx).await?;
104 Ok(AppResponseData::ok(result))
105}
106
107#[utoipa::path(
108 delete,
109 path = "/admin/tasks/{source}/{id}",
110 tag = "admin-tasks",
111 description = "删除终态任务记录;仅超级管理员可操作。",
112 params(
113 ("source" = String, Path, description = "task source"),
114 ("id" = Uuid, Path, description = "task id")
115 ),
116 responses(
117 (status = 200, description = "task deleted", body = AppResponseData<NoData>),
118 (status = 409, description = "resource conflict", body = AppResponseData<NoData>)
119 )
120)]
121pub async fn delete_task(
122 State(service): State<AdminService>,
123 CurrentAdmin(actor): CurrentAdmin,
124 Path((source, id)): Path<(String, Uuid)>,
125 headers: HeaderMap,
126 request_id: Option<Extension<RequestId>>,
127) -> Result<AppResponseData<NoData>, AdminApiError> {
128 let ctx = audit_ctx(&headers, request_id);
129 service.delete_task(&actor, &source, id, &ctx).await?;
130 Ok(AppResponseData::empty())
131}
No newline at end of file
Added lib/admin/src/handlers/users.rs View lib/admin/src/handlers/users.rs
@@ -0,0 +1,208 @@
1use axum::Json;
2use axum::extract::{Extension, Path, Query, State};
3use axum::http::HeaderMap;
4use export::pagination::Page;
5use export::rest::{AppResponseData, NoData};
6use export::middleware::RequestId;
7use uuid::Uuid;
8
9use crate::errors::AdminApiError;
10use crate::services::service::AdminService;
11use crate::services::users::{
12 AdminSuspensionDto, AdminUserDetail, AdminUserSummary, DeleteUserResp, ListUsersQuery,
13 ResetUserPasswordCmd, RevokeSessionsResp, SuspendUserCmd, UpdateUserCmd,
14};
15use crate::share::roles::CurrentAdmin;
16
17use super::audit_ctx;
18
19#[utoipa::path(
20 get,
21 path = "/admin/users",
22 tag = "admin-users",
23 description = "用户列表:关键字(用户名/显示名/邮箱)、状态筛选与分页。",
24 params(ListUsersQuery),
25 responses(
26 (status = 200, description = "user page", body = AppResponseData<Page<AdminUserSummary>>),
27 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn list_users(
31 State(service): State<AdminService>,
32 Query(query): Query<ListUsersQuery>,
33) -> Result<AppResponseData<Page<AdminUserSummary>>, AdminApiError> {
34 let result = service.list_users(query).await?;
35 Ok(AppResponseData::ok(result))
36}
37
38#[utoipa::path(
39 get,
40 path = "/admin/users/{uid}",
41 tag = "admin-users",
42 description = "用户详情:账号状态、邮箱、会话、所属 Space 与封禁记录。",
43 params(("uid" = Uuid, Path, description = "user id")),
44 responses(
45 (status = 200, description = "user detail", body = AppResponseData<AdminUserDetail>),
46 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
47 )
48)]
49pub async fn get_user(
50 State(service): State<AdminService>,
51 Path(uid): Path<Uuid>,
52) -> Result<AppResponseData<AdminUserDetail>, AdminApiError> {
53 let detail = service.get_user(uid).await?;
54 Ok(AppResponseData::ok(detail))
55}
56
57#[utoipa::path(
58 patch,
59 path = "/admin/users/{uid}",
60 tag = "admin-users",
61 description = "更新用户账号标志:显示名、启用、受限、禁止登录。",
62 params(("uid" = Uuid, Path, description = "user id")),
63 request_body = UpdateUserCmd,
64 responses(
65 (status = 200, description = "user updated", body = AppResponseData<AdminUserSummary>),
66 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
67 )
68)]
69pub async fn update_user(
70 State(service): State<AdminService>,
71 CurrentAdmin(actor): CurrentAdmin,
72 Path(uid): Path<Uuid>,
73 headers: HeaderMap,
74 request_id: Option<Extension<RequestId>>,
75 Json(cmd): Json<UpdateUserCmd>,
76) -> Result<AppResponseData<AdminUserSummary>, AdminApiError> {
77 let ctx = audit_ctx(&headers, request_id);
78 let result = service.update_user(&actor, uid, cmd, &ctx).await?;
79 Ok(AppResponseData::ok(result))
80}
81
82#[utoipa::path(
83 post,
84 path = "/admin/users/{uid}/suspend",
85 tag = "admin-users",
86 description = "封禁用户:写入封禁记录、标记受限并吊销全部会话。",
87 params(("uid" = Uuid, Path, description = "user id")),
88 request_body = SuspendUserCmd,
89 responses(
90 (status = 200, description = "user suspended", body = AppResponseData<AdminSuspensionDto>),
91 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
92 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
93 )
94)]
95pub async fn suspend_user(
96 State(service): State<AdminService>,
97 CurrentAdmin(actor): CurrentAdmin,
98 Path(uid): Path<Uuid>,
99 headers: HeaderMap,
100 request_id: Option<Extension<RequestId>>,
101 Json(cmd): Json<SuspendUserCmd>,
102) -> Result<AppResponseData<AdminSuspensionDto>, AdminApiError> {
103 let ctx = audit_ctx(&headers, request_id);
104 let result = service.suspend_user(&actor, uid, cmd, &ctx).await?;
105 Ok(AppResponseData::ok(result))
106}
107
108#[utoipa::path(
109 post,
110 path = "/admin/users/{uid}/suspensions/{suspension_id}/revoke",
111 tag = "admin-users",
112 description = "撤销用户封禁记录;无剩余生效封禁时解除受限标志。",
113 params(
114 ("uid" = Uuid, Path, description = "user id"),
115 ("suspension_id" = Uuid, Path, description = "suspension id")
116 ),
117 responses(
118 (status = 200, description = "suspension revoked", body = AppResponseData<NoData>),
119 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
120 )
121)]
122pub async fn revoke_suspension(
123 State(service): State<AdminService>,
124 CurrentAdmin(actor): CurrentAdmin,
125 Path((uid, suspension_id)): Path<(Uuid, Uuid)>,
126 headers: HeaderMap,
127 request_id: Option<Extension<RequestId>>,
128) -> Result<AppResponseData<NoData>, AdminApiError> {
129 let ctx = audit_ctx(&headers, request_id);
130 service
131 .revoke_suspension(&actor, uid, suspension_id, &ctx)
132 .await?;
133 Ok(AppResponseData::empty())
134}
135
136#[utoipa::path(
137 post,
138 path = "/admin/users/{uid}/reset-password",
139 tag = "admin-users",
140 description = "重置用户口令并吊销全部会话。",
141 params(("uid" = Uuid, Path, description = "user id")),
142 request_body = ResetUserPasswordCmd,
143 responses(
144 (status = 200, description = "password reset", body = AppResponseData<NoData>),
145 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
146 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
147 )
148)]
149pub async fn reset_user_password(
150 State(service): State<AdminService>,
151 CurrentAdmin(actor): CurrentAdmin,
152 Path(uid): Path<Uuid>,
153 headers: HeaderMap,
154 request_id: Option<Extension<RequestId>>,
155 Json(cmd): Json<ResetUserPasswordCmd>,
156) -> Result<AppResponseData<NoData>, AdminApiError> {
157 let ctx = audit_ctx(&headers, request_id);
158 service
159 .reset_user_password(&actor, uid, cmd, &ctx)
160 .await?;
161 Ok(AppResponseData::empty())
162}
163
164#[utoipa::path(
165 post,
166 path = "/admin/users/{uid}/revoke-sessions",
167 tag = "admin-users",
168 description = "吊销用户全部有效会话。",
169 params(("uid" = Uuid, Path, description = "user id")),
170 responses(
171 (status = 200, description = "sessions revoked", body = AppResponseData<RevokeSessionsResp>),
172 (status = 404, description = "resource not found", body = AppResponseData<NoData>)
173 )
174)]
175pub async fn revoke_user_sessions(
176 State(service): State<AdminService>,
177 CurrentAdmin(actor): CurrentAdmin,
178 Path(uid): Path<Uuid>,
179 headers: HeaderMap,
180 request_id: Option<Extension<RequestId>>,
181) -> Result<AppResponseData<RevokeSessionsResp>, AdminApiError> {
182 let ctx = audit_ctx(&headers, request_id);
183 let result = service.admin_revoke_user_sessions(&actor, uid, &ctx).await?;
184 Ok(AppResponseData::ok(result))
185}
186
187#[utoipa::path(
188 delete,
189 path = "/admin/users/{uid}",
190 tag = "admin-users",
191 description = "强制注销用户:立即执行匿名化并禁用账号;仅超级管理员可操作。",
192 params(("uid" = Uuid, Path, description = "user id")),
193 responses(
194 (status = 200, description = "user deleted", body = AppResponseData<DeleteUserResp>),
195 (status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
196 )
197)]
198pub async fn delete_user(
199 State(service): State<AdminService>,
200 CurrentAdmin(actor): CurrentAdmin,
201 Path(uid): Path<Uuid>,
202 headers: HeaderMap,
203 request_id: Option<Extension<RequestId>>,
204) -> Result<AppResponseData<DeleteUserResp>, AdminApiError> {
205 let ctx = audit_ctx(&headers, request_id);
206 let result = service.admin_delete_user(&actor, uid, &ctx).await?;
207 Ok(AppResponseData::ok(result))
208}
No newline at end of file
Added lib/admin/src/services/audit.rs View lib/admin/src/services/audit.rs
@@ -0,0 +1,88 @@
1use axum::http::HeaderMap;
2use models::admin::admin_audit_log;
3use sea_orm::ActiveModelTrait;
4use sea_orm::Set;
5use serde_json::Value;
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::errors::AdminError;
10use crate::services::service::AdminService;
11use crate::share::roles::AdminActor;
12
13#[derive(Debug, Clone, Default)]
14pub struct AuditCtx {
15 pub ip: Option<String>,
16 pub user_agent: Option<String>,
17 pub request_id: Option<String>,
18}
19
20impl AuditCtx {
21 pub fn from_headers(headers: &HeaderMap) -> Self {
22 let ip = headers
23 .get("x-forwarded-for")
24 .and_then(|value| value.to_str().ok())
25 .and_then(|value| value.split(',').next())
26 .map(str::trim)
27 .filter(|value| !value.is_empty())
28 .map(str::to_owned)
29 .or_else(|| {
30 headers
31 .get("x-real-ip")
32 .and_then(|value| value.to_str().ok())
33 .map(str::to_owned)
34 });
35 let user_agent = headers
36 .get(axum::http::header::USER_AGENT)
37 .and_then(|value| value.to_str().ok())
38 .map(str::to_owned);
39 Self {
40 ip,
41 user_agent,
42 request_id: None,
43 }
44 }
45
46 pub fn with_request_id(mut self, request_id: Option<String>) -> Self {
47 self.request_id = request_id;
48 self
49 }
50}
51
52pub struct AuditRecord<'a> {
53 pub action: &'a str,
54 pub target: Option<(&'a str, String)>,
55 pub detail: Value,
56}
57
58impl AdminService {
59 pub async fn audit(
60 &self,
61 actor: Option<&AdminActor>,
62 record: AuditRecord<'_>,
63 ctx: &AuditCtx,
64 ) -> Result<(), AdminError> {
65 let (target_type, target_id) = match record.target {
66 Some((kind, id)) => (Some(kind.to_string()), Some(id)),
67 None => (None, None),
68 };
69 admin_audit_log::ActiveModel {
70 id: Set(Uuid::now_v7()),
71 admin_id: Set(actor.map(|actor| actor.id)),
72 admin_username: Set(actor
73 .map(|actor| actor.username.clone())
74 .unwrap_or_else(|| "system".to_string())),
75 action: Set(record.action.to_string()),
76 target_type: Set(target_type),
77 target_id: Set(target_id),
78 detail: Set(record.detail),
79 ip: Set(ctx.ip.clone()),
80 user_agent: Set(ctx.user_agent.clone()),
81 request_id: Set(ctx.request_id.clone()),
82 created_at: Set(OffsetDateTime::now_utc()),
83 }
84 .insert(self.db().write())
85 .await?;
86 Ok(())
87 }
88}
No newline at end of file
Added lib/admin/src/services/auth.rs View lib/admin/src/services/auth.rs
@@ -0,0 +1,592 @@
1use export::pagination::{Page, PageQuery};
2use models::admin::admin_user::{self, AdminRole};
3use sea_orm::PaginatorTrait;
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use session::Session;
8use time::OffsetDateTime;
9use utoipa::ToSchema;
10use uuid::Uuid;
11
12use crate::errors::AdminError;
13use crate::services::audit::{AuditCtx, AuditRecord};
14use crate::services::service::AdminService;
15use crate::share::roles::AdminActor;
16use crate::share::session::{apply_admin_login, clear_admin};
17
18const LOGIN_ATTEMPT_MAX: u64 = 5;
19const LOGIN_ATTEMPT_WINDOW_SECS: u64 = 300;
20
21#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
22pub struct AdminLoginCmd {
23 pub username: String,
24 pub password: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
28pub struct AdminDto {
29 pub id: Uuid,
30 pub username: String,
31 pub display_name: String,
32 pub role: AdminRole,
33 pub is_active: bool,
34 pub must_change_password: bool,
35 pub last_login_at: Option<OffsetDateTime>,
36 pub last_login_ip: Option<String>,
37 pub created_at: OffsetDateTime,
38 pub updated_at: OffsetDateTime,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
42pub struct AdminLoginResp {
43 pub admin: AdminDto,
44 pub must_change_password: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
48pub struct ChangePasswordCmd {
49 pub current_password: String,
50 pub new_password: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
54pub struct CreateAdminCmd {
55 pub username: String,
56 pub display_name: Option<String>,
57 pub password: String,
58 pub role: AdminRole,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
62pub struct UpdateAdminCmd {
63 #[serde(default)]
64 pub display_name: Option<String>,
65 #[serde(default)]
66 pub role: Option<AdminRole>,
67 #[serde(default)]
68 pub is_active: Option<bool>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
72pub struct ResetAdminPasswordCmd {
73 pub new_password: String,
74}
75
76fn admin_dto(row: &admin_user::Model) -> AdminDto {
77 AdminDto {
78 id: row.id,
79 username: row.username.clone(),
80 display_name: row.display_name.clone(),
81 role: row.role,
82 is_active: row.is_active,
83 must_change_password: row.must_change_password,
84 last_login_at: row.last_login_at,
85 last_login_ip: row.last_login_ip.clone(),
86 created_at: row.created_at,
87 updated_at: row.updated_at,
88 }
89}
90
91fn actor_from(row: &admin_user::Model) -> AdminActor {
92 AdminActor {
93 id: row.id,
94 username: row.username.clone(),
95 display_name: row.display_name.clone(),
96 role: row.role,
97 must_change_password: row.must_change_password,
98 }
99}
100
101fn dummy_hash() -> &'static str {
102 static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
103 HASH.get_or_init(|| {
104 auth::share::crypto::hash_password("admin-dummy-password").unwrap_or_default()
105 })
106}
107
108fn normalize_username(raw: &str) -> String {
109 raw.trim().to_ascii_lowercase()
110}
111
112fn validate_username(username: &str) -> Result<(), AdminError> {
113 let len = username.chars().count();
114 if !(3..=32).contains(&len) {
115 return Err(AdminError::BadRequest(
116 "username must be 3 to 32 characters".to_string(),
117 ));
118 }
119 if !username
120 .chars()
121 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
122 {
123 return Err(AdminError::BadRequest(
124 "username may contain letters, digits, underscore, dash and dot only".to_string(),
125 ));
126 }
127 Ok(())
128}
129
130impl AdminService {
131 async fn other_active_super_exists(&self, excluded: Uuid) -> Result<bool, AdminError> {
132 let count = admin_user::Entity::find()
133 .filter(admin_user::Column::Role.eq(AdminRole::SuperAdmin))
134 .filter(admin_user::Column::IsActive.eq(true))
135 .filter(admin_user::Column::Id.ne(excluded))
136 .count(self.db().read())
137 .await?;
138 Ok(count > 0)
139 }
140
141 async fn ensure_super_admin_remains(&self, target: Uuid) -> Result<(), AdminError> {
142 if self.other_active_super_exists(target).await? {
143 Ok(())
144 } else {
145 Err(AdminError::Conflict(
146 "at least one active super admin is required".to_string(),
147 ))
148 }
149 }
150
151 pub async fn admin_login(
152 &self,
153 session: &Session,
154 cmd: AdminLoginCmd,
155 ctx: &AuditCtx,
156 ) -> Result<AdminLoginResp, AdminError> {
157 let username = normalize_username(&cmd.username);
158 if username.is_empty() || cmd.password.is_empty() {
159 return Err(AdminError::BadRequest(
160 "username and password are required".to_string(),
161 ));
162 }
163 let attempts_key = format!("admin:login_attempts:{username}");
164 if !auth::share::rate::attempts_track(
165 self.redis(),
166 &attempts_key,
167 LOGIN_ATTEMPT_MAX,
168 LOGIN_ATTEMPT_WINDOW_SECS,
169 )
170 .await?
171 {
172 return Err(AdminError::RateLimited(
173 "too many login attempts, try again later".to_string(),
174 ));
175 }
176
177 let row = admin_user::Entity::find()
178 .filter(admin_user::Column::LowerName.eq(&username))
179 .one(self.db().read())
180 .await?;
181 let Some(row) = row else {
182 let _ = auth::share::crypto::verify_password(&cmd.password, dummy_hash());
183 self.audit(
184 None,
185 AuditRecord {
186 action: "admin.login_failed",
187 target: None,
188 detail: json!({ "username": username }),
189 },
190 ctx,
191 )
192 .await?;
193 return Err(AdminError::InvalidCredentials);
194 };
195 if !row.is_active {
196 let _ = auth::share::crypto::verify_password(&cmd.password, dummy_hash());
197 self.audit(
198 None,
199 AuditRecord {
200 action: "admin.login_failed",
201 target: Some(("admin_user", row.id.to_string())),
202 detail: json!({ "username": username, "reason": "inactive" }),
203 },
204 ctx,
205 )
206 .await?;
207 return Err(AdminError::InvalidCredentials);
208 }
209 if !auth::share::crypto::verify_password(&cmd.password, &row.passwd_hash) {
210 self.audit(
211 None,
212 AuditRecord {
213 action: "admin.login_failed",
214 target: Some(("admin_user", row.id.to_string())),
215 detail: json!({ "username": username }),
216 },
217 ctx,
218 )
219 .await?;
220 return Err(AdminError::InvalidCredentials);
221 }
222
223 auth::share::rate::attempts_reset(self.redis(), &attempts_key).await?;
224 let now = OffsetDateTime::now_utc();
225 let mut am: admin_user::ActiveModel = row.into();
226 am.last_login_at = Set(Some(now));
227 am.last_login_ip = Set(ctx.ip.clone());
228 am.updated_at = Set(now);
229 let row = am.update(self.db().write()).await?;
230
231 apply_admin_login(session, &row);
232 let actor = actor_from(&row);
233 self.audit(
234 Some(&actor),
235 AuditRecord {
236 action: "admin.login",
237 target: Some(("admin_user", row.id.to_string())),
238 detail: json!({ "username": row.username }),
239 },
240 ctx,
241 )
242 .await?;
243
244 Ok(AdminLoginResp {
245 admin: admin_dto(&row),
246 must_change_password: row.must_change_password,
247 })
248 }
249
250 pub async fn admin_logout(
251 &self,
252 session: &Session,
253 actor: Option<&AdminActor>,
254 ctx: &AuditCtx,
255 ) -> Result<(), AdminError> {
256 clear_admin(session);
257 if let Some(actor) = actor {
258 self.audit(
259 Some(actor),
260 AuditRecord {
261 action: "admin.logout",
262 target: Some(("admin_user", actor.id.to_string())),
263 detail: json!({}),
264 },
265 ctx,
266 )
267 .await?;
268 }
269 Ok(())
270 }
271
272 pub async fn admin_me(&self, actor: &AdminActor) -> Result<AdminDto, AdminError> {
273 let row = admin_user::Entity::find_by_id(actor.id)
274 .one(self.db().read())
275 .await?
276 .ok_or(AdminError::NotFound)?;
277 Ok(admin_dto(&row))
278 }
279
280 pub async fn change_admin_password(
281 &self,
282 session: &Session,
283 actor: &AdminActor,
284 cmd: ChangePasswordCmd,
285 ctx: &AuditCtx,
286 ) -> Result<(), AdminError> {
287 let row = admin_user::Entity::find_by_id(actor.id)
288 .one(self.db().read())
289 .await?
290 .ok_or(AdminError::NotFound)?;
291 if !auth::share::crypto::verify_password(&cmd.current_password, &row.passwd_hash) {
292 return Err(AdminError::BadRequest(
293 "current password is incorrect".to_string(),
294 ));
295 }
296 auth::share::crypto::validate_password(
297 &cmd.new_password,
298 &self.config().auth.password_policy,
299 )?;
300 let now = OffsetDateTime::now_utc();
301 let hash = auth::share::crypto::hash_password(&cmd.new_password)?;
302 let mut am: admin_user::ActiveModel = row.into();
303 am.passwd_hash = Set(hash);
304 am.must_change_password = Set(false);
305 am.passwd_version = Set(now.unix_timestamp());
306 am.updated_at = Set(now);
307 let row = am.update(self.db().write()).await?;
308 apply_admin_login(session, &row);
309 self.audit(
310 Some(actor),
311 AuditRecord {
312 action: "admin.password_changed",
313 target: Some(("admin_user", row.id.to_string())),
314 detail: json!({}),
315 },
316 ctx,
317 )
318 .await?;
319 Ok(())
320 }
321 pub async fn list_admins(
322 &self,
323 actor: &AdminActor,
324 page: PageQuery,
325 ) -> Result<Page<AdminDto>, AdminError> {
326 actor.require(AdminRole::SuperAdmin)?;
327 let (limit, offset) = page.page();
328 let select = admin_user::Entity::find().order_by_asc(admin_user::Column::CreatedAt);
329 let total = select.clone().count(self.db().read()).await? as i64;
330 let rows = select
331 .limit(limit)
332 .offset(offset)
333 .all(self.db().read())
334 .await?;
335 Ok(Page {
336 items: rows.iter().map(admin_dto).collect(),
337 total,
338 limit,
339 offset,
340 })
341 }
342
343 pub async fn create_admin(
344 &self,
345 actor: &AdminActor,
346 cmd: CreateAdminCmd,
347 ctx: &AuditCtx,
348 ) -> Result<AdminDto, AdminError> {
349 actor.require(AdminRole::SuperAdmin)?;
350 let username = normalize_username(&cmd.username);
351 validate_username(&username)?;
352 auth::share::crypto::validate_password(&cmd.password, &self.config().auth.password_policy)?;
353 let existing = admin_user::Entity::find()
354 .filter(admin_user::Column::LowerName.eq(&username))
355 .one(self.db().read())
356 .await?;
357 if existing.is_some() {
358 return Err(AdminError::Conflict(
359 "admin username already exists".to_string(),
360 ));
361 }
362 let now = OffsetDateTime::now_utc();
363 let hash = auth::share::crypto::hash_password(&cmd.password)?;
364 let display_name = cmd
365 .display_name
366 .map(|value| value.trim().to_string())
367 .filter(|value| !value.is_empty())
368 .unwrap_or_else(|| username.clone());
369 let row = admin_user::ActiveModel {
370 id: Set(Uuid::now_v7()),
371 username: Set(username.clone()),
372 lower_name: Set(username),
373 display_name: Set(display_name),
374 passwd_hash: Set(hash),
375 role: Set(cmd.role),
376 is_active: Set(true),
377 must_change_password: Set(false),
378 passwd_version: Set(now.unix_timestamp()),
379 last_login_at: Set(None),
380 last_login_ip: Set(None),
381 created_at: Set(now),
382 updated_at: Set(now),
383 }
384 .insert(self.db().write())
385 .await?;
386 self.audit(
387 Some(actor),
388 AuditRecord {
389 action: "admin.created",
390 target: Some(("admin_user", row.id.to_string())),
391 detail: json!({ "username": row.username, "role": row.role }),
392 },
393 ctx,
394 )
395 .await?;
396 Ok(admin_dto(&row))
397 }
398
399 pub async fn get_admin(&self, actor: &AdminActor, id: Uuid) -> Result<AdminDto, AdminError> {
400 actor.require(AdminRole::SuperAdmin)?;
401 let row = admin_user::Entity::find_by_id(id)
402 .one(self.db().read())
403 .await?
404 .ok_or(AdminError::NotFound)?;
405 Ok(admin_dto(&row))
406 }
407
408 pub async fn update_admin(
409 &self,
410 actor: &AdminActor,
411 id: Uuid,
412 cmd: UpdateAdminCmd,
413 ctx: &AuditCtx,
414 ) -> Result<AdminDto, AdminError> {
415 actor.require(AdminRole::SuperAdmin)?;
416 let row = admin_user::Entity::find_by_id(id)
417 .one(self.db().read())
418 .await?
419 .ok_or(AdminError::NotFound)?;
420 let mut changed: Vec<String> = Vec::new();
421 let mut am: admin_user::ActiveModel = row.clone().into();
422 if let Some(display_name) = cmd.display_name {
423 let display_name = display_name.trim().to_string();
424 if display_name.is_empty() {
425 return Err(AdminError::BadRequest(
426 "display_name must not be empty".to_string(),
427 ));
428 }
429 am.display_name = Set(display_name);
430 changed.push("display_name".to_string());
431 }
432 if let Some(role) = cmd.role
433 && role != row.role
434 {
435 if id == actor.id {
436 return Err(AdminError::Conflict(
437 "cannot change your own role".to_string(),
438 ));
439 }
440 if row.role == AdminRole::SuperAdmin {
441 self.ensure_super_admin_remains(id).await?;
442 }
443 am.role = Set(role);
444 changed.push("role".to_string());
445 }
446 if let Some(is_active) = cmd.is_active
447 && is_active != row.is_active
448 {
449 if !is_active {
450 if id == actor.id {
451 return Err(AdminError::Conflict(
452 "cannot deactivate your own account".to_string(),
453 ));
454 }
455 if row.role == AdminRole::SuperAdmin {
456 self.ensure_super_admin_remains(id).await?;
457 }
458 }
459 am.is_active = Set(is_active);
460 changed.push("is_active".to_string());
461 }
462 if changed.is_empty() {
463 return Ok(admin_dto(&row));
464 }
465 am.updated_at = Set(OffsetDateTime::now_utc());
466 let row = am.update(self.db().write()).await?;
467 self.audit(
468 Some(actor),
469 AuditRecord {
470 action: "admin.updated",
471 target: Some(("admin_user", row.id.to_string())),
472 detail: json!({ "changed": changed, "role": row.role, "is_active": row.is_active }),
473 },
474 ctx,
475 )
476 .await?;
477 Ok(admin_dto(&row))
478 }
479
480 pub async fn delete_admin(
481 &self,
482 actor: &AdminActor,
483 id: Uuid,
484 ctx: &AuditCtx,
485 ) -> Result<(), AdminError> {
486 actor.require(AdminRole::SuperAdmin)?;
487 if id == actor.id {
488 return Err(AdminError::Conflict(
489 "cannot delete your own account".to_string(),
490 ));
491 }
492 let row = admin_user::Entity::find_by_id(id)
493 .one(self.db().read())
494 .await?
495 .ok_or(AdminError::NotFound)?;
496 if row.role == AdminRole::SuperAdmin && row.is_active {
497 self.ensure_super_admin_remains(id).await?;
498 }
499 admin_user::Entity::delete_by_id(id)
500 .exec(self.db().write())
501 .await?;
502 self.audit(
503 Some(actor),
504 AuditRecord {
505 action: "admin.deleted",
506 target: Some(("admin_user", id.to_string())),
507 detail: json!({ "username": row.username, "role": row.role }),
508 },
509 ctx,
510 )
511 .await?;
512 Ok(())
513 }
514
515 pub async fn reset_admin_password(
516 &self,
517 actor: &AdminActor,
518 id: Uuid,
519 cmd: ResetAdminPasswordCmd,
520 ctx: &AuditCtx,
521 ) -> Result<(), AdminError> {
522 actor.require(AdminRole::SuperAdmin)?;
523 auth::share::crypto::validate_password(&cmd.new_password, &self.config().auth.password_policy)?;
524 let row = admin_user::Entity::find_by_id(id)
525 .one(self.db().read())
526 .await?
527 .ok_or(AdminError::NotFound)?;
528 let now = OffsetDateTime::now_utc();
529 let hash = auth::share::crypto::hash_password(&cmd.new_password)?;
530 let mut am: admin_user::ActiveModel = row.into();
531 am.passwd_hash = Set(hash);
532 am.must_change_password = Set(true);
533 am.passwd_version = Set(now.unix_timestamp());
534 am.updated_at = Set(now);
535 am.update(self.db().write()).await?;
536 self.audit(
537 Some(actor),
538 AuditRecord {
539 action: "admin.password_reset",
540 target: Some(("admin_user", id.to_string())),
541 detail: json!({}),
542 },
543 ctx,
544 )
545 .await?;
546 Ok(())
547 }
548
549 pub async fn seed_admin_if_empty(&self) -> Result<Option<Uuid>, AdminError> {
550 let count = admin_user::Entity::find().count(self.db().read()).await?;
551 if count > 0 {
552 return Ok(None);
553 }
554 let (Some(username), Some(password)) = (
555 self.admin_config().seed_username(),
556 self.admin_config().seed_password(),
557 ) else {
558 tracing::warn!(
559 "admin_user table is empty and [admin] seed_username/seed_password is not configured"
560 );
561 return Ok(None);
562 };
563 let username = normalize_username(username);
564 validate_username(&username)?;
565 auth::share::crypto::validate_password(password, &self.config().auth.password_policy)?;
566 let now = OffsetDateTime::now_utc();
567 let hash = auth::share::crypto::hash_password(password)?;
568 let row = admin_user::ActiveModel {
569 id: Set(Uuid::now_v7()),
570 username: Set(username.clone()),
571 lower_name: Set(username.clone()),
572 display_name: Set(username),
573 passwd_hash: Set(hash),
574 role: Set(AdminRole::SuperAdmin),
575 is_active: Set(true),
576 must_change_password: Set(true),
577 passwd_version: Set(now.unix_timestamp()),
578 last_login_at: Set(None),
579 last_login_ip: Set(None),
580 created_at: Set(now),
581 updated_at: Set(now),
582 }
583 .insert(self.db().write())
584 .await?;
585 tracing::info!(
586 admin_id = %row.id,
587 username = %row.username,
588 "seeded initial super admin; password change required on first login"
589 );
590 Ok(Some(row.id))
591 }
592}
No newline at end of file
Added lib/admin/src/services/logs.rs View lib/admin/src/services/logs.rs
@@ -0,0 +1,300 @@
1use models::admin::admin_audit_log;
2use models::chat::webhook_deliveries;
3use models::import::job;
4use models::repo::pipeline_run::{self, PipelineRunState};
5use models::search::index_job;
6use models::system::system_tasks;
7use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::{IntoParams, ToSchema};
11use uuid::Uuid;
12
13use crate::errors::AdminError;
14use crate::services::service::AdminService;
15
16#[derive(Debug, Clone, Deserialize, IntoParams)]
17pub struct ListAuditQuery {
18 pub admin_id: Option<Uuid>,
19 pub action: Option<String>,
20 pub target_type: Option<String>,
21 pub target_id: Option<String>,
22 pub from: Option<OffsetDateTime>,
23 pub to: Option<OffsetDateTime>,
24 pub limit: Option<u64>,
25 pub offset: Option<u64>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
29pub struct AuditLogDto {
30 pub id: Uuid,
31 pub admin_id: Option<Uuid>,
32 pub admin_username: String,
33 pub action: String,
34 pub target_type: Option<String>,
35 pub target_id: Option<String>,
36 pub detail: serde_json::Value,
37 pub ip: Option<String>,
38 pub user_agent: Option<String>,
39 pub request_id: Option<String>,
40 pub created_at: OffsetDateTime,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
44pub struct AuditLogPageDto {
45 pub items: Vec<AuditLogDto>,
46 pub total: i64,
47 pub limit: u64,
48 pub offset: u64,
49}
50
51#[derive(Debug, Clone, Deserialize, IntoParams)]
52pub struct ListJobLogsQuery {
53 pub source: Option<String>,
54 pub from: Option<OffsetDateTime>,
55 pub to: Option<OffsetDateTime>,
56 pub limit: Option<u64>,
57 pub offset: Option<u64>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
61pub struct JobLogDto {
62 pub source: String,
63 pub id: Uuid,
64 pub state: String,
65 pub error: Option<String>,
66 pub created_at: OffsetDateTime,
67 pub updated_at: Option<OffsetDateTime>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
71pub struct JobLogPageDto {
72 pub items: Vec<JobLogDto>,
73 pub total: i64,
74 pub limit: u64,
75 pub offset: u64,
76}
77
78impl AdminService {
79 pub async fn list_audit_logs(
80 &self,
81 query: ListAuditQuery,
82 ) -> Result<AuditLogPageDto, AdminError> {
83 let limit = query.limit.unwrap_or(50).clamp(1, 100);
84 let offset = query.offset.unwrap_or(0);
85 let mut select = admin_audit_log::Entity::find();
86 if let Some(admin_id) = query.admin_id {
87 select = select.filter(admin_audit_log::Column::AdminId.eq(admin_id));
88 }
89 if let Some(action) = query
90 .action
91 .as_deref()
92 .map(str::trim)
93 .filter(|value| !value.is_empty())
94 {
95 select =
96 select.filter(admin_audit_log::Column::Action.contains(action.to_ascii_lowercase()));
97 }
98 if let Some(target_type) = query
99 .target_type
100 .as_deref()
101 .map(str::trim)
102 .filter(|value| !value.is_empty())
103 {
104 select = select.filter(admin_audit_log::Column::TargetType.eq(target_type));
105 }
106 if let Some(target_id) = query
107 .target_id
108 .as_deref()
109 .map(str::trim)
110 .filter(|value| !value.is_empty())
111 {
112 select = select.filter(admin_audit_log::Column::TargetId.eq(target_id));
113 }
114 if let Some(from) = query.from {
115 select = select.filter(admin_audit_log::Column::CreatedAt.gte(from));
116 }
117 if let Some(to) = query.to {
118 select = select.filter(admin_audit_log::Column::CreatedAt.lte(to));
119 }
120 select = select.order_by_desc(admin_audit_log::Column::CreatedAt);
121 let total = select.clone().count(self.db().read()).await? as i64;
122 let rows = select
123 .limit(limit)
124 .offset(offset)
125 .all(self.db().read())
126 .await?;
127 let items = rows
128 .into_iter()
129 .map(|row| AuditLogDto {
130 id: row.id,
131 admin_id: row.admin_id,
132 admin_username: row.admin_username,
133 action: row.action,
134 target_type: row.target_type,
135 target_id: row.target_id,
136 detail: row.detail,
137 ip: row.ip,
138 user_agent: row.user_agent,
139 request_id: row.request_id,
140 created_at: row.created_at,
141 })
142 .collect();
143 Ok(AuditLogPageDto {
144 items,
145 total,
146 limit,
147 offset,
148 })
149 }
150
151 pub async fn list_job_logs(
152 &self,
153 query: ListJobLogsQuery,
154 ) -> Result<JobLogPageDto, AdminError> {
155 let limit = query.limit.unwrap_or(50).clamp(1, 100);
156 let offset = query.offset.unwrap_or(0);
157 let fetch_limit = (offset + limit).clamp(1, 500);
158 let mut logs: Vec<JobLogDto> = Vec::new();
159
160 let sources = match query.source.as_deref() {
161 Some("import") => vec!["import"],
162 Some("search_index") => vec!["search_index"],
163 Some("chat_webhook") => vec!["chat_webhook"],
164 Some("pipeline") => vec!["pipeline"],
165 Some("system_tasks") => vec!["system_tasks"],
166 _ => vec![
167 "import",
168 "search_index",
169 "chat_webhook",
170 "pipeline",
171 "system_tasks",
172 ],
173 };
174
175 for source in sources {
176 match source {
177 "import" => {
178 let mut select = job::Entity::find()
179 .filter(job::Column::LastError.is_not_null())
180 .order_by_desc(job::Column::UpdatedAt);
181 if let Some(from) = query.from {
182 select = select.filter(job::Column::CreatedAt.gte(from));
183 }
184 if let Some(to) = query.to {
185 select = select.filter(job::Column::CreatedAt.lte(to));
186 }
187 for row in select.limit(fetch_limit).all(self.db().read()).await? {
188 logs.push(JobLogDto {
189 source: "import".to_string(),
190 id: row.id,
191 state: row.state,
192 error: row.last_error,
193 created_at: row.created_at,
194 updated_at: Some(row.updated_at),
195 });
196 }
197 }
198 "search_index" => {
199 let mut select = index_job::Entity::find()
200 .filter(index_job::Column::LastError.is_not_null())
201 .order_by_desc(index_job::Column::UpdatedAt);
202 if let Some(from) = query.from {
203 select = select.filter(index_job::Column::CreatedAt.gte(from));
204 }
205 if let Some(to) = query.to {
206 select = select.filter(index_job::Column::CreatedAt.lte(to));
207 }
208 for row in select.limit(fetch_limit).all(self.db().read()).await? {
209 logs.push(JobLogDto {
210 source: "search_index".to_string(),
211 id: row.id,
212 state: row.state,
213 error: row.last_error,
214 created_at: row.created_at,
215 updated_at: Some(row.updated_at),
216 });
217 }
218 }
219 "chat_webhook" => {
220 let mut select = webhook_deliveries::Entity::find()
221 .filter(webhook_deliveries::Column::LastError.is_not_null())
222 .order_by_desc(webhook_deliveries::Column::UpdatedAt);
223 if let Some(from) = query.from {
224 select = select.filter(webhook_deliveries::Column::CreatedAt.gte(from));
225 }
226 if let Some(to) = query.to {
227 select = select.filter(webhook_deliveries::Column::CreatedAt.lte(to));
228 }
229 for row in select.limit(fetch_limit).all(self.db().read()).await? {
230 logs.push(JobLogDto {
231 source: "chat_webhook".to_string(),
232 id: row.id,
233 state: format!("{:?}", row.status).to_ascii_lowercase(),
234 error: row.last_error,
235 created_at: row.created_at,
236 updated_at: Some(row.updated_at),
237 });
238 }
239 }
240 "pipeline" => {
241 let mut select = pipeline_run::Entity::find()
242 .filter(pipeline_run::Column::State.eq(PipelineRunState::Failure))
243 .order_by_desc(pipeline_run::Column::UpdatedAt);
244 if let Some(from) = query.from {
245 select = select.filter(pipeline_run::Column::CreatedAt.gte(from));
246 }
247 if let Some(to) = query.to {
248 select = select.filter(pipeline_run::Column::CreatedAt.lte(to));
249 }
250 for row in select.limit(fetch_limit).all(self.db().read()).await? {
251 logs.push(JobLogDto {
252 source: "pipeline".to_string(),
253 id: row.id,
254 state: "failure".to_string(),
255 error: None,
256 created_at: row.created_at,
257 updated_at: Some(row.updated_at),
258 });
259 }
260 }
261 "system_tasks" => {
262 let mut select = system_tasks::Entity::find()
263 .filter(system_tasks::Column::Error.is_not_null())
264 .order_by_desc(system_tasks::Column::CreatedAt);
265 if let Some(from) = query.from {
266 select = select.filter(system_tasks::Column::CreatedAt.gte(from));
267 }
268 if let Some(to) = query.to {
269 select = select.filter(system_tasks::Column::CreatedAt.lte(to));
270 }
271 for row in select.limit(fetch_limit).all(self.db().read()).await? {
272 logs.push(JobLogDto {
273 source: "system_tasks".to_string(),
274 id: row.id,
275 state: format!("{:?}", row.status).to_ascii_lowercase(),
276 error: row.error,
277 created_at: row.created_at,
278 updated_at: row.updated_at,
279 });
280 }
281 }
282 _ => {}
283 }
284 }
285
286 logs.sort_by(|left, right| right.created_at.cmp(&left.created_at));
287 let total = logs.len() as i64;
288 let items = logs
289 .into_iter()
290 .skip(offset as usize)
291 .take(limit as usize)
292 .collect();
293 Ok(JobLogPageDto {
294 items,
295 total,
296 limit,
297 offset,
298 })
299 }
300}
No newline at end of file
Added lib/admin/src/services/mod.rs View lib/admin/src/services/mod.rs
@@ -0,0 +1,10 @@
1pub mod audit;
2pub mod auth;
3pub mod logs;
4pub mod queues;
5pub mod service;
6pub mod spaces;
7pub mod storage;
8pub mod system;
9pub mod tasks;
10pub mod users;
No newline at end of file
Added lib/admin/src/services/queues.rs View lib/admin/src/services/queues.rs
@@ -0,0 +1,484 @@
1use models::admin::admin_user::AdminRole;
2use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus};
3use models::import::job;
4use models::repo::pipeline_run::{self, PipelineRunState};
5use models::search::index_job;
6use models::system::system_tasks::{self, TaskStatus};
7use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
8use sea_orm::sea_query::Expr;
9use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12use utoipa::ToSchema;
13
14use crate::errors::AdminError;
15use crate::services::audit::{AuditCtx, AuditRecord};
16use crate::services::service::AdminService;
17use crate::share::roles::AdminActor;
18
19pub const QUEUE_GIT_SYNC: &str = "git_sync";
20pub const QUEUE_EMAIL: &str = "email";
21pub const QUEUE_SEARCH_INDEX: &str = "search_index";
22pub const QUEUE_IMPORT: &str = "import";
23pub const QUEUE_CHAT_WEBHOOK: &str = "chat_webhook";
24pub const QUEUE_PIPELINE: &str = "pipeline";
25pub const QUEUE_DELETION: &str = "deletion";
26pub const QUEUE_SYSTEM_TASKS: &str = "system_tasks";
27
28#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
29pub struct QueueSnapshotDto {
30 pub name: String,
31 pub kind: String,
32 pub enabled: bool,
33 pub depth: Option<i64>,
34 pub queued: i64,
35 pub running: i64,
36 pub failed: i64,
37 pub succeeded: i64,
38 pub note: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
42pub struct QueueActionResp {
43 pub queue: String,
44 pub affected: u64,
45}
46
47impl AdminService {
48 async fn git_sync_depth(&self) -> Result<i64, AdminError> {
49 let topic = self.config().git.sync.queue_topic.clone();
50 let mut conn = self.redis().connection_clone();
51 let depth: i64 = match &mut conn {
52 export::RedisConnection::Standalone(connection) => {
53 redis::cmd("LLEN").arg(&topic).query_async(connection).await?
54 }
55 export::RedisConnection::Cluster(connection) => {
56 redis::cmd("LLEN").arg(&topic).query_async(connection).await?
57 }
58 };
59 Ok(depth)
60 }
61
62 pub async fn queue_snapshots(&self) -> Result<Vec<QueueSnapshotDto>, AdminError> {
63 let mut snapshots = Vec::new();
64
65 let depth = self.git_sync_depth().await?;
66 snapshots.push(QueueSnapshotDto {
67 name: QUEUE_GIT_SYNC.to_string(),
68 kind: "redis_list".to_string(),
69 enabled: true,
70 depth: Some(depth),
71 queued: depth,
72 running: 0,
73 failed: 0,
74 succeeded: 0,
75 note: Some(format!(
76 "topic={} concurrency={}",
77 self.config().git.sync.queue_topic,
78 self.config().git.sync.concurrency.max(1)
79 )),
80 });
81
82 snapshots.push(QueueSnapshotDto {
83 name: QUEUE_EMAIL.to_string(),
84 kind: "redis_pubsub".to_string(),
85 enabled: self.config().email_queue.enabled(),
86 depth: None,
87 queued: 0,
88 running: 0,
89 failed: 0,
90 succeeded: 0,
91 note: Some("redis pub/sub fan-out, no persistent backlog".to_string()),
92 });
93
94 let search_queued = index_job::Entity::find()
95 .filter(index_job::Column::State.eq("queued"))
96 .count(self.db().read())
97 .await? as i64;
98 let search_processing = index_job::Entity::find()
99 .filter(index_job::Column::State.eq("processing"))
100 .count(self.db().read())
101 .await? as i64;
102 let search_succeeded = index_job::Entity::find()
103 .filter(index_job::Column::State.eq("succeeded"))
104 .count(self.db().read())
105 .await? as i64;
106 let search_failed = index_job::Entity::find()
107 .filter(index_job::Column::LastError.is_not_null())
108 .count(self.db().read())
109 .await? as i64;
110 snapshots.push(QueueSnapshotDto {
111 name: QUEUE_SEARCH_INDEX.to_string(),
112 kind: "db".to_string(),
113 enabled: self.config().search.enabled,
114 depth: Some(search_queued + search_processing),
115 queued: search_queued,
116 running: search_processing,
117 failed: search_failed,
118 succeeded: search_succeeded,
119 note: None,
120 });
121
122 let import_queued = job::Entity::find()
123 .filter(job::Column::State.eq("queued"))
124 .count(self.db().read())
125 .await? as i64;
126 let import_processing = job::Entity::find()
127 .filter(job::Column::State.eq("processing"))
128 .count(self.db().read())
129 .await? as i64;
130 let import_failed = job::Entity::find()
131 .filter(job::Column::State.eq("failed"))
132 .count(self.db().read())
133 .await? as i64;
134 let import_succeeded = job::Entity::find()
135 .filter(job::Column::State.eq("succeeded"))
136 .count(self.db().read())
137 .await? as i64;
138 snapshots.push(QueueSnapshotDto {
139 name: QUEUE_IMPORT.to_string(),
140 kind: "db".to_string(),
141 enabled: self.config().import.enabled,
142 depth: Some(import_queued + import_processing),
143 queued: import_queued,
144 running: import_processing,
145 failed: import_failed,
146 succeeded: import_succeeded,
147 note: Some(format!(
148 "max_concurrent_jobs={}",
149 self.config().import.max_concurrent_jobs.max(1)
150 )),
151 });
152
153 let webhook_pending = webhook_deliveries::Entity::find()
154 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending))
155 .count(self.db().read())
156 .await? as i64;
157 let webhook_running = webhook_deliveries::Entity::find()
158 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Delivering))
159 .count(self.db().read())
160 .await? as i64;
161 let webhook_failed = webhook_deliveries::Entity::find()
162 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed))
163 .count(self.db().read())
164 .await? as i64;
165 let webhook_done = webhook_deliveries::Entity::find()
166 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Done))
167 .count(self.db().read())
168 .await? as i64;
169 snapshots.push(QueueSnapshotDto {
170 name: QUEUE_CHAT_WEBHOOK.to_string(),
171 kind: "db".to_string(),
172 enabled: true,
173 depth: Some(webhook_pending + webhook_running),
174 queued: webhook_pending,
175 running: webhook_running,
176 failed: webhook_failed,
177 succeeded: webhook_done,
178 note: None,
179 });
180
181 let pipeline_queued = pipeline_run::Entity::find()
182 .filter(pipeline_run::Column::State.eq(PipelineRunState::Queued))
183 .count(self.db().read())
184 .await? as i64;
185 let pipeline_running = pipeline_run::Entity::find()
186 .filter(pipeline_run::Column::State.eq(PipelineRunState::Running))
187 .count(self.db().read())
188 .await? as i64;
189 let pipeline_failed = pipeline_run::Entity::find()
190 .filter(pipeline_run::Column::State.eq(PipelineRunState::Failure))
191 .count(self.db().read())
192 .await? as i64;
193 let pipeline_succeeded = pipeline_run::Entity::find()
194 .filter(pipeline_run::Column::State.eq(PipelineRunState::Success))
195 .count(self.db().read())
196 .await? as i64;
197 snapshots.push(QueueSnapshotDto {
198 name: QUEUE_PIPELINE.to_string(),
199 kind: "db".to_string(),
200 enabled: true,
201 depth: Some(pipeline_queued + pipeline_running),
202 queued: pipeline_queued,
203 running: pipeline_running,
204 failed: pipeline_failed,
205 succeeded: pipeline_succeeded,
206 note: Some("external runner driven".to_string()),
207 });
208
209 let deletion_pending = user_deletion_requests::Entity::find()
210 .filter(user_deletion_requests::Column::Status.eq(UserDeletionRequestStatus::Pending))
211 .count(self.db().read())
212 .await? as i64;
213 let deletion_scheduled = user_deletion_requests::Entity::find()
214 .filter(user_deletion_requests::Column::Status.eq(UserDeletionRequestStatus::Scheduled))
215 .count(self.db().read())
216 .await? as i64;
217 let deletion_completed = user_deletion_requests::Entity::find()
218 .filter(user_deletion_requests::Column::Status.eq(UserDeletionRequestStatus::Completed))
219 .count(self.db().read())
220 .await? as i64;
221 snapshots.push(QueueSnapshotDto {
222 name: QUEUE_DELETION.to_string(),
223 kind: "db".to_string(),
224 enabled: true,
225 depth: Some(deletion_pending + deletion_scheduled),
226 queued: deletion_scheduled,
227 running: deletion_pending,
228 failed: 0,
229 succeeded: deletion_completed,
230 note: None,
231 });
232
233 let task_pending = system_tasks::Entity::find()
234 .filter(system_tasks::Column::Status.eq(TaskStatus::Pending))
235 .count(self.db().read())
236 .await? as i64;
237 let task_running = system_tasks::Entity::find()
238 .filter(system_tasks::Column::Status.eq(TaskStatus::Running))
239 .count(self.db().read())
240 .await? as i64;
241 let task_failed = system_tasks::Entity::find()
242 .filter(system_tasks::Column::Status.eq(TaskStatus::Failed))
243 .count(self.db().read())
244 .await? as i64;
245 let task_succeeded = system_tasks::Entity::find()
246 .filter(system_tasks::Column::Status.eq(TaskStatus::Success))
247 .count(self.db().read())
248 .await? as i64;
249 snapshots.push(QueueSnapshotDto {
250 name: QUEUE_SYSTEM_TASKS.to_string(),
251 kind: "db".to_string(),
252 enabled: true,
253 depth: Some(task_pending + task_running),
254 queued: task_pending,
255 running: task_running,
256 failed: task_failed,
257 succeeded: task_succeeded,
258 note: Some("reserved scheduler table".to_string()),
259 });
260
261 Ok(snapshots)
262 }
263
264 pub async fn requeue_failed(
265 &self,
266 actor: &AdminActor,
267 queue: &str,
268 ctx: &AuditCtx,
269 ) -> Result<QueueActionResp, AdminError> {
270 actor.require(AdminRole::Operator)?;
271 let now = time::OffsetDateTime::now_utc();
272 let affected = match queue {
273 QUEUE_SEARCH_INDEX => index_job::Entity::update_many()
274 .col_expr(index_job::Column::State, Expr::value("queued"))
275 .col_expr(index_job::Column::RunAfter, Expr::value(now))
276 .col_expr(
277 index_job::Column::LockedAt,
278 Expr::value(Option::<time::OffsetDateTime>::None),
279 )
280 .col_expr(index_job::Column::UpdatedAt, Expr::value(now))
281 .filter(index_job::Column::LastError.is_not_null())
282 .filter(index_job::Column::State.eq("queued"))
283 .exec(self.db().write())
284 .await?
285 .rows_affected,
286 QUEUE_IMPORT => job::Entity::update_many()
287 .col_expr(job::Column::State, Expr::value("queued"))
288 .col_expr(job::Column::RunAfter, Expr::value(now))
289 .col_expr(
290 job::Column::LockedAt,
291 Expr::value(Option::<time::OffsetDateTime>::None),
292 )
293 .col_expr(
294 job::Column::FinishedAt,
295 Expr::value(Option::<time::OffsetDateTime>::None),
296 )
297 .col_expr(job::Column::UpdatedAt, Expr::value(now))
298 .filter(job::Column::State.is_in(["failed", "canceled"]))
299 .exec(self.db().write())
300 .await?
301 .rows_affected,
302 QUEUE_CHAT_WEBHOOK => webhook_deliveries::Entity::update_many()
303 .col_expr(
304 webhook_deliveries::Column::Status,
305 Expr::value(WebhookDeliveryStatus::Pending),
306 )
307 .col_expr(webhook_deliveries::Column::NextRetryAt, Expr::value(now))
308 .col_expr(webhook_deliveries::Column::UpdatedAt, Expr::value(now))
309 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed))
310 .exec(self.db().write())
311 .await?
312 .rows_affected,
313 QUEUE_PIPELINE => pipeline_run::Entity::update_many()
314 .col_expr(
315 pipeline_run::Column::State,
316 Expr::value(PipelineRunState::Queued),
317 )
318 .col_expr(
319 pipeline_run::Column::StartedAt,
320 Expr::value(Option::<time::OffsetDateTime>::None),
321 )
322 .col_expr(
323 pipeline_run::Column::FinishedAt,
324 Expr::value(Option::<time::OffsetDateTime>::None),
325 )
326 .col_expr(pipeline_run::Column::UpdatedAt, Expr::value(now))
327 .filter(
328 pipeline_run::Column::State
329 .is_in([PipelineRunState::Failure, PipelineRunState::Cancelled]),
330 )
331 .exec(self.db().write())
332 .await?
333 .rows_affected,
334 QUEUE_DELETION => user_deletion_requests::Entity::update_many()
335 .col_expr(
336 user_deletion_requests::Column::Status,
337 Expr::value(UserDeletionRequestStatus::Scheduled),
338 )
339 .col_expr(
340 user_deletion_requests::Column::ScheduledFor,
341 Expr::value(Some(now)),
342 )
343 .col_expr(
344 user_deletion_requests::Column::CancelledAt,
345 Expr::value(Option::<time::OffsetDateTime>::None),
346 )
347 .col_expr(user_deletion_requests::Column::UpdatedAt, Expr::value(now))
348 .filter(
349 user_deletion_requests::Column::Status
350 .eq(UserDeletionRequestStatus::Cancelled),
351 )
352 .exec(self.db().write())
353 .await?
354 .rows_affected,
355 QUEUE_SYSTEM_TASKS => system_tasks::Entity::update_many()
356 .col_expr(system_tasks::Column::Status, Expr::value(TaskStatus::Pending))
357 .col_expr(system_tasks::Column::NextRunAt, Expr::value(Some(now)))
358 .col_expr(
359 system_tasks::Column::Error,
360 Expr::value(Option::<String>::None),
361 )
362 .col_expr(
363 system_tasks::Column::StartedAt,
364 Expr::value(Option::<time::OffsetDateTime>::None),
365 )
366 .col_expr(
367 system_tasks::Column::FinishedAt,
368 Expr::value(Option::<time::OffsetDateTime>::None),
369 )
370 .col_expr(system_tasks::Column::UpdatedAt, Expr::value(now))
371 .filter(system_tasks::Column::Status.eq(TaskStatus::Failed))
372 .exec(self.db().write())
373 .await?
374 .rows_affected,
375 _ => {
376 return Err(AdminError::BadRequest(format!(
377 "queue {queue} does not support requeue"
378 )));
379 }
380 };
381 self.audit(
382 Some(actor),
383 AuditRecord {
384 action: "queue.requeued",
385 target: Some(("queue", queue.to_string())),
386 detail: json!({ "affected": affected }),
387 },
388 ctx,
389 )
390 .await?;
391 Ok(QueueActionResp {
392 queue: queue.to_string(),
393 affected,
394 })
395 }
396
397 pub async fn purge_queue(
398 &self,
399 actor: &AdminActor,
400 queue: &str,
401 ctx: &AuditCtx,
402 ) -> Result<QueueActionResp, AdminError> {
403 actor.require(AdminRole::SuperAdmin)?;
404 let affected = match queue {
405 QUEUE_GIT_SYNC => {
406 let topic = self.config().git.sync.queue_topic.clone();
407 let mut conn = self.redis().connection_clone();
408 let deleted: i64 = match &mut conn {
409 export::RedisConnection::Standalone(connection) => {
410 redis::cmd("DEL").arg(&topic).query_async(connection).await?
411 }
412 export::RedisConnection::Cluster(connection) => {
413 redis::cmd("DEL").arg(&topic).query_async(connection).await?
414 }
415 };
416 deleted.max(0) as u64
417 }
418 QUEUE_SEARCH_INDEX => index_job::Entity::delete_many()
419 .filter(index_job::Column::State.is_in(["succeeded", "cancelled"]))
420 .exec(self.db().write())
421 .await?
422 .rows_affected,
423 QUEUE_IMPORT => job::Entity::delete_many()
424 .filter(job::Column::State.is_in(["succeeded", "failed", "canceled"]))
425 .exec(self.db().write())
426 .await?
427 .rows_affected,
428 QUEUE_CHAT_WEBHOOK => webhook_deliveries::Entity::delete_many()
429 .filter(
430 webhook_deliveries::Column::Status
431 .is_in([WebhookDeliveryStatus::Done, WebhookDeliveryStatus::Failed]),
432 )
433 .exec(self.db().write())
434 .await?
435 .rows_affected,
436 QUEUE_PIPELINE => pipeline_run::Entity::delete_many()
437 .filter(
438 pipeline_run::Column::State.is_in([
439 PipelineRunState::Success,
440 PipelineRunState::Failure,
441 PipelineRunState::Cancelled,
442 ]),
443 )
444 .exec(self.db().write())
445 .await?
446 .rows_affected,
447 QUEUE_DELETION => user_deletion_requests::Entity::delete_many()
448 .filter(user_deletion_requests::Column::Status.is_in([
449 UserDeletionRequestStatus::Completed,
450 UserDeletionRequestStatus::Cancelled,
451 ]))
452 .exec(self.db().write())
453 .await?
454 .rows_affected,
455 QUEUE_SYSTEM_TASKS => system_tasks::Entity::delete_many()
456 .filter(
457 system_tasks::Column::Status
458 .is_in([TaskStatus::Success, TaskStatus::Failed]),
459 )
460 .exec(self.db().write())
461 .await?
462 .rows_affected,
463 _ => {
464 return Err(AdminError::BadRequest(format!(
465 "queue {queue} does not support purge"
466 )));
467 }
468 };
469 self.audit(
470 Some(actor),
471 AuditRecord {
472 action: "queue.purged",
473 target: Some(("queue", queue.to_string())),
474 detail: json!({ "affected": affected }),
475 },
476 ctx,
477 )
478 .await?;
479 Ok(QueueActionResp {
480 queue: queue.to_string(),
481 affected,
482 })
483 }
484}
No newline at end of file
Added lib/admin/src/services/service.rs View lib/admin/src/services/service.rs
@@ -0,0 +1,44 @@
1use std::sync::Arc;
2use std::time::Instant;
3
4use config::AppConfig;
5use export::{AppDatabase, AppRedis, AppState, AppStorage};
6
7#[derive(Clone)]
8pub struct AdminService {
9 pub state: AppState,
10 started_at: Instant,
11}
12
13impl AdminService {
14 pub fn new(state: AppState) -> Self {
15 Self {
16 state,
17 started_at: Instant::now(),
18 }
19 }
20
21 pub fn config(&self) -> &AppConfig {
22 &self.state.config
23 }
24
25 pub fn admin_config(&self) -> &config::admin::AdminConfig {
26 &self.state.config.admin
27 }
28
29 pub fn db(&self) -> &Arc<AppDatabase> {
30 &self.state.database
31 }
32
33 pub fn redis(&self) -> &AppRedis {
34 &self.state.redis
35 }
36
37 pub fn storage(&self) -> &Arc<AppStorage> {
38 &self.state.storage
39 }
40
41 pub fn uptime_secs(&self) -> u64 {
42 self.started_at.elapsed().as_secs()
43 }
44}
No newline at end of file
Added lib/admin/src/services/spaces.rs View lib/admin/src/services/spaces.rs
@@ -0,0 +1,483 @@
1use export::pagination::Page;
2use models::admin::admin_user::AdminRole;
3use models::dir::dirs;
4use models::repo::repos;
5use models::room::rooms;
6use models::space::space_members::{self, MemberStatus};
7use models::space::spaces;
8use models::users::user;
9use sea_orm::sea_query::Expr;
10use sea_orm::{
11 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
12 QuerySelect, Set,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use time::OffsetDateTime;
17use utoipa::{IntoParams, ToSchema};
18use uuid::Uuid;
19
20use crate::errors::AdminError;
21use crate::services::audit::{AuditCtx, AuditRecord};
22use crate::services::service::AdminService;
23use crate::share::roles::AdminActor;
24
25#[derive(Debug, Clone, Deserialize, IntoParams)]
26pub struct ListSpacesQuery {
27 pub q: Option<String>,
28 pub status: Option<String>,
29 pub owner_id: Option<Uuid>,
30 pub limit: Option<u64>,
31 pub offset: Option<u64>,
32}
33
34#[derive(Debug, Clone, Deserialize, IntoParams)]
35pub struct ListMembersQuery {
36 pub status: Option<String>,
37 pub limit: Option<u64>,
38 pub offset: Option<u64>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
42pub struct AdminSpaceSummary {
43 pub id: Uuid,
44 pub name: String,
45 pub lower_name: String,
46 pub description: Option<String>,
47 pub owner_id: Uuid,
48 pub owner_username: Option<String>,
49 pub is_private: bool,
50 pub is_active: bool,
51 pub nums_members: i64,
52 pub created_at: OffsetDateTime,
53 pub updated_at: OffsetDateTime,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
57pub struct AdminSpaceDetail {
58 pub space: AdminSpaceSummary,
59 pub repos_count: i64,
60 pub repos_size: i64,
61 pub rooms_count: i64,
62 pub dirs_count: i64,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
66pub struct UpdateSpaceCmd {
67 #[serde(default)]
68 pub name: Option<String>,
69 #[serde(default)]
70 pub description: Option<Option<String>>,
71 #[serde(default)]
72 pub is_private: Option<bool>,
73 #[serde(default)]
74 pub is_active: Option<bool>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
78pub struct TransferSpaceCmd {
79 pub new_owner_id: Uuid,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
83pub struct AdminSpaceMemberDto {
84 pub user_id: Uuid,
85 pub username: Option<String>,
86 pub display_name: Option<String>,
87 pub alias_name: Option<String>,
88 pub status: String,
89 pub joined_at: OffsetDateTime,
90 pub last_active_at: Option<OffsetDateTime>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
94pub struct RemoveMemberResp {
95 pub space_id: Uuid,
96 pub user_id: Uuid,
97 pub removed: bool,
98}
99
100fn member_status_str(status: MemberStatus) -> String {
101 match status {
102 MemberStatus::Active => "active",
103 MemberStatus::Inactive => "inactive",
104 MemberStatus::Left => "left",
105 MemberStatus::Removed => "removed",
106 }
107 .to_string()
108}
109
110impl AdminService {
111 async fn owner_usernames(
112 &self,
113 owner_ids: &[Uuid],
114 ) -> Result<std::collections::HashMap<Uuid, String>, AdminError> {
115 if owner_ids.is_empty() {
116 return Ok(std::collections::HashMap::new());
117 }
118 let rows = user::Entity::find()
119 .filter(user::Column::Uid.is_in(owner_ids.to_vec()))
120 .all(self.db().read())
121 .await?;
122 Ok(rows
123 .into_iter()
124 .map(|row| (row.uid, row.username))
125 .collect())
126 }
127
128 pub async fn list_spaces(
129 &self,
130 query: ListSpacesQuery,
131 ) -> Result<Page<AdminSpaceSummary>, AdminError> {
132 let limit = query.limit.unwrap_or(50).clamp(1, 100);
133 let offset = query.offset.unwrap_or(0);
134 let mut select = spaces::Entity::find();
135 if let Some(raw) = query
136 .q
137 .as_deref()
138 .map(str::trim)
139 .filter(|value| !value.is_empty())
140 {
141 let needle = raw.to_ascii_lowercase();
142 select = select.filter(
143 Condition::any()
144 .add(spaces::Column::LowerName.contains(needle))
145 .add(spaces::Column::Name.contains(raw)),
146 );
147 }
148 if let Some(owner_id) = query.owner_id {
149 select = select.filter(spaces::Column::OwnerId.eq(owner_id));
150 }
151 select = match query.status.as_deref() {
152 Some("active") => select.filter(spaces::Column::IsActive.eq(true)),
153 Some("disabled") => select.filter(spaces::Column::IsActive.eq(false)),
154 _ => select,
155 };
156 select = select.order_by_desc(spaces::Column::CreatedAt);
157 let total = select.clone().count(self.db().read()).await? as i64;
158 let rows = select
159 .limit(limit)
160 .offset(offset)
161 .all(self.db().read())
162 .await?;
163 let owner_ids: Vec<Uuid> = rows.iter().map(|row| row.owner_id).collect();
164 let owners = self.owner_usernames(&owner_ids).await?;
165 let items = rows
166 .iter()
167 .map(|row| AdminSpaceSummary {
168 id: row.id,
169 name: row.name.clone(),
170 lower_name: row.lower_name.clone(),
171 description: row.description.clone(),
172 owner_id: row.owner_id,
173 owner_username: owners.get(&row.owner_id).cloned(),
174 is_private: row.is_private,
175 is_active: row.is_active,
176 nums_members: row.nums_members,
177 created_at: row.created_at,
178 updated_at: row.updated_at,
179 })
180 .collect();
181 Ok(Page {
182 items,
183 total,
184 limit,
185 offset,
186 })
187 }
188
189 pub async fn get_space(&self, id: Uuid) -> Result<AdminSpaceDetail, AdminError> {
190 let row = spaces::Entity::find_by_id(id)
191 .one(self.db().read())
192 .await?
193 .ok_or(AdminError::NotFound)?;
194 let owners = self.owner_usernames(&[row.owner_id]).await?;
195 let dir_ids: Vec<Uuid> = dirs::Entity::find()
196 .filter(dirs::Column::SpaceId.eq(id))
197 .all(self.db().read())
198 .await?
199 .into_iter()
200 .map(|dir| dir.id)
201 .collect();
202 let dirs_count = dir_ids.len() as i64;
203 let (repos_count, repos_size) = if dir_ids.is_empty() {
204 (0, 0)
205 } else {
206 let repo_rows = repos::Entity::find()
207 .filter(repos::Column::DirId.is_in(dir_ids))
208 .all(self.db().read())
209 .await?;
210 let count = repo_rows.len() as i64;
211 let size = repo_rows.iter().map(|repo| repo.size).sum();
212 (count, size)
213 };
214 let rooms_count = rooms::Entity::find()
215 .filter(rooms::Column::SpaceId.eq(id))
216 .count(self.db().read())
217 .await? as i64;
218 Ok(AdminSpaceDetail {
219 space: AdminSpaceSummary {
220 id: row.id,
221 name: row.name.clone(),
222 lower_name: row.lower_name.clone(),
223 description: row.description.clone(),
224 owner_id: row.owner_id,
225 owner_username: owners.get(&row.owner_id).cloned(),
226 is_private: row.is_private,
227 is_active: row.is_active,
228 nums_members: row.nums_members,
229 created_at: row.created_at,
230 updated_at: row.updated_at,
231 },
232 repos_count,
233 repos_size,
234 rooms_count,
235 dirs_count,
236 })
237 }
238 pub async fn update_space(
239 &self,
240 actor: &AdminActor,
241 id: Uuid,
242 cmd: UpdateSpaceCmd,
243 ctx: &AuditCtx,
244 ) -> Result<AdminSpaceDetail, AdminError> {
245 actor.require(AdminRole::Operator)?;
246 let row = spaces::Entity::find_by_id(id)
247 .one(self.db().read())
248 .await?
249 .ok_or(AdminError::NotFound)?;
250 let mut am: spaces::ActiveModel = row.into();
251 let mut changed: Vec<String> = Vec::new();
252 if let Some(name) = cmd.name {
253 let name = name.trim().to_string();
254 if name.is_empty() || name.chars().count() > 64 {
255 return Err(AdminError::BadRequest(
256 "name must be 1 to 64 characters".to_string(),
257 ));
258 }
259 am.name = Set(name);
260 changed.push("name".to_string());
261 }
262 if let Some(description) = cmd.description {
263 am.description = Set(description.map(|value| value.trim().to_string()));
264 changed.push("description".to_string());
265 }
266 if let Some(value) = cmd.is_private {
267 am.is_private = Set(value);
268 changed.push("is_private".to_string());
269 }
270 if let Some(value) = cmd.is_active {
271 am.is_active = Set(value);
272 changed.push("is_active".to_string());
273 }
274 if changed.is_empty() {
275 return self.get_space(id).await;
276 }
277 am.updated_at = Set(OffsetDateTime::now_utc());
278 am.update(self.db().write()).await?;
279 self.audit(
280 Some(actor),
281 AuditRecord {
282 action: "space.updated",
283 target: Some(("space", id.to_string())),
284 detail: json!({ "changed": changed }),
285 },
286 ctx,
287 )
288 .await?;
289 self.get_space(id).await
290 }
291
292 pub async fn transfer_space(
293 &self,
294 actor: &AdminActor,
295 id: Uuid,
296 cmd: TransferSpaceCmd,
297 ctx: &AuditCtx,
298 ) -> Result<AdminSpaceDetail, AdminError> {
299 actor.require(AdminRole::SuperAdmin)?;
300 let row = spaces::Entity::find_by_id(id)
301 .one(self.db().read())
302 .await?
303 .ok_or(AdminError::NotFound)?;
304 let new_owner = user::Entity::find_by_id(cmd.new_owner_id)
305 .one(self.db().read())
306 .await?
307 .ok_or_else(|| AdminError::BadRequest("new owner does not exist".to_string()))?;
308 if !new_owner.is_active {
309 return Err(AdminError::BadRequest(
310 "new owner account is not active".to_string(),
311 ));
312 }
313 if new_owner.uid == row.owner_id {
314 return Err(AdminError::Conflict(
315 "new owner is already the space owner".to_string(),
316 ));
317 }
318 let previous = row.owner_id;
319 let mut am: spaces::ActiveModel = row.into();
320 am.owner_id = Set(new_owner.uid);
321 am.updated_at = Set(OffsetDateTime::now_utc());
322 am.update(self.db().write()).await?;
323 self.audit(
324 Some(actor),
325 AuditRecord {
326 action: "space.transferred",
327 target: Some(("space", id.to_string())),
328 detail: json!({ "previous_owner_id": previous, "new_owner_id": new_owner.uid, "new_owner_username": new_owner.username }),
329 },
330 ctx,
331 )
332 .await?;
333 self.get_space(id).await
334 }
335
336 pub async fn delete_space(
337 &self,
338 actor: &AdminActor,
339 id: Uuid,
340 ctx: &AuditCtx,
341 ) -> Result<(), AdminError> {
342 actor.require(AdminRole::SuperAdmin)?;
343 let row = spaces::Entity::find_by_id(id)
344 .one(self.db().read())
345 .await?
346 .ok_or(AdminError::NotFound)?;
347 if !row.is_active {
348 return Ok(());
349 }
350 let mut am: spaces::ActiveModel = row.into();
351 am.is_active = Set(false);
352 am.updated_at = Set(OffsetDateTime::now_utc());
353 am.update(self.db().write()).await?;
354 self.audit(
355 Some(actor),
356 AuditRecord {
357 action: "space.deleted",
358 target: Some(("space", id.to_string())),
359 detail: json!({}),
360 },
361 ctx,
362 )
363 .await?;
364 Ok(())
365 }
366
367 pub async fn list_space_members(
368 &self,
369 space_id: Uuid,
370 query: ListMembersQuery,
371 ) -> Result<Page<AdminSpaceMemberDto>, AdminError> {
372 let limit = query.limit.unwrap_or(50).clamp(1, 100);
373 let offset = query.offset.unwrap_or(0);
374 let mut select =
375 space_members::Entity::find().filter(space_members::Column::SpaceId.eq(space_id));
376 select = match query.status.as_deref() {
377 Some("active") => select.filter(space_members::Column::Status.eq(MemberStatus::Active)),
378 Some("inactive") => {
379 select.filter(space_members::Column::Status.eq(MemberStatus::Inactive))
380 }
381 Some("left") => select.filter(space_members::Column::Status.eq(MemberStatus::Left)),
382 Some("removed") => select.filter(space_members::Column::Status.eq(MemberStatus::Removed)),
383 _ => select,
384 };
385 select = select.order_by_desc(space_members::Column::JoinedAt);
386 let total = select.clone().count(self.db().read()).await? as i64;
387 let rows = select
388 .limit(limit)
389 .offset(offset)
390 .all(self.db().read())
391 .await?;
392 let user_ids: Vec<Uuid> = rows.iter().map(|row| row.user_id).collect();
393 let users: std::collections::HashMap<Uuid, (String, String)> = if user_ids.is_empty() {
394 std::collections::HashMap::new()
395 } else {
396 user::Entity::find()
397 .filter(user::Column::Uid.is_in(user_ids))
398 .all(self.db().read())
399 .await?
400 .into_iter()
401 .map(|row| (row.uid, (row.username, row.display_name)))
402 .collect()
403 };
404 let items = rows
405 .iter()
406 .map(|row| {
407 let info = users.get(&row.user_id);
408 AdminSpaceMemberDto {
409 user_id: row.user_id,
410 username: info.map(|value| value.0.clone()),
411 display_name: info.map(|value| value.1.clone()),
412 alias_name: row.alias_name.clone(),
413 status: member_status_str(row.status),
414 joined_at: row.joined_at,
415 last_active_at: row.last_active_at,
416 }
417 })
418 .collect();
419 Ok(Page {
420 items,
421 total,
422 limit,
423 offset,
424 })
425 }
426
427 pub async fn remove_space_member(
428 &self,
429 actor: &AdminActor,
430 space_id: Uuid,
431 user_id: Uuid,
432 ctx: &AuditCtx,
433 ) -> Result<RemoveMemberResp, AdminError> {
434 actor.require(AdminRole::Operator)?;
435 let space = spaces::Entity::find_by_id(space_id)
436 .one(self.db().read())
437 .await?
438 .ok_or(AdminError::NotFound)?;
439 let row = space_members::Entity::find_by_id((space_id, user_id))
440 .one(self.db().read())
441 .await?
442 .ok_or(AdminError::NotFound)?;
443 if row.status == MemberStatus::Removed {
444 return Ok(RemoveMemberResp {
445 space_id,
446 user_id,
447 removed: false,
448 });
449 }
450 let was_active = row.status == MemberStatus::Active;
451 let now = OffsetDateTime::now_utc();
452 let mut am: space_members::ActiveModel = row.into();
453 am.status = Set(MemberStatus::Removed);
454 am.left_at = Set(Some(now));
455 am.updated_at = Set(Some(now));
456 am.update(self.db().write()).await?;
457 if was_active {
458 spaces::Entity::update_many()
459 .col_expr(
460 spaces::Column::NumsMembers,
461 Expr::cust("GREATEST(nums_members - 1, 0)"),
462 )
463 .filter(spaces::Column::Id.eq(space.id))
464 .exec(self.db().write())
465 .await?;
466 }
467 self.audit(
468 Some(actor),
469 AuditRecord {
470 action: "space.member_removed",
471 target: Some(("space", space_id.to_string())),
472 detail: json!({ "user_id": user_id }),
473 },
474 ctx,
475 )
476 .await?;
477 Ok(RemoveMemberResp {
478 space_id,
479 user_id,
480 removed: true,
481 })
482 }
483}
No newline at end of file
Added lib/admin/src/services/storage.rs View lib/admin/src/services/storage.rs
@@ -0,0 +1,230 @@
1use export::redis;
2use models::admin::admin_user::AdminRole;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use time::OffsetDateTime;
6use utoipa::{IntoParams, ToSchema};
7
8use crate::errors::AdminError;
9use crate::services::audit::{AuditCtx, AuditRecord};
10use crate::services::service::AdminService;
11use crate::share::roles::AdminActor;
12
13const OVERVIEW_CACHE_KEY: &str = "admin:storage:overview";
14const OVERVIEW_CACHE_TTL_SECS: u64 = 60;
15const MAX_PRESIGN_SECS: u32 = 3600;
16
17const CATEGORY_PREFIXES: [(&str, &str); 6] = [
18 ("user_avatars", "avatars/"),
19 ("space_avatars", "space-avatars/"),
20 ("space_images", "space-images/"),
21 ("chat", "chat/"),
22 ("lfs", "lfs/"),
23 ("git", "git/"),
24];
25
26#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
27pub struct StorageCategoryDto {
28 pub name: String,
29 pub prefix: String,
30 pub bytes: u64,
31 pub objects: u64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
35pub struct StorageOverviewDto {
36 pub bucket: String,
37 pub total_bytes: u64,
38 pub total_objects: u64,
39 pub categories: Vec<StorageCategoryDto>,
40 pub generated_at: OffsetDateTime,
41 pub cached: bool,
42}
43
44#[derive(Debug, Clone, Deserialize, IntoParams)]
45pub struct ListObjectsQuery {
46 pub prefix: Option<String>,
47 pub token: Option<String>,
48 pub limit: Option<usize>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
52pub struct StorageObjectDto {
53 pub key: String,
54 pub size: u64,
55 pub last_modified: Option<serde_json::Value>,
56 pub etag: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
60pub struct StorageObjectPageDto {
61 pub prefix: String,
62 pub items: Vec<StorageObjectDto>,
63 pub next_token: Option<String>,
64}
65
66#[derive(Debug, Clone, Deserialize, IntoParams)]
67pub struct ObjectKeyQuery {
68 pub key: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
72pub struct DownloadUrlDto {
73 pub key: String,
74 pub url: String,
75 pub expires_in_secs: u32,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
79pub struct DeleteObjectResp {
80 pub key: String,
81 pub deleted: bool,
82}
83
84impl AdminService {
85 pub async fn storage_overview(&self, refresh: bool) -> Result<StorageOverviewDto, AdminError> {
86 if !refresh
87 && let Ok(Some(raw)) = redis::get_string(self.redis(), OVERVIEW_CACHE_KEY).await
88 && let Ok(mut cached) = serde_json::from_str::<StorageOverviewDto>(&raw)
89 {
90 cached.cached = true;
91 return Ok(cached);
92 }
93 let client = self.storage().client();
94 let objects = client.list("").await?;
95 let mut total_bytes: u64 = 0;
96 let mut total_objects: u64 = 0;
97 let mut categories: Vec<StorageCategoryDto> = CATEGORY_PREFIXES
98 .iter()
99 .map(|(name, prefix)| StorageCategoryDto {
100 name: name.to_string(),
101 prefix: prefix.to_string(),
102 bytes: 0,
103 objects: 0,
104 })
105 .collect();
106 let mut other = StorageCategoryDto {
107 name: "other".to_string(),
108 prefix: String::new(),
109 bytes: 0,
110 objects: 0,
111 };
112 for object in &objects {
113 total_bytes += object.size;
114 total_objects += 1;
115 match categories
116 .iter_mut()
117 .find(|category| object.key.starts_with(&category.prefix))
118 {
119 Some(category) => {
120 category.bytes += object.size;
121 category.objects += 1;
122 }
123 None => {
124 other.bytes += object.size;
125 other.objects += 1;
126 }
127 }
128 }
129 categories.push(other);
130 let overview = StorageOverviewDto {
131 bucket: client.bucket_name().to_string(),
132 total_bytes,
133 total_objects,
134 categories,
135 generated_at: OffsetDateTime::now_utc(),
136 cached: false,
137 };
138 if let Ok(serialized) = serde_json::to_string(&overview) {
139 let _ = redis::set_string(
140 self.redis(),
141 OVERVIEW_CACHE_KEY,
142 &serialized,
143 OVERVIEW_CACHE_TTL_SECS,
144 )
145 .await;
146 }
147 Ok(overview)
148 }
149
150 pub async fn list_storage_objects(
151 &self,
152 query: ListObjectsQuery,
153 ) -> Result<StorageObjectPageDto, AdminError> {
154 let prefix = query.prefix.unwrap_or_default();
155 let limit = query.limit.unwrap_or(100).clamp(1, 1000);
156 let page = self
157 .storage()
158 .client()
159 .list_page(&prefix, query.token.as_deref(), Some(limit))
160 .await?;
161 let items = page
162 .objects
163 .into_iter()
164 .map(|object| StorageObjectDto {
165 key: object.key,
166 size: object.size,
167 last_modified: object
168 .last_modified
169 .as_ref()
170 .and_then(|value| serde_json::to_value(value).ok()),
171 etag: object.etag,
172 })
173 .collect();
174 Ok(StorageObjectPageDto {
175 prefix,
176 items,
177 next_token: page.next_continuation_token,
178 })
179 }
180
181 pub async fn storage_download_url(&self, key: String) -> Result<DownloadUrlDto, AdminError> {
182 if key.trim().is_empty() {
183 return Err(AdminError::BadRequest("key must not be empty".to_string()));
184 }
185 let exists = self.storage().client().exists(&key).await?;
186 if !exists {
187 return Err(AdminError::NotFound);
188 }
189 let url = self
190 .storage()
191 .client()
192 .presign_get(&key, MAX_PRESIGN_SECS)?;
193 Ok(DownloadUrlDto {
194 key,
195 url,
196 expires_in_secs: MAX_PRESIGN_SECS,
197 })
198 }
199
200 pub async fn delete_storage_object(
201 &self,
202 actor: &AdminActor,
203 key: String,
204 ctx: &AuditCtx,
205 ) -> Result<DeleteObjectResp, AdminError> {
206 actor.require(AdminRole::SuperAdmin)?;
207 if key.trim().is_empty() {
208 return Err(AdminError::BadRequest("key must not be empty".to_string()));
209 }
210 let exists = self.storage().client().exists(&key).await?;
211 if !exists {
212 return Err(AdminError::NotFound);
213 }
214 self.storage().client().delete(&key).await?;
215 self.audit(
216 Some(actor),
217 AuditRecord {
218 action: "storage.object_deleted",
219 target: Some(("storage_object", key.clone())),
220 detail: json!({ "bucket": self.storage().config().bucket }),
221 },
222 ctx,
223 )
224 .await?;
225 Ok(DeleteObjectResp {
226 key,
227 deleted: true,
228 })
229 }
230}
No newline at end of file
Added lib/admin/src/services/system.rs View lib/admin/src/services/system.rs
@@ -0,0 +1,268 @@
1use std::time::Instant;
2
3use models::admin::admin_user;
4use models::chat::messages;
5use models::issue::issues;
6use models::repo::repos;
7use models::room::rooms;
8use models::space::spaces;
9use models::users::user;
10use sea_orm::{
11 ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, PaginatorTrait, QueryFilter, Statement,
12};
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use utoipa::ToSchema;
16
17use crate::errors::AdminError;
18use crate::services::logs::JobLogDto;
19use crate::services::queues::QueueSnapshotDto;
20use crate::services::service::AdminService;
21
22#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
23pub struct ProcessInfoDto {
24 pub version: String,
25 pub uptime_secs: u64,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
29pub struct DependencyDto {
30 pub ok: bool,
31 pub latency_ms: Option<u64>,
32 pub detail: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
36pub struct DependenciesDto {
37 pub database: DependencyDto,
38 pub redis: DependencyDto,
39 pub storage: DependencyDto,
40 pub search: DependencyDto,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
44pub struct FeaturesDto {
45 pub search_enabled: bool,
46 pub import_enabled: bool,
47 pub email_enabled: bool,
48 pub email_queue_enabled: bool,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
52pub struct CountsDto {
53 pub users: i64,
54 pub active_users: i64,
55 pub spaces: i64,
56 pub active_spaces: i64,
57 pub repos: i64,
58 pub rooms: i64,
59 pub issues: i64,
60 pub messages: i64,
61 pub admin_users: i64,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
65pub struct SystemStatusDto {
66 pub process: ProcessInfoDto,
67 pub dependencies: DependenciesDto,
68 pub features: FeaturesDto,
69 pub counts: CountsDto,
70 pub queues: Vec<QueueSnapshotDto>,
71 pub recent_failures: Vec<JobLogDto>,
72 pub generated_at: OffsetDateTime,
73}
74
75fn latency_ms(start: Instant) -> u64 {
76 start.elapsed().as_millis() as u64
77}
78
79fn info_value(info: &str, key: &str) -> Option<String> {
80 info.lines()
81 .find_map(|line| line.strip_prefix(key))
82 .map(|value| value.trim().to_string())
83}
84
85impl AdminService {
86 async fn database_health(&self) -> DependencyDto {
87 let start = Instant::now();
88 let result = self
89 .db()
90 .read()
91 .query_one_raw(Statement::from_string(DbBackend::Postgres, "SELECT 1"))
92 .await;
93 match result {
94 Ok(_) => DependencyDto {
95 ok: true,
96 latency_ms: Some(latency_ms(start)),
97 detail: None,
98 },
99 Err(error) => DependencyDto {
100 ok: false,
101 latency_ms: Some(latency_ms(start)),
102 detail: Some(error.to_string()),
103 },
104 }
105 }
106
107 async fn redis_health(&self) -> DependencyDto {
108 let start = Instant::now();
109 let mut conn = self.redis().connection_clone();
110 let (ok, info) = match &mut conn {
111 export::RedisConnection::Standalone(connection) => {
112 let ping = redis::cmd("PING")
113 .query_async::<String>(connection)
114 .await
115 .is_ok();
116 let info = redis::cmd("INFO").query_async::<String>(connection).await.ok();
117 (ping, info)
118 }
119 export::RedisConnection::Cluster(connection) => {
120 let ping = redis::cmd("PING")
121 .query_async::<String>(connection)
122 .await
123 .is_ok();
124 let info = redis::cmd("INFO").query_async::<String>(connection).await.ok();
125 (ping, info)
126 }
127 };
128 let detail = info.map(|info| {
129 let memory = info_value(&info, "used_memory_human:")
130 .unwrap_or_else(|| info_value(&info, "used_memory:").unwrap_or_default());
131 let clients = info_value(&info, "connected_clients:").unwrap_or_default();
132 let keys = info
133 .lines()
134 .find_map(|line| line.strip_prefix("db0:keys="))
135 .and_then(|value| value.split(',').next())
136 .unwrap_or_default()
137 .to_string();
138 format!("memory={memory} clients={clients} keys={keys}")
139 });
140 DependencyDto {
141 ok,
142 latency_ms: Some(latency_ms(start)),
143 detail,
144 }
145 }
146
147 async fn storage_health(&self) -> DependencyDto {
148 let start = Instant::now();
149 match self.storage().client().bucket_exists().await {
150 Ok(ok) => DependencyDto {
151 ok,
152 latency_ms: Some(latency_ms(start)),
153 detail: Some(format!(
154 "bucket={}",
155 self.storage().client().bucket_name()
156 )),
157 },
158 Err(error) => DependencyDto {
159 ok: false,
160 latency_ms: Some(latency_ms(start)),
161 detail: Some(error.to_string()),
162 },
163 }
164 }
165
166 async fn search_health(&self) -> DependencyDto {
167 let config = &self.config().search;
168 if !config.enabled {
169 return DependencyDto {
170 ok: false,
171 latency_ms: None,
172 detail: Some("search is disabled".to_string()),
173 };
174 }
175 let start = Instant::now();
176 let url = format!("{}/health", config.url.trim_end_matches('/'));
177 let client = match reqwest::Client::builder()
178 .timeout(std::time::Duration::from_secs(2))
179 .build()
180 {
181 Ok(client) => client,
182 Err(error) => {
183 return DependencyDto {
184 ok: false,
185 latency_ms: Some(latency_ms(start)),
186 detail: Some(error.to_string()),
187 };
188 }
189 };
190 match client.get(url).send().await {
191 Ok(response) => DependencyDto {
192 ok: response.status().is_success(),
193 latency_ms: Some(latency_ms(start)),
194 detail: Some(format!("status={}", response.status().as_u16())),
195 },
196 Err(error) => DependencyDto {
197 ok: false,
198 latency_ms: Some(latency_ms(start)),
199 detail: Some(error.to_string()),
200 },
201 }
202 }
203
204 async fn counts(&self) -> Result<CountsDto, AdminError> {
205 Ok(CountsDto {
206 users: user::Entity::find().count(self.db().read()).await? as i64,
207 active_users: user::Entity::find()
208 .filter(models::users::user::Column::IsActive.eq(true))
209 .count(self.db().read())
210 .await? as i64,
211 spaces: spaces::Entity::find().count(self.db().read()).await? as i64,
212 active_spaces: spaces::Entity::find()
213 .filter(models::space::spaces::Column::IsActive.eq(true))
214 .count(self.db().read())
215 .await? as i64,
216 repos: repos::Entity::find().count(self.db().read()).await? as i64,
217 rooms: rooms::Entity::find().count(self.db().read()).await? as i64,
218 issues: issues::Entity::find().count(self.db().read()).await? as i64,
219 messages: messages::Entity::find().count(self.db().read()).await? as i64,
220 admin_users: admin_user::Entity::find().count(self.db().read()).await? as i64,
221 })
222 }
223
224 pub async fn system_status(&self) -> Result<SystemStatusDto, AdminError> {
225 let (database, redis, storage, search, counts, queues) = tokio::join!(
226 self.database_health(),
227 self.redis_health(),
228 self.storage_health(),
229 self.search_health(),
230 self.counts(),
231 self.queue_snapshots(),
232 );
233 let counts = counts?;
234 let queues = queues?;
235 let recent_failures = self
236 .list_job_logs(crate::services::logs::ListJobLogsQuery {
237 source: None,
238 from: None,
239 to: None,
240 limit: Some(10),
241 offset: Some(0),
242 })
243 .await?
244 .items;
245 Ok(SystemStatusDto {
246 process: ProcessInfoDto {
247 version: env!("CARGO_PKG_VERSION").to_string(),
248 uptime_secs: self.uptime_secs(),
249 },
250 dependencies: DependenciesDto {
251 database,
252 redis,
253 storage,
254 search,
255 },
256 features: FeaturesDto {
257 search_enabled: self.config().search.enabled,
258 import_enabled: self.config().import.enabled,
259 email_enabled: self.config().email.enabled(),
260 email_queue_enabled: self.config().email_queue.enabled(),
261 },
262 counts,
263 queues,
264 recent_failures,
265 generated_at: OffsetDateTime::now_utc(),
266 })
267 }
268}
No newline at end of file
Added lib/admin/src/services/tasks.rs View lib/admin/src/services/tasks.rs
@@ -0,0 +1,962 @@
1use models::admin::admin_user::AdminRole;
2use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus};
3use models::import::job;
4use models::repo::pipeline_run::{self, PipelineRunState};
5use models::search::index_job;
6use models::system::system_tasks::{self, TaskStatus};
7use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use time::OffsetDateTime;
14use utoipa::{IntoParams, ToSchema};
15use uuid::Uuid;
16
17use crate::errors::AdminError;
18use crate::services::audit::{AuditCtx, AuditRecord};
19use crate::services::service::AdminService;
20use crate::share::roles::AdminActor;
21
22pub const SOURCE_IMPORT: &str = "import";
23pub const SOURCE_SEARCH_INDEX: &str = "search_index";
24pub const SOURCE_CHAT_WEBHOOK: &str = "chat_webhook";
25pub const SOURCE_PIPELINE: &str = "pipeline";
26pub const SOURCE_DELETION: &str = "deletion";
27pub const SOURCE_SYSTEM_TASKS: &str = "system_tasks";
28
29#[derive(Debug, Clone, Deserialize, IntoParams)]
30pub struct ListTasksQuery {
31 pub source: Option<String>,
32 pub state: Option<String>,
33 pub q: Option<String>,
34 pub from: Option<OffsetDateTime>,
35 pub to: Option<OffsetDateTime>,
36 pub limit: Option<u64>,
37 pub offset: Option<u64>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
41pub struct TaskDto {
42 pub id: Uuid,
43 pub source: String,
44 pub title: String,
45 pub state: String,
46 pub created_at: OffsetDateTime,
47 pub updated_at: Option<OffsetDateTime>,
48 pub finished_at: Option<OffsetDateTime>,
49 pub last_error: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
53pub struct TaskPageDto {
54 pub items: Vec<TaskDto>,
55 pub total: i64,
56 pub limit: u64,
57 pub offset: u64,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
61pub struct TaskDetailDto {
62 pub task: TaskDto,
63 pub detail: Value,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
67pub struct TaskActionResp {
68 pub source: String,
69 pub id: Uuid,
70 pub state: String,
71}
72
73fn pipeline_state_str(state: PipelineRunState) -> String {
74 match state {
75 PipelineRunState::Queued => "queued",
76 PipelineRunState::Running => "running",
77 PipelineRunState::Success => "success",
78 PipelineRunState::Failure => "failure",
79 PipelineRunState::CancelRequested => "cancel_requested",
80 PipelineRunState::Cancelled => "cancelled",
81 }
82 .to_string()
83}
84
85fn deletion_state_str(state: UserDeletionRequestStatus) -> String {
86 match state {
87 UserDeletionRequestStatus::Pending => "pending",
88 UserDeletionRequestStatus::Scheduled => "scheduled",
89 UserDeletionRequestStatus::Completed => "completed",
90 UserDeletionRequestStatus::Cancelled => "cancelled",
91 }
92 .to_string()
93}
94
95fn task_state_str(state: TaskStatus) -> String {
96 match state {
97 TaskStatus::Pending => "pending",
98 TaskStatus::Running => "running",
99 TaskStatus::Success => "success",
100 TaskStatus::Failed => "failed",
101 }
102 .to_string()
103}
104
105fn webhook_state_str(state: WebhookDeliveryStatus) -> String {
106 match state {
107 WebhookDeliveryStatus::Pending => "pending",
108 WebhookDeliveryStatus::Delivering => "delivering",
109 WebhookDeliveryStatus::Done => "done",
110 WebhookDeliveryStatus::Failed => "failed",
111 }
112 .to_string()
113}
114
115fn short_sha(sha: &str) -> String {
116 sha.chars().take(8).collect()
117}
118
119fn parse_pipeline_state(value: &str) -> Option<PipelineRunState> {
120 match value {
121 "queued" => Some(PipelineRunState::Queued),
122 "running" => Some(PipelineRunState::Running),
123 "success" => Some(PipelineRunState::Success),
124 "failure" => Some(PipelineRunState::Failure),
125 "cancel_requested" => Some(PipelineRunState::CancelRequested),
126 "cancelled" => Some(PipelineRunState::Cancelled),
127 _ => None,
128 }
129}
130
131fn parse_deletion_state(value: &str) -> Option<UserDeletionRequestStatus> {
132 match value {
133 "pending" => Some(UserDeletionRequestStatus::Pending),
134 "scheduled" => Some(UserDeletionRequestStatus::Scheduled),
135 "completed" => Some(UserDeletionRequestStatus::Completed),
136 "cancelled" => Some(UserDeletionRequestStatus::Cancelled),
137 _ => None,
138 }
139}
140
141fn parse_task_status(value: &str) -> Option<TaskStatus> {
142 match value {
143 "pending" => Some(TaskStatus::Pending),
144 "running" => Some(TaskStatus::Running),
145 "success" => Some(TaskStatus::Success),
146 "failed" => Some(TaskStatus::Failed),
147 _ => None,
148 }
149}
150
151fn parse_webhook_state(value: &str) -> Option<WebhookDeliveryStatus> {
152 match value {
153 "pending" => Some(WebhookDeliveryStatus::Pending),
154 "delivering" => Some(WebhookDeliveryStatus::Delivering),
155 "done" => Some(WebhookDeliveryStatus::Done),
156 "failed" => Some(WebhookDeliveryStatus::Failed),
157 _ => None,
158 }
159}
160
161impl AdminService {
162 fn requested_sources(&self, source: &Option<String>) -> Vec<&'static str> {
163 match source.as_deref() {
164 Some(SOURCE_IMPORT) => vec![SOURCE_IMPORT],
165 Some(SOURCE_SEARCH_INDEX) => vec![SOURCE_SEARCH_INDEX],
166 Some(SOURCE_CHAT_WEBHOOK) => vec![SOURCE_CHAT_WEBHOOK],
167 Some(SOURCE_PIPELINE) => vec![SOURCE_PIPELINE],
168 Some(SOURCE_DELETION) => vec![SOURCE_DELETION],
169 Some(SOURCE_SYSTEM_TASKS) => vec![SOURCE_SYSTEM_TASKS],
170 _ => vec![
171 SOURCE_IMPORT,
172 SOURCE_SEARCH_INDEX,
173 SOURCE_CHAT_WEBHOOK,
174 SOURCE_PIPELINE,
175 SOURCE_DELETION,
176 SOURCE_SYSTEM_TASKS,
177 ],
178 }
179 }
180
181 pub async fn list_tasks(&self, query: ListTasksQuery) -> Result<TaskPageDto, AdminError> {
182 let limit = query.limit.unwrap_or(50).clamp(1, 100);
183 let offset = query.offset.unwrap_or(0);
184 let fetch_limit = (offset + limit).clamp(1, 500);
185 let mut tasks: Vec<TaskDto> = Vec::new();
186 for source in self.requested_sources(&query.source) {
187 tasks.extend(
188 self.fetch_source(source, fetch_limit, query.state.as_deref(), query.from, query.to)
189 .await?,
190 );
191 }
192 if let Some(needle) = query
193 .q
194 .as_deref()
195 .map(str::trim)
196 .filter(|value| !value.is_empty())
197 {
198 let needle = needle.to_ascii_lowercase();
199 tasks.retain(|task| {
200 task.title.to_ascii_lowercase().contains(&needle)
201 || task.id.to_string().contains(&needle)
202 || task
203 .last_error
204 .as_deref()
205 .is_some_and(|error| error.to_ascii_lowercase().contains(&needle))
206 });
207 }
208 tasks.sort_by(|left, right| right.created_at.cmp(&left.created_at));
209 let total = tasks.len() as i64;
210 let items = tasks
211 .into_iter()
212 .skip(offset as usize)
213 .take(limit as usize)
214 .collect();
215 Ok(TaskPageDto {
216 items,
217 total,
218 limit,
219 offset,
220 })
221 }
222 async fn fetch_source(
223 &self,
224 source: &str,
225 fetch_limit: u64,
226 state: Option<&str>,
227 from: Option<OffsetDateTime>,
228 to: Option<OffsetDateTime>,
229 ) -> Result<Vec<TaskDto>, AdminError> {
230 let mut tasks = Vec::new();
231 match source {
232 SOURCE_IMPORT => {
233 let mut select = job::Entity::find().order_by_desc(job::Column::CreatedAt);
234 if let Some(state) = state {
235 select = select.filter(job::Column::State.eq(state));
236 }
237 if let Some(from) = from {
238 select = select.filter(job::Column::CreatedAt.gte(from));
239 }
240 if let Some(to) = to {
241 select = select.filter(job::Column::CreatedAt.lte(to));
242 }
243 for row in select.limit(fetch_limit).all(self.db().read()).await? {
244 tasks.push(TaskDto {
245 id: row.id,
246 source: SOURCE_IMPORT.to_string(),
247 title: format!("import {}", row.repo_name),
248 state: row.state,
249 created_at: row.created_at,
250 updated_at: Some(row.updated_at),
251 finished_at: row.finished_at,
252 last_error: row.last_error,
253 });
254 }
255 }
256 SOURCE_SEARCH_INDEX => {
257 let mut select =
258 index_job::Entity::find().order_by_desc(index_job::Column::CreatedAt);
259 if let Some(state) = state {
260 select = select.filter(index_job::Column::State.eq(state));
261 }
262 if let Some(from) = from {
263 select = select.filter(index_job::Column::CreatedAt.gte(from));
264 }
265 if let Some(to) = to {
266 select = select.filter(index_job::Column::CreatedAt.lte(to));
267 }
268 for row in select.limit(fetch_limit).all(self.db().read()).await? {
269 tasks.push(TaskDto {
270 id: row.id,
271 source: SOURCE_SEARCH_INDEX.to_string(),
272 title: format!(
273 "index {} {}",
274 row.kind,
275 row.target_id
276 .map(|value| value.to_string())
277 .unwrap_or_else(|| "-".to_string())
278 ),
279 state: row.state,
280 created_at: row.created_at,
281 updated_at: Some(row.updated_at),
282 finished_at: None,
283 last_error: row.last_error,
284 });
285 }
286 }
287 SOURCE_CHAT_WEBHOOK => {
288 let mut select = webhook_deliveries::Entity::find()
289 .order_by_desc(webhook_deliveries::Column::CreatedAt);
290 if let Some(state) = state {
291 select = select.filter(
292 webhook_deliveries::Column::Status.eq(parse_webhook_state(state).ok_or_else(
293 || AdminError::BadRequest("invalid webhook state".to_string()),
294 )?),
295 );
296 }
297 if let Some(from) = from {
298 select = select.filter(webhook_deliveries::Column::CreatedAt.gte(from));
299 }
300 if let Some(to) = to {
301 select = select.filter(webhook_deliveries::Column::CreatedAt.lte(to));
302 }
303 for row in select.limit(fetch_limit).all(self.db().read()).await? {
304 tasks.push(TaskDto {
305 id: row.id,
306 source: SOURCE_CHAT_WEBHOOK.to_string(),
307 title: format!("{} {}", row.event_type, row.webhook_id),
308 state: webhook_state_str(row.status),
309 created_at: row.created_at,
310 updated_at: Some(row.updated_at),
311 finished_at: None,
312 last_error: row.last_error,
313 });
314 }
315 }
316 SOURCE_PIPELINE => {
317 let mut select =
318 pipeline_run::Entity::find().order_by_desc(pipeline_run::Column::CreatedAt);
319 if let Some(state) = state {
320 select = select.filter(
321 pipeline_run::Column::State.eq(parse_pipeline_state(state).ok_or_else(
322 || AdminError::BadRequest("invalid pipeline state".to_string()),
323 )?),
324 );
325 }
326 if let Some(from) = from {
327 select = select.filter(pipeline_run::Column::CreatedAt.gte(from));
328 }
329 if let Some(to) = to {
330 select = select.filter(pipeline_run::Column::CreatedAt.lte(to));
331 }
332 for row in select.limit(fetch_limit).all(self.db().read()).await? {
333 tasks.push(TaskDto {
334 id: row.id,
335 source: SOURCE_PIPELINE.to_string(),
336 title: format!("run {}@{}", short_sha(&row.commit_sha), row.branch),
337 state: pipeline_state_str(row.state),
338 created_at: row.created_at,
339 updated_at: Some(row.updated_at),
340 finished_at: row.finished_at,
341 last_error: None,
342 });
343 }
344 }
345 SOURCE_DELETION => {
346 let mut select = user_deletion_requests::Entity::find()
347 .order_by_desc(user_deletion_requests::Column::CreatedAt);
348 if let Some(state) = state {
349 select = select.filter(
350 user_deletion_requests::Column::Status.eq(
351 parse_deletion_state(state).ok_or_else(|| {
352 AdminError::BadRequest("invalid deletion state".to_string())
353 })?,
354 ),
355 );
356 }
357 if let Some(from) = from {
358 select = select.filter(user_deletion_requests::Column::CreatedAt.gte(from));
359 }
360 if let Some(to) = to {
361 select = select.filter(user_deletion_requests::Column::CreatedAt.lte(to));
362 }
363 for row in select.limit(fetch_limit).all(self.db().read()).await? {
364 tasks.push(TaskDto {
365 id: row.id,
366 source: SOURCE_DELETION.to_string(),
367 title: format!("deletion request {}", row.user_id),
368 state: deletion_state_str(row.status),
369 created_at: row.created_at,
370 updated_at: Some(row.updated_at),
371 finished_at: row.completed_at,
372 last_error: None,
373 });
374 }
375 }
376 SOURCE_SYSTEM_TASKS => {
377 let mut select =
378 system_tasks::Entity::find().order_by_desc(system_tasks::Column::CreatedAt);
379 if let Some(state) = state {
380 select = select.filter(
381 system_tasks::Column::Status.eq(parse_task_status(state).ok_or_else(|| {
382 AdminError::BadRequest("invalid task state".to_string())
383 })?),
384 );
385 }
386 if let Some(from) = from {
387 select = select.filter(system_tasks::Column::CreatedAt.gte(from));
388 }
389 if let Some(to) = to {
390 select = select.filter(system_tasks::Column::CreatedAt.lte(to));
391 }
392 for row in select.limit(fetch_limit).all(self.db().read()).await? {
393 tasks.push(TaskDto {
394 id: row.id,
395 source: SOURCE_SYSTEM_TASKS.to_string(),
396 title: row.task_type,
397 state: task_state_str(row.status),
398 created_at: row.created_at,
399 updated_at: row.updated_at,
400 finished_at: row.finished_at,
401 last_error: row.error,
402 });
403 }
404 }
405 _ => {}
406 }
407 Ok(tasks)
408 }
409 pub async fn get_task(&self, source: &str, id: Uuid) -> Result<TaskDetailDto, AdminError> {
410 let (task, detail) = match source {
411 SOURCE_IMPORT => {
412 let row = job::Entity::find_by_id(id)
413 .one(self.db().read())
414 .await?
415 .ok_or(AdminError::NotFound)?;
416 let detail = json!({
417 "provider": row.provider,
418 "source_url": row.source_url,
419 "clone_url": row.clone_url,
420 "space_id": row.space_id,
421 "repo_id": row.repo_id,
422 "repo_name": row.repo_name,
423 "state": row.state,
424 "stage": row.stage,
425 "attempts": row.attempts,
426 "stats": row.stats,
427 "options": row.options,
428 "run_after": row.run_after,
429 "locked_at": row.locked_at,
430 "started_at": row.started_at,
431 "finished_at": row.finished_at,
432 "token": "[redacted]",
433 });
434 let task = TaskDto {
435 id: row.id,
436 source: SOURCE_IMPORT.to_string(),
437 title: format!("import {}", row.repo_name),
438 state: row.state,
439 created_at: row.created_at,
440 updated_at: Some(row.updated_at),
441 finished_at: row.finished_at,
442 last_error: row.last_error,
443 };
444 (task, detail)
445 }
446 SOURCE_SEARCH_INDEX => {
447 let row = index_job::Entity::find_by_id(id)
448 .one(self.db().read())
449 .await?
450 .ok_or(AdminError::NotFound)?;
451 let detail = json!({
452 "kind": row.kind,
453 "target_id": row.target_id,
454 "repo_id": row.repo_id,
455 "attempts": row.attempts,
456 "run_after": row.run_after,
457 "locked_at": row.locked_at,
458 });
459 let task = TaskDto {
460 id: row.id,
461 source: SOURCE_SEARCH_INDEX.to_string(),
462 title: format!("index {}", row.kind),
463 state: row.state,
464 created_at: row.created_at,
465 updated_at: Some(row.updated_at),
466 finished_at: None,
467 last_error: row.last_error,
468 };
469 (task, detail)
470 }
471 SOURCE_CHAT_WEBHOOK => {
472 let row = webhook_deliveries::Entity::find_by_id(id)
473 .one(self.db().read())
474 .await?
475 .ok_or(AdminError::NotFound)?;
476 let detail = json!({
477 "webhook_id": row.webhook_id,
478 "room_id": row.room_id,
479 "event_type": row.event_type,
480 "attempts": row.attempts,
481 "next_retry_at": row.next_retry_at,
482 "payload": "[redacted]",
483 });
484 let task = TaskDto {
485 id: row.id,
486 source: SOURCE_CHAT_WEBHOOK.to_string(),
487 title: format!("{} {}", row.event_type, row.webhook_id),
488 state: webhook_state_str(row.status),
489 created_at: row.created_at,
490 updated_at: Some(row.updated_at),
491 finished_at: None,
492 last_error: row.last_error,
493 };
494 (task, detail)
495 }
496 SOURCE_PIPELINE => {
497 let row = pipeline_run::Entity::find_by_id(id)
498 .one(self.db().read())
499 .await?
500 .ok_or(AdminError::NotFound)?;
501 let detail = json!({
502 "repo_id": row.repo_id,
503 "trigger_user_id": row.trigger_user_id,
504 "commit_sha": row.commit_sha,
505 "branch": row.branch,
506 "started_at": row.started_at,
507 });
508 let task = TaskDto {
509 id: row.id,
510 source: SOURCE_PIPELINE.to_string(),
511 title: format!("run {}@{}", short_sha(&row.commit_sha), row.branch),
512 state: pipeline_state_str(row.state),
513 created_at: row.created_at,
514 updated_at: Some(row.updated_at),
515 finished_at: row.finished_at,
516 last_error: None,
517 };
518 (task, detail)
519 }
520 SOURCE_DELETION => {
521 let row = user_deletion_requests::Entity::find_by_id(id)
522 .one(self.db().read())
523 .await?
524 .ok_or(AdminError::NotFound)?;
525 let detail = json!({
526 "user_id": row.user_id,
527 "reason": row.reason,
528 "scheduled_for": row.scheduled_for,
529 "completed_at": row.completed_at,
530 "cancelled_at": row.cancelled_at,
531 });
532 let task = TaskDto {
533 id: row.id,
534 source: SOURCE_DELETION.to_string(),
535 title: format!("deletion request {}", row.user_id),
536 state: deletion_state_str(row.status),
537 created_at: row.created_at,
538 updated_at: Some(row.updated_at),
539 finished_at: row.completed_at,
540 last_error: None,
541 };
542 (task, detail)
543 }
544 SOURCE_SYSTEM_TASKS => {
545 let row = system_tasks::Entity::find_by_id(id)
546 .one(self.db().read())
547 .await?
548 .ok_or(AdminError::NotFound)?;
549 let detail = json!({
550 "task_type": row.task_type,
551 "payload": row.payload,
552 "retry_count": row.retry_count,
553 "next_run_at": row.next_run_at,
554 "started_at": row.started_at,
555 });
556 let task = TaskDto {
557 id: row.id,
558 source: SOURCE_SYSTEM_TASKS.to_string(),
559 title: row.task_type,
560 state: task_state_str(row.status),
561 created_at: row.created_at,
562 updated_at: row.updated_at,
563 finished_at: row.finished_at,
564 last_error: row.error,
565 };
566 (task, detail)
567 }
568 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
569 };
570 Ok(TaskDetailDto { task, detail })
571 }
572 pub async fn retry_task(
573 &self,
574 actor: &AdminActor,
575 source: &str,
576 id: Uuid,
577 ctx: &AuditCtx,
578 ) -> Result<TaskActionResp, AdminError> {
579 actor.require(AdminRole::Operator)?;
580 let now = OffsetDateTime::now_utc();
581 let state = match source {
582 SOURCE_IMPORT => {
583 let row = job::Entity::find_by_id(id)
584 .one(self.db().read())
585 .await?
586 .ok_or(AdminError::NotFound)?;
587 if !matches!(row.state.as_str(), "failed" | "canceled") {
588 return Err(AdminError::Conflict(
589 "only failed or canceled imports can be retried".to_string(),
590 ));
591 }
592 let mut am: job::ActiveModel = row.into();
593 am.state = Set("queued".to_string());
594 am.run_after = Set(now);
595 am.locked_at = Set(None);
596 am.finished_at = Set(None);
597 am.updated_at = Set(now);
598 am.update(self.db().write()).await?;
599 "queued".to_string()
600 }
601 SOURCE_SEARCH_INDEX => {
602 let row = index_job::Entity::find_by_id(id)
603 .one(self.db().read())
604 .await?
605 .ok_or(AdminError::NotFound)?;
606 let mut am: index_job::ActiveModel = row.into();
607 am.state = Set("queued".to_string());
608 am.run_after = Set(now);
609 am.locked_at = Set(None);
610 am.updated_at = Set(now);
611 am.update(self.db().write()).await?;
612 "queued".to_string()
613 }
614 SOURCE_CHAT_WEBHOOK => {
615 let row = webhook_deliveries::Entity::find_by_id(id)
616 .one(self.db().read())
617 .await?
618 .ok_or(AdminError::NotFound)?;
619 if row.status != WebhookDeliveryStatus::Failed {
620 return Err(AdminError::Conflict(
621 "only failed webhook deliveries can be retried".to_string(),
622 ));
623 }
624 let mut am: webhook_deliveries::ActiveModel = row.into();
625 am.status = Set(WebhookDeliveryStatus::Pending);
626 am.next_retry_at = Set(now);
627 am.updated_at = Set(now);
628 am.update(self.db().write()).await?;
629 "pending".to_string()
630 }
631 SOURCE_PIPELINE => {
632 let row = pipeline_run::Entity::find_by_id(id)
633 .one(self.db().read())
634 .await?
635 .ok_or(AdminError::NotFound)?;
636 if !matches!(
637 row.state,
638 PipelineRunState::Failure | PipelineRunState::Cancelled
639 ) {
640 return Err(AdminError::Conflict(
641 "only failed or cancelled pipeline runs can be retried".to_string(),
642 ));
643 }
644 let mut am: pipeline_run::ActiveModel = row.into();
645 am.state = Set(PipelineRunState::Queued);
646 am.started_at = Set(None);
647 am.finished_at = Set(None);
648 am.updated_at = Set(now);
649 am.update(self.db().write()).await?;
650 "queued".to_string()
651 }
652 SOURCE_DELETION => {
653 let row = user_deletion_requests::Entity::find_by_id(id)
654 .one(self.db().read())
655 .await?
656 .ok_or(AdminError::NotFound)?;
657 if row.status != UserDeletionRequestStatus::Cancelled {
658 return Err(AdminError::Conflict(
659 "only cancelled deletion requests can be retried".to_string(),
660 ));
661 }
662 let mut am: user_deletion_requests::ActiveModel = row.into();
663 am.status = Set(UserDeletionRequestStatus::Scheduled);
664 am.scheduled_for = Set(Some(now));
665 am.cancelled_at = Set(None);
666 am.updated_at = Set(now);
667 am.update(self.db().write()).await?;
668 "scheduled".to_string()
669 }
670 SOURCE_SYSTEM_TASKS => {
671 let row = system_tasks::Entity::find_by_id(id)
672 .one(self.db().read())
673 .await?
674 .ok_or(AdminError::NotFound)?;
675 if row.status != TaskStatus::Failed {
676 return Err(AdminError::Conflict(
677 "only failed system tasks can be retried".to_string(),
678 ));
679 }
680 let mut am: system_tasks::ActiveModel = row.into();
681 am.status = Set(TaskStatus::Pending);
682 am.error = Set(None);
683 am.next_run_at = Set(Some(now));
684 am.started_at = Set(None);
685 am.finished_at = Set(None);
686 am.updated_at = Set(Some(now));
687 am.update(self.db().write()).await?;
688 "pending".to_string()
689 }
690 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
691 };
692 self.audit(
693 Some(actor),
694 AuditRecord {
695 action: "task.retried",
696 target: Some((source, id.to_string())),
697 detail: json!({ "state": state }),
698 },
699 ctx,
700 )
701 .await?;
702 Ok(TaskActionResp {
703 source: source.to_string(),
704 id,
705 state,
706 })
707 }
708
709 pub async fn cancel_task(
710 &self,
711 actor: &AdminActor,
712 source: &str,
713 id: Uuid,
714 ctx: &AuditCtx,
715 ) -> Result<TaskActionResp, AdminError> {
716 actor.require(AdminRole::Operator)?;
717 let now = OffsetDateTime::now_utc();
718 let state = match source {
719 SOURCE_IMPORT => {
720 let row = job::Entity::find_by_id(id)
721 .one(self.db().read())
722 .await?
723 .ok_or(AdminError::NotFound)?;
724 if row.state == "processing" {
725 return Err(AdminError::Conflict(
726 "import is already processing and cannot be cancelled".to_string(),
727 ));
728 }
729 if row.state != "queued" {
730 return Err(AdminError::Conflict(
731 "only queued imports can be cancelled".to_string(),
732 ));
733 }
734 let mut am: job::ActiveModel = row.into();
735 am.state = Set("canceled".to_string());
736 am.finished_at = Set(Some(now));
737 am.updated_at = Set(now);
738 am.update(self.db().write()).await?;
739 "canceled".to_string()
740 }
741 SOURCE_SEARCH_INDEX => {
742 let row = index_job::Entity::find_by_id(id)
743 .one(self.db().read())
744 .await?
745 .ok_or(AdminError::NotFound)?;
746 if !matches!(row.state.as_str(), "queued" | "processing") {
747 return Err(AdminError::Conflict(
748 "only queued or processing index jobs can be cancelled".to_string(),
749 ));
750 }
751 let mut am: index_job::ActiveModel = row.into();
752 am.state = Set("cancelled".to_string());
753 am.locked_at = Set(None);
754 am.updated_at = Set(now);
755 am.update(self.db().write()).await?;
756 "cancelled".to_string()
757 }
758 SOURCE_CHAT_WEBHOOK => {
759 let row = webhook_deliveries::Entity::find_by_id(id)
760 .one(self.db().read())
761 .await?
762 .ok_or(AdminError::NotFound)?;
763 if !matches!(
764 row.status,
765 WebhookDeliveryStatus::Pending | WebhookDeliveryStatus::Delivering
766 ) {
767 return Err(AdminError::Conflict(
768 "only pending webhook deliveries can be cancelled".to_string(),
769 ));
770 }
771 let mut am: webhook_deliveries::ActiveModel = row.into();
772 am.status = Set(WebhookDeliveryStatus::Failed);
773 am.last_error = Set(Some("cancelled by admin".to_string()));
774 am.updated_at = Set(now);
775 am.update(self.db().write()).await?;
776 "failed".to_string()
777 }
778 SOURCE_PIPELINE => {
779 let row = pipeline_run::Entity::find_by_id(id)
780 .one(self.db().read())
781 .await?
782 .ok_or(AdminError::NotFound)?;
783 if !matches!(row.state, PipelineRunState::Queued | PipelineRunState::Running) {
784 return Err(AdminError::Conflict(
785 "only queued or running pipeline runs can be cancelled".to_string(),
786 ));
787 }
788 let mut am: pipeline_run::ActiveModel = row.into();
789 am.state = Set(PipelineRunState::CancelRequested);
790 am.updated_at = Set(now);
791 am.update(self.db().write()).await?;
792 "cancel_requested".to_string()
793 }
794 SOURCE_DELETION => {
795 let row = user_deletion_requests::Entity::find_by_id(id)
796 .one(self.db().read())
797 .await?
798 .ok_or(AdminError::NotFound)?;
799 if !matches!(
800 row.status,
801 UserDeletionRequestStatus::Pending | UserDeletionRequestStatus::Scheduled
802 ) {
803 return Err(AdminError::Conflict(
804 "only pending deletion requests can be cancelled".to_string(),
805 ));
806 }
807 let mut am: user_deletion_requests::ActiveModel = row.into();
808 am.status = Set(UserDeletionRequestStatus::Cancelled);
809 am.cancelled_at = Set(Some(now));
810 am.updated_at = Set(now);
811 am.update(self.db().write()).await?;
812 "cancelled".to_string()
813 }
814 SOURCE_SYSTEM_TASKS => {
815 let row = system_tasks::Entity::find_by_id(id)
816 .one(self.db().read())
817 .await?
818 .ok_or(AdminError::NotFound)?;
819 if !matches!(row.status, TaskStatus::Pending | TaskStatus::Running) {
820 return Err(AdminError::Conflict(
821 "only pending or running tasks can be cancelled".to_string(),
822 ));
823 }
824 let mut am: system_tasks::ActiveModel = row.into();
825 am.status = Set(TaskStatus::Failed);
826 am.error = Set(Some("cancelled by admin".to_string()));
827 am.finished_at = Set(Some(now));
828 am.updated_at = Set(Some(now));
829 am.update(self.db().write()).await?;
830 "failed".to_string()
831 }
832 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
833 };
834 self.audit(
835 Some(actor),
836 AuditRecord {
837 action: "task.cancelled",
838 target: Some((source, id.to_string())),
839 detail: json!({ "state": state }),
840 },
841 ctx,
842 )
843 .await?;
844 Ok(TaskActionResp {
845 source: source.to_string(),
846 id,
847 state,
848 })
849 }
850 pub async fn delete_task(
851 &self,
852 actor: &AdminActor,
853 source: &str,
854 id: Uuid,
855 ctx: &AuditCtx,
856 ) -> Result<(), AdminError> {
857 actor.require(AdminRole::SuperAdmin)?;
858 let terminal = match source {
859 SOURCE_IMPORT => {
860 let row = job::Entity::find_by_id(id)
861 .one(self.db().read())
862 .await?
863 .ok_or(AdminError::NotFound)?;
864 matches!(row.state.as_str(), "succeeded" | "failed" | "canceled")
865 }
866 SOURCE_SEARCH_INDEX => {
867 let row = index_job::Entity::find_by_id(id)
868 .one(self.db().read())
869 .await?
870 .ok_or(AdminError::NotFound)?;
871 matches!(row.state.as_str(), "succeeded" | "cancelled")
872 }
873 SOURCE_CHAT_WEBHOOK => {
874 let row = webhook_deliveries::Entity::find_by_id(id)
875 .one(self.db().read())
876 .await?
877 .ok_or(AdminError::NotFound)?;
878 matches!(
879 row.status,
880 WebhookDeliveryStatus::Done | WebhookDeliveryStatus::Failed
881 )
882 }
883 SOURCE_PIPELINE => {
884 let row = pipeline_run::Entity::find_by_id(id)
885 .one(self.db().read())
886 .await?
887 .ok_or(AdminError::NotFound)?;
888 matches!(
889 row.state,
890 PipelineRunState::Success
891 | PipelineRunState::Failure
892 | PipelineRunState::Cancelled
893 )
894 }
895 SOURCE_DELETION => {
896 let row = user_deletion_requests::Entity::find_by_id(id)
897 .one(self.db().read())
898 .await?
899 .ok_or(AdminError::NotFound)?;
900 matches!(
901 row.status,
902 UserDeletionRequestStatus::Completed | UserDeletionRequestStatus::Cancelled
903 )
904 }
905 SOURCE_SYSTEM_TASKS => {
906 let row = system_tasks::Entity::find_by_id(id)
907 .one(self.db().read())
908 .await?
909 .ok_or(AdminError::NotFound)?;
910 matches!(row.status, TaskStatus::Success | TaskStatus::Failed)
911 }
912 _ => return Err(AdminError::BadRequest("unknown task source".to_string())),
913 };
914 if !terminal {
915 return Err(AdminError::Conflict(
916 "only finished tasks can be deleted".to_string(),
917 ));
918 }
919 match source {
920 SOURCE_IMPORT => {
921 job::Entity::delete_by_id(id).exec(self.db().write()).await?;
922 }
923 SOURCE_SEARCH_INDEX => {
924 index_job::Entity::delete_by_id(id)
925 .exec(self.db().write())
926 .await?;
927 }
928 SOURCE_CHAT_WEBHOOK => {
929 webhook_deliveries::Entity::delete_by_id(id)
930 .exec(self.db().write())
931 .await?;
932 }
933 SOURCE_PIPELINE => {
934 pipeline_run::Entity::delete_by_id(id)
935 .exec(self.db().write())
936 .await?;
937 }
938 SOURCE_DELETION => {
939 user_deletion_requests::Entity::delete_by_id(id)
940 .exec(self.db().write())
941 .await?;
942 }
943 SOURCE_SYSTEM_TASKS => {
944 system_tasks::Entity::delete_by_id(id)
945 .exec(self.db().write())
946 .await?;
947 }
948 _ => {}
949 }
950 self.audit(
951 Some(actor),
952 AuditRecord {
953 action: "task.deleted",
954 target: Some((source, id.to_string())),
955 detail: json!({}),
956 },
957 ctx,
958 )
959 .await?;
960 Ok(())
961 }
962}
No newline at end of file
Added lib/admin/src/services/users.rs View lib/admin/src/services/users.rs
@@ -0,0 +1,707 @@
1use std::collections::HashSet;
2
3use export::pagination::Page;
4use models::users::user_suspensions::{self, SuspensionType};
5use models::users::{
6 user, user_access_keys, user_deletion_requests, user_emails, user_profiles, user_recovery_codes,
7 user_sessions, user_ssh_keys,
8};
9use models::space::spaces;
10use sea_orm::sea_query::Expr;
11use sea_orm::{
12 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, PaginatorTrait,
13 QueryFilter, QueryOrder, QuerySelect, Set,
14};
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17use time::OffsetDateTime;
18use utoipa::{IntoParams, ToSchema};
19use uuid::Uuid;
20
21use crate::errors::AdminError;
22use crate::services::audit::{AuditCtx, AuditRecord};
23use crate::services::service::AdminService;
24use crate::share::roles::AdminActor;
25use models::admin::admin_user::AdminRole;
26
27#[derive(Debug, Clone, Deserialize, IntoParams)]
28pub struct ListUsersQuery {
29 pub q: Option<String>,
30 pub status: Option<String>,
31 pub limit: Option<u64>,
32 pub offset: Option<u64>,
33 pub sort: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
37pub struct AdminUserSummary {
38 pub uid: Uuid,
39 pub username: String,
40 pub display_name: String,
41 pub avatar_url: String,
42 pub is_active: bool,
43 pub is_restricted: bool,
44 pub prohibit_login: bool,
45 pub bot: bool,
46 pub system: bool,
47 pub suspended: bool,
48 pub created_at: OffsetDateTime,
49 pub last_login_at: Option<OffsetDateTime>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
53pub struct AdminUserEmailDto {
54 pub id: Uuid,
55 pub email: String,
56 pub is_primary: bool,
57 pub is_activated: bool,
58 pub created_at: OffsetDateTime,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
62pub struct AdminUserSessionDto {
63 pub id: Uuid,
64 pub kind: String,
65 pub last_seen_at: OffsetDateTime,
66 pub created_at: OffsetDateTime,
67 pub expires_at: OffsetDateTime,
68 pub is_revoked: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
72pub struct AdminUserSpaceDto {
73 pub id: Uuid,
74 pub name: String,
75 pub lower_name: String,
76 pub is_active: bool,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
80pub struct AdminSuspensionDto {
81 pub id: Uuid,
82 pub suspension_type: String,
83 pub reason: Option<String>,
84 pub starts_at: OffsetDateTime,
85 pub ends_at: Option<OffsetDateTime>,
86 pub revoked_at: Option<OffsetDateTime>,
87 pub suspended_by: Option<Uuid>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
91pub struct AdminUserDetail {
92 pub user: AdminUserSummary,
93 pub emails: Vec<AdminUserEmailDto>,
94 pub sessions: Vec<AdminUserSessionDto>,
95 pub spaces: Vec<AdminUserSpaceDto>,
96 pub suspensions: Vec<AdminSuspensionDto>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
100pub struct UpdateUserCmd {
101 #[serde(default)]
102 pub display_name: Option<String>,
103 #[serde(default)]
104 pub is_active: Option<bool>,
105 #[serde(default)]
106 pub is_restricted: Option<bool>,
107 #[serde(default)]
108 pub prohibit_login: Option<bool>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
112pub struct SuspendUserCmd {
113 pub suspension_type: SuspensionType,
114 #[serde(default)]
115 pub reason: Option<String>,
116 #[serde(default)]
117 pub ends_at: Option<OffsetDateTime>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
121pub struct ResetUserPasswordCmd {
122 pub new_password: String,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
126pub struct RevokeSessionsResp {
127 pub revoked: u64,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
131pub struct DeleteUserResp {
132 pub user_id: Uuid,
133 pub anonymized: bool,
134}
135
136fn user_summary(row: &user::Model, suspended: bool) -> AdminUserSummary {
137 AdminUserSummary {
138 uid: row.uid,
139 username: row.username.clone(),
140 display_name: row.display_name.clone(),
141 avatar_url: row.avatar_url.clone(),
142 is_active: row.is_active,
143 is_restricted: row.is_restricted,
144 prohibit_login: row.prohibit_login,
145 bot: row.bot,
146 system: row.system,
147 suspended,
148 created_at: row.created_at,
149 last_login_at: row.last_login_at,
150 }
151}
152
153impl AdminService {
154 pub async fn list_users(&self, query: ListUsersQuery) -> Result<Page<AdminUserSummary>, AdminError> {
155 let limit = query.limit.unwrap_or(50).clamp(1, 100);
156 let offset = query.offset.unwrap_or(0);
157 let mut select = user::Entity::find();
158
159 if let Some(raw) = query
160 .q
161 .as_deref()
162 .map(str::trim)
163 .filter(|value| !value.is_empty())
164 {
165 let needle = raw.to_ascii_lowercase();
166 let mut cond = Condition::any()
167 .add(user::Column::LowerName.contains(needle.clone()))
168 .add(user::Column::Username.contains(needle.clone()))
169 .add(user::Column::DisplayName.contains(raw));
170 let email_uids: Vec<Uuid> = user_emails::Entity::find()
171 .filter(user_emails::Column::LowerEmail.contains(needle))
172 .limit(200)
173 .all(self.db().read())
174 .await?
175 .into_iter()
176 .map(|row| row.user_id)
177 .collect();
178 if !email_uids.is_empty() {
179 cond = cond.add(user::Column::Uid.is_in(email_uids));
180 }
181 select = select.filter(cond);
182 }
183
184 select = match query.status.as_deref() {
185 Some("active") => select.filter(user::Column::IsActive.eq(true)),
186 Some("inactive") => select.filter(user::Column::IsActive.eq(false)),
187 Some("restricted") => select.filter(user::Column::IsRestricted.eq(true)),
188 Some("prohibited") => select.filter(user::Column::ProhibitLogin.eq(true)),
189 _ => select,
190 };
191
192 select = match query.sort.as_deref() {
193 Some("created_at_asc") => select.order_by_asc(user::Column::CreatedAt),
194 Some("last_login") => select.order_by_desc(user::Column::LastLoginAt),
195 _ => select.order_by_desc(user::Column::CreatedAt),
196 };
197
198 let total = select.clone().count(self.db().read()).await? as i64;
199 let rows = select
200 .limit(limit)
201 .offset(offset)
202 .all(self.db().read())
203 .await?;
204 let ids: Vec<Uuid> = rows.iter().map(|row| row.uid).collect();
205 let suspended = self.active_suspension_ids(&ids).await?;
206 let items = rows
207 .iter()
208 .map(|row| user_summary(row, suspended.contains(&row.uid)))
209 .collect();
210 Ok(Page {
211 items,
212 total,
213 limit,
214 offset,
215 })
216 }
217
218 async fn active_suspension_ids(&self, ids: &[Uuid]) -> Result<HashSet<Uuid>, AdminError> {
219 if ids.is_empty() {
220 return Ok(HashSet::new());
221 }
222 let now = OffsetDateTime::now_utc();
223 let rows = user_suspensions::Entity::find()
224 .filter(user_suspensions::Column::UserId.is_in(ids.to_vec()))
225 .filter(user_suspensions::Column::RevokedAt.is_null())
226 .filter(user_suspensions::Column::StartsAt.lte(now))
227 .filter(
228 Condition::any()
229 .add(user_suspensions::Column::EndsAt.is_null())
230 .add(user_suspensions::Column::EndsAt.gt(now)),
231 )
232 .all(self.db().read())
233 .await?;
234 Ok(rows.into_iter().map(|row| row.user_id).collect())
235 }
236
237 pub async fn get_user(&self, uid: Uuid) -> Result<AdminUserDetail, AdminError> {
238 let row = user::Entity::find_by_id(uid)
239 .one(self.db().read())
240 .await?
241 .ok_or(AdminError::NotFound)?;
242 let suspended = self.active_suspension_ids(&[uid]).await?;
243 let emails = user_emails::Entity::find()
244 .filter(user_emails::Column::UserId.eq(uid))
245 .filter(user_emails::Column::DeletedAt.is_null())
246 .order_by_desc(user_emails::Column::IsPrimary)
247 .all(self.db().read())
248 .await?
249 .into_iter()
250 .map(|email| AdminUserEmailDto {
251 id: email.id,
252 email: email.email,
253 is_primary: email.is_primary,
254 is_activated: email.is_activated,
255 created_at: email.created_at,
256 })
257 .collect();
258 let sessions = user_sessions::Entity::find()
259 .filter(user_sessions::Column::UserId.eq(uid))
260 .order_by_desc(user_sessions::Column::LastSeenAt)
261 .limit(50)
262 .all(self.db().read())
263 .await?
264 .into_iter()
265 .map(|session| AdminUserSessionDto {
266 id: session.id,
267 kind: format!("{:?}", session.kind).to_ascii_lowercase(),
268 last_seen_at: session.last_seen_at,
269 created_at: session.created_at,
270 expires_at: session.expires_at,
271 is_revoked: session.is_revoked,
272 })
273 .collect();
274 let spaces = spaces::Entity::find()
275 .filter(spaces::Column::OwnerId.eq(uid))
276 .order_by_desc(spaces::Column::CreatedAt)
277 .limit(100)
278 .all(self.db().read())
279 .await?
280 .into_iter()
281 .map(|space| AdminUserSpaceDto {
282 id: space.id,
283 name: space.name,
284 lower_name: space.lower_name,
285 is_active: space.is_active,
286 })
287 .collect();
288 let suspensions = user_suspensions::Entity::find()
289 .filter(user_suspensions::Column::UserId.eq(uid))
290 .order_by_desc(user_suspensions::Column::CreatedAt)
291 .limit(50)
292 .all(self.db().read())
293 .await?
294 .into_iter()
295 .map(|row| AdminSuspensionDto {
296 id: row.id,
297 suspension_type: match row.suspension_type {
298 SuspensionType::Temporary => "temporary",
299 SuspensionType::Permanent => "permanent",
300 }
301 .to_string(),
302 reason: row.reason,
303 starts_at: row.starts_at,
304 ends_at: row.ends_at,
305 revoked_at: row.revoked_at,
306 suspended_by: row.suspended_by,
307 })
308 .collect();
309 Ok(AdminUserDetail {
310 user: user_summary(&row, suspended.contains(&uid)),
311 emails,
312 sessions,
313 spaces,
314 suspensions,
315 })
316 }
317
318 pub async fn update_user(
319 &self,
320 actor: &AdminActor,
321 uid: Uuid,
322 cmd: UpdateUserCmd,
323 ctx: &AuditCtx,
324 ) -> Result<AdminUserSummary, AdminError> {
325 actor.require(AdminRole::Operator)?;
326 let row = user::Entity::find_by_id(uid)
327 .one(self.db().read())
328 .await?
329 .ok_or(AdminError::NotFound)?;
330 let mut am: user::ActiveModel = row.clone().into();
331 let mut changed: Vec<String> = Vec::new();
332 if let Some(display_name) = cmd.display_name {
333 let display_name = display_name.trim().to_string();
334 if display_name.is_empty() {
335 return Err(AdminError::BadRequest(
336 "display_name must not be empty".to_string(),
337 ));
338 }
339 am.display_name = Set(display_name);
340 changed.push("display_name".to_string());
341 }
342 if let Some(value) = cmd.is_active {
343 am.is_active = Set(value);
344 changed.push("is_active".to_string());
345 }
346 if let Some(value) = cmd.is_restricted {
347 am.is_restricted = Set(value);
348 changed.push("is_restricted".to_string());
349 }
350 if let Some(value) = cmd.prohibit_login {
351 am.prohibit_login = Set(value);
352 changed.push("prohibit_login".to_string());
353 }
354 if changed.is_empty() {
355 let suspended = self.active_suspension_ids(&[uid]).await?;
356 return Ok(user_summary(&am_as_model(&row), suspended.contains(&uid)));
357 }
358 am.updated_at = Set(OffsetDateTime::now_utc());
359 am.version = Set(row.version + 1);
360 let row = am.update(self.db().write()).await?;
361 self.audit(
362 Some(actor),
363 AuditRecord {
364 action: "user.updated",
365 target: Some(("user", uid.to_string())),
366 detail: json!({ "changed": changed, "is_active": row.is_active, "is_restricted": row.is_restricted, "prohibit_login": row.prohibit_login }),
367 },
368 ctx,
369 )
370 .await?;
371 let suspended = self.active_suspension_ids(&[uid]).await?;
372 Ok(user_summary(&row, suspended.contains(&uid)))
373 }
374
375 pub async fn suspend_user(
376 &self,
377 actor: &AdminActor,
378 uid: Uuid,
379 cmd: SuspendUserCmd,
380 ctx: &AuditCtx,
381 ) -> Result<AdminSuspensionDto, AdminError> {
382 actor.require(AdminRole::Operator)?;
383 let row = user::Entity::find_by_id(uid)
384 .one(self.db().read())
385 .await?
386 .ok_or(AdminError::NotFound)?;
387 let now = OffsetDateTime::now_utc();
388 let ends_at = match cmd.suspension_type {
389 SuspensionType::Temporary => {
390 let ends_at = cmd.ends_at.ok_or_else(|| {
391 AdminError::BadRequest("temporary suspension requires ends_at".to_string())
392 })?;
393 if ends_at <= now {
394 return Err(AdminError::BadRequest(
395 "ends_at must be in the future".to_string(),
396 ));
397 }
398 Some(ends_at)
399 }
400 SuspensionType::Permanent => None,
401 };
402 let reason = cmd
403 .reason
404 .map(|value| value.trim().to_string())
405 .filter(|value| !value.is_empty());
406 let suspension = user_suspensions::ActiveModel {
407 id: Set(Uuid::now_v7()),
408 user_id: Set(uid),
409 suspension_type: Set(cmd.suspension_type),
410 reason: Set(reason.clone()),
411 suspended_by: Set(Some(actor.id)),
412 starts_at: Set(now),
413 ends_at: Set(ends_at),
414 revoked_at: Set(None),
415 revoked_by: Set(None),
416 created_at: Set(now),
417 updated_at: Set(now),
418 }
419 .insert(self.db().write())
420 .await?;
421
422 let mut am: user::ActiveModel = row.into();
423 am.is_restricted = Set(true);
424 am.updated_at = Set(now);
425 am.update(self.db().write()).await?;
426 let revoked = self.revoke_user_sessions(uid).await?;
427
428 self.audit(
429 Some(actor),
430 AuditRecord {
431 action: "user.suspended",
432 target: Some(("user", uid.to_string())),
433 detail: json!({
434 "suspension_id": suspension.id,
435 "suspension_type": format!("{:?}", cmd.suspension_type).to_ascii_lowercase(),
436 "reason": reason,
437 "ends_at": ends_at.map(|value| value.to_string()),
438 "sessions_revoked": revoked,
439 }),
440 },
441 ctx,
442 )
443 .await?;
444
445 Ok(AdminSuspensionDto {
446 id: suspension.id,
447 suspension_type: match suspension.suspension_type {
448 SuspensionType::Temporary => "temporary",
449 SuspensionType::Permanent => "permanent",
450 }
451 .to_string(),
452 reason: suspension.reason,
453 starts_at: suspension.starts_at,
454 ends_at: suspension.ends_at,
455 revoked_at: suspension.revoked_at,
456 suspended_by: suspension.suspended_by,
457 })
458 }
459
460 pub async fn revoke_suspension(
461 &self,
462 actor: &AdminActor,
463 uid: Uuid,
464 suspension_id: Uuid,
465 ctx: &AuditCtx,
466 ) -> Result<(), AdminError> {
467 actor.require(AdminRole::Operator)?;
468 let row = user_suspensions::Entity::find_by_id(suspension_id)
469 .one(self.db().read())
470 .await?
471 .filter(|row| row.user_id == uid)
472 .ok_or(AdminError::NotFound)?;
473 if row.revoked_at.is_some() {
474 return Ok(());
475 }
476 let now = OffsetDateTime::now_utc();
477 let mut am: user_suspensions::ActiveModel = row.into();
478 am.revoked_at = Set(Some(now));
479 am.revoked_by = Set(Some(actor.id));
480 am.updated_at = Set(now);
481 am.update(self.db().write()).await?;
482
483 let remaining = self.active_suspension_ids(&[uid]).await?;
484 if !remaining.contains(&uid)
485 && let Some(user_row) = user::Entity::find_by_id(uid)
486 .one(self.db().write())
487 .await?
488 {
489 let mut um: user::ActiveModel = user_row.into();
490 um.is_restricted = Set(false);
491 um.updated_at = Set(now);
492 um.update(self.db().write()).await?;
493 }
494
495 self.audit(
496 Some(actor),
497 AuditRecord {
498 action: "user.suspension_revoked",
499 target: Some(("user", uid.to_string())),
500 detail: json!({ "suspension_id": suspension_id }),
501 },
502 ctx,
503 )
504 .await?;
505 Ok(())
506 }
507
508 pub async fn reset_user_password(
509 &self,
510 actor: &AdminActor,
511 uid: Uuid,
512 cmd: ResetUserPasswordCmd,
513 ctx: &AuditCtx,
514 ) -> Result<(), AdminError> {
515 actor.require(AdminRole::Operator)?;
516 auth::share::crypto::validate_password(&cmd.new_password, &self.config().auth.password_policy)?;
517 let row = user::Entity::find_by_id(uid)
518 .one(self.db().read())
519 .await?
520 .ok_or(AdminError::NotFound)?;
521 let now = OffsetDateTime::now_utc();
522 let hash = auth::share::crypto::hash_password(&cmd.new_password)?;
523 let mut am: user::ActiveModel = row.into();
524 am.passwd_hash = Set(Some(hash));
525 am.password_last_changed_at = Set(Some(now));
526 am.passwd_version = Set(Some(now.unix_timestamp()));
527 am.updated_at = Set(now);
528 am.update(self.db().write()).await?;
529 let revoked = self.revoke_user_sessions(uid).await?;
530 self.audit(
531 Some(actor),
532 AuditRecord {
533 action: "user.password_reset",
534 target: Some(("user", uid.to_string())),
535 detail: json!({ "sessions_revoked": revoked }),
536 },
537 ctx,
538 )
539 .await?;
540 Ok(())
541 }
542
543 pub async fn revoke_user_sessions(&self, uid: Uuid) -> Result<u64, AdminError> {
544 let now = OffsetDateTime::now_utc();
545 let result = user_sessions::Entity::update_many()
546 .col_expr(user_sessions::Column::IsRevoked, Expr::value(true))
547 .col_expr(user_sessions::Column::RevokedAt, Expr::value(Some(now)))
548 .col_expr(user_sessions::Column::UpdatedAt, Expr::value(now))
549 .filter(user_sessions::Column::UserId.eq(uid))
550 .filter(user_sessions::Column::IsRevoked.eq(false))
551 .exec(self.db().write())
552 .await?;
553 Ok(result.rows_affected)
554 }
555
556 pub async fn admin_revoke_user_sessions(
557 &self,
558 actor: &AdminActor,
559 uid: Uuid,
560 ctx: &AuditCtx,
561 ) -> Result<RevokeSessionsResp, AdminError> {
562 actor.require(AdminRole::Operator)?;
563 let row = user::Entity::find_by_id(uid)
564 .one(self.db().read())
565 .await?
566 .ok_or(AdminError::NotFound)?;
567 let revoked = self.revoke_user_sessions(uid).await?;
568 self.audit(
569 Some(actor),
570 AuditRecord {
571 action: "user.sessions_revoked",
572 target: Some(("user", row.uid.to_string())),
573 detail: json!({ "revoked": revoked }),
574 },
575 ctx,
576 )
577 .await?;
578 Ok(RevokeSessionsResp { revoked })
579 }
580
581 pub async fn admin_delete_user(
582 &self,
583 actor: &AdminActor,
584 uid: Uuid,
585 ctx: &AuditCtx,
586 ) -> Result<DeleteUserResp, AdminError> {
587 actor.require(AdminRole::SuperAdmin)?;
588 let Some(user_row) = user::Entity::find_by_id(uid).one(self.db().write()).await? else {
589 return Ok(DeleteUserResp {
590 user_id: uid,
591 anonymized: false,
592 });
593 };
594 let now = OffsetDateTime::now_utc();
595 let anon_name = format!("deleted-{}", &uid.simple().to_string()[..8]);
596 let mut active: user::ActiveModel = user_row.into_active_model();
597 active.username = Set(anon_name.clone());
598 active.lower_name = Set(anon_name.clone());
599 active.display_name = Set("deleted user".to_string());
600 active.avatar_url = Set(String::new());
601 active.is_active = Set(false);
602 active.prohibit_login = Set(true);
603 active.passwd_hash = Set(None);
604 active.password_last_changed_at = Set(None);
605 active.passwd_version = Set(Some(now.unix_timestamp()));
606 active.totp_encrypted_secret = Set(None);
607 active.totp_enabled = Set(false);
608 active.timezone = Set(None);
609 active.locale = Set(None);
610 active.language = Set(None);
611 active.updated_at = Set(now);
612 active.update(self.db().write()).await?;
613
614 if let Some(profile) = user_profiles::Entity::find_by_id(uid)
615 .one(self.db().write())
616 .await?
617 {
618 let mut am: user_profiles::ActiveModel = profile.into();
619 am.real_name = Set(None);
620 am.bio = Set(None);
621 am.pronouns = Set(None);
622 am.website = Set(None);
623 am.location = Set(None);
624 am.company = Set(None);
625 am.date_of_birth = Set(None);
626 am.banner_url = Set(None);
627 am.banner_hash = Set(None);
628 am.banner_color = Set(None);
629 am.accent_color = Set(None);
630 am.custom_status_emoji = Set(None);
631 am.custom_status_text = Set(None);
632 am.custom_status_expires_at = Set(None);
633 am.updated_at = Set(now);
634 am.update(self.db().write()).await?;
635 }
636
637 user_emails::Entity::update_many()
638 .col_expr(
639 user_emails::Column::Email,
640 Expr::value(format!("{anon_name}@deleted.invalid")),
641 )
642 .col_expr(
643 user_emails::Column::LowerEmail,
644 Expr::value(format!("{anon_name}@deleted.invalid")),
645 )
646 .col_expr(user_emails::Column::DeletedAt, Expr::value(Some(now)))
647 .col_expr(user_emails::Column::UpdatedAt, Expr::value(now))
648 .filter(user_emails::Column::UserId.eq(uid))
649 .exec(self.db().write())
650 .await?;
651
652 user_sessions::Entity::update_many()
653 .col_expr(user_sessions::Column::IsRevoked, Expr::value(true))
654 .col_expr(user_sessions::Column::RevokedAt, Expr::value(Some(now)))
655 .col_expr(user_sessions::Column::UpdatedAt, Expr::value(now))
656 .filter(user_sessions::Column::UserId.eq(uid))
657 .filter(user_sessions::Column::IsRevoked.eq(false))
658 .exec(self.db().write())
659 .await?;
660
661 user_access_keys::Entity::delete_many()
662 .filter(user_access_keys::Column::UserId.eq(uid))
663 .exec(self.db().write())
664 .await?;
665 user_ssh_keys::Entity::delete_many()
666 .filter(user_ssh_keys::Column::UserId.eq(uid))
667 .exec(self.db().write())
668 .await?;
669 user_recovery_codes::Entity::delete_many()
670 .filter(user_recovery_codes::Column::UserId.eq(uid))
671 .exec(self.db().write())
672 .await?;
673
674 user_deletion_requests::Entity::update_many()
675 .col_expr(
676 user_deletion_requests::Column::Status,
677 Expr::value("completed"),
678 )
679 .col_expr(
680 user_deletion_requests::Column::CompletedAt,
681 Expr::value(Some(now)),
682 )
683 .col_expr(user_deletion_requests::Column::UpdatedAt, Expr::value(now))
684 .filter(user_deletion_requests::Column::UserId.eq(uid))
685 .exec(self.db().write())
686 .await?;
687
688 self.audit(
689 Some(actor),
690 AuditRecord {
691 action: "user.deleted",
692 target: Some(("user", uid.to_string())),
693 detail: json!({ "anonymized": true }),
694 },
695 ctx,
696 )
697 .await?;
698 Ok(DeleteUserResp {
699 user_id: uid,
700 anonymized: true,
701 })
702 }
703}
704
705fn am_as_model(row: &user::Model) -> user::Model {
706 row.clone()
707}
No newline at end of file
Added lib/admin/src/share/guard.rs View lib/admin/src/share/guard.rs
@@ -0,0 +1,56 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::{IntoResponse, Response};
4use models::admin::admin_user;
5use sea_orm::EntityTrait;
6use session::Session;
7
8use crate::errors::AdminApiError;
9use crate::errors::password_change_required_response;
10use crate::services::service::AdminService;
11use crate::share::roles::AdminActor;
12use crate::share::session::{admin_uid, clear_admin, pwd_version_matches};
13
14pub async fn admin_guard(
15 State(service): State<AdminService>,
16 session: Session,
17 mut request: Request,
18 next: Next,
19) -> Response {
20 let Some(uid) = admin_uid(&session) else {
21 return AdminApiError::unauthorized().into_response();
22 };
23 let row = match admin_user::Entity::find_by_id(uid)
24 .one(service.db().read())
25 .await
26 {
27 Ok(row) => row,
28 Err(error) => return AdminApiError::internal(error).into_response(),
29 };
30 let Some(row) = row else {
31 clear_admin(&session);
32 return AdminApiError::unauthorized().into_response();
33 };
34 if !row.is_active || !pwd_version_matches(&session, row.passwd_version) {
35 clear_admin(&session);
36 return AdminApiError::unauthorized().into_response();
37 }
38 if row.must_change_password && !password_change_path_allowed(request.uri().path()) {
39 return password_change_required_response();
40 }
41 request.extensions_mut().insert(AdminActor {
42 id: row.id,
43 username: row.username,
44 display_name: row.display_name,
45 role: row.role,
46 must_change_password: row.must_change_password,
47 });
48 next.run(request).await
49}
50
51fn password_change_path_allowed(path: &str) -> bool {
52 let path = path.trim_end_matches('/');
53 path.ends_with("/admin/me")
54 || path.ends_with("/admin/me/password")
55 || path.ends_with("/admin/auth/logout")
56}
No newline at end of file
Added lib/admin/src/share/mod.rs View lib/admin/src/share/mod.rs
@@ -0,0 +1,3 @@
1pub mod guard;
2pub mod roles;
3pub mod session;
No newline at end of file
Added lib/admin/src/share/roles.rs View lib/admin/src/share/roles.rs
@@ -0,0 +1,54 @@
1use axum::extract::FromRequestParts;
2use axum::http::request::Parts;
3use models::admin::admin_user::AdminRole;
4use uuid::Uuid;
5
6use crate::errors::AdminApiError;
7use crate::errors::AdminError;
8
9#[derive(Debug, Clone)]
10pub struct AdminActor {
11 pub id: Uuid,
12 pub username: String,
13 pub display_name: String,
14 pub role: AdminRole,
15 pub must_change_password: bool,
16}
17
18impl AdminActor {
19 pub fn level(role: AdminRole) -> u8 {
20 match role {
21 AdminRole::SuperAdmin => 3,
22 AdminRole::Operator => 2,
23 AdminRole::Viewer => 1,
24 }
25 }
26
27 pub fn is_super(&self) -> bool {
28 matches!(self.role, AdminRole::SuperAdmin)
29 }
30
31 pub fn require(&self, minimum: AdminRole) -> Result<(), AdminError> {
32 if Self::level(self.role) >= Self::level(minimum) {
33 Ok(())
34 } else {
35 Err(AdminError::Forbidden)
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct CurrentAdmin(pub AdminActor);
42
43impl<S: Send + Sync> FromRequestParts<S> for CurrentAdmin {
44 type Rejection = AdminApiError;
45
46 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
47 parts
48 .extensions
49 .get::<AdminActor>()
50 .cloned()
51 .map(CurrentAdmin)
52 .ok_or_else(AdminApiError::unauthorized)
53 }
54}
No newline at end of file
Added lib/admin/src/share/session.rs View lib/admin/src/share/session.rs
@@ -0,0 +1,29 @@
1use models::admin::admin_user;
2use session::Session;
3use session::SessionUserMut;
4use uuid::Uuid;
5
6pub const ADMIN_UID_KEY: &str = "admin:uid";
7pub const ADMIN_PWD_VERSION_KEY: &str = "admin:pwd_version";
8
9pub fn admin_uid(session: &Session) -> Option<Uuid> {
10 session.get::<Uuid>(ADMIN_UID_KEY)
11}
12
13pub fn apply_admin_login(session: &Session, admin: &admin_user::Model) {
14 session.set_store(true);
15 session.set(ADMIN_UID_KEY, admin.id);
16 session.set(ADMIN_PWD_VERSION_KEY, admin.passwd_version);
17}
18
19pub fn clear_admin(session: &Session) {
20 session.remove(ADMIN_UID_KEY);
21 session.remove(ADMIN_PWD_VERSION_KEY);
22 session.clear_uid();
23}
24
25pub fn pwd_version_matches(session: &Session, current: i64) -> bool {
26 session
27 .get::<i64>(ADMIN_PWD_VERSION_KEY)
28 .is_some_and(|value| value == current)
29}
No newline at end of file
Added lib/admin/src/errors.rs View lib/admin/src/errors.rs
@@ -0,0 +1,110 @@
1use std::fmt;
2
3use axum::response::{IntoResponse, Response};
4use export::rest::AppResponseData;
5use export::rest::NoData;
6
7#[derive(Debug)]
8pub enum AdminError {
9 BadRequest(String),
10 InvalidCredentials,
11 Unauthorized,
12 Forbidden,
13 NotFound,
14 Conflict(String),
15 RateLimited(String),
16 Internal(String),
17}
18
19impl fmt::Display for AdminError {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 match self {
22 Self::BadRequest(message) => write!(f, "{message}"),
23 Self::InvalidCredentials => write!(f, "invalid credentials"),
24 Self::Unauthorized => write!(f, "unauthorized"),
25 Self::Forbidden => write!(f, "forbidden"),
26 Self::NotFound => write!(f, "not found"),
27 Self::Conflict(message) => write!(f, "{message}"),
28 Self::RateLimited(message) => write!(f, "{message}"),
29 Self::Internal(message) => write!(f, "{message}"),
30 }
31 }
32}
33
34impl std::error::Error for AdminError {}
35
36export::db_error_conversions!(AdminError);
37export::storage_error_conversion!(AdminError);
38export::redis_error_conversion!(AdminError);
39export::export_error_conversion!(AdminError);
40export::json_payload_conversion!(AdminError);
41
42impl From<auth::errors::AuthError> for AdminError {
43 fn from(error: auth::errors::AuthError) -> Self {
44 match error {
45 auth::errors::AuthError::BadRequest(message) => Self::BadRequest(message),
46 auth::errors::AuthError::InvalidCredentials => Self::InvalidCredentials,
47 auth::errors::AuthError::Unauthorized => Self::Unauthorized,
48 auth::errors::AuthError::Forbidden => Self::Forbidden,
49 auth::errors::AuthError::NotFound => Self::NotFound,
50 auth::errors::AuthError::Conflict(message) => Self::Conflict(message),
51 auth::errors::AuthError::RateLimited => {
52 Self::RateLimited("rate limit exceeded".to_string())
53 }
54 auth::errors::AuthError::PowFailed => {
55 Self::BadRequest("proof of work failed".to_string())
56 }
57 auth::errors::AuthError::Internal(message) => Self::Internal(message),
58 }
59 }
60}
61
62export::api_error! {
63 log: "admin",
64 pub enum AdminApiError {
65 BadRequest(400, "request failed validation"),
66 Unauthorized(401, "authentication required"),
67 Forbidden(403, "operation not allowed"),
68 NotFound(404, "resource not found"),
69 Conflict(409, "resource conflict"),
70 RateLimited(429, "rate limit exceeded"),
71 Internal(500, "internal server error"),
72 }
73}
74
75impl AdminApiError {
76 pub fn invalid_credentials() -> Self {
77 Self::build(
78 401,
79 AppResponseData::<NoData>::err(401, "invalid username or password")
80 .with_error_code("invalid_credentials"),
81 )
82 }
83
84 pub fn password_change_required() -> Self {
85 Self::build(
86 403,
87 AppResponseData::<NoData>::err(403, "password change required")
88 .with_error_code("password_change_required"),
89 )
90 }
91}
92
93pub fn password_change_required_response() -> Response {
94 AdminApiError::password_change_required().into_response()
95}
96
97impl From<AdminError> for AdminApiError {
98 fn from(error: AdminError) -> Self {
99 match error {
100 AdminError::BadRequest(message) => Self::bad_request(message),
101 AdminError::InvalidCredentials => Self::invalid_credentials(),
102 AdminError::Unauthorized => Self::unauthorized(),
103 AdminError::Forbidden => Self::forbidden(),
104 AdminError::NotFound => Self::not_found("admin resource not found"),
105 AdminError::Conflict(message) => Self::conflict(message),
106 AdminError::RateLimited(message) => Self::rate_limited(message),
107 AdminError::Internal(message) => Self::internal(message),
108 }
109 }
110}
No newline at end of file
Added lib/admin/src/lib.rs View lib/admin/src/lib.rs
@@ -0,0 +1,9 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
7
8pub use errors::AdminApiError;
9pub use services::service::AdminService;
No newline at end of file
Added lib/admin/src/openapi.rs View lib/admin/src/openapi.rs
@@ -0,0 +1,127 @@
1use utoipa::OpenApi;
2
3#[derive(OpenApi)]
4#[openapi(
5 info(
6 title = "gitdata admin api",
7 version = "1.0",
8 description = "GitData 管理后台 API。独立进程(adminhost)、独立会话 Cookie(gitdata_admin_session)、固定角色 super_admin/operator/viewer。"
9 ),
10 paths(
11 crate::handlers::auth::login,
12 crate::handlers::auth::logout,
13 crate::handlers::auth::me,
14 crate::handlers::auth::change_password,
15 crate::handlers::admins::list_admins,
16 crate::handlers::admins::create_admin,
17 crate::handlers::admins::get_admin,
18 crate::handlers::admins::update_admin,
19 crate::handlers::admins::delete_admin,
20 crate::handlers::admins::reset_admin_password,
21 crate::handlers::users::list_users,
22 crate::handlers::users::get_user,
23 crate::handlers::users::update_user,
24 crate::handlers::users::suspend_user,
25 crate::handlers::users::revoke_suspension,
26 crate::handlers::users::reset_user_password,
27 crate::handlers::users::revoke_user_sessions,
28 crate::handlers::users::delete_user,
29 crate::handlers::spaces::list_spaces,
30 crate::handlers::spaces::get_space,
31 crate::handlers::spaces::update_space,
32 crate::handlers::spaces::transfer_space,
33 crate::handlers::spaces::delete_space,
34 crate::handlers::spaces::list_space_members,
35 crate::handlers::spaces::remove_space_member,
36 crate::handlers::storage::overview,
37 crate::handlers::storage::list_objects,
38 crate::handlers::storage::download_url,
39 crate::handlers::storage::delete_object,
40 crate::handlers::queues::list_queues,
41 crate::handlers::queues::requeue_failed,
42 crate::handlers::queues::purge,
43 crate::handlers::tasks::list_tasks,
44 crate::handlers::tasks::get_task,
45 crate::handlers::tasks::retry_task,
46 crate::handlers::tasks::cancel_task,
47 crate::handlers::tasks::delete_task,
48 crate::handlers::logs::list_audit_logs,
49 crate::handlers::logs::list_job_logs,
50 crate::handlers::system::status
51 ),
52 components(schemas(
53 export::rest::NoData,
54 export::pagination::PageQuery,
55 models::admin::admin_user::AdminRole,
56 models::users::user_suspensions::SuspensionType,
57 crate::services::auth::AdminLoginCmd,
58 crate::services::auth::AdminLoginResp,
59 crate::services::auth::AdminDto,
60 crate::services::auth::ChangePasswordCmd,
61 crate::services::auth::CreateAdminCmd,
62 crate::services::auth::UpdateAdminCmd,
63 crate::services::auth::ResetAdminPasswordCmd,
64 crate::services::users::AdminUserSummary,
65 crate::services::users::AdminUserEmailDto,
66 crate::services::users::AdminUserSessionDto,
67 crate::services::users::AdminUserSpaceDto,
68 crate::services::users::AdminSuspensionDto,
69 crate::services::users::AdminUserDetail,
70 crate::services::users::UpdateUserCmd,
71 crate::services::users::SuspendUserCmd,
72 crate::services::users::ResetUserPasswordCmd,
73 crate::services::users::RevokeSessionsResp,
74 crate::services::users::DeleteUserResp,
75 crate::services::spaces::AdminSpaceSummary,
76 crate::services::spaces::AdminSpaceDetail,
77 crate::services::spaces::UpdateSpaceCmd,
78 crate::services::spaces::TransferSpaceCmd,
79 crate::services::spaces::AdminSpaceMemberDto,
80 crate::services::spaces::RemoveMemberResp,
81 crate::services::storage::StorageCategoryDto,
82 crate::services::storage::StorageOverviewDto,
83 crate::services::storage::StorageObjectDto,
84 crate::services::storage::StorageObjectPageDto,
85 crate::services::storage::DownloadUrlDto,
86 crate::services::storage::DeleteObjectResp,
87 crate::services::queues::QueueSnapshotDto,
88 crate::services::queues::QueueActionResp,
89 crate::services::tasks::TaskDto,
90 crate::services::tasks::TaskPageDto,
91 crate::services::tasks::TaskDetailDto,
92 crate::services::tasks::TaskActionResp,
93 crate::services::logs::AuditLogDto,
94 crate::services::logs::AuditLogPageDto,
95 crate::services::logs::JobLogDto,
96 crate::services::logs::JobLogPageDto,
97 crate::services::system::ProcessInfoDto,
98 crate::services::system::DependencyDto,
99 crate::services::system::DependenciesDto,
100 crate::services::system::FeaturesDto,
101 crate::services::system::CountsDto,
102 crate::services::system::SystemStatusDto
103 ))
104)]
105pub struct AdminApiDoc;
106
107pub fn api_doc() -> utoipa::openapi::OpenApi {
108 let mut doc = AdminApiDoc::openapi();
109 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
110 {
111 use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
112 let components = doc
113 .components
114 .get_or_insert_with(utoipa::openapi::Components::default);
115 components.add_security_scheme(
116 "admin_session_cookie",
117 SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("gitdata_admin_session"))),
118 );
119 }
120 doc.security = Some(vec![
121 utoipa::openapi::security::SecurityRequirement::new(
122 "admin_session_cookie",
123 Vec::<String>::new(),
124 ),
125 ]);
126 doc
127}
No newline at end of file
Added lib/admin/src/routes.rs View lib/admin/src/routes.rs
@@ -0,0 +1,97 @@
1use axum::Router;
2use axum::routing::{delete, get, patch, post};
3
4use crate::handlers::{admins, auth, logs, queues, spaces, storage, system, tasks, users};
5use crate::services::service::AdminService;
6use crate::share::guard::admin_guard;
7
8pub fn router(service: AdminService) -> Router<AdminService> {
9 let public: Router<AdminService> = Router::new()
10 .route("/admin/auth/login", post(auth::login))
11 .route("/admin/auth/logout", post(auth::logout));
12
13 let protected = Router::new()
14 .route("/admin/me", get(auth::me))
15 .route("/admin/me/password", patch(auth::change_password))
16 .route(
17 "/admin/admins",
18 get(admins::list_admins).post(admins::create_admin),
19 )
20 .route(
21 "/admin/admins/{id}",
22 get(admins::get_admin)
23 .patch(admins::update_admin)
24 .delete(admins::delete_admin),
25 )
26 .route(
27 "/admin/admins/{id}/reset-password",
28 post(admins::reset_admin_password),
29 )
30 .route("/admin/users", get(users::list_users))
31 .route(
32 "/admin/users/{uid}",
33 get(users::get_user)
34 .patch(users::update_user)
35 .delete(users::delete_user),
36 )
37 .route("/admin/users/{uid}/suspend", post(users::suspend_user))
38 .route(
39 "/admin/users/{uid}/suspensions/{suspension_id}/revoke",
40 post(users::revoke_suspension),
41 )
42 .route(
43 "/admin/users/{uid}/reset-password",
44 post(users::reset_user_password),
45 )
46 .route(
47 "/admin/users/{uid}/revoke-sessions",
48 post(users::revoke_user_sessions),
49 )
50 .route("/admin/spaces", get(spaces::list_spaces))
51 .route(
52 "/admin/spaces/{id}",
53 get(spaces::get_space)
54 .patch(spaces::update_space)
55 .delete(spaces::delete_space),
56 )
57 .route("/admin/spaces/{id}/transfer", post(spaces::transfer_space))
58 .route(
59 "/admin/spaces/{id}/members",
60 get(spaces::list_space_members),
61 )
62 .route(
63 "/admin/spaces/{id}/members/{uid}",
64 delete(spaces::remove_space_member),
65 )
66 .route("/admin/storage/overview", get(storage::overview))
67 .route(
68 "/admin/storage/objects",
69 get(storage::list_objects).delete(storage::delete_object),
70 )
71 .route("/admin/storage/download-url", get(storage::download_url))
72 .route("/admin/queues", get(queues::list_queues))
73 .route(
74 "/admin/queues/{name}/requeue-failed",
75 post(queues::requeue_failed),
76 )
77 .route("/admin/queues/{name}/purge", post(queues::purge))
78 .route("/admin/tasks", get(tasks::list_tasks))
79 .route(
80 "/admin/tasks/{source}/{id}",
81 get(tasks::get_task).delete(tasks::delete_task),
82 )
83 .route("/admin/tasks/{source}/{id}/retry", post(tasks::retry_task))
84 .route(
85 "/admin/tasks/{source}/{id}/cancel",
86 post(tasks::cancel_task),
87 )
88 .route("/admin/logs/audit", get(logs::list_audit_logs))
89 .route("/admin/logs/jobs", get(logs::list_job_logs))
90 .route("/admin/system/status", get(system::status))
91 .route_layer(axum::middleware::from_fn_with_state(
92 service.clone(),
93 admin_guard,
94 ));
95
96 public.merge(protected)
97}
No newline at end of file
Added lib/admin/Cargo.toml View lib/admin/Cargo.toml
@@ -0,0 +1,23 @@
1[package]
2name = "admin"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7auth = { workspace = true }
8axum = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11models = { workspace = true }
12redis = { workspace = true }
13reqwest = { workspace = true }
14sea-orm = { workspace = true }
15serde = { workspace = true, features = ["derive"] }
16serde_json = { workspace = true }
17session = { workspace = true }
18storage = { workspace = true }
19tokio = { workspace = true, features = ["macros", "time"] }
20time = { workspace = true }
21tracing = { workspace = true }
22utoipa = { workspace = true }
23uuid = { workspace = true }
No newline at end of file
Added lib/config/src/admin.rs View lib/config/src/admin.rs
@@ -0,0 +1,110 @@
1use std::net::IpAddr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6#[serde(default)]
7pub struct AdminConfig {
8 pub enabled: Option<bool>,
9 pub bind_addr: Option<String>,
10 pub port: Option<u16>,
11 pub seed_username: Option<String>,
12 pub seed_password: Option<String>,
13 pub session_cookie_name: Option<String>,
14 pub session_table_name: Option<String>,
15 pub cors_allow_origins: Vec<String>,
16 pub cors_allow_credentials: Option<bool>,
17}
18
19impl AdminConfig {
20 pub fn enabled(&self) -> bool {
21 self.enabled.unwrap_or(true)
22 }
23
24 pub fn bind_addr(&self) -> &str {
25 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
26 }
27
28 pub fn port(&self) -> u16 {
29 self.port.unwrap_or(3004)
30 }
31
32 pub fn seed_username(&self) -> Option<&str> {
33 self.seed_username
34 .as_deref()
35 .map(str::trim)
36 .filter(|value| !value.is_empty())
37 }
38
39 pub fn seed_password(&self) -> Option<&str> {
40 self.seed_password
41 .as_deref()
42 .filter(|value| !value.is_empty())
43 }
44
45 pub fn session_cookie_name(&self) -> &str {
46 self.session_cookie_name
47 .as_deref()
48 .unwrap_or("gitdata_admin_session")
49 }
50
51 pub fn session_table_name(&self) -> &str {
52 self.session_table_name
53 .as_deref()
54 .unwrap_or("admin_session")
55 }
56
57 pub fn cors_allow_credentials(&self) -> bool {
58 self.cors_allow_credentials.unwrap_or(false)
59 }
60
61 pub fn is_valid(&self) -> Result<(), String> {
62 if !self.enabled() {
63 return Ok(());
64 }
65 let bind = self.bind_addr();
66 if bind.parse::<IpAddr>().is_err() {
67 return Err(format!("admin.bind_addr is not a valid IP: {bind:?}"));
68 }
69 if self.port == Some(0) {
70 return Err("admin.port must be greater than 0".to_string());
71 }
72 if self.session_cookie_name().trim().is_empty() {
73 return Err("admin.session_cookie_name must not be empty".to_string());
74 }
75 let table = self.session_table_name();
76 if table.trim().is_empty() {
77 return Err("admin.session_table_name must not be empty".to_string());
78 }
79 if table.contains(['*', '?', '[', ']', '\\']) {
80 return Err(format!(
81 "admin.session_table_name must not contain glob characters: {table:?}"
82 ));
83 }
84 match (self.seed_username(), self.seed_password()) {
85 (Some(username), Some(password)) => {
86 if username.len() < 3 {
87 return Err("admin.seed_username must be at least 3 characters".to_string());
88 }
89 if password.len() < 8 {
90 return Err("admin.seed_password must be at least 8 characters".to_string());
91 }
92 }
93 (Some(_), None) => {
94 return Err("admin.seed_username requires admin.seed_password".to_string());
95 }
96 (None, Some(_)) => {
97 return Err("admin.seed_password requires admin.seed_username".to_string());
98 }
99 (None, None) => {}
100 }
101 for origin in &self.cors_allow_origins {
102 if !origin.starts_with("http://") && !origin.starts_with("https://") {
103 return Err(format!(
104 "admin.cors_allow_origins entry must be an origin URL: {origin:?}"
105 ));
106 }
107 }
108 Ok(())
109 }
110}
No newline at end of file
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -1,3 +1,4 @@
1pub mod admin;
12pub mod apihost;
23pub mod auth;
34pub mod cache;
@@ -27,6 +28,7 @@
2728
2829pub use error::ConfigError;
2930
31use crate::admin::AdminConfig;
3032use crate::apihost::ApihostConfig;
3133use crate::auth::AuthConfig;
3234use crate::cache::CacheConfig;
@@ -75,6 +77,7 @@
7577 pub githost: GithostConfig,
7678 pub im: ImServerConfig,
7779 pub health: HealthConfig,
80 pub admin: AdminConfig,
7881}
7982
8083impl AppConfig {
@@ -100,6 +103,7 @@
100103 self.githost.is_valid().map_err(ConfigError::Validation)?;
101104 self.im.is_valid().map_err(ConfigError::Validation)?;
102105 self.health.is_valid().map_err(ConfigError::Validation)?;
106 self.admin.is_valid().map_err(ConfigError::Validation)?;
103107 Ok(())
104108 }
105109}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -1,6 +1,7 @@
11use serde::Deserialize;
22use serde::de::DeserializeOwned;
33
4use crate::admin::AdminConfig as AdminSection;
45use crate::apihost::ApihostConfig as ApihostSection;
56use crate::auth::AuthConfig;
67use crate::cache::CacheConfig;
@@ -241,3 +242,52 @@
241242 self.im.is_valid()
242243 }
243244}
245
246#[derive(Clone, Debug, Default, Deserialize)]
247#[serde(default)]
248pub struct AdminhostConfig {
249 pub database: DatabaseConfig,
250 pub redis: RedisConfig,
251 pub s3: S3Config,
252 pub session: SessionConfig,
253 pub auth: AuthConfig,
254 pub email: EmailConfig,
255 pub email_queue: EmailQueueConfig,
256 pub search: SearchConfig,
257 pub import: ImportConfig,
258 pub git: GitConfig,
259 pub admin: AdminSection,
260}
261
262impl AdminhostConfig {
263 pub fn into_app_config(self) -> AppConfig {
264 AppConfig {
265 database: self.database,
266 redis: self.redis,
267 s3: self.s3,
268 session: self.session,
269 auth: self.auth,
270 email: self.email,
271 email_queue: self.email_queue,
272 search: self.search,
273 import: self.import,
274 git: self.git,
275 admin: self.admin,
276 ..Default::default()
277 }
278 }
279}
280
281impl ScopedConfig for AdminhostConfig {
282 fn validate_scoped(&self) -> Result<(), String> {
283 self.redis.validate()?;
284 self.database.validate()?;
285 self.auth.validate()?;
286 self.email.is_valid()?;
287 self.email_queue.is_valid()?;
288 self.session.is_valid()?;
289 self.search.validate()?;
290 self.import.validate()?;
291 self.admin.is_valid()
292 }
293}
No newline at end of file
Added lib/models/src/admin/admin_audit_log.rs View lib/models/src/admin/admin_audit_log.rs
@@ -0,0 +1,28 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
8#[sea_orm(table_name = "admin_audit_log")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 #[sea_orm(indexed)]
13 pub admin_id: Option<Uuid>,
14 pub admin_username: String,
15 #[sea_orm(indexed)]
16 pub action: String,
17 #[sea_orm(indexed)]
18 pub target_type: Option<String>,
19 pub target_id: Option<String>,
20 #[sea_orm(column_type = "JsonBinary")]
21 pub detail: JsonValue,
22 pub ip: Option<String>,
23 pub user_agent: Option<String>,
24 pub request_id: Option<String>,
25 pub created_at: OffsetDateTime,
26}
27
28impl ActiveModelBehavior for ActiveModel {}
No newline at end of file
Added lib/models/src/admin/admin_user.rs View lib/models/src/admin/admin_user.rs
@@ -0,0 +1,54 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum AdminRole {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 SuperAdmin,
25 #[sea_orm(num_value = 1)]
26 Operator,
27 #[sea_orm(num_value = 2)]
28 Viewer,
29}
30
31#[sea_orm::model]
32#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
33#[sea_orm(table_name = "admin_user")]
34pub struct Model {
35 #[sea_orm(primary_key, auto_increment = false)]
36 pub id: Uuid,
37 pub username: String,
38 #[sea_orm(unique)]
39 pub lower_name: String,
40 pub display_name: String,
41 pub passwd_hash: String,
42 pub role: AdminRole,
43 #[sea_orm(default_value = "true")]
44 pub is_active: bool,
45 #[sea_orm(default_value = "false")]
46 pub must_change_password: bool,
47 pub passwd_version: i64,
48 pub last_login_at: Option<OffsetDateTime>,
49 pub last_login_ip: Option<String>,
50 pub created_at: OffsetDateTime,
51 pub updated_at: OffsetDateTime,
52}
53
54impl ActiveModelBehavior for ActiveModel {}
No newline at end of file
Added lib/models/src/admin/mod.rs View lib/models/src/admin/mod.rs
@@ -0,0 +1,2 @@
1pub mod admin_audit_log;
2pub mod admin_user;
No newline at end of file
Modified lib/models/src/lib.rs View lib/models/src/lib.rs
@@ -1,3 +1,4 @@
1pub mod admin;
12pub mod chat;
23pub mod common;
34pub mod dir;
Modified lib/models/schema_extras.sql View lib/models/schema_extras.sql
@@ -262,3 +262,10 @@
262262 CHECK ((has_merged = false) OR merged_at IS NOT NULL);
263263 END IF;
264264END $$;
265
266-- ---------------------------------------------------------------------------
267-- Admin domain (lib/admin): audit log lookups by time, actor and target.
268-- ---------------------------------------------------------------------------
269CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log (created_at DESC);
270CREATE INDEX IF NOT EXISTS idx_admin_audit_log_admin ON admin_audit_log (admin_id, created_at DESC);
271CREATE INDEX IF NOT EXISTS idx_admin_audit_log_target ON admin_audit_log (target_type, target_id);
No newline at end of file
Modified .gitignore View .gitignore
@@ -7,3 +7,5 @@
77.agents
88web/dist
99web/node_modules
10admin/dist
11admin/node_modules
Modified Cargo.lock View Cargo.lock
@@ -15,6 +15,50 @@
1515checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
1616
1717[[package]]
18name = "admin"
19version = "1.0.0"
20dependencies = [
21 "auth",
22 "axum",
23 "config",
24 "export",
25 "models",
26 "redis",
27 "reqwest 0.12.28",
28 "sea-orm",
29 "serde",
30 "serde_json",
31 "session",
32 "storage",
33 "time 0.3.55",
34 "tokio",
35 "tracing",
36 "utoipa",
37 "uuid",
38]
39
40[[package]]
41name = "admin-web"
42version = "1.0.0"
43
44[[package]]
45name = "adminhost"
46version = "1.0.0"
47dependencies = [
48 "admin",
49 "admin-web",
50 "anyhow",
51 "axum",
52 "config",
53 "export",
54 "session",
55 "tokio",
56 "tower-http 0.7.0",
57 "tracing",
58 "utoipa-swagger-ui",
59]
60
61[[package]]
1862name = "aead"
1963version = "0.5.2"
2064source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5491,6 +5535,7 @@
54915535name = "openapi"
54925536version = "1.0.0"
54935537dependencies = [
5538 "admin",
54945539 "anyhow",
54955540 "webapi",
54965541]
Modified Cargo.toml View Cargo.toml
@@ -2,7 +2,8 @@
22members = [
33 "lib/*",
44 "app/*",
5 "web"
5 "web",
6 "admin"
67]
78
89resolver = "3"
@@ -14,6 +15,8 @@
1415
1516
1617[workspace.dependencies]
18admin = { path = "lib/admin" }
19admin-web = { path = "admin" }
1720auth = { path = "lib/auth" }
1821cache = { path = "lib/cache" }
1922chat = { path = "lib/chat" }
Modified Dockerfile View Dockerfile
@@ -22,13 +22,14 @@
2222COPY lib ./lib
2323COPY app ./app
2424COPY web ./web
25COPY admin ./admin
2526
2627RUN --mount=type=cache,target=/usr/local/cargo/registry \
2728 --mount=type=cache,target=/usr/local/cargo/git \
2829 --mount=type=cache,target=/build/target \
2930 cargo build --release --workspace --bins && \
3031 mkdir -p /out && \
31 for b in apihost githost synchost mails migrate openapi im; do \
32 for b in apihost githost synchost mails migrate openapi im adminhost; do \
3233 cp "target/release/$b" /out/; \
3334 done
3435
@@ -49,10 +50,10 @@
4950WORKDIR /app
5051
5152COPY --chown=gitdata:gitdata config/default.toml config/default.toml
52COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /usr/local/bin/
53COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /out/adminhost /usr/local/bin/
5354
5455USER gitdata
5556
56EXPOSE 3000 9418 22 3001 3002 3003
57EXPOSE 3000 9418 22 3001 3002 3003 3004
5758
5859CMD ["apihost"]