From 261c2fe3386792c23b165530a625149c4c1fdf01 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Sun, 15 Mar 2026 22:46:52 +0100 Subject: [PATCH] Add environments (spaces) support - API client sends _environment param on all content/asset requests - GET /api/environments endpoint to list configured environments - EnvironmentSwitcher in Sidebar with i18n label "Space" - clearSession() also clears environment from sessionStorage - .env.example documents ADMIN_USERNAME/PASSWORD/SESSION_SECRET Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 16 ++++- admin-ui/messages/de.json | 3 + admin-ui/messages/en.json | 3 + admin-ui/src/components/Sidebar.tsx | 4 ++ admin-ui/src/lib/api.ts | 92 +++++++++++++++++++++++++---- src/api/handlers.rs | 16 +++++ src/api/routes.rs | 3 +- 7 files changed, 120 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 0530c4f..a2875a2 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,9 @@ RUSTYCMS_API_KEY=dein-geheimes-token # Format: key1:role1,key2:role2. Roles: read (GET only), read_write (content + assets), admin (+ schemas). # RUSTYCMS_API_KEYS=key1:read,key2:read_write,key3:admin -# Optional: environments (e.g. production,staging). File store only. Content under content/ and content//. -# API: ?_environment=staging. Default = first in list. -# RUSTYCMS_ENVIRONMENTS=production,staging +# Optional: environments = spaces (e.g. one per website). File store only. First = content/, others = content//. +# API: ?_environment=justpm for the other space. Default = first in list. +# RUSTYCMS_ENVIRONMENTS=windwiderstand,justpm # Optional: webhook URLs (comma-separated). POST with JSON { event, collection?, slug?, ... } on content/asset/schema changes. # RUSTYCMS_WEBHOOKS=https://example.com/hook @@ -34,3 +34,13 @@ RUSTYCMS_API_KEY=dein-geheimes-token # Optional: Paths to types and content directories. Useful for keeping content in a separate repo. # RUSTYCMS_TYPES_DIR=./types # RUSTYCMS_CONTENT_DIR=./content + +# --- Admin UI (Next.js, when running admin-ui e.g. npm run dev in admin-ui/) --- +# Login: open /admin/login. Required for login to succeed. +# ADMIN_USERNAME=admin +# ADMIN_PASSWORD=your-secret-password +# Optional: session cookie encryption. Change in production. +# SESSION_SECRET=min-32-chars-secret-for-iron-session!! +# Optional: API base URL and key for the admin (if not set, login returns API key from RUSTYCMS_API_KEY). +# NEXT_PUBLIC_RUSTYCMS_API_URL=http://127.0.0.1:3000 +# NEXT_PUBLIC_RUSTYCMS_API_KEY=dein-geheimes-token diff --git a/admin-ui/messages/de.json b/admin-ui/messages/de.json index 0bc93e0..4b3ee76 100644 --- a/admin-ui/messages/de.json +++ b/admin-ui/messages/de.json @@ -10,6 +10,9 @@ "invalidCredentials": "Ungültige Zugangsdaten", "networkError": "Netzwerkfehler, bitte erneut versuchen" }, + "EnvironmentSwitcher": { + "label": "Space" + }, "Sidebar": { "dashboard": "Dashboard", "types": "Typen", diff --git a/admin-ui/messages/en.json b/admin-ui/messages/en.json index 0ee0e0a..3a154ce 100644 --- a/admin-ui/messages/en.json +++ b/admin-ui/messages/en.json @@ -10,6 +10,9 @@ "invalidCredentials": "Invalid credentials", "networkError": "Network error, please try again" }, + "EnvironmentSwitcher": { + "label": "Space" + }, "Sidebar": { "dashboard": "Dashboard", "types": "Types", diff --git a/admin-ui/src/components/Sidebar.tsx b/admin-ui/src/components/Sidebar.tsx index 27ef83d..7fee7b8 100644 --- a/admin-ui/src/components/Sidebar.tsx +++ b/admin-ui/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { useQueries, useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { fetchCollections, fetchContentList, getApiKey, clearSession } from "@/lib/api"; import { LocaleSwitcher } from "./LocaleSwitcher"; +import { EnvironmentSwitcher } from "./EnvironmentSwitcher"; const navLinkClass = "inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium min-h-[44px] md:min-h-0 md:py-2"; @@ -101,6 +102,9 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) { )} +
+ +
{ + try { + const res = await fetch(`${getBaseUrl()}/api/environments`, { headers: getHeaders() }); + if (res.ok) return res.json(); + } catch { /* API not reachable */ } + return { environments: [], default: null }; +} + /** Items per page for content lists (stored in localStorage). */ export function getPerPage(): number { if (typeof window === "undefined") return DEFAULT_PER_PAGE; @@ -100,9 +130,10 @@ export function setPerPage(n: number): void { export { PER_PAGE_OPTIONS, DEFAULT_PER_PAGE }; -/** Clear API key and all rustycms_* localStorage (e.g. for shared devices). */ +/** Clear API key, current environment, and all rustycms_* storage (e.g. for shared devices). */ export function clearSession(): void { setApiKey(null); + setCurrentEnvironment(null); if (typeof window === "undefined") return; const keys: string[] = []; for (let i = 0; i < localStorage.length; i++) { @@ -110,6 +141,10 @@ export function clearSession(): void { if (k?.startsWith("rustycms_")) keys.push(k); } keys.forEach((k) => localStorage.removeItem(k)); + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i); + if (k?.startsWith("rustycms_")) sessionStorage.removeItem(k); + } } /** Check backend health (GET /health). */ @@ -281,11 +316,13 @@ export type SlugCheckResponse = { export async function checkSlug( collection: string, slug: string, - params: { exclude?: string; _locale?: string } = {} + params: { exclude?: string; _locale?: string; _environment?: string } = {} ): Promise { const sp = new URLSearchParams({ slug: slug.trim() }); if (params.exclude) sp.set("exclude", params.exclude); if (params._locale) sp.set("_locale", params._locale); + const env = params._environment ?? getCurrentEnvironment(); + if (env) sp.set("_environment", env); const res = await fetch( `${getBaseUrl()}/api/collections/${encodeURIComponent(collection)}/slug-check?${sp}`, { headers: getHeaders() } @@ -301,6 +338,7 @@ export type ContentListParams = { _order?: "asc" | "desc"; _resolve?: string; _locale?: string; + _environment?: string; [key: string]: string | number | undefined; }; @@ -309,7 +347,10 @@ export async function fetchContentList>( params: ContentListParams = {} ): Promise> { const sp = new URLSearchParams(); + const env = params._environment ?? getCurrentEnvironment(); + if (env) sp.set("_environment", env); Object.entries(params).forEach(([k, v]) => { + if (k === "_environment") return; if (v !== undefined && v !== "") sp.set(k, String(v)); }); const qs = sp.toString(); @@ -322,11 +363,13 @@ export async function fetchContentList>( export async function fetchEntry>( collection: string, slug: string, - params: { _resolve?: string; _locale?: string } = {} + params: { _resolve?: string; _locale?: string; _environment?: string } = {} ): Promise { const sp = new URLSearchParams(); if (params._resolve) sp.set("_resolve", params._resolve); if (params._locale) sp.set("_locale", params._locale); + const env = params._environment ?? getCurrentEnvironment(); + if (env) sp.set("_environment", env); const qs = sp.toString(); const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`; const res = await fetch(url, { headers: getHeaders() }); @@ -344,9 +387,12 @@ export type Referrer = { export async function fetchReferrers( collection: string, - slug: string + slug: string, + params: { _environment?: string } = {} ): Promise { - const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/referrers`; + const env = params._environment ?? getCurrentEnvironment(); + const qs = env ? `?_environment=${encodeURIComponent(env)}` : ""; + const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}/referrers${qs}`; const res = await fetch(url, { headers: getHeaders() }); if (!res.ok) return []; const data = await res.json(); @@ -356,10 +402,12 @@ export async function fetchReferrers( export async function createEntry( collection: string, data: Record, - params: { _locale?: string } = {} + params: { _locale?: string; _environment?: string } = {} ): Promise> { const sp = new URLSearchParams(); if (params._locale) sp.set("_locale", params._locale); + const env = params._environment ?? getCurrentEnvironment(); + if (env) sp.set("_environment", env); const qs = sp.toString(); const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}${qs ? `?${qs}` : ""}`; const res = await fetch(url, { @@ -381,10 +429,12 @@ export async function updateEntry( collection: string, slug: string, data: Record, - params: { _locale?: string } = {} + params: { _locale?: string; _environment?: string } = {} ): Promise> { const sp = new URLSearchParams(); if (params._locale) sp.set("_locale", params._locale); + const env = params._environment ?? getCurrentEnvironment(); + if (env) sp.set("_environment", env); const qs = sp.toString(); const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`; const res = await fetch(url, { @@ -432,19 +482,25 @@ export type FoldersResponse = { export async function fetchAssets(folder?: string): Promise { const url = new URL(`${getBaseUrl()}/api/assets`); if (folder !== undefined) url.searchParams.set("folder", folder); + const env = getCurrentEnvironment(); + if (env) url.searchParams.set("_environment", env); const res = await fetch(url.toString(), { headers: getHeaders() }); if (!res.ok) throw new Error(`Assets: ${res.status}`); return res.json(); } export async function fetchFolders(): Promise { - const res = await fetch(`${getBaseUrl()}/api/assets/folders`, { headers: getHeaders() }); + const env = getCurrentEnvironment(); + const qs = env ? `?_environment=${encodeURIComponent(env)}` : ""; + const res = await fetch(`${getBaseUrl()}/api/assets/folders${qs}`, { headers: getHeaders() }); if (!res.ok) throw new Error(`Folders: ${res.status}`); return res.json(); } export async function createFolder(name: string): Promise { - const res = await fetch(`${getBaseUrl()}/api/assets/folders`, { + const env = getCurrentEnvironment(); + const qs = env ? `?_environment=${encodeURIComponent(env)}` : ""; + const res = await fetch(`${getBaseUrl()}/api/assets/folders${qs}`, { method: "POST", headers: getHeaders(), body: JSON.stringify({ name }), @@ -456,7 +512,9 @@ export async function createFolder(name: string): Promise { } export async function deleteFolder(name: string): Promise { - const res = await fetch(`${getBaseUrl()}/api/assets/folders/${encodeURIComponent(name)}`, { + const env = getCurrentEnvironment(); + const qs = env ? `?_environment=${encodeURIComponent(env)}` : ""; + const res = await fetch(`${getBaseUrl()}/api/assets/folders/${encodeURIComponent(name)}${qs}`, { method: "DELETE", headers: getHeaders(), }); @@ -476,6 +534,8 @@ const getUploadHeaders = (): HeadersInit => { export async function uploadAsset(file: File, folder?: string): Promise { const url = new URL(`${getBaseUrl()}/api/assets`); if (folder) url.searchParams.set("folder", folder); + const env = getCurrentEnvironment(); + if (env) url.searchParams.set("_environment", env); const body = new FormData(); body.append("file", file); const res = await fetch(url.toString(), { method: "POST", headers: getUploadHeaders(), body }); @@ -488,7 +548,9 @@ export async function uploadAsset(file: File, folder?: string): Promise { /** path = "hero.jpg" or "blog/hero.jpg" */ export async function deleteAsset(path: string): Promise { - const res = await fetch(`${getBaseUrl()}/api/assets/${path}`, { + const env = getCurrentEnvironment(); + const qs = env ? `?_environment=${encodeURIComponent(env)}` : ""; + const res = await fetch(`${getBaseUrl()}/api/assets/${path}${qs}`, { method: "DELETE", headers: getHeaders(), }); @@ -500,7 +562,9 @@ export async function deleteAsset(path: string): Promise { /** Rename asset; path = "hero.jpg" or "blog/hero.jpg", newFilename = "newname.jpg" */ export async function renameAsset(path: string, newFilename: string): Promise { - const res = await fetch(`${getBaseUrl()}/api/assets/${path}`, { + const env = getCurrentEnvironment(); + const qs = env ? `?_environment=${encodeURIComponent(env)}` : ""; + const res = await fetch(`${getBaseUrl()}/api/assets/${path}${qs}`, { method: "PATCH", headers: getHeaders(), body: JSON.stringify({ filename: newFilename }), @@ -590,10 +654,12 @@ export async function fetchLocales(): Promise { export async function deleteEntry( collection: string, slug: string, - params: { _locale?: string } = {} + params: { _locale?: string; _environment?: string } = {} ): Promise { const sp = new URLSearchParams(); if (params._locale) sp.set("_locale", params._locale); + const env = params._environment ?? getCurrentEnvironment(); + if (env) sp.set("_environment", env); const qs = sp.toString(); const url = `${getBaseUrl()}/api/content/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}${qs ? `?${qs}` : ""}`; const res = await fetch(url, { diff --git a/src/api/handlers.rs b/src/api/handlers.rs index 4768345..062f9cf 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -1058,6 +1058,22 @@ pub async fn list_locales(State(state): State>) -> Json { } } +// --------------------------------------------------------------------------- +// GET /api/environments – list spaces (when RUSTYCMS_ENVIRONMENTS is set) +// --------------------------------------------------------------------------- +pub async fn list_environments(State(state): State>) -> Json { + match &state.environments { + Some(envs) if !envs.is_empty() => Json(json!({ + "environments": envs, + "default": envs[0], + })), + _ => Json(json!({ + "environments": [], + "default": null, + })), + } +} + // --------------------------------------------------------------------------- // DELETE /api/content/:collection/:slug // --------------------------------------------------------------------------- diff --git a/src/api/routes.rs b/src/api/routes.rs index a14554b..a6c6feb 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -13,8 +13,9 @@ pub fn create_router(state: Arc) -> Router { Router::new() // Health (for Load Balancer / K8s) .route("/health", get(handlers::health)) - // Locales + // Locales & environments (spaces) .route("/api/locales", get(handlers::list_locales)) + .route("/api/environments", get(handlers::list_environments)) // API index (Living Documentation entry point) .route("/api", get(openapi::api_index)) .route("/api/", get(openapi::api_index))