mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
* 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>
692 lines
31 KiB
TypeScript
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=<token>
|
|
containr <command></code> or <code className="text-[var(--text-secondary)]">Authorization: Bearer
|
|
<token></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>
|
|
);
|
|
}
|