Files
Containr/app/frontend/src/features/admin/pages/AdminPage.tsx
T
Tomáš DvořákandDevin 00e69b3733 feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:43:12 +02:00

692 lines
31 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
CheckCircle2,
Clock,
Copy,
FolderKanban,
Megaphone,
Pencil,
ShieldCheck,
Trash2,
UserPlus,
Users,
XCircle,
} from 'lucide-react';
import {
createBanner,
createInvite,
deleteBanner,
deleteProject,
getAdminOverview,
impersonateUser,
type ImpersonationResult,
listAdminBanners,
listAdminUsers,
listInvites,
listProjects,
revokeInvite,
setProjectApproval,
setUserAdmin,
updateBanner,
updateProject,
type AdminUser,
type Banner,
type ProjectEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { DemoRestricted, 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 isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const myId = sessionQuery.data?.user.id;
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
const invitesQuery = useQuery({
queryKey: ['admin-invites'],
queryFn: listInvites,
enabled: !isDemoMode,
select: (list) =>
list.map((invite) => ({
...invite,
expired: !invite.used_at && new Date(invite.expires_at).getTime() < Date.now(),
})),
});
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const [inviteEmail, setInviteEmail] = useState('');
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [impersonation, setImpersonation] = useState<ImpersonationResult | null>(null);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
queryClient.invalidateQueries({ queryKey: ['admin-invites'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
const inviteCreateMutation = useMutation({
mutationFn: () => createInvite({ email: inviteEmail.trim() || undefined }),
onSuccess: (result) => {
setInviteLink(`${window.location.origin}${result.url}`);
setInviteEmail('');
invalidate();
},
onError: (error) => showToast('error', 'Invite failed', error instanceof Error ? error.message : undefined),
});
const inviteRevokeMutation = useMutation({
mutationFn: (id: string) => revokeInvite(id),
onSuccess: () => {
showToast('success', 'Invite revoked');
invalidate();
},
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined),
});
const impersonateMutation = useMutation({
mutationFn: (id: string) => impersonateUser(id),
onSuccess: (result) => setImpersonation(result),
onError: (error) => showToast('error', 'Impersonation failed', error instanceof Error ? error.message : undefined),
});
const bannerCreateMutation = useMutation({
mutationFn: () =>
createBanner({
title: bannerForm.title.trim(),
body: bannerForm.body.trim(),
level: bannerForm.level,
}),
onSuccess: () => {
showToast('success', 'Banner published');
setBannerForm({ title: '', body: '', level: 'info' });
invalidate();
},
onError: (error) => showToast('error', 'Create failed', error instanceof Error ? error.message : undefined),
});
const bannerToggleMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateBanner(id, { active }),
onSuccess: () => invalidate(),
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const bannerDeleteMutation = useMutation({
mutationFn: (id: string) => deleteBanner(id),
onSuccess: () => {
showToast('success', 'Banner deleted');
invalidate();
},
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
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 (isDemoMode) {
return <DemoRestricted feature="The admin console" />;
}
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 ?? [];
const banners = bannersQuery.data ?? [];
const invites = invitesQuery.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">
<div className="mb-3 flex items-center gap-2">
<Megaphone size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={bannerForm.title}
onChange={(e) => setBannerForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Title"
className="h-9 min-w-48 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<input
value={bannerForm.body}
onChange={(e) => setBannerForm((f) => ({ ...f, body: e.target.value }))}
placeholder="Body (optional)"
className="h-9 min-w-48 flex-[2] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<select
value={bannerForm.level}
onChange={(e) => setBannerForm((f) => ({ ...f, level: e.target.value as Banner['level'] }))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm"
>
<option value="info">info</option>
<option value="success">success</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<button
type="button"
onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button>
</div>
{banners.map((banner) => (
<div
key={banner.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">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">{banner.title}</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
{banner.level}
{banner.body ? ` · ${banner.body}` : ''}
</p>
</div>
<button
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.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"
>
{banner.active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete banner "${banner.title}"?`)) {
bannerDeleteMutation.mutate(banner.id);
}
}}
disabled={bannerDeleteMutation.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 banner"
>
<Trash2 size={12} />
</button>
</div>
))}
{banners.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No announcements.</p>
) : null}
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<UserPlus size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Bind to email (optional)"
type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button>
</div>
{inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied');
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
<button
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div>
) : null}
{invites.map((invite) => {
const used = Boolean(invite.used_at);
const expired = !used && invite.expired;
return (
<div
key={invite.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">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
{invite.email || 'Open invite'}
</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
expires {new Date(invite.expires_at).toLocaleString()}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
{used ? 'used' : expired ? 'expired' : 'open'}
</span>
{!used ? (
<button
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending}
className="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"
>
Revoke
</button>
) : null}
</div>
);
})}
{invites.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No invites 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 })}
onImpersonate={user.is_admin || user.id === myId ? undefined : () => impersonateMutation.mutate(user.id)}
/>
))}
{users.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No users.</p>
) : null}
</div>
</section>
{impersonation ? (
<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-lg 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)]">
Impersonating {impersonation.user.email}
</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
</p>
</div>
<div className="space-y-3 px-5 py-4">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(impersonation.token);
showToast('success', 'Token copied');
}}
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
</div>
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt;
containr &lt;command&gt;</code> or <code className="text-[var(--text-secondary)]">Authorization: Bearer
&lt;token&gt;</code>. Requests run with that user's permissions for 15 minutes.
</p>
</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={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
</div>
</div>
</div>
) : null}
{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,
onImpersonate,
}: {
user: AdminUser;
isSelf: boolean;
pending: boolean;
onToggle: (isAdmin: boolean) => void;
onImpersonate?: () => 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}
{onImpersonate ? (
<button
type="button"
onClick={onImpersonate}
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)]"
>
Impersonate
</button>
) : 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>
);
}