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
@@ -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');
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user