Files
Containr/app/frontend/src/app/App.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

182 lines
6.9 KiB
TypeScript

import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Compass, ShieldCheck } from 'lucide-react';
import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import { useBranding } from '@/lib/use-branding';
import {
DocsPage,
PeoplePage,
SettingsPage,
UsagePage,
DatabasesPage,
HighAvailabilityPage,
SecurityPage,
ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
import { ErrorBoundary, LoadingState } from '@/shared/components';
function AuthRequired() {
const location = useLocation();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
if (isDemoMode) {
return <Outlet />;
}
if (sessionQuery.isPending) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<LoadingState message="Checking session..." className="h-screen" />
</div>
);
}
if (!sessionQuery.data) {
const redirect = `${location.pathname}${location.search}`;
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirect)}`} replace />;
}
return <Outlet />;
}
function AdminRequired() {
const location = useLocation();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const profileQuery = useQuery({
queryKey: ['current-profile'],
queryFn: getCurrentUserProfile,
enabled: !isDemoMode && Boolean(sessionQuery.data),
retry: false,
});
if (isDemoMode) {
return <Outlet />;
}
if (sessionQuery.isPending || (sessionQuery.data && profileQuery.isPending)) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<LoadingState message="Checking admin access..." className="h-screen" />
</div>
);
}
if (!sessionQuery.data) {
const redirect = `${location.pathname}${location.search}`;
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirect)}`} replace />;
}
if (!profileQuery.data?.isAdmin) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--bg-void)] px-6 text-center">
<ShieldCheck size={32} className="text-[var(--error)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Admin access required</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
This area is restricted to the platform administrator.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
return <Outlet />;
}
function NotFoundPage() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 px-6 text-center">
<Compass size={32} className="text-[var(--text-tertiary)]" />
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Page not found</h1>
<p className="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">
The page you are looking for does not exist or was moved.
</p>
</div>
<Link
to="/"
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Back to home
</Link>
</div>
);
}
export default function App() {
useBranding();
return (
<ErrorBoundary>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
{/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} />
{/* Authenticated areas */}
<Route element={<AuthRequired />}>
<Route path="/builds" element={<BuildsPage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/settings/webhooks" element={<WebhooksPage />} />
<Route path="/showcase" element={<ComponentShowcase />} />
</Route>
{/* Platform owner only */}
<Route element={<AdminRequired />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
{/* Full-bleed design demo — renders its own chrome, no app shell */}
<Route element={<AuthRequired />}>
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
</Route>
</Routes>
</ErrorBoundary>
);
}