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

+17 -2
View File
@@ -1,11 +1,26 @@
-- name: ListDatabaseServicesByUser :many
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE user_id = $1
ORDER BY created_at DESC;
-- name: ListAllDatabaseServices :many
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
ORDER BY created_at DESC;
-- name: GetDatabaseServiceByID :one
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1;
-- name: GetDatabaseServiceOwnerID :one
SELECT user_id
FROM database_services
WHERE id = $1;
-- name: GetDatabaseServiceByIDAndUser :one
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
FROM database_services
WHERE id = $1 AND user_id = $2;
+43 -15
View File
@@ -1,9 +1,14 @@
-- Visibility model: a project is readable when it is approved for public
-- display, when the caller owns or is a member of it, or when the caller is a
-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
-- name: ListProjectsWithStatsByUser :many
SELECT
p.id,
p.name,
p.description,
p.owner_id,
p.is_approved,
p.created_at,
p.updated_at,
COUNT(DISTINCT s.id)::bigint AS service_count,
@@ -21,17 +26,22 @@ FROM projects p
LEFT JOIN services s ON s.project_id = p.id
LEFT JOIN deployments d ON d.service_id = s.id
WHERE
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
))
(
p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
)
)
AND (
sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%')
)
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
ORDER BY p.updated_at DESC
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
@@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
SELECT COUNT(*)::bigint AS total
FROM projects p
WHERE
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
))
(
p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
)
)
AND (
sqlc.narg(search)::text IS NULL
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
@@ -51,11 +66,13 @@ WHERE
);
-- name: GetProjectByIDForUser :one
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.id = sqlc.arg(project_id)
AND (
p.owner_id = sqlc.arg(user_id)
p.is_approved
OR p.owner_id = sqlc.arg(user_id)
OR sqlc.arg(is_admin)::bool
OR EXISTS (
SELECT 1
FROM project_members pm
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
-- name: CreateProject :one
INSERT INTO projects (name, description, owner_id)
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id))
RETURNING id, name, description, owner_id, created_at, updated_at;
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at;
-- name: InsertProjectEnvironment :exec
INSERT INTO environments (name, project_id)
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
-- name: GetProjectRoleForUser :one
SELECT (CASE
WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner'
WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner'
ELSE COALESCE(pm.role, '')
END)::text AS role
FROM projects p
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
-- name: DeleteProjectByID :execrows
DELETE FROM projects
WHERE id = sqlc.arg(project_id);
-- name: SetProjectApproved :execrows
UPDATE projects
SET is_approved = sqlc.arg(is_approved), updated_at = NOW()
WHERE id = sqlc.arg(project_id);
-- name: ListPendingProjects :many
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
FROM projects p
WHERE p.is_approved = false
ORDER BY p.created_at DESC;
+63 -6
View File
@@ -1,19 +1,76 @@
-- name: ListServiceTemplates :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
WHERE is_official = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC;
-- name: ListServiceTemplatesByCategory :many
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
WHERE category = $1
WHERE category = sqlc.arg(category)
AND (is_official = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
FROM service_templates
WHERE id = $1;
-- name: UpsertServiceTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
sqlc.narg(description),
sqlc.arg(category),
sqlc.narg(logo),
sqlc.arg(config),
sqlc.narg(variables),
sqlc.arg(is_official)
)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
logo = EXCLUDED.logo,
config = EXCLUDED.config,
variables = EXCLUDED.variables,
is_official = EXCLUDED.is_official,
updated_at = NOW();
-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
sqlc.narg(description),
sqlc.arg(category),
sqlc.narg(logo),
sqlc.arg(config),
sqlc.narg(variables),
false,
sqlc.arg(owner_id)
);
-- name: UpdateUserTemplate :execrows
UPDATE service_templates
SET name = sqlc.arg(name),
description = sqlc.narg(description),
category = sqlc.arg(category),
logo = sqlc.narg(logo),
config = sqlc.arg(config),
variables = sqlc.narg(variables),
updated_at = NOW()
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
AND is_official = false;
-- name: DeleteUserTemplate :execrows
DELETE FROM service_templates
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
AND is_official = false;
-- name: GetProjectOwnerID :one
SELECT owner_id
FROM projects
+2
View File
@@ -3,6 +3,7 @@ CREATE TABLE projects (
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id UUID NOT NULL,
is_approved BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -82,6 +83,7 @@ CREATE TABLE service_templates (
config JSONB NOT NULL,
variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false,
owner_id UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);