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:
Tomas DvorakandDevin committed 2026-09-22 19:58:59 +02:00
1 parent 8f5789bf93
commit 8b6214a0f5
62 files changed
+4986 -1133

No files matched your search

@@ -28,7 +28,7 @@ import {
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import {
Plus,
@@ -287,7 +287,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
deleteMutation.isPending;
useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
const hasStoredViewport = hasCanvasMetadata(projectId);
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
@@ -19,6 +19,7 @@ import {
type CreateServiceInput,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { useAuthSession } from '@/lib/use-auth-session';
import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
@@ -46,10 +47,10 @@ import {
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings },
];
@@ -345,7 +346,7 @@ function ServiceCreateDialog(props: {
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
repoFullName === repo.full_name
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'
}`}
>
<span className="mono text-xs truncate">{repo.full_name}</span>
@@ -466,9 +467,19 @@ export function ProjectWorkspacePage() {
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const openAddService = () => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
};
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
@@ -569,7 +580,8 @@ export function ProjectWorkspacePage() {
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
},
enabled: !isDemoMode && services.length > 0,
enabled: !isDemoMode && signedIn && services.length > 0,
retry: false,
});
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
@@ -599,7 +611,8 @@ export function ProjectWorkspacePage() {
})
.slice(0, 200);
},
enabled: !isDemoMode && activeView === 'logs' && services.length > 0,
enabled: !isDemoMode && signedIn && activeView === 'logs' && services.length > 0,
retry: false,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
@@ -695,7 +708,7 @@ export function ProjectWorkspacePage() {
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={() => setCreateOpen(true)}
onClick={openAddService}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: 'var(--accent-primary)' }}
>
@@ -726,7 +739,7 @@ export function ProjectWorkspacePage() {
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.map((item) => {
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
@@ -761,7 +774,7 @@ export function ProjectWorkspacePage() {
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={() => setCreateOpen(true)}
onAddService={openAddService}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
@@ -973,6 +986,10 @@ export function ProjectWorkspacePage() {
open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onAddService={(type) => {
if (!signedIn) {
navigate('/auth/sign-in');
return;
}
setCreateOpen(true);
toast.showToast(`Creating ${type} service...`, 'info');
}}
+15 -3
View File
@@ -151,12 +151,24 @@ export function canvasStorageKey(projectId: string): string {
return `containr.canvas.v1.${projectId}`;
}
function canvasStorage(): Storage | null {
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null;
}
}
export function hasCanvasMetadata(projectId: string): boolean {
return canvasStorage()?.getItem(canvasStorageKey(projectId)) != null;
}
export function clearCanvasMetadata(projectId: string): void {
localStorage.removeItem(canvasStorageKey(projectId));
canvasStorage()?.removeItem(canvasStorageKey(projectId));
}
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
}
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
@@ -164,7 +176,7 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
const fallback = createDefaultCanvasMetadata(services);
const serviceIds = new Set(services.map((service) => service.id));
const raw = localStorage.getItem(key);
const raw = canvasStorage()?.getItem(key);
if (!raw) {
saveCanvasMetadata(projectId, fallback);
return fallback;