Add environments (spaces) support
All checks were successful
Deploy to Server / deploy (push) Successful in 2m28s

- 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 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-03-15 22:46:52 +01:00
parent 6f2c4d8576
commit 261c2fe338
7 changed files with 120 additions and 17 deletions

View File

@@ -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/<env>/.
# 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/<env>/.
# 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

View File

@@ -10,6 +10,9 @@
"invalidCredentials": "Ungültige Zugangsdaten",
"networkError": "Netzwerkfehler, bitte erneut versuchen"
},
"EnvironmentSwitcher": {
"label": "Space"
},
"Sidebar": {
"dashboard": "Dashboard",
"types": "Typen",

View File

@@ -10,6 +10,9 @@
"invalidCredentials": "Invalid credentials",
"networkError": "Network error, please try again"
},
"EnvironmentSwitcher": {
"label": "Space"
},
"Sidebar": {
"dashboard": "Dashboard",
"types": "Types",

View File

@@ -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) {
</button>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 pb-1">
<EnvironmentSwitcher />
</div>
<div className="flex shrink-0 flex-col gap-1">
<Link
href="/"

View File

@@ -47,6 +47,7 @@ export function collapseAssetUrlsForAdmin(
}
const STORAGE_KEY = "rustycms_admin_api_key";
const ENVIRONMENT_STORAGE_KEY = "rustycms_admin_environment";
const PER_PAGE_KEY = "rustycms_per_page";
const DEFAULT_PER_PAGE = 25;
const PER_PAGE_OPTIONS = [10, 25, 50, 100] as const;
@@ -81,6 +82,35 @@ export function syncStoredApiKey(): void {
if (stored) clientApiKey = stored;
}
/** Current environment (space) for content/assets. When set, all content/asset API calls include _environment. */
let currentEnvironment: string | null = null;
export function getCurrentEnvironment(): string | null {
if (typeof window === "undefined") return null;
return currentEnvironment ?? sessionStorage.getItem(ENVIRONMENT_STORAGE_KEY);
}
export function setCurrentEnvironment(env: string | null): void {
currentEnvironment = env;
if (typeof window !== "undefined") {
if (env) sessionStorage.setItem(ENVIRONMENT_STORAGE_KEY, env);
else sessionStorage.removeItem(ENVIRONMENT_STORAGE_KEY);
}
}
export type EnvironmentsResponse = {
environments: string[];
default: string | null;
};
export async function fetchEnvironments(): Promise<EnvironmentsResponse> {
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<SlugCheckResponse> {
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<T = Record<string, unknown>>(
params: ContentListParams = {}
): Promise<ListResponse<T>> {
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<T = Record<string, unknown>>(
export async function fetchEntry<T = Record<string, unknown>>(
collection: string,
slug: string,
params: { _resolve?: string; _locale?: string } = {}
params: { _resolve?: string; _locale?: string; _environment?: string } = {}
): Promise<T> {
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<Referrer[]> {
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<string, unknown>,
params: { _locale?: string } = {}
params: { _locale?: string; _environment?: string } = {}
): Promise<Record<string, unknown>> {
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<string, unknown>,
params: { _locale?: string } = {}
params: { _locale?: string; _environment?: string } = {}
): Promise<Record<string, unknown>> {
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<AssetsResponse> {
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<FoldersResponse> {
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<void> {
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<void> {
}
export async function deleteFolder(name: string): Promise<void> {
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<Asset> {
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<Asset> {
/** path = "hero.jpg" or "blog/hero.jpg" */
export async function deleteAsset(path: string): Promise<void> {
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<void> {
/** Rename asset; path = "hero.jpg" or "blog/hero.jpg", newFilename = "newname.jpg" */
export async function renameAsset(path: string, newFilename: string): Promise<Asset> {
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<LocalesResponse> {
export async function deleteEntry(
collection: string,
slug: string,
params: { _locale?: string } = {}
params: { _locale?: string; _environment?: string } = {}
): Promise<void> {
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, {

View File

@@ -1058,6 +1058,22 @@ pub async fn list_locales(State(state): State<Arc<AppState>>) -> Json<Value> {
}
}
// ---------------------------------------------------------------------------
// GET /api/environments list spaces (when RUSTYCMS_ENVIRONMENTS is set)
// ---------------------------------------------------------------------------
pub async fn list_environments(State(state): State<Arc<AppState>>) -> Json<Value> {
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
// ---------------------------------------------------------------------------

View File

@@ -13,8 +13,9 @@ pub fn create_router(state: Arc<AppState>) -> 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))