Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/api-playground-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/api": minor
"@cloudhal/cli": minor
---

Playground mode (RFC-0036): `createApi({ playground: { info } })` answers every request as the fixed `PLAYGROUND_PRINCIPAL` (an Editor with site settings and nothing installation-wide or upload-related), refuses blocked routes with `403 {error: "playground"}` through the pure `isBlockedInPlayground(method, path)` guard (also exported for the playground Worker), limits request bodies, and reports `features.playground` in `/api/instance`. Every declared route is either blocked or allowed, and a route on neither list is refused. Studio shows a session bar (time left, Reset, Deploy to Cloudflare) and leaves out team, agent and account screens on a playground.
3 changes: 3 additions & 0 deletions apps/studio/src/components/AppFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { setThemePreference, THEME_PREFERENCES, useThemePreference } from "../li
import type { PaletteSearch } from "./CommandMenu.tsx";
import { LanguageSwitch } from "./LanguageSwitch.tsx";
import { NotificationsBell } from "./NotificationsBell.tsx";
import { PlaygroundSessionBar } from "./PlaygroundSessionBar.tsx";
import { ThemeSwitch } from "./ThemeSwitch.tsx";
import { UserMenu } from "./UserMenu.tsx";

Expand Down Expand Up @@ -174,6 +175,8 @@ function AppFrameShell({
<UserMenu />
</div>
</header>
{/* RFC-0036 §5: only on a playground session. */}
<PlaygroundSessionBar />
<main className="min-w-0 flex-1">{children}</main>
<CloseSheetOnNavigate />
</div>
Expand Down
50 changes: 50 additions & 0 deletions apps/studio/src/components/PlaygroundSessionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { PlaygroundBar } from "@cloudhal/ui";
import { useEffect, useState } from "react";
import { useT } from "../i18n/index.tsx";
import { minutesLeft, usePlayground } from "./usePlayground.ts";

/**
* The playground session bar (RFC-0036 §5): time left, Reset and "Deploy to Cloudflare", under the
* top bar on every screen. When the time is up, or after Reset, the browser goes back to the
* playground's landing page, which starts a new session.
*/
export function PlaygroundSessionBar() {
const t = useT();
const playground = usePlayground();
const [now, setNow] = useState(() => Date.now());
const [resetting, setResetting] = useState(false);

useEffect(() => {
if (!playground) return;
const timer = setInterval(() => setNow(Date.now()), 15_000);
return () => clearInterval(timer);
}, [playground]);

const left = playground ? minutesLeft(playground.expiresAt, now) : 1;
useEffect(() => {
if (playground && left === 0) globalThis.location.assign("/");
}, [playground, left]);

if (!playground) return null;
const reset = async () => {
setResetting(true);
await fetch(playground.resetUrl, { method: "POST", credentials: "same-origin" }).catch(
() => undefined,
);
globalThis.location.assign("/");
};
return (
<PlaygroundBar
className="rounded-none border-x-0 border-t-0"
labels={{
title: t("playground.title"),
description: t("playground.description", { count: left }),
reset: t("playground.reset"),
deploy: t("playground.deploy"),
}}
deployHref={playground.deployUrl}
onReset={() => void reset()}
resetting={resetting}
/>
);
}
23 changes: 23 additions & 0 deletions apps/studio/src/components/PlaygroundUnavailable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Empty, LinkButton } from "@cloudhal/ui";
import { CloudArrowUp, LockSimple } from "@phosphor-icons/react";
import { useT } from "../i18n/index.tsx";

/**
* Shown in place of an account screen a playground refuses (RFC-0036 §5): team, agent keys and
* skills, account settings. They need sign-in or reach outside the session.
*/
export function PlaygroundUnavailable({ deployUrl }: { deployUrl: string }) {
const t = useT();
return (
<Empty
icon={<LockSimple size={48} className="text-kumo-inactive" />}
title={t("playground.unavailableTitle")}
description={t("playground.unavailableDescription")}
contents={
<LinkButton href={deployUrl} external variant="primary" icon={CloudArrowUp}>
{t("playground.deploy")}
</LinkButton>
}
/>
);
}
29 changes: 19 additions & 10 deletions apps/studio/src/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useThemePreference } from "../lib/theme.ts";
import { RoleBadge } from "./Roles.tsx";
import { ThemeRadioItems, themeIcon } from "./ThemeSwitch.tsx";
import { useMe } from "./useMe.ts";
import { usePlayground } from "./usePlayground.ts";

/**
* The signed-in person's menu, at the right end of the top bar (DESIGN.md §1): an avatar button
Expand All @@ -20,6 +21,8 @@ export function UserMenu() {
const navigate = useNavigate();
const [theme] = useThemePreference();
const me = useMe();
// RFC-0036 §5: a playground has no account, team or sign-out.
const people = !usePlayground();
const name = me.data?.actor.name ?? t("nav.account");
const role = me.data?.role ?? undefined;
const signOut = async () => {
Expand Down Expand Up @@ -54,12 +57,16 @@ export function UserMenu() {
{role && <RoleBadge value={role} />}
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={UserGear} onClick={() => navigate({ to: "/account" })}>
{t("nav.account")}
</DropdownMenu.Item>
<DropdownMenu.Item icon={UsersThree} onClick={() => navigate({ to: "/team" })}>
{t("nav.team")}
</DropdownMenu.Item>
{people && (
<DropdownMenu.Item icon={UserGear} onClick={() => navigate({ to: "/account" })}>
{t("nav.account")}
</DropdownMenu.Item>
)}
{people && (
<DropdownMenu.Item icon={UsersThree} onClick={() => navigate({ to: "/team" })}>
{t("nav.team")}
</DropdownMenu.Item>
)}
<DropdownMenu.Item icon={SquaresFour} onClick={() => navigate({ to: "/sites" })}>
{t("nav.allSites")}
</DropdownMenu.Item>
Expand All @@ -85,10 +92,12 @@ export function UserMenu() {
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item icon={directional(SignOut)} onClick={signOut}>
{t("common.signOut")}
</DropdownMenu.Item>
{people && <DropdownMenu.Separator />}
{people && (
<DropdownMenu.Item icon={directional(SignOut)} onClick={signOut}>
{t("common.signOut")}
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
);
Expand Down
23 changes: 23 additions & 0 deletions apps/studio/src/components/usePlayground.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { api, type PlaygroundInfo } from "../lib/api.ts";

/** RFC-0036 §5: the playground session (`features.playground`), when Studio runs on one. */
export function usePlayground(): PlaygroundInfo | undefined {
const instance = useQuery({
queryKey: ["instance"],
queryFn: api.instance,
retry: false,
staleTime: 60_000,
});
return instance.data?.features.playground;
}

/** Account screens a playground refuses (people, agent keys and skills, sign-in settings). */
export function isPlaygroundBlockedPath(pathname: string): boolean {
return /^\/(agents|team|account)(\/|$)/.test(pathname);
}

/** Whole minutes until `expiresAt`, never below zero. */
export function minutesLeft(expiresAt: string, now: number): number {
return Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 60_000));
}
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/ar.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 00788bd78f14efa0
// hash: 586c8d46bd63e844
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "سجّل الدخول لإدارة مواقعك.",
},
playground: {
title: "Playground",
description:
"{count, plural, zero {لم يتبقَّ أي دقيقة} one {تتبقى دقيقة واحدة} two {تتبقى دقيقتان} few {تتبقى # دقائق} many {تتبقى # دقيقة} other {تتبقى # دقيقة}}. يُحذف كل شيء هنا عند انتهاء الجلسة، لذا لا تُدخل بيانات شخصية.",
reset: "إعادة تعيين",
deploy: "النشر على Cloudflare",
unavailableTitle: "متاح في تثبيتك الخاص",
unavailableDescription:
"يتطلب أعضاء الفريق ومفاتيح الوكلاء وإعدادات الحساب تسجيل الدخول، لذلك لا يتضمنها الـ playground. انشر Cloudhal على حسابك في Cloudflare لاستخدامها.",
},
common: {
save: "احفظ",
cancel: "ألغِ",
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): de core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 60015e56e0e5cd97
// hash: 8a6fa48d080c8dcb
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "Melde dich an, um deine Websites zu verwalten.",
},
playground: {
title: "Playground",
description:
"{count, plural, one {Noch # Minute} other {Noch # Minuten}}. Alles hier wird gelöscht, wenn die Sitzung endet – gib also keine persönlichen Daten ein.",
reset: "Zurücksetzen",
deploy: "Auf Cloudflare deployen",
unavailableTitle: "In deiner eigenen Installation verfügbar",
unavailableDescription:
"Teammitglieder, Agent-Schlüssel und Kontoeinstellungen brauchen eine Anmeldung, deshalb lässt der Playground sie weg. Deploye Cloudhal in dein Cloudflare-Konto, um sie zu nutzen.",
},
common: {
save: "Speichern",
cancel: "Abbrechen",
Expand Down
11 changes: 11 additions & 0 deletions apps/studio/src/i18n/en.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
/** English (source of truth for keys). Other locales are typed against this object. */
export const en = {
app: { name: "Cloudhal Studio", tagline: "Sign in to operate your sites." },
/** RFC-0036 §5: the playground session bar and the screens a playground does not offer. */
playground: {
title: "Playground",
description:
"{count, plural, one {# minute left} other {# minutes left}}. Everything here is deleted when the session ends, so don't enter personal data.",
reset: "Reset",
deploy: "Deploy to Cloudflare",
unavailableTitle: "Available in your own install",
unavailableDescription:
"Team members, agent keys and account settings need sign-in, so the playground leaves them out. Deploy Cloudhal to your Cloudflare account to use them.",
},
common: {
save: "Save",
cancel: "Cancel",
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/es.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): es core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: eb9a3ca039b813b3
// hash: b279cd0e5ba1d754
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "Inicia sesión para gestionar tus sitios.",
},
playground: {
title: "Playground",
description:
"{count, plural, one {Queda # minuto} many {Quedan # de minutos} other {Quedan # minutos}}. Todo se elimina al terminar la sesión, así que no introduzcas datos personales.",
reset: "Restablecer",
deploy: "Desplegar en Cloudflare",
unavailableTitle: "Disponible en tu propia instalación",
unavailableDescription:
"Los miembros del equipo, las claves de agente y los ajustes de la cuenta requieren iniciar sesión, por eso el playground no los incluye. Despliega Cloudhal en tu cuenta de Cloudflare para usarlos.",
},
common: {
save: "Guardar",
cancel: "Cancelar",
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/fa.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): fa core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 3d646b558dc42fa0
// hash: 090d022080bd43f1
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "برای مدیریت سایت‌هایتان وارد شوید.",
},
playground: {
title: "Playground",
description:
"{count, plural, one {# دقیقه مانده} other {# دقیقه مانده}}. با پایان جلسه همه‌چیز پاک می‌شود، پس اطلاعات شخصی وارد نکنید.",
reset: "بازنشانی",
deploy: "استقرار در Cloudflare",
unavailableTitle: "در نصب خودتان در دسترس است",
unavailableDescription:
"اعضای تیم، کلیدهای عامل و تنظیمات حساب به ورود نیاز دارند، برای همین playground آن‌ها را ندارد. Cloudhal را در حساب Cloudflare خود مستقر کنید تا از آن‌ها استفاده کنید.",
},
common: {
save: "ذخیره",
cancel: "لغو",
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/fr.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): fr core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 75bc94d0e343c481
// hash: 30ea644215e0e3d3
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "Connectez-vous pour gérer vos sites.",
},
playground: {
title: "Playground",
description:
"{count, plural, one {Encore # minute} many {Encore # de minutes} other {Encore # minutes}}. Tout est supprimé à la fin de la session : n’entrez pas de données personnelles.",
reset: "Réinitialiser",
deploy: "Déployer sur Cloudflare",
unavailableTitle: "Disponible dans votre propre installation",
unavailableDescription:
"Les membres de l’équipe, les clés d’agent et les paramètres du compte nécessitent une connexion ; le playground ne les propose donc pas. Déployez Cloudhal sur votre compte Cloudflare pour les utiliser.",
},
common: {
save: "Enregistrer",
cancel: "Annuler",
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/i18n/generated/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ export interface MessageParams {
"pickers.tagHint": { max: string | number };
"pickers.under": { path: string | number };
"pickers.viaSynonym": { value: string | number };
"playground.description": { count: number };
"preview.frameTitle": { name: string | number };
"projects.waiting": { count: number };
"proposals.acceptSome": { count: string | number; total: string | number };
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/he.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): he core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: a15e8f76b61934df
// hash: 1806f4748c6597c1
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "יש להתחבר כדי לנהל את האתרים.",
},
playground: {
title: "Playground",
description:
"{count, plural, one {נותרה דקה אחת} two {נותרו שתי דקות} other {נותרו # דקות}}. הכול נמחק בסיום הסשן, לכן אל תזינו מידע אישי.",
reset: "איפוס",
deploy: "פריסה ל-Cloudflare",
unavailableTitle: "זמין בהתקנה שלכם",
unavailableDescription:
"חברי צוות, מפתחות סוכנים והגדרות חשבון דורשים התחברות, ולכן ה-playground לא כולל אותם. פרסו את Cloudhal לחשבון ה-Cloudflare שלכם כדי להשתמש בהם.",
},
common: {
save: "שמירה",
cancel: "ביטול",
Expand Down
12 changes: 11 additions & 1 deletion apps/studio/src/i18n/hi.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
// Generated by `pnpm i18n:import` (ADR-0034): hi core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 17fc36df14eea135
// hash: fade4988bcfe031b
import type { Messages } from "./en.ts";

export const messages: Messages = {
app: {
name: "Cloudhal Studio",
tagline: "अपनी साइटें चलाने के लिए साइन इन करें।",
},
playground: {
title: "Playground",
description:
"{count, plural, one {# मिनट बाकी} other {# मिनट बाकी}}. सत्र खत्म होने पर यहाँ का सब कुछ मिट जाता है, इसलिए निजी जानकारी न डालें।",
reset: "रीसेट करें",
deploy: "Cloudflare पर डिप्लॉय करें",
unavailableTitle: "आपके अपने इंस्टॉल में उपलब्ध",
unavailableDescription:
"टीम सदस्य, एजेंट कुंजियाँ और खाता सेटिंग्स के लिए साइन-इन ज़रूरी है, इसलिए playground में ये नहीं हैं। इन्हें इस्तेमाल करने के लिए Cloudhal को अपने Cloudflare खाते में डिप्लॉय करें।",
},
common: {
save: "सहेजें",
cancel: "रद्द करें",
Expand Down
Loading
Loading