Files
gooseek/backend/webui/src/app/(main)/admin/audit/page.tsx
home a0e3748dde feat: auth service + security audit fixes + cleanup legacy services
Major changes:
- Add auth-svc: JWT auth, register/login/refresh, password reset
- Add auth UI: modals, pages (/login, /register, /forgot-password)
- Add usage tracking (usage_metrics table, daily limits)
- Add tiered rate limiting (free/pro/business)
- Add LLM usage limits per tier

Security fixes:
- All repos now require userID for Update/Delete operations
- JWT middleware in chat-svc, llm-svc, agent-svc, discover-svc
- ErrNotFound/ErrForbidden errors for proper access control

Cleanup:
- Remove legacy TypeScript services/ directory
- Remove computer-svc (to be reimplemented)
- Remove old deploy/docker configs

New files:
- backend/cmd/auth-svc/main.go
- backend/internal/auth/{types,repository}.go
- backend/internal/usage/{types,repository}.go
- backend/pkg/middleware/{llm_limits,ratelimit_tiered}.go
- backend/webui/src/components/auth/*
- backend/webui/src/app/(auth)/*

Made-with: Cursor
2026-02-28 01:33:49 +03:00

226 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Shield,
User,
FileText,
Settings,
Compass,
Clock,
} from 'lucide-react';
import { fetchAuditLogs } from '@/lib/api';
import type { AuditLog } from '@/lib/types';
const actionIcons: Record<string, React.ElementType> = {
create: FileText,
update: Settings,
delete: Shield,
publish: Compass,
reorder: Settings,
};
const actionLabels: Record<string, string> = {
create: 'Создание',
update: 'Изменение',
delete: 'Удаление',
publish: 'Публикация',
reorder: 'Сортировка',
};
const resourceLabels: Record<string, string> = {
user: 'Пользователь',
post: 'Пост',
settings: 'Настройки',
discover_category: 'Категория Discover',
discover_categories: 'Категории Discover',
discover_source: 'Источник Discover',
};
export default function AdminAuditPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [actionFilter, setActionFilter] = useState('');
const [resourceFilter, setResourceFilter] = useState('');
const [loading, setLoading] = useState(true);
const loadLogs = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAuditLogs(
page,
50,
actionFilter || undefined,
resourceFilter || undefined
);
setLogs(data.logs);
setTotal(data.total);
} catch (err) {
console.error('Failed to load audit logs:', err);
} finally {
setLoading(false);
}
}, [page, actionFilter, resourceFilter]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
const totalPages = Math.ceil(total / 50);
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getActionColor = (action: string) => {
switch (action) {
case 'create':
return 'bg-green-500/10 text-green-400';
case 'update':
return 'bg-blue-500/10 text-blue-400';
case 'delete':
return 'bg-red-500/10 text-red-400';
case 'publish':
return 'bg-purple-500/10 text-purple-400';
default:
return 'bg-gray-500/10 text-gray-400';
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-primary">Аудит</h1>
<p className="text-muted">История действий администраторов</p>
</div>
<div className="bg-surface rounded-xl border border-border/30">
<div className="p-4 border-b border-border/30 flex gap-4">
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="">Все действия</option>
<option value="create">Создание</option>
<option value="update">Изменение</option>
<option value="delete">Удаление</option>
<option value="publish">Публикация</option>
</select>
<select
value={resourceFilter}
onChange={(e) => {
setResourceFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="">Все ресурсы</option>
<option value="user">Пользователи</option>
<option value="post">Посты</option>
<option value="settings">Настройки</option>
<option value="discover_category">Категории Discover</option>
<option value="discover_source">Источники Discover</option>
</select>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted">
<Shield className="w-12 h-12 mb-4 opacity-50" />
<p>Записей аудита нет</p>
</div>
) : (
<>
<div className="divide-y divide-border/20">
{logs.map((log) => {
const ActionIcon = actionIcons[log.action] || Shield;
return (
<div key={log.id} className="px-4 py-3 hover:bg-base/50">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-lg ${getActionColor(log.action)}`}>
<ActionIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${getActionColor(log.action)}`}>
{actionLabels[log.action] || log.action}
</span>
<span className="text-secondary">
{resourceLabels[log.resource] || log.resource}
</span>
{log.resourceId && (
<span className="text-xs text-muted font-mono">
{log.resourceId.slice(0, 8)}...
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-muted">
<User className="w-3 h-3" />
{log.userEmail}
</span>
<span className="flex items-center gap-1 text-muted">
<Clock className="w-3 h-3" />
{formatDate(log.createdAt)}
</span>
{log.ipAddress && (
<span className="text-xs text-muted">
IP: {log.ipAddress}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border/30">
<p className="text-sm text-muted">
Показано {logs.length} из {total}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Назад
</button>
<span className="px-3 py-1 text-sm text-muted">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-border/50 rounded text-sm disabled:opacity-50"
>
Вперёд
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}