mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat: public platform, admin console, templates, landing docs
Projects become publicly browsable after admin approval; management and secret-adjacent reads (logs, variables, exec, connection URLs) require owner/member/admin auth, and platform controls sit behind RequireAdmin. - Backend: OptionalAuth middleware, read/authed/admin route groups, projects.is_approved + service_templates.owner_id migration, visibility-aware sqlc queries, admin overview/users/approval endpoints, EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with owner CRUD, managed-database connection URLs gated owner+admin, fix DB.Stats() infinite recursion. - Frontend: expandable persisted sidebar + clickable user menu, public landing page with searchable docs browser (GitHub sync, bundled snapshot + localStorage fallback), DocsBrowser shared with /docs, template create/edit/JSON-upload for owned templates, database create dialog, admin console (stats, approvals, project edit/delete, user admin toggles), HA/Security/Usage polish with dead CSS tokens mapped to real theme vars, anonymous-safe gating on every mutation control. - Tests: safe window.localStorage access fixes happy-dom crashes in PlatformShell and canvas storage. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8f5789bf93
commit
8b6214a0f5
62 files changed
+4986
-1133
No files matched your search
@@ -0,0 +1,369 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FolderKanban,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Users,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
deleteProject,
|
||||
getAdminOverview,
|
||||
listAdminUsers,
|
||||
listProjects,
|
||||
setProjectApproval,
|
||||
setUserAdmin,
|
||||
updateProject,
|
||||
type AdminUser,
|
||||
type ProjectEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { LoadingState, useToast } from '@/shared/components';
|
||||
|
||||
const statLabels: Record<string, string> = {
|
||||
users: 'Users',
|
||||
projects: 'Projects',
|
||||
pending_projects: 'Pending approval',
|
||||
services: 'Services',
|
||||
running_services: 'Running',
|
||||
databases: 'Databases',
|
||||
agents: 'Agents',
|
||||
templates: 'Templates',
|
||||
user_templates: 'User templates',
|
||||
cron_jobs: 'Cron jobs',
|
||||
deployments_total: 'Deployments',
|
||||
};
|
||||
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const sessionQuery = useAuthSession();
|
||||
const myId = sessionQuery.data?.user.id;
|
||||
|
||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview });
|
||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers });
|
||||
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) });
|
||||
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
};
|
||||
|
||||
const approvalMutation = useMutation({
|
||||
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
|
||||
onSuccess: (_, vars) => {
|
||||
showToast('success', vars.approved ? 'Project approved' : 'Project unapproved');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const adminMutation = useMutation({
|
||||
mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) => setUserAdmin(id, isAdmin),
|
||||
onSuccess: (_, vars) => {
|
||||
showToast('success', vars.isAdmin ? 'Admin granted' : 'Admin revoked');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateProject(editingProject!.id, {
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Project updated');
|
||||
setEditingProject(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteProject(id),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Project deleted');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
if (overviewQuery.isPending) {
|
||||
return <LoadingState message="Loading admin console…" className="py-24" />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-5 py-24 text-center">
|
||||
<ShieldCheck size={28} className="mx-auto text-[var(--error)]" />
|
||||
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
This area is restricted to platform administrators.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
const stats = overview?.stats ?? {};
|
||||
const pending = overview?.pending_projects ?? [];
|
||||
const users = usersQuery.data ?? [];
|
||||
const allProjects = projectsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-5 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-[24px] font-bold tracking-tight text-[var(--text-primary)]">Admin console</h1>
|
||||
<p className="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
Platform overview, project approvals, and user management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||
{Object.entries(statLabels).map(([key, label]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-3.5"
|
||||
>
|
||||
<p className="text-[20px] font-bold tabular-nums text-[var(--text-primary)]">{stats[key] ?? 0}</p>
|
||||
<p className="mt-0.5 text-[10.5px] uppercase tracking-wider text-[var(--text-tertiary)]">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Clock size={15} className="text-[var(--warning)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Pending approvals</h2>
|
||||
{pending.length > 0 ? (
|
||||
<span className="rounded-full bg-[var(--warning-soft)] px-2 py-0.5 text-[10.5px] font-semibold text-[var(--warning)]">
|
||||
{pending.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{pending.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{pending.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<FolderKanban size={15} className="shrink-0 text-[var(--text-tertiary)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{project.description || 'No description'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approvalMutation.mutate({ id: project.id, approved: true })}
|
||||
disabled={approvalMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3 py-1.5 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 size={13} /> Approve
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">
|
||||
Nothing waiting for approval.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<FolderKanban size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">All projects</h2>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{allProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={`/projects/${project.id}`} className="text-[13.5px] font-medium text-[var(--text-primary)] hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
|
||||
{project.stats?.service_count ?? 0} services · {project.stats?.deployment_count ?? 0} deploys
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
|
||||
project.isApproved
|
||||
? 'bg-[var(--success-soft)] text-[var(--success)]'
|
||||
: 'bg-[var(--warning-soft)] text-[var(--warning)]'
|
||||
}`}
|
||||
>
|
||||
{project.isApproved ? 'public' : 'pending'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingProject(project);
|
||||
setEditForm({ name: project.name, description: project.description ?? '' });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Edit project"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete project "${project.name}"? This removes its services and deployments.`)) {
|
||||
deleteMutation.mutate(project.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => approvalMutation.mutate({ id: project.id, approved: !project.isApproved })}
|
||||
disabled={approvalMutation.isPending}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{project.isApproved ? 'Unapprove' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{allProjects.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No projects yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 pb-10">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Users</h2>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
isSelf={user.id === myId}
|
||||
pending={adminMutation.isPending}
|
||||
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
|
||||
/>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No users.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{editingProject ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
|
||||
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Edit project</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Admin edit — changes apply immediately.</p>
|
||||
</div>
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Name</label>
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Description</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProject(null)}
|
||||
className="rounded-[var(--radius-md)] px-3.5 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editMutation.mutate()}
|
||||
disabled={editMutation.isPending || editForm.name.trim().length < 2}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{editMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
pending,
|
||||
onToggle,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
isSelf: boolean;
|
||||
pending: boolean;
|
||||
onToggle: (isAdmin: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[11px] font-bold text-[var(--text-secondary)]">
|
||||
{user.name.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
|
||||
{user.name}
|
||||
{isSelf ? <span className="ml-2 text-[10.5px] text-[var(--text-tertiary)]">(you)</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">{user.email}</p>
|
||||
</div>
|
||||
{user.is_admin ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent-primary-soft)] px-2.5 py-0.5 text-[10.5px] font-semibold text-[var(--accent-primary)]">
|
||||
<ShieldCheck size={11} /> admin
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!user.is_admin)}
|
||||
disabled={pending || isSelf}
|
||||
title={isSelf ? 'You cannot change your own admin flag' : undefined}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{user.is_admin ? (
|
||||
<span className="inline-flex items-center gap-1"><XCircle size={12} /> Revoke</span>
|
||||
) : (
|
||||
'Make admin'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user