diff --git a/apps/app/pr/screenshots/cloud-admin-forbidden.png b/apps/app/pr/screenshots/cloud-admin-forbidden.png new file mode 100644 index 000000000..caa258826 Binary files /dev/null and b/apps/app/pr/screenshots/cloud-admin-forbidden.png differ diff --git a/apps/app/pr/screenshots/cloud-auth-route.png b/apps/app/pr/screenshots/cloud-auth-route.png new file mode 100644 index 000000000..3d0525a84 Binary files /dev/null and b/apps/app/pr/screenshots/cloud-auth-route.png differ diff --git a/apps/app/pr/screenshots/cloud-live-dashboard.png b/apps/app/pr/screenshots/cloud-live-dashboard.png new file mode 100644 index 000000000..6698f74a9 Binary files /dev/null and b/apps/app/pr/screenshots/cloud-live-dashboard.png differ diff --git a/apps/app/pr/screenshots/cloud-route-unavailable.png b/apps/app/pr/screenshots/cloud-route-unavailable.png new file mode 100644 index 000000000..2216c9b3d Binary files /dev/null and b/apps/app/pr/screenshots/cloud-route-unavailable.png differ diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index c117cdcdd..f1a5d623f 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -40,6 +40,7 @@ import StatusToast from "./components/status-toast"; import OnboardingView from "./pages/onboarding"; import DashboardView from "./pages/dashboard"; import SessionView from "./pages/session"; +import CloudView from "./pages/cloud"; import ProtoWorkspacesView from "./pages/proto-workspaces"; import ProtoV1UxView from "./pages/proto-v1-ux"; import { createClient, unwrap, waitForHealthy, type OpencodeAuth } from "./lib/opencode"; @@ -288,6 +289,7 @@ type SettingsReturnTarget = { view: View; tab: DashboardTab; sessionId: string | null; + cloudPath: string | null; }; function normalizeSharedBundleImportIntent(value: string | null | undefined): SharedBundleImportIntent { @@ -845,6 +847,7 @@ export default function App() { const path = location.pathname.toLowerCase(); if (path.startsWith("/onboarding")) return "onboarding"; if (path.startsWith("/session")) return "session"; + if (path.startsWith("/cloud")) return "cloud"; if (path.startsWith("/proto")) return "proto"; return "dashboard"; }); @@ -879,6 +882,10 @@ export default function App() { navigate("/proto/workspaces"); return; } + if (next === "cloud") { + navigate("/cloud"); + return; + } if (next === "onboarding") { navigate("/onboarding"); return; @@ -1352,6 +1359,7 @@ export default function App() { view: "dashboard", tab: "scheduled", sessionId: null, + cloudPath: null, }); const SESSION_BY_WORKSPACE_KEY = "openwork.workspace-last-session.v1"; const readSessionByWorkspace = () => { @@ -1404,6 +1412,10 @@ export default function App() { view, tab: currentTab, sessionId: selectedSessionId(), + cloudPath: + view === "cloud" + ? `${location.pathname}${location.search}` + : null, }); }); @@ -1425,6 +1437,10 @@ export default function App() { navigate("/proto/workspaces"); return; } + if (target.view === "cloud") { + navigate(target.cloudPath || "/cloud"); + return; + } goToDashboard(target.tab); }; @@ -4326,9 +4342,8 @@ export default function App() { setProcessingDenAuthDeepLink(true); setPendingDenAuthDeepLink(null); - setView("dashboard"); - setSettingsTab("den"); - goToDashboard("settings"); + setView("cloud"); + navigate("/cloud", { replace: true }); void createDenClient({ baseUrl: pending.denBaseUrl }) .exchangeDesktopHandoff(pending.grant) @@ -7203,6 +7218,10 @@ export default function App() { return; } + if (path.startsWith("/cloud")) { + return; + } + if (path.startsWith("/session")) { const [, , sessionSegment] = rawPath.split("/"); const id = (sessionSegment ?? "").trim(); @@ -7284,6 +7303,13 @@ export default function App() { + + toggleSettingsView("den")} + connectRemoteWorkspace={workspaceStore.createRemoteWorkspaceFlow} + /> + diff --git a/apps/app/src/app/components/den-settings-panel.tsx b/apps/app/src/app/components/den-settings-panel.tsx index 9d19e8452..53a63cf50 100644 --- a/apps/app/src/app/components/den-settings-panel.tsx +++ b/apps/app/src/app/components/den-settings-panel.tsx @@ -13,6 +13,20 @@ import { resolveDenBaseUrls, writeDenSettings, } from "../lib/den"; +import { + denStatusBadgeClass, + denWorkerStatusMeta, + formatDenIsoDate, + formatDenMoneyMinor, + formatDenRecurringInterval, + formatDenSubscriptionStatus, +} from "../features/den/formatters"; +import { buildDenBrowserAuthUrl } from "../features/den/browser-auth"; +import { + canConfigureDenBaseUrlOverride, + dispatchDenConfigUpdated, + readDenFeatureGate, +} from "../lib/den-gate"; import { isDesktopDeployment } from "../lib/openwork-deployment"; import { usePlatform } from "../context/platform"; @@ -26,110 +40,17 @@ type DenSettingsPanelProps = { }) => Promise; }; -function statusBadgeClass(kind: "ready" | "warning" | "neutral" | "error") { - switch (kind) { - case "ready": - return "border-green-7/30 bg-green-3/20 text-green-11"; - case "warning": - return "border-amber-7/30 bg-amber-3/20 text-amber-11"; - case "error": - return "border-red-7/30 bg-red-3/20 text-red-11"; - default: - return "border-gray-6/60 bg-gray-3/20 text-gray-11"; - } -} - -function workerStatusMeta(status: string) { - const normalized = status.trim().toLowerCase(); - switch (normalized) { - case "healthy": - return { label: "Ready", tone: "ready" as const, canOpen: true }; - case "provisioning": - return { label: "Provisioning", tone: "warning" as const, canOpen: false }; - case "failed": - return { label: "Failed", tone: "error" as const, canOpen: false }; - case "stopped": - return { label: "Stopped", tone: "neutral" as const, canOpen: false }; - default: - return { - label: normalized ? `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}` : "Unknown", - tone: "neutral" as const, - canOpen: normalized === "ready", - }; - } -} - -function formatMoneyMinor(amount: number | null, currency: string | null): string { - if (typeof amount !== "number" || !Number.isFinite(amount)) { - return "Not available"; - } - - const normalizedCurrency = (currency ?? "USD").toUpperCase(); - const majorValue = amount / 100; - - try { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: normalizedCurrency, - }).format(majorValue); - } catch { - return `${majorValue.toFixed(2)} ${normalizedCurrency}`; - } -} - -function formatIsoDate(value: string | null): string { - if (!value) { - return "Not available"; - } - - try { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return "Not available"; - } - return date.toLocaleDateString(); - } catch { - return "Not available"; - } -} - -function formatRecurringInterval(interval: string | null, count: number | null): string { - if (!interval) { - return "billing cycle"; - } - - const normalizedInterval = interval.replace(/_/g, " "); - const normalizedCount = typeof count === "number" && Number.isFinite(count) ? count : 1; - if (normalizedCount <= 1) { - return `per ${normalizedInterval}`; - } - - const pluralSuffix = normalizedInterval.endsWith("s") ? "" : "s"; - return `every ${normalizedCount} ${normalizedInterval}${pluralSuffix}`; -} - -function formatSubscriptionStatus(status: string): string { - const normalized = status.trim().toLowerCase(); - if (!normalized) { - return "Unknown"; - } - - return normalized - .split("_") - .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) - .join(" "); -} - export default function DenSettingsPanel(props: DenSettingsPanelProps) { const platform = usePlatform(); + const initialGate = readDenFeatureGate(props.developerMode); const initial = readDenSettings(); - const initialBaseUrl = props.developerMode ? initial.baseUrl || DEFAULT_DEN_BASE_URL : DEFAULT_DEN_BASE_URL; + const initialBaseUrl = initialGate.baseUrl ?? ""; const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl); const [baseUrlError, setBaseUrlError] = createSignal(null); - const [authToken, setAuthToken] = createSignal(initial.authToken?.trim() || ""); - const [activeOrgId, setActiveOrgId] = createSignal(initial.activeOrgId?.trim() || ""); + const [authToken, setAuthToken] = createSignal(initialGate.enabled ? initial.authToken?.trim() || "" : ""); + const [activeOrgId, setActiveOrgId] = createSignal(initialGate.enabled ? initial.activeOrgId?.trim() || "" : ""); const [authBusy, setAuthBusy] = createSignal(false); const [sessionBusy, setSessionBusy] = createSignal(false); const [orgsBusy, setOrgsBusy] = createSignal(false); @@ -159,17 +80,21 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const [billingError, setBillingError] = createSignal(null); const activeOrg = createMemo(() => orgs().find((org) => org.id === activeOrgId()) ?? null); + const canEditBaseUrl = createMemo(() => canConfigureDenBaseUrlOverride(props.developerMode)); + const isConfigured = createMemo(() => Boolean(baseUrl().trim())); const client = createMemo(() => createDenClient({ baseUrl: baseUrl(), token: authToken() })); - const isSignedIn = createMemo(() => Boolean(user() && authToken().trim())); + const isSignedIn = createMemo(() => isConfigured() && Boolean(user() && authToken().trim())); const billingSubscription = createMemo(() => billingSummary()?.subscription ?? null); const billingCheckoutUrl = createMemo(() => billingSummary()?.checkoutUrl ?? null); const summaryTone = createMemo(() => { + if (!isConfigured()) return "neutral" as const; if (authError() || workersError() || orgsError() || billingError()) return "error" as const; if (sessionBusy() || orgsBusy() || workersBusy() || billingBusy() || billingCheckoutBusy() || billingSubscriptionBusy()) return "warning" as const; if (isSignedIn()) return "ready" as const; return "neutral" as const; }); const summaryLabel = createMemo(() => { + if (!isConfigured()) return canEditBaseUrl() ? "Cloud hidden" : "Unavailable"; if (authError()) return "Needs attention"; if (billingError()) return "Billing issue"; if (sessionBusy()) return "Checking session"; @@ -178,33 +103,48 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { }); createEffect(() => { + const nextBaseUrl = baseUrl().trim(); + if (!nextBaseUrl) { + clearDenSession({ includeBaseUrls: true }); + return; + } + writeDenSettings({ - baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL, + baseUrl: nextBaseUrl, authToken: authToken() || null, activeOrgId: activeOrgId() || null, }); }); createEffect(() => { - if (!props.developerMode) { - setBaseUrl(DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(DEFAULT_DEN_BASE_URL); + if (!canEditBaseUrl()) { + const nextBaseUrl = readDenFeatureGate(props.developerMode).baseUrl ?? ""; + setBaseUrl(nextBaseUrl); + setBaseUrlDraft(nextBaseUrl); setBaseUrlError(null); } }); const openControlPlane = () => { + if (!isConfigured()) { + setBaseUrlError("Set a Den control plane URL before opening Cloud in your browser."); + return; + } platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl); }; const openBrowserAuth = (mode: "sign-in" | "sign-up") => { - const target = new URL(resolveDenBaseUrls(baseUrl()).baseUrl); - target.searchParams.set("mode", mode); - if (isDesktopDeployment()) { - target.searchParams.set("desktopAuth", "1"); - target.searchParams.set("desktopScheme", "openwork"); + if (!isConfigured()) { + setBaseUrlError("Set a Den control plane URL before starting Cloud auth."); + return; } - platform.openLink(target.toString()); + const target = buildDenBrowserAuthUrl({ + baseUrl: baseUrl(), + mode, + desktopAuth: isDesktopDeployment(), + desktopScheme: "openwork", + }); + platform.openLink(target); setStatusMessage(mode === "sign-up" ? "Finish account creation in your browser to connect OpenWork." : "Finish signing in in your browser to connect OpenWork."); setAuthError(null); }; @@ -232,11 +172,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { }; const clearSignedInState = (message?: string | null) => { - clearDenSession({ includeBaseUrls: !props.developerMode }); - if (!props.developerMode) { - setBaseUrl(DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(DEFAULT_DEN_BASE_URL); - } + clearDenSession(); setAuthToken(""); setOpeningWorkerId(null); clearSessionState(); @@ -246,6 +182,10 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { }; const applyBaseUrl = () => { + if (!canEditBaseUrl()) { + return; + } + const normalized = normalizeDenBaseUrl(baseUrlDraft()); if (!normalized) { setBaseUrlError("Enter a valid http:// or https:// Den control plane URL."); @@ -263,6 +203,31 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { setBaseUrl(resolved.baseUrl); setBaseUrlDraft(resolved.baseUrl); clearSignedInState("Updated the Den control plane URL. Sign in again to continue."); + dispatchDenConfigUpdated({ + source: "override", + baseUrl: resolved.baseUrl, + enabled: true, + }); + }; + + const disableCloud = () => { + if (!canEditBaseUrl()) { + return; + } + + setBaseUrl(""); + setBaseUrlDraft(""); + setAuthToken(""); + setOpeningWorkerId(null); + clearSessionState(); + setBaseUrlError(null); + setAuthError(null); + setStatusMessage("Cloud features disabled on this device."); + dispatchDenConfigUpdated({ + source: "none", + baseUrl: null, + enabled: false, + }); }; const refreshOrgs = async (quiet = false) => { @@ -391,6 +356,13 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const currentBaseUrl = baseUrl(); let cancelled = false; + if (!currentBaseUrl.trim()) { + setSessionBusy(false); + clearSessionState(); + setAuthError(null); + return; + } + if (!token) { setSessionBusy(false); clearSessionState(); @@ -453,10 +425,12 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const handler = (event: Event) => { const customEvent = event as CustomEvent<{ status?: string; email?: string | null; message?: string | null }>; const nextSettings = readDenSettings(); - setBaseUrl(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setAuthToken(nextSettings.authToken?.trim() || ""); - setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); + const nextGate = readDenFeatureGate(props.developerMode); + const nextBaseUrl = nextGate.baseUrl ?? nextSettings.baseUrl ?? ""; + setBaseUrl(nextBaseUrl); + setBaseUrlDraft(nextBaseUrl); + setAuthToken(nextGate.enabled ? nextSettings.authToken?.trim() || "" : ""); + setActiveOrgId(nextGate.enabled ? nextSettings.activeOrgId?.trim() || "" : ""); if (customEvent.detail?.status === "success") { setAuthError(null); setStatusMessage( @@ -534,25 +508,37 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
-
-
-
- - OpenWork Den -
-
-
Sign in, pick an org, and open Den workers from Settings.
-
Sign in to OpenWork Den to keep your tasks alive even when your computer sleeps.
+
+
+
+ + OpenWork Den +
+
+
+ {isConfigured() + ? "Sign in, pick an org, and open Den workers from Settings." + : canEditBaseUrl() + ? "Cloud stays hidden until you save a Den control plane URL." + : "Cloud is unavailable in this build until a Den URL is configured."} +
+
+ {isConfigured() + ? "Sign in to OpenWork Den to keep your tasks alive even when your computer sleeps." + : canEditBaseUrl() + ? "Developer mode lets you unlock Cloud locally by pointing OpenWork at a Den control plane." + : "Set VITE_DEN_BASE_URL for this build to enable Cloud features for users."} +
+
-
-
+
{summaryLabel()}
} > <> @@ -572,10 +558,17 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { - + + <> + + + +
@@ -590,7 +583,20 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
- + +
+
+ {canEditBaseUrl() ? "Unlock Cloud locally" : "Cloud is turned off"} +
+
+ {canEditBaseUrl() + ? "Save a Den control plane URL above to enable Cloud routes, auth, billing, and worker access on this device." + : "This build does not expose Cloud because no Den control plane URL was provided. In developer mode, you can add one locally."} +
+
+
+ +
Sign in to OpenWork Den
@@ -616,7 +622,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
- +
@@ -737,7 +743,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) {
{summary.price && summary.price.amount !== null - ? `${formatMoneyMinor(summary.price.amount, summary.price.currency)} ${formatRecurringInterval(summary.price.recurringInterval, summary.price.recurringIntervalCount)}` + ? `${formatDenMoneyMinor(summary.price.amount, summary.price.currency)} ${formatDenRecurringInterval(summary.price.recurringInterval, summary.price.recurringIntervalCount)}` : "Current plan amount is unavailable."}
@@ -752,14 +758,14 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const subscription = subscriptionAccessor(); return ( <> -
{formatSubscriptionStatus(subscription.status)}
+
{formatDenSubscriptionStatus(subscription.status)}
- {formatMoneyMinor(subscription.amount, subscription.currency)} {formatRecurringInterval(subscription.recurringInterval, subscription.recurringIntervalCount)} + {formatDenMoneyMinor(subscription.amount, subscription.currency)} {formatDenRecurringInterval(subscription.recurringInterval, subscription.recurringIntervalCount)}
{subscription.cancelAtPeriodEnd - ? `Cancels on ${formatIsoDate(subscription.currentPeriodEnd)}` - : `Renews on ${formatIsoDate(subscription.currentPeriodEnd)}`} + ? `Cancels on ${formatDenIsoDate(subscription.currentPeriodEnd)}` + : `Renews on ${formatDenIsoDate(subscription.currentPeriodEnd)}`}
); @@ -814,9 +820,9 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { {(invoice) => (
-
{invoice.invoiceNumber ?? formatSubscriptionStatus(invoice.status)}
+
{invoice.invoiceNumber ?? formatDenSubscriptionStatus(invoice.status)}
- {formatIsoDate(invoice.createdAt)} · {formatMoneyMinor(invoice.totalAmount, invoice.currency)} · {formatSubscriptionStatus(invoice.status)} + {formatDenIsoDate(invoice.createdAt)} · {formatDenMoneyMinor(invoice.totalAmount, invoice.currency)} · {formatDenSubscriptionStatus(invoice.status)}
{(worker) => { - const status = createMemo(() => workerStatusMeta(worker.status)); + const status = createMemo(() => denWorkerStatusMeta(worker.status)); return (
{worker.workerName}
-
+
{status().label}
diff --git a/apps/app/src/app/features/den/browser-auth.ts b/apps/app/src/app/features/den/browser-auth.ts new file mode 100644 index 000000000..1cf0ca422 --- /dev/null +++ b/apps/app/src/app/features/den/browser-auth.ts @@ -0,0 +1,58 @@ +import { normalizeDenBaseUrl, resolveDenBaseUrls } from "../../lib/den"; + +type DenAuthMode = "sign-in" | "sign-up"; + +function appHostedCloudBaseUrl(): URL | null { + if (typeof window === "undefined") { + return null; + } + + try { + const current = new URL(window.location.href); + if (current.protocol !== "http:" && current.protocol !== "https:") { + return null; + } + return new URL("/cloud", current.origin); + } catch { + return null; + } +} + +export function buildDenBrowserAuthUrl(input: { + baseUrl: string; + mode: DenAuthMode; + desktopAuth?: boolean; + desktopScheme?: string | null; +}): string { + const hosted = appHostedCloudBaseUrl(); + const normalizedBaseUrl = normalizeDenBaseUrl(input.baseUrl); + + if (hosted) { + hosted.searchParams.set("mode", input.mode); + if (input.desktopAuth) { + hosted.searchParams.set("desktopAuth", "1"); + hosted.searchParams.set("desktopScheme", input.desktopScheme?.trim() || "openwork"); + } + if (normalizedBaseUrl) { + hosted.searchParams.set("denBaseUrl", normalizedBaseUrl); + } + return hosted.toString(); + } + + const fallback = new URL(resolveDenBaseUrls(input.baseUrl).baseUrl); + fallback.searchParams.set("mode", input.mode); + if (input.desktopAuth) { + fallback.searchParams.set("desktopAuth", "1"); + fallback.searchParams.set("desktopScheme", input.desktopScheme?.trim() || "openwork"); + } + return fallback.toString(); +} + +export function buildDenSocialCallbackUrl(mode: DenAuthMode): string | null { + const hosted = appHostedCloudBaseUrl(); + if (!hosted) { + return null; + } + hosted.searchParams.set("mode", mode); + return hosted.toString(); +} diff --git a/apps/app/src/app/features/den/formatters.ts b/apps/app/src/app/features/den/formatters.ts new file mode 100644 index 000000000..4e7b4638a --- /dev/null +++ b/apps/app/src/app/features/den/formatters.ts @@ -0,0 +1,111 @@ +export function denStatusBadgeClass( + kind: "ready" | "warning" | "neutral" | "error", +) { + switch (kind) { + case "ready": + return "border-green-7/30 bg-green-3/20 text-green-11"; + case "warning": + return "border-amber-7/30 bg-amber-3/20 text-amber-11"; + case "error": + return "border-red-7/30 bg-red-3/20 text-red-11"; + default: + return "border-gray-6/60 bg-gray-3/20 text-gray-11"; + } +} + +export function denWorkerStatusMeta(status: string) { + const normalized = status.trim().toLowerCase(); + switch (normalized) { + case "healthy": + case "ready": + return { label: "Ready", tone: "ready" as const, canOpen: true }; + case "provisioning": + case "starting": + return { + label: "Provisioning", + tone: "warning" as const, + canOpen: false, + }; + case "failed": + return { label: "Failed", tone: "error" as const, canOpen: false }; + case "stopped": + case "suspended": + return { label: "Stopped", tone: "neutral" as const, canOpen: false }; + default: + return { + label: normalized + ? `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}` + : "Unknown", + tone: "neutral" as const, + canOpen: normalized === "ready", + }; + } +} + +export function formatDenMoneyMinor( + amount: number | null, + currency: string | null, +): string { + if (typeof amount !== "number" || !Number.isFinite(amount)) { + return "Not available"; + } + + const normalizedCurrency = (currency ?? "USD").toUpperCase(); + const majorValue = amount / 100; + + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: normalizedCurrency, + }).format(majorValue); + } catch { + return `${majorValue.toFixed(2)} ${normalizedCurrency}`; + } +} + +export function formatDenIsoDate(value: string | null): string { + if (!value) { + return "Not available"; + } + + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Not available"; + } + return date.toLocaleDateString(); + } catch { + return "Not available"; + } +} + +export function formatDenRecurringInterval( + interval: string | null, + count: number | null, +): string { + if (!interval) { + return "billing cycle"; + } + + const normalizedInterval = interval.replace(/_/g, " "); + const normalizedCount = + typeof count === "number" && Number.isFinite(count) ? count : 1; + if (normalizedCount <= 1) { + return `per ${normalizedInterval}`; + } + + const pluralSuffix = normalizedInterval.endsWith("s") ? "" : "s"; + return `every ${normalizedCount} ${normalizedInterval}${pluralSuffix}`; +} + +export function formatDenSubscriptionStatus(status: string): string { + const normalized = status.trim().toLowerCase(); + if (!normalized) { + return "Unknown"; + } + + return normalized + .split("_") + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} diff --git a/apps/app/src/app/features/den/state.ts b/apps/app/src/app/features/den/state.ts new file mode 100644 index 000000000..b4fb5a9a3 --- /dev/null +++ b/apps/app/src/app/features/den/state.ts @@ -0,0 +1,1288 @@ +import { + createEffect, + createMemo, + createSignal, + onCleanup, + type Accessor, +} from "solid-js"; +import { + clearDenSession, + createDenClient, + type DenAdminOverview, + DenApiError, + type DenBillingSummary, + type DenSocialProvider, + type DenUser, + type DenWorkerLaunch, + type DenWorkerRuntimeSnapshot, + type DenWorkerSummary, + normalizeDenBaseUrl, + readDenSettings, + resolveDenBaseUrls, + writeDenSettings, +} from "../../lib/den"; +import { + canConfigureDenBaseUrlOverride, + DEN_CONFIG_UPDATED_EVENT, + dispatchDenConfigUpdated, + readDenFeatureGate, +} from "../../lib/den-gate"; +import { isDesktopDeployment } from "../../lib/openwork-deployment"; +import { + buildDenBrowserAuthUrl, + buildDenSocialCallbackUrl, +} from "./browser-auth"; + +type DenAuthMode = "sign-in" | "sign-up"; + +type DenFeatureStateOptions = { + developerMode: Accessor; + openLink: (url: string) => void; + connectRemoteWorkspace?: (input: { + openworkHostUrl?: string | null; + openworkToken?: string | null; + directory?: string | null; + displayName?: string | null; + }) => Promise; +}; + +const DEFAULT_WORKER_NAME = "My Worker"; +const ONBOARDING_INTENT_STORAGE_KEY = "openwork:web:onboarding-intent"; + +type OnboardingIntent = { + version: 1; + workerName: string; + shouldLaunch: boolean; + completed: boolean; + authMethod: "email" | "github" | "google"; +}; + +function deriveOnboardingWorkerName(user: DenUser): string { + const rawIdentity = (user.name?.trim() || user.email.split("@")[0] || DEFAULT_WORKER_NAME) + .replace(/[._-]+/g, " ") + .trim(); + const base = rawIdentity + .split(/\s+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); + + const owner = base || DEFAULT_WORKER_NAME; + const suffix = owner.endsWith("s") ? "' Worker" : "'s Worker"; + return `${owner}${suffix}`; +} + +function readOnboardingIntent(): OnboardingIntent | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(ONBOARDING_INTENT_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || parsed.version !== 1 || typeof parsed.workerName !== "string") { + return null; + } + return { + version: 1, + workerName: parsed.workerName, + shouldLaunch: parsed.shouldLaunch === true, + completed: parsed.completed === true, + authMethod: + parsed.authMethod === "github" || parsed.authMethod === "google" + ? parsed.authMethod + : "email", + }; + } catch { + return null; + } +} + +function writeOnboardingIntent(next: OnboardingIntent | null) { + if (typeof window === "undefined") return; + if (!next) { + window.localStorage.removeItem(ONBOARDING_INTENT_STORAGE_KEY); + return; + } + window.localStorage.setItem(ONBOARDING_INTENT_STORAGE_KEY, JSON.stringify(next)); +} + +function workerSummaryToLaunch( + worker: DenWorkerSummary, + current?: DenWorkerLaunch | null, +): DenWorkerLaunch { + return { + workerId: worker.workerId, + workerName: worker.workerName, + status: worker.status, + provider: worker.provider, + instanceUrl: worker.instanceUrl, + openworkUrl: + current?.workerId === worker.workerId + ? current.openworkUrl + : worker.instanceUrl, + workspaceId: current?.workerId === worker.workerId ? current.workspaceId : null, + clientToken: current?.workerId === worker.workerId ? current.clientToken : null, + ownerToken: current?.workerId === worker.workerId ? current.ownerToken : null, + hostToken: current?.workerId === worker.workerId ? current.hostToken : null, + }; +} + +export function createDenFeatureState(options: DenFeatureStateOptions) { + const initialGate = readDenFeatureGate(options.developerMode()); + const initialSettings = readDenSettings(); + const initialBaseUrl = initialGate.baseUrl ?? ""; + + const [authMode, setAuthMode] = createSignal("sign-in"); + const [email, setEmail] = createSignal(""); + const [password, setPassword] = createSignal(""); + const [workerName, setWorkerName] = createSignal(DEFAULT_WORKER_NAME); + const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); + const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl); + const [baseUrlError, setBaseUrlError] = createSignal(null); + const [authToken, setAuthToken] = createSignal( + initialGate.enabled ? initialSettings.authToken?.trim() || "" : "", + ); + const [activeOrgId, setActiveOrgId] = createSignal( + initialGate.enabled ? initialSettings.activeOrgId?.trim() || "" : "", + ); + const [selectedWorkerId, setSelectedWorkerId] = createSignal(null); + const [selectedWorkerLaunch, setSelectedWorkerLaunch] = + createSignal(null); + const [onboardingIntent, setOnboardingIntent] = + createSignal(readOnboardingIntent()); + const [desktopAuthRequested, setDesktopAuthRequested] = createSignal(false); + const [desktopAuthScheme, setDesktopAuthScheme] = createSignal("openwork"); + const [desktopRedirectBusy, setDesktopRedirectBusy] = createSignal(false); + const [desktopRedirectUrl, setDesktopRedirectUrl] = createSignal(null); + + const [authBusy, setAuthBusy] = createSignal(false); + const [sessionBusy, setSessionBusy] = createSignal(false); + const [orgsBusy, setOrgsBusy] = createSignal(false); + const [workersBusy, setWorkersBusy] = createSignal(false); + const [billingBusy, setBillingBusy] = createSignal(false); + const [billingCheckoutBusy, setBillingCheckoutBusy] = createSignal(false); + const [billingSubscriptionBusy, setBillingSubscriptionBusy] = + createSignal(false); + const [workerActionBusy, setWorkerActionBusy] = createSignal(false); + const [runtimeBusy, setRuntimeBusy] = createSignal(false); + const [adminBusy, setAdminBusy] = createSignal(false); + const [openingWorkerId, setOpeningWorkerId] = createSignal(null); + + const [user, setUser] = createSignal(null); + const [orgs, setOrgs] = createSignal< + Array<{ id: string; name: string; slug: string; role: "owner" | "member" }> + >([]); + const [workers, setWorkers] = createSignal([]); + const [runtimeSnapshot, setRuntimeSnapshot] = + createSignal(null); + const [billingSummary, setBillingSummary] = + createSignal(null); + const [adminOverview, setAdminOverview] = + createSignal(null); + + const [statusMessage, setStatusMessage] = createSignal(null); + const [authError, setAuthError] = createSignal(null); + const [orgsError, setOrgsError] = createSignal(null); + const [workersError, setWorkersError] = createSignal(null); + const [runtimeError, setRuntimeError] = createSignal(null); + const [billingError, setBillingError] = createSignal(null); + const [adminError, setAdminError] = createSignal(null); + + const canEditBaseUrl = createMemo(() => + canConfigureDenBaseUrlOverride(options.developerMode()), + ); + const isConfigured = createMemo(() => Boolean(baseUrl().trim())); + const client = createMemo(() => + isConfigured() + ? createDenClient({ baseUrl: baseUrl(), token: authToken() }) + : null, + ); + const activeOrg = createMemo( + () => orgs().find((org) => org.id === activeOrgId()) ?? null, + ); + const isSignedIn = createMemo( + () => isConfigured() && Boolean(user() && authToken().trim()), + ); + const billingSubscription = createMemo( + () => billingSummary()?.subscription ?? null, + ); + const billingCheckoutUrl = createMemo( + () => billingSummary()?.checkoutUrl ?? null, + ); + const selectedWorkerSummary = createMemo( + () => workers().find((worker) => worker.workerId === selectedWorkerId()) ?? null, + ); + const selectedWorker = createMemo(() => { + const summary = selectedWorkerSummary(); + const current = selectedWorkerLaunch(); + if (summary) { + return workerSummaryToLaunch(summary, current); + } + return current && current.workerId === selectedWorkerId() ? current : null; + }); + const selectedWorkerStatus = createMemo( + () => selectedWorker()?.status ?? selectedWorkerSummary()?.status ?? "", + ); + const onboardingPending = createMemo( + () => Boolean(onboardingIntent()?.shouldLaunch && !onboardingIntent()?.completed), + ); + + const clearRuntimeState = () => { + setRuntimeSnapshot(null); + setRuntimeError(null); + setRuntimeBusy(false); + }; + + const clearSessionState = () => { + setUser(null); + setOrgs([]); + setWorkers([]); + setSelectedWorkerId(null); + setSelectedWorkerLaunch(null); + setBillingSummary(null); + setAdminOverview(null); + setActiveOrgId(""); + setOrgsError(null); + setWorkersError(null); + setBillingError(null); + setAdminError(null); + setDesktopRedirectUrl(null); + setDesktopRedirectBusy(false); + clearRuntimeState(); + }; + + const persistOnboardingIntent = (next: OnboardingIntent | null) => { + setOnboardingIntent(next); + writeOnboardingIntent(next); + }; + + const markOnboardingComplete = () => { + const current = onboardingIntent(); + if (!current) return; + persistOnboardingIntent({ + ...current, + shouldLaunch: false, + completed: true, + }); + }; + + const clearSignedInState = (message?: string | null) => { + clearDenSession(); + setAuthToken(""); + clearSessionState(); + setAuthError(null); + setStatusMessage(message ?? null); + }; + + const syncGateState = () => { + const nextGate = readDenFeatureGate(options.developerMode()); + const nextSettings = readDenSettings(); + const nextBaseUrl = nextGate.baseUrl ?? ""; + setBaseUrl(nextBaseUrl); + setBaseUrlDraft(nextBaseUrl); + setBaseUrlError(null); + if (!nextGate.enabled) { + setAuthToken(""); + setActiveOrgId(""); + clearSessionState(); + return; + } + setAuthToken(nextSettings.authToken?.trim() || ""); + setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); + }; + + createEffect(() => { + const nextBaseUrl = baseUrl().trim(); + if (!nextBaseUrl) { + clearDenSession({ includeBaseUrls: true }); + return; + } + + const nextSettings = readDenSettings(); + writeDenSettings({ + baseUrl: nextBaseUrl, + authToken: authToken() || null, + activeOrgId: activeOrgId() || null, + apiBaseUrl: nextSettings.apiBaseUrl, + }); + }); + + createEffect(() => { + options.developerMode(); + if (!canEditBaseUrl()) { + syncGateState(); + } + }); + + if (typeof window !== "undefined") { + try { + const params = new URLSearchParams(window.location.search); + const requestedMode = params.get("mode")?.trim().toLowerCase(); + if (requestedMode === "sign-up" || requestedMode === "sign-in") { + setAuthMode(requestedMode); + } + + if (params.get("desktopAuth") === "1") { + setDesktopAuthRequested(true); + } + + const requestedScheme = params.get("desktopScheme")?.trim() ?? ""; + if (/^[a-z][a-z0-9+.-]*$/i.test(requestedScheme)) { + setDesktopAuthScheme(requestedScheme); + } + + if (!readDenFeatureGate(options.developerMode()).enabled) { + const queryBaseUrl = normalizeDenBaseUrl(params.get("denBaseUrl")?.trim() ?? ""); + if (queryBaseUrl) { + setBaseUrl(queryBaseUrl); + setBaseUrlDraft(queryBaseUrl); + } + } + } catch { + // ignore search param parsing failures + } + } + + createEffect(() => { + const currentBaseUrl = baseUrl().trim(); + const token = authToken().trim(); + let cancelled = false; + + if (!currentBaseUrl) { + setSessionBusy(false); + clearSessionState(); + setAuthError(null); + return; + } + + if (!token) { + setSessionBusy(false); + clearSessionState(); + setAuthError(null); + return; + } + + setSessionBusy(true); + setAuthError(null); + + void createDenClient({ baseUrl: currentBaseUrl, token }) + .getSession() + .then((nextUser) => { + if (cancelled) return; + setUser(nextUser); + }) + .catch((error) => { + if (cancelled) return; + if (error instanceof DenApiError && error.status === 401) { + clearSignedInState(); + } else { + clearSessionState(); + } + setAuthError( + error instanceof Error ? error.message : "No active Cloud session found.", + ); + }) + .finally(() => { + if (!cancelled) { + setSessionBusy(false); + } + }); + + onCleanup(() => { + cancelled = true; + }); + }); + + createEffect(() => { + if (!user()) return; + void refreshOrgs(true); + }); + + createEffect(() => { + if (!user() || !activeOrgId().trim()) return; + void refreshWorkers(true); + }); + + createEffect(() => { + if (!user()) return; + void refreshBilling({ quiet: true }); + }); + + createEffect(() => { + const currentUser = user(); + if (!currentUser) return; + if (onboardingPending()) return; + if (workerName().trim() && workerName().trim() !== DEFAULT_WORKER_NAME) return; + setWorkerName(deriveOnboardingWorkerName(currentUser)); + }); + + createEffect(() => { + if (!user() || !onboardingPending()) { + return; + } + + const summary = billingSummary(); + if (!summary) return; + if (summary.featureGateEnabled && !summary.hasActivePlan) return; + if (workers().length > 0) { + markOnboardingComplete(); + return; + } + if (workerActionBusy()) return; + + void launchWorker({ + workerNameOverride: onboardingIntent()?.workerName ?? DEFAULT_WORKER_NAME, + source: "signup_auto", + }); + }); + + createEffect(() => { + const worker = selectedWorker(); + if (!worker) { + clearRuntimeState(); + return; + } + + if (!["provisioning", "starting"].includes(worker.status.trim().toLowerCase())) { + return; + } + + const interval = window.setInterval(() => { + void refreshSelectedWorker(true); + }, 5_000); + + onCleanup(() => window.clearInterval(interval)); + }); + + if (typeof window !== "undefined") { + const handleSessionUpdated = (event: Event) => { + const customEvent = event as CustomEvent<{ + status?: string; + email?: string | null; + message?: string | null; + }>; + syncGateState(); + if (customEvent.detail?.status === "success") { + setAuthError(null); + setStatusMessage( + customEvent.detail.email?.trim() + ? `Connected OpenWork Den as ${customEvent.detail.email.trim()}.` + : "Connected OpenWork Den.", + ); + } else if (customEvent.detail?.status === "error") { + setAuthError( + customEvent.detail.message?.trim() || + "Failed to finish OpenWork Den sign-in.", + ); + } + }; + + const handleConfigUpdated = () => { + syncGateState(); + }; + + window.addEventListener( + "openwork-den-session-updated", + handleSessionUpdated as EventListener, + ); + window.addEventListener(DEN_CONFIG_UPDATED_EVENT, handleConfigUpdated); + onCleanup(() => { + window.removeEventListener( + "openwork-den-session-updated", + handleSessionUpdated as EventListener, + ); + window.removeEventListener(DEN_CONFIG_UPDATED_EVENT, handleConfigUpdated); + }); + } + + createEffect(() => { + if (!desktopAuthRequested() || !isSignedIn() || desktopRedirectUrl() || desktopRedirectBusy()) { + return; + } + void createDesktopRedirect(); + }); + + async function refreshOrgs(quiet = false) { + const activeClient = client(); + if (!activeClient || !authToken().trim()) { + setOrgs([]); + setActiveOrgId(""); + return; + } + + setOrgsBusy(true); + if (!quiet) setOrgsError(null); + + try { + const response = await activeClient.listOrgs(); + setOrgs(response.orgs); + const current = activeOrgId().trim(); + const fallback = response.defaultOrgId ?? response.orgs[0]?.id ?? ""; + const next = response.orgs.some((org) => org.id === current) + ? current + : fallback; + setActiveOrgId(next); + if (!quiet && response.orgs.length > 0) { + setStatusMessage( + `Loaded ${response.orgs.length} org${response.orgs.length === 1 ? "" : "s"}.`, + ); + } + } catch (error) { + setOrgsError(error instanceof Error ? error.message : "Failed to load orgs."); + } finally { + setOrgsBusy(false); + } + } + + async function refreshWorkers(quiet = false) { + const activeClient = client(); + const orgId = activeOrgId().trim(); + if (!activeClient || !authToken().trim() || !orgId) { + setWorkers([]); + setSelectedWorkerId(null); + setSelectedWorkerLaunch(null); + clearRuntimeState(); + return; + } + + setWorkersBusy(true); + if (!quiet) setWorkersError(null); + + try { + const nextWorkers = await activeClient.listWorkers(orgId, 20); + setWorkers(nextWorkers); + const currentId = selectedWorkerId(); + const fallbackId = nextWorkers[0]?.workerId ?? null; + const nextSelectedId = + currentId && nextWorkers.some((worker) => worker.workerId === currentId) + ? currentId + : fallbackId; + setSelectedWorkerId(nextSelectedId); + if (!nextSelectedId) { + setSelectedWorkerLaunch(null); + clearRuntimeState(); + } + if (!quiet) { + setStatusMessage( + nextWorkers.length > 0 + ? `Loaded ${nextWorkers.length} worker${nextWorkers.length === 1 ? "" : "s"}.` + : `No workers found for ${activeOrg()?.name ?? "this org"}.`, + ); + } + } catch (error) { + setWorkersError( + error instanceof Error ? error.message : "Failed to load workers.", + ); + } finally { + setWorkersBusy(false); + } + } + + async function refreshBilling(options: { + quiet?: boolean; + includeCheckout?: boolean; + } = {}) { + const activeClient = client(); + if (!activeClient || !authToken().trim()) { + setBillingSummary(null); + return null; + } + + const quiet = options.quiet === true; + if (options.includeCheckout) { + setBillingCheckoutBusy(true); + } else { + setBillingBusy(true); + } + if (!quiet) setBillingError(null); + + try { + const summary = await activeClient.getBillingStatus({ + includeCheckout: options.includeCheckout, + }); + setBillingSummary(summary); + return summary; + } catch (error) { + if (!quiet) { + setBillingError( + error instanceof Error ? error.message : "Failed to load billing.", + ); + } + return null; + } finally { + if (options.includeCheckout) { + setBillingCheckoutBusy(false); + } else { + setBillingBusy(false); + } + } + } + + function resolveLandingRoute() { + const summary = billingSummary(); + if ( + summary && + summary.featureGateEnabled && + summary.checkoutRequired && + !summary.hasActivePlan + ) { + return "/cloud/checkout" as const; + } + return "/cloud/dashboard" as const; + } + + async function handleCheckoutReturn(customerSessionToken: string | null) { + if (customerSessionToken) { + setStatusMessage( + "Checkout return detected. Billing is refreshing now.", + ); + } + await refreshBilling({ quiet: true }); + return resolveLandingRoute(); + } + + async function submitEmailAuth() { + const activeClient = client(); + if (!activeClient) { + setBaseUrlError( + "Set a Den control plane URL before attempting Cloud auth.", + ); + return null; + } + + const nextEmail = email().trim(); + if (!nextEmail || !password()) { + setAuthError("Enter your email and password."); + return null; + } + + setAuthBusy(true); + setAuthError(null); + + try { + const result = + authMode() === "sign-up" + ? await activeClient.signUpEmail(nextEmail, password()) + : await activeClient.signInEmail(nextEmail, password()); + + if (!result.token || !result.user) { + throw new Error( + authMode() === "sign-up" + ? "Cloud sign-up completed, but the response was missing session details." + : "Cloud sign-in completed, but the response was missing session details.", + ); + } + + setAuthToken(result.token); + setUser(result.user); + setPassword(""); + if (authMode() === "sign-up") { + const autoName = deriveOnboardingWorkerName(result.user); + setWorkerName(autoName); + persistOnboardingIntent({ + version: 1, + workerName: autoName, + shouldLaunch: true, + completed: false, + authMethod: "email", + }); + } + setStatusMessage( + authMode() === "sign-up" + ? `Created Cloud account for ${result.user.email}.` + : `Signed in as ${result.user.email}.`, + ); + await refreshOrgs(true); + await refreshBilling({ includeCheckout: authMode() === "sign-up", quiet: true }); + return resolveLandingRoute(); + } catch (error) { + setAuthError( + error instanceof Error ? error.message : "Failed to complete Cloud auth.", + ); + return null; + } finally { + setAuthBusy(false); + } + } + + async function beginSocialAuth(provider: DenSocialProvider) { + if (!isConfigured()) { + setBaseUrlError("Set a Den control plane URL before starting Cloud auth."); + return false; + } + + if (isDesktopDeployment()) { + openBrowserAuth(authMode()); + return true; + } + + const activeClient = client(); + if (!activeClient || typeof window === "undefined") { + setAuthError("Cloud social auth is unavailable in this environment."); + return false; + } + + try { + const callbackUrl = buildDenSocialCallbackUrl(authMode()); + if (!callbackUrl) { + openBrowserAuth(authMode()); + return true; + } + const result = await activeClient.beginSocialAuth({ + provider, + callbackURL: callbackUrl, + errorCallbackURL: callbackUrl, + }); + window.location.assign(result.url); + return true; + } catch (error) { + setAuthError( + error instanceof Error ? error.message : "Failed to start social auth.", + ); + return false; + } + } + + function openControlPlane() { + if (!isConfigured()) { + setBaseUrlError( + "Set a Den control plane URL before opening Cloud in your browser.", + ); + return; + } + options.openLink(resolveDenBaseUrls(baseUrl()).baseUrl); + } + + function openBrowserAuth(mode: DenAuthMode) { + if (!isConfigured()) { + setBaseUrlError( + "Set a Den control plane URL before starting Cloud auth.", + ); + return; + } + const target = buildDenBrowserAuthUrl({ + baseUrl: baseUrl(), + mode, + desktopAuth: isDesktopDeployment(), + desktopScheme: "openwork", + }); + options.openLink(target); + setStatusMessage( + mode === "sign-up" + ? "Finish account creation in your browser to connect OpenWork." + : "Finish signing in in your browser to connect OpenWork.", + ); + setAuthError(null); + } + + function applyBaseUrl() { + if (!canEditBaseUrl()) return; + const normalized = normalizeDenBaseUrl(baseUrlDraft()); + if (!normalized) { + setBaseUrlError( + "Enter a valid http:// or https:// Den control plane URL.", + ); + return; + } + + const resolved = resolveDenBaseUrls(normalized); + setBaseUrlError(null); + setBaseUrl(resolved.baseUrl); + setBaseUrlDraft(resolved.baseUrl); + clearSignedInState("Updated the Den control plane URL. Sign in again to continue."); + dispatchDenConfigUpdated({ + source: "override", + baseUrl: resolved.baseUrl, + enabled: true, + }); + } + + function disableCloud() { + if (!canEditBaseUrl()) return; + setBaseUrl(""); + setBaseUrlDraft(""); + setAuthToken(""); + clearSessionState(); + setBaseUrlError(null); + setAuthError(null); + setStatusMessage("Cloud features disabled on this device."); + dispatchDenConfigUpdated({ + source: "none", + baseUrl: null, + enabled: false, + }); + } + + async function signOut() { + const activeClient = client(); + if (authBusy()) return; + setAuthBusy(true); + try { + if (activeClient && authToken().trim()) { + await activeClient.signOut(); + } + } catch { + // ignore remote sign-out failures + } finally { + setAuthBusy(false); + } + clearSignedInState( + "Signed out and cleared your OpenWork Den session on this device.", + ); + } + + async function createDesktopRedirect() { + const activeClient = client(); + if (!activeClient || !desktopAuthRequested() || !isSignedIn()) { + return null; + } + + setDesktopRedirectBusy(true); + try { + const result = await activeClient.createDesktopHandoffGrant({ + desktopScheme: desktopAuthScheme(), + }); + if (!result.openworkUrl) { + throw new Error( + "Cloud auth completed, but OpenWork did not receive a desktop handoff link.", + ); + } + setDesktopRedirectUrl(result.openworkUrl); + setStatusMessage("Desktop handoff is ready. Open OpenWork to finish sign-in."); + return result.openworkUrl; + } catch (error) { + setAuthError( + error instanceof Error + ? error.message + : "Failed to prepare desktop sign-in handoff.", + ); + return null; + } finally { + setDesktopRedirectBusy(false); + } + } + + function openDesktopRedirect() { + const target = desktopRedirectUrl(); + if (!target) return; + options.openLink(target); + } + + async function launchWorker(options: { + workerNameOverride?: string | null; + source?: "manual" | "signup_auto"; + } = {}) { + const activeClient = client(); + if (!activeClient || !isSignedIn()) { + setWorkersError("Sign in before launching a Cloud worker."); + return null; + } + + const nextWorkerName = + options.workerNameOverride?.trim() || workerName().trim() || DEFAULT_WORKER_NAME; + setWorkerActionBusy(true); + setWorkersError(null); + try { + const result = await activeClient.createWorker({ + name: nextWorkerName, + destination: "cloud", + }); + if (result.kind === "paywall") { + setBillingSummary((current) => + current + ? { + ...current, + hasActivePlan: false, + checkoutRequired: true, + checkoutUrl: result.checkoutUrl ?? current.checkoutUrl, + productId: result.productId ?? current.productId, + benefitId: result.benefitId ?? current.benefitId, + } + : current, + ); + setStatusMessage( + "Payment is required before another Cloud worker can be created.", + ); + return "/cloud/checkout" as const; + } + + setSelectedWorkerId(result.worker.workerId); + setSelectedWorkerLaunch(result.worker); + markOnboardingComplete(); + setStatusMessage( + result.launchMode === "async" + ? `Provisioning ${result.worker.workerName}...` + : `${result.worker.workerName} is ready.`, + ); + await refreshWorkers(true); + return "/cloud/dashboard" as const; + } catch (error) { + setWorkersError( + error instanceof Error ? error.message : "Failed to launch a worker.", + ); + return null; + } finally { + setWorkerActionBusy(false); + } + } + + function selectWorker(workerId: string) { + setSelectedWorkerId(workerId); + setRuntimeSnapshot(null); + setRuntimeError(null); + } + + async function refreshSelectedWorker(quiet = false) { + const activeClient = client(); + const workerId = selectedWorkerId(); + if (!activeClient || !workerId) return null; + + if (!quiet) { + setWorkerActionBusy(true); + setWorkersError(null); + } + + try { + const summary = await activeClient.getWorker(workerId); + setWorkers((current) => { + const next = current.slice(); + const index = next.findIndex((worker) => worker.workerId === workerId); + if (index >= 0) { + next[index] = summary; + return next; + } + return [summary, ...next]; + }); + setSelectedWorkerLaunch((current) => + current?.workerId === workerId ? workerSummaryToLaunch(summary, current) : current, + ); + return summary; + } catch (error) { + if (!quiet) { + setWorkersError( + error instanceof Error ? error.message : "Failed to refresh worker.", + ); + } + return null; + } finally { + if (!quiet) setWorkerActionBusy(false); + } + } + + async function openWorker(workerId?: string) { + const activeClient = client(); + const targetWorkerId = workerId ?? selectedWorkerId(); + const orgId = activeOrgId().trim(); + if (!activeClient || !targetWorkerId || !orgId) { + setWorkersError("Choose an org and worker before opening Cloud."); + return false; + } + if (!options.connectRemoteWorkspace) { + setWorkersError("Opening a Cloud worker is unavailable in this environment."); + return false; + } + + const workerLabel = + workers().find((worker) => worker.workerId === targetWorkerId)?.workerName ?? + "Cloud worker"; + setOpeningWorkerId(targetWorkerId); + setWorkersError(null); + try { + const tokens = await activeClient.getWorkerTokens(targetWorkerId, orgId); + setSelectedWorkerLaunch((current) => { + if (current?.workerId === targetWorkerId) { + return { + ...current, + openworkUrl: tokens.openworkUrl ?? current.openworkUrl, + workspaceId: tokens.workspaceId ?? current.workspaceId, + clientToken: tokens.clientToken, + ownerToken: tokens.ownerToken, + hostToken: tokens.hostToken, + }; + } + const summary = workers().find((worker) => worker.workerId === targetWorkerId); + return summary + ? { + ...workerSummaryToLaunch(summary, null), + openworkUrl: tokens.openworkUrl, + workspaceId: tokens.workspaceId, + clientToken: tokens.clientToken, + ownerToken: tokens.ownerToken, + hostToken: tokens.hostToken, + } + : current; + }); + const openworkUrl = tokens.openworkUrl?.trim() ?? ""; + const accessToken = + tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; + if (!openworkUrl || !accessToken) { + throw new Error( + "Worker is not ready to open yet. Try again after provisioning finishes.", + ); + } + + const ok = await options.connectRemoteWorkspace({ + openworkHostUrl: openworkUrl, + openworkToken: accessToken, + directory: null, + displayName: workerLabel, + }); + if (!ok) { + throw new Error(`Failed to open ${workerLabel} in OpenWork.`); + } + setStatusMessage(`Opened ${workerLabel} in OpenWork.`); + return true; + } catch (error) { + setWorkersError( + error instanceof Error ? error.message : `Failed to open ${workerLabel}.`, + ); + return false; + } finally { + setOpeningWorkerId(null); + } + } + + async function refreshRuntime() { + const activeClient = client(); + const worker = selectedWorker(); + if (!activeClient || !worker) { + setRuntimeSnapshot(null); + return null; + } + setRuntimeBusy(true); + setRuntimeError(null); + try { + const runtime = await activeClient.getWorkerRuntime(worker.workerId); + setRuntimeSnapshot(runtime); + return runtime; + } catch (error) { + setRuntimeError( + error instanceof Error ? error.message : "Failed to load runtime details.", + ); + return null; + } finally { + setRuntimeBusy(false); + } + } + + async function upgradeRuntime() { + const activeClient = client(); + const worker = selectedWorker(); + if (!activeClient || !worker) return null; + setRuntimeBusy(true); + setRuntimeError(null); + try { + const runtime = await activeClient.upgradeWorkerRuntime(worker.workerId); + setRuntimeSnapshot(runtime); + setStatusMessage(`Requested runtime upgrade for ${worker.workerName}.`); + return runtime; + } catch (error) { + setRuntimeError( + error instanceof Error ? error.message : "Failed to upgrade runtime.", + ); + return null; + } finally { + setRuntimeBusy(false); + } + } + + async function deleteWorker(workerId?: string) { + const activeClient = client(); + const targetWorkerId = workerId ?? selectedWorkerId(); + if (!activeClient || !targetWorkerId) return false; + + setWorkerActionBusy(true); + setWorkersError(null); + try { + await activeClient.deleteWorker(targetWorkerId); + setWorkers((current) => + current.filter((worker) => worker.workerId !== targetWorkerId), + ); + if (selectedWorkerId() === targetWorkerId) { + setSelectedWorkerId(null); + setSelectedWorkerLaunch(null); + clearRuntimeState(); + } + setStatusMessage("Deleted Cloud worker."); + return true; + } catch (error) { + setWorkersError( + error instanceof Error ? error.message : "Failed to delete worker.", + ); + return false; + } finally { + setWorkerActionBusy(false); + } + } + + async function redeployWorker(workerId?: string) { + const worker = + workers().find((entry) => entry.workerId === (workerId ?? selectedWorkerId())) ?? + selectedWorkerSummary(); + if (!worker) return null; + const deleted = await deleteWorker(worker.workerId); + if (!deleted) return null; + setWorkerName(worker.workerName); + return launchWorker(); + } + + async function updateSubscriptionCancellation(cancelAtPeriodEnd: boolean) { + const activeClient = client(); + if (!activeClient || !user()) return null; + setBillingSubscriptionBusy(true); + setBillingError(null); + try { + const next = await activeClient.updateSubscriptionCancellation( + cancelAtPeriodEnd, + ); + setBillingSummary(next.billing); + setStatusMessage( + cancelAtPeriodEnd + ? "Subscription will cancel at period end." + : "Subscription auto-renew resumed.", + ); + return next; + } catch (error) { + setBillingError( + error instanceof Error + ? error.message + : "Failed to update subscription.", + ); + return null; + } finally { + setBillingSubscriptionBusy(false); + } + } + + async function refreshAdminOverview(includeBilling = true) { + const activeClient = client(); + if (!activeClient || !authToken().trim()) { + setAdminOverview(null); + return null; + } + setAdminBusy(true); + setAdminError(null); + try { + const overview = await activeClient.getAdminOverview({ includeBilling }); + setAdminOverview(overview); + return overview; + } catch (error) { + setAdminError( + error instanceof Error + ? error.message + : "Failed to load Cloud admin overview.", + ); + return null; + } finally { + setAdminBusy(false); + } + } + + return { + authMode, + setAuthMode, + email, + setEmail, + password, + setPassword, + workerName, + setWorkerName, + onboardingPending, + baseUrl, + baseUrlDraft, + setBaseUrlDraft, + baseUrlError, + authToken, + activeOrgId, + setActiveOrgId, + user, + orgs, + workers, + selectedWorkerId, + selectedWorker, + selectedWorkerSummary, + billingSummary, + billingSubscription, + billingCheckoutUrl, + runtimeSnapshot, + adminOverview, + activeOrg, + isConfigured, + isSignedIn, + canEditBaseUrl, + authBusy, + sessionBusy, + orgsBusy, + workersBusy, + billingBusy, + billingCheckoutBusy, + billingSubscriptionBusy, + workerActionBusy, + runtimeBusy, + adminBusy, + openingWorkerId, + desktopAuthRequested, + desktopRedirectBusy, + desktopRedirectUrl, + statusMessage, + authError, + orgsError, + workersError, + runtimeError, + billingError, + adminError, + summaryTone: createMemo(() => { + if (!isConfigured()) return "neutral" as const; + if (authError() || workersError() || orgsError() || billingError()) { + return "error" as const; + } + if ( + sessionBusy() || + orgsBusy() || + workersBusy() || + billingBusy() || + billingCheckoutBusy() || + billingSubscriptionBusy() || + workerActionBusy() + ) { + return "warning" as const; + } + if (isSignedIn()) return "ready" as const; + return "neutral" as const; + }), + summaryLabel: createMemo(() => { + if (!isConfigured()) return canEditBaseUrl() ? "Cloud hidden" : "Unavailable"; + if (authError()) return "Needs attention"; + if (billingError()) return "Billing issue"; + if (sessionBusy()) return "Checking session"; + if (isSignedIn()) return "Connected"; + return "Signed out"; + }), + resolveLandingRoute, + handleCheckoutReturn, + openControlPlane, + openBrowserAuth, + applyBaseUrl, + disableCloud, + submitEmailAuth, + beginSocialAuth, + createDesktopRedirect, + openDesktopRedirect, + signOut, + refreshSession: syncGateState, + refreshOrgs, + refreshWorkers, + launchWorker, + selectWorker, + refreshSelectedWorker, + openWorker, + refreshRuntime, + upgradeRuntime, + deleteWorker, + redeployWorker, + refreshBilling, + updateSubscriptionCancellation, + refreshAdminOverview, + }; +} + +export type DenFeatureState = ReturnType; diff --git a/apps/app/src/app/lib/den-gate.ts b/apps/app/src/app/lib/den-gate.ts new file mode 100644 index 000000000..f93b3a352 --- /dev/null +++ b/apps/app/src/app/lib/den-gate.ts @@ -0,0 +1,88 @@ +import { + ENV_DEN_BASE_URL, + normalizeDenBaseUrl, + readStoredDenBaseUrls, + resolveDenBaseUrls, +} from "./den"; + +export const DEN_CONFIG_UPDATED_EVENT = "openwork-den-config-updated"; + +export type DenFeatureConfigSource = "env" | "override" | "none"; + +export type DenFeatureGate = { + enabled: boolean; + source: DenFeatureConfigSource; + baseUrl: string | null; + apiBaseUrl: string | null; + envBaseUrl: string | null; + overrideBaseUrl: string | null; + canConfigureInDeveloperMode: boolean; +}; + +export function canConfigureDenBaseUrlOverride(developerMode: boolean): boolean { + return developerMode && !ENV_DEN_BASE_URL; +} + +export function readDenFeatureGate(developerMode: boolean): DenFeatureGate { + if (ENV_DEN_BASE_URL) { + const resolved = resolveDenBaseUrls(ENV_DEN_BASE_URL); + return { + enabled: true, + source: "env", + baseUrl: resolved.baseUrl, + apiBaseUrl: resolved.apiBaseUrl, + envBaseUrl: resolved.baseUrl, + overrideBaseUrl: readStoredDenBaseUrlOverride(), + canConfigureInDeveloperMode: false, + }; + } + + const stored = readStoredDenBaseUrls(); + const overrideBaseUrl = stored.baseUrl ?? normalizeDenBaseUrl(stored.apiBaseUrl); + if (overrideBaseUrl) { + const resolved = resolveDenBaseUrls({ + baseUrl: overrideBaseUrl, + apiBaseUrl: stored.apiBaseUrl, + }); + return { + enabled: true, + source: "override", + baseUrl: resolved.baseUrl, + apiBaseUrl: resolved.apiBaseUrl, + envBaseUrl: null, + overrideBaseUrl: resolved.baseUrl, + canConfigureInDeveloperMode: canConfigureDenBaseUrlOverride(developerMode), + }; + } + + return { + enabled: false, + source: "none", + baseUrl: null, + apiBaseUrl: null, + envBaseUrl: null, + overrideBaseUrl: null, + canConfigureInDeveloperMode: canConfigureDenBaseUrlOverride(developerMode), + }; +} + +export function readStoredDenBaseUrlOverride(): string | null { + const stored = readStoredDenBaseUrls(); + return stored.baseUrl ?? normalizeDenBaseUrl(stored.apiBaseUrl); +} + +export function dispatchDenConfigUpdated(detail?: { + source?: DenFeatureConfigSource; + baseUrl?: string | null; + enabled?: boolean; +}) { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(DEN_CONFIG_UPDATED_EVENT, { + detail, + }), + ); +} diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 8d3692572..058defb3f 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -7,11 +7,33 @@ const STORAGE_AUTH_TOKEN = "openwork.den.authToken"; const STORAGE_ACTIVE_ORG_ID = "openwork.den.activeOrgId"; const DEFAULT_DEN_TIMEOUT_MS = 12_000; +export const DEN_BASE_URL_STORAGE_KEY = STORAGE_BASE_URL; +export const DEN_API_BASE_URL_STORAGE_KEY = STORAGE_API_BASE_URL; +export const DEN_AUTH_TOKEN_STORAGE_KEY = STORAGE_AUTH_TOKEN; +export const DEN_ACTIVE_ORG_ID_STORAGE_KEY = STORAGE_ACTIVE_ORG_ID; + export const DEFAULT_DEN_AUTH_NAME = "OpenWork User"; +export const ENV_DEN_BASE_URL = (() => { + const rawValue = + typeof import.meta !== "undefined" && typeof import.meta.env?.VITE_DEN_BASE_URL === "string" + ? import.meta.env.VITE_DEN_BASE_URL.trim() + : ""; + if (!rawValue) { + return null; + } + + try { + const url = new URL(rawValue); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return null; + } +})(); export const DEFAULT_DEN_BASE_URL = - (typeof import.meta !== "undefined" && typeof import.meta.env?.VITE_DEN_BASE_URL === "string" - ? import.meta.env.VITE_DEN_BASE_URL - : "").trim() || "https://app.openworklabs.com"; + ENV_DEN_BASE_URL ?? "https://app.openworklabs.com"; export type DenSettings = { baseUrl: string; @@ -56,6 +78,135 @@ export type DenWorkerTokens = { workspaceId: string | null; }; +export type DenWorkerLaunch = { + workerId: string; + workerName: string; + status: string; + provider: string | null; + instanceUrl: string | null; + openworkUrl: string | null; + workspaceId: string | null; + clientToken: string | null; + ownerToken: string | null; + hostToken: string | null; +}; + +export type DenRuntimeServiceName = + | "openwork-server" + | "opencode" + | "opencode-router"; + +export type DenWorkerRuntimeService = { + name: DenRuntimeServiceName; + enabled: boolean; + running: boolean; + targetVersion: string | null; + actualVersion: string | null; + upgradeAvailable: boolean; +}; + +export type DenWorkerRuntimeSnapshot = { + services: DenWorkerRuntimeService[]; + upgrade: { + status: "idle" | "running" | "failed"; + startedAt: string | null; + finishedAt: string | null; + error: string | null; + }; +}; + +export type DenSocialProvider = "github" | "google"; + +export type DenDesktopHandoffGrant = { + grant: string; + expiresAt: string | null; + openworkUrl: string | null; +}; + +export type DenWorkerCreateInput = { + name: string; + description?: string; + destination: "local" | "cloud"; + workspacePath?: string; + sandboxBackend?: string; + imageVersion?: string; +}; + +export type DenWorkerCreateResult = + | { + kind: "success"; + worker: DenWorkerLaunch; + launchMode: "async" | "instant"; + pollAfterMs: number; + } + | { + kind: "paywall"; + checkoutUrl: string | null; + productId: string | null; + benefitId: string | null; + }; + +export type DenAdminBillingStatus = { + status: "paid" | "unpaid" | "unavailable"; + featureGateEnabled: boolean; + subscriptionId: string | null; + subscriptionStatus: string | null; + currentPeriodEnd: string | null; + source: "benefit" | "subscription" | "unavailable"; + note: string | null; +}; + +export type DenAdminEntry = { + email: string; + note: string | null; +}; + +export type DenAdminSummary = { + totalUsers: number; + verifiedUsers: number; + recentUsers7d: number; + recentUsers30d: number; + totalWorkers: number; + cloudWorkers: number; + localWorkers: number; + usersWithWorkers: number; + usersWithoutWorkers: number; + paidUsers: number | null; + unpaidUsers: number | null; + billingUnavailableUsers: number | null; + adminCount: number; + billingLoaded: boolean; +}; + +export type DenAdminUser = { + id: string; + name: string | null; + email: string; + emailVerified: boolean; + createdAt: string | null; + updatedAt: string | null; + lastSeenAt: string | null; + sessionCount: number; + authProviders: string[]; + workerCount: number; + cloudWorkerCount: number; + localWorkerCount: number; + latestWorkerCreatedAt: string | null; + billing: DenAdminBillingStatus | null; +}; + +export type DenAdminOverview = { + viewer: { + id: string; + email: string | null; + name: string | null; + }; + admins: DenAdminEntry[]; + summary: DenAdminSummary; + users: DenAdminUser[]; + generatedAt: string | null; +}; + export type DenBillingPrice = { amount: number | null; currency: string | null; @@ -114,6 +265,7 @@ type RawJsonResponse = { ok: boolean; status: number; json: T | null; + headers: Headers; }; export class DenApiError extends Error { @@ -243,6 +395,23 @@ export function readDenSettings(): DenSettings { }; } +export function readStoredDenBaseUrls(): { + baseUrl: string | null; + apiBaseUrl: string | null; +} { + if (typeof window === "undefined") { + return { + baseUrl: null, + apiBaseUrl: null, + }; + } + + return { + baseUrl: normalizeDenBaseUrl(window.localStorage.getItem(STORAGE_BASE_URL) ?? ""), + apiBaseUrl: normalizeDenBaseUrl(window.localStorage.getItem(STORAGE_API_BASE_URL) ?? ""), + }; +} + export function writeDenSettings(next: DenSettings) { if (typeof window === "undefined") { return; @@ -325,6 +494,13 @@ function getToken(payload: unknown): string | null { return payload.token.trim() || null; } +function getCheckoutUrl(payload: unknown): string | null { + if (!isRecord(payload) || !isRecord(payload.polar)) { + return null; + } + return typeof payload.polar.checkoutUrl === "string" ? payload.polar.checkoutUrl : null; +} + function getOrgList(payload: unknown): DenOrgSummary[] { if (!isRecord(payload) || !Array.isArray(payload.orgs)) { return []; @@ -393,6 +569,146 @@ function getWorkerTokens(payload: unknown): DenWorkerTokens | null { }; } +function getEffectiveWorkerStatus( + workerStatus: unknown, + instance: Record | null, +): string { + const normalizedWorkerStatus = typeof workerStatus === "string" ? workerStatus : "unknown"; + const normalized = normalizedWorkerStatus.trim().toLowerCase(); + const instanceStatus = + instance && typeof instance.status === "string" + ? instance.status.trim().toLowerCase() + : null; + + if (!instanceStatus) { + return normalizedWorkerStatus; + } + + if (normalized === "provisioning" || normalized === "starting") { + return instanceStatus; + } + + return normalizedWorkerStatus; +} + +function getWorker(payload: unknown): DenWorkerLaunch | null { + if (!isRecord(payload) || !isRecord(payload.worker)) { + return null; + } + + const worker = payload.worker; + if (typeof worker.id !== "string" || typeof worker.name !== "string") { + return null; + } + + const instance = isRecord(payload.instance) ? payload.instance : null; + const tokens = isRecord(payload.tokens) ? payload.tokens : null; + + return { + workerId: worker.id, + workerName: worker.name, + status: getEffectiveWorkerStatus(worker.status, instance), + provider: instance && typeof instance.provider === "string" ? instance.provider : null, + instanceUrl: instance && typeof instance.url === "string" ? instance.url : null, + openworkUrl: instance && typeof instance.url === "string" ? instance.url : null, + workspaceId: null, + clientToken: tokens && typeof tokens.client === "string" ? tokens.client : null, + ownerToken: tokens && typeof tokens.owner === "string" ? tokens.owner : null, + hostToken: tokens && typeof tokens.host === "string" ? tokens.host : null, + }; +} + +function getWorkerSummary(payload: unknown): DenWorkerSummary | null { + if (!isRecord(payload) || !isRecord(payload.worker)) { + return null; + } + + const worker = payload.worker; + if (typeof worker.id !== "string" || typeof worker.name !== "string") { + return null; + } + + const instance = isRecord(payload.instance) ? payload.instance : null; + + return { + workerId: worker.id, + workerName: worker.name, + status: getEffectiveWorkerStatus(worker.status, instance), + instanceUrl: instance && typeof instance.url === "string" ? instance.url : null, + provider: instance && typeof instance.provider === "string" ? instance.provider : null, + isMine: worker.isMine === true, + createdAt: typeof worker.createdAt === "string" ? worker.createdAt : null, + }; +} + +function getWorkerRuntimeSnapshot(payload: unknown): DenWorkerRuntimeSnapshot | null { + if (!isRecord(payload) || !Array.isArray(payload.services)) { + return null; + } + + const services = payload.services + .map((value) => { + if (!isRecord(value) || typeof value.name !== "string") { + return null; + } + + const name = value.name; + if ( + name !== "openwork-server" && + name !== "opencode" && + name !== "opencode-router" + ) { + return null; + } + + return { + name, + enabled: value.enabled === true, + running: value.running === true, + targetVersion: + typeof value.targetVersion === "string" ? value.targetVersion : null, + actualVersion: + typeof value.actualVersion === "string" ? value.actualVersion : null, + upgradeAvailable: value.upgradeAvailable === true, + } satisfies DenWorkerRuntimeService; + }) + .filter((item): item is DenWorkerRuntimeService => item !== null); + + const upgrade = isRecord(payload.upgrade) ? payload.upgrade : null; + + return { + services, + upgrade: { + status: + upgrade?.status === "running" || + upgrade?.status === "failed" || + upgrade?.status === "idle" + ? upgrade.status + : "idle", + startedAt: + typeof upgrade?.startedAt === "number" + ? new Date(upgrade.startedAt).toISOString() + : null, + finishedAt: + typeof upgrade?.finishedAt === "number" + ? new Date(upgrade.finishedAt).toISOString() + : null, + error: typeof upgrade?.error === "string" ? upgrade.error : null, + }, + }; +} + +export function getRuntimeServiceLabel(name: DenRuntimeServiceName): string { + switch (name) { + case "openwork-server": + return "OpenWork server"; + case "opencode": + return "OpenCode"; + case "opencode-router": + return "OpenCode Router"; + } +} + function getBillingPrice(value: unknown): DenBillingPrice | null { if (!isRecord(value)) { return null; @@ -472,6 +788,136 @@ function getBillingSummary(payload: unknown): DenBillingSummary | null { }; } +function toNumberValue(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function toNullableNumberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function parseAdminBillingStatus(value: unknown): DenAdminBillingStatus | null { + if (!isRecord(value)) { + return null; + } + + const status = + value.status === "paid" || + value.status === "unpaid" || + value.status === "unavailable" + ? value.status + : "unavailable"; + const source = + value.source === "benefit" || + value.source === "subscription" || + value.source === "unavailable" + ? value.source + : "unavailable"; + + return { + status, + featureGateEnabled: value.featureGateEnabled === true, + subscriptionId: + typeof value.subscriptionId === "string" ? value.subscriptionId : null, + subscriptionStatus: + typeof value.subscriptionStatus === "string" + ? value.subscriptionStatus + : null, + currentPeriodEnd: + typeof value.currentPeriodEnd === "string" ? value.currentPeriodEnd : null, + source, + note: typeof value.note === "string" ? value.note : null, + }; +} + +function getAdminOverview(payload: unknown): DenAdminOverview | null { + if ( + !isRecord(payload) || + !isRecord(payload.summary) || + !Array.isArray(payload.users) || + !Array.isArray(payload.admins) + ) { + return null; + } + + const viewer = isRecord(payload.viewer) ? payload.viewer : {}; + const summary = payload.summary; + + const users: DenAdminUser[] = payload.users + .map((value) => { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.email !== "string") { + return null; + } + + const authProviders = Array.isArray(value.authProviders) + ? value.authProviders.filter( + (provider): provider is string => typeof provider === "string", + ) + : []; + + return { + id: value.id, + name: typeof value.name === "string" ? value.name : null, + email: value.email, + emailVerified: value.emailVerified === true, + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + lastSeenAt: typeof value.lastSeenAt === "string" ? value.lastSeenAt : null, + sessionCount: toNumberValue(value.sessionCount), + authProviders, + workerCount: toNumberValue(value.workerCount), + cloudWorkerCount: toNumberValue(value.cloudWorkerCount), + localWorkerCount: toNumberValue(value.localWorkerCount), + latestWorkerCreatedAt: + typeof value.latestWorkerCreatedAt === "string" + ? value.latestWorkerCreatedAt + : null, + billing: parseAdminBillingStatus(value.billing), + } satisfies DenAdminUser; + }) + .filter((value): value is DenAdminUser => value !== null); + + const admins: DenAdminEntry[] = payload.admins + .map((value) => { + if (!isRecord(value) || typeof value.email !== "string") { + return null; + } + + return { + email: value.email, + note: typeof value.note === "string" ? value.note : null, + } satisfies DenAdminEntry; + }) + .filter((value): value is DenAdminEntry => value !== null); + + return { + viewer: { + id: typeof viewer.id === "string" ? viewer.id : "unknown", + email: typeof viewer.email === "string" ? viewer.email : null, + name: typeof viewer.name === "string" ? viewer.name : null, + }, + admins, + summary: { + totalUsers: toNumberValue(summary.totalUsers), + verifiedUsers: toNumberValue(summary.verifiedUsers), + recentUsers7d: toNumberValue(summary.recentUsers7d), + recentUsers30d: toNumberValue(summary.recentUsers30d), + totalWorkers: toNumberValue(summary.totalWorkers), + cloudWorkers: toNumberValue(summary.cloudWorkers), + localWorkers: toNumberValue(summary.localWorkers), + usersWithWorkers: toNumberValue(summary.usersWithWorkers), + usersWithoutWorkers: toNumberValue(summary.usersWithoutWorkers), + paidUsers: toNullableNumberValue(summary.paidUsers), + unpaidUsers: toNullableNumberValue(summary.unpaidUsers), + billingUnavailableUsers: toNullableNumberValue(summary.billingUnavailableUsers), + adminCount: toNumberValue(summary.adminCount), + billingLoaded: summary.billingLoaded === true, + }, + users, + generatedAt: typeof payload.generatedAt === "string" ? payload.generatedAt : null, + }; +} + const resolveFetch = () => (isTauriRuntime() ? tauriFetch : globalThis.fetch); type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -539,7 +985,7 @@ async function requestJsonRaw( } catch { json = null; } - return { ok: response.ok, status: response.status, json }; + return { ok: response.ok, status: response.status, json, headers: response.headers }; } async function requestJson( @@ -585,6 +1031,36 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul return { user: getUser(payload), token: getToken(payload) }; }, + async beginSocialAuth(input: { + provider: DenSocialProvider; + callbackURL: string; + errorCallbackURL?: string | null; + }): Promise<{ url: string }> { + const raw = await requestJsonRaw(baseUrls, "/api/auth/sign-in/social", { + method: "POST", + body: { + provider: input.provider, + callbackURL: input.callbackURL, + errorCallbackURL: input.errorCallbackURL ?? input.callbackURL, + }, + }); + + if (!raw.ok) { + const payload = raw.json; + const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : "request_failed"; + const message = getErrorMessage(payload, `Request failed with ${raw.status}.`); + throw new DenApiError(raw.status, code, message, isRecord(payload) ? payload.details : undefined); + } + + const payloadUrl = isRecord(raw.json) && typeof raw.json.url === "string" ? raw.json.url.trim() : ""; + const headerUrl = raw.headers.get("location")?.trim() ?? ""; + const url = payloadUrl || headerUrl; + if (!url) { + throw new DenApiError(500, "missing_redirect_url", "Social auth did not return a redirect URL."); + } + return { url }; + }, + async signOut() { await requestJsonRaw(baseUrls, "/api/auth/sign-out", { method: "POST", @@ -613,6 +1089,26 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul return { user: getUser(payload), token: getToken(payload) }; }, + async createDesktopHandoffGrant(input: { + next?: string | null; + desktopScheme?: string | null; + } = {}): Promise { + const payload = await requestJson(baseUrls, "/v1/auth/desktop-handoff", { + method: "POST", + token, + body: { + next: input.next?.trim() || undefined, + desktopScheme: input.desktopScheme?.trim() || undefined, + }, + }); + + return { + grant: isRecord(payload) && typeof payload.grant === "string" ? payload.grant : "", + expiresAt: isRecord(payload) && typeof payload.expiresAt === "string" ? payload.expiresAt : null, + openworkUrl: isRecord(payload) && typeof payload.openworkUrl === "string" ? payload.openworkUrl : null, + }; + }, + async listOrgs(): Promise<{ orgs: DenOrgSummary[]; defaultOrgId: string | null }> { const payload = await requestJson(baseUrls, "/v1/me/orgs", { method: "GET", @@ -635,6 +1131,62 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul return getWorkers(payload); }, + async createWorker(input: DenWorkerCreateInput): Promise { + const raw = await requestJsonRaw(baseUrls, "/v1/workers", { + method: "POST", + token, + body: { + name: input.name.trim(), + description: input.description?.trim() || undefined, + destination: input.destination, + workspacePath: input.workspacePath?.trim() || undefined, + sandboxBackend: input.sandboxBackend?.trim() || undefined, + imageVersion: input.imageVersion?.trim() || undefined, + }, + }); + + if (raw.status === 402) { + return { + kind: "paywall", + checkoutUrl: getCheckoutUrl(raw.json), + productId: isRecord(raw.json) && isRecord(raw.json.polar) && typeof raw.json.polar.productId === "string" ? raw.json.polar.productId : null, + benefitId: isRecord(raw.json) && isRecord(raw.json.polar) && typeof raw.json.polar.benefitId === "string" ? raw.json.polar.benefitId : null, + }; + } + + if (!raw.ok) { + const payload = raw.json; + const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : "request_failed"; + const message = getErrorMessage(payload, `Request failed with ${raw.status}.`); + throw new DenApiError(raw.status, code, message, isRecord(payload) ? payload.details : undefined); + } + + const worker = getWorker(raw.json); + if (!worker) { + throw new DenApiError(500, "invalid_worker_payload", "Worker create response was missing worker details."); + } + + const launch = isRecord(raw.json) && isRecord(raw.json.launch) ? raw.json.launch : null; + return { + kind: "success", + worker, + launchMode: launch?.mode === "instant" ? "instant" : "async", + pollAfterMs: typeof launch?.pollAfterMs === "number" ? launch.pollAfterMs : 0, + }; + }, + + async getWorker(workerId: string): Promise { + const payload = await requestJson(baseUrls, `/v1/workers/${encodeURIComponent(workerId)}`, { + method: "GET", + token, + }); + const worker = getWorkerSummary(payload); + if (!worker) { + throw new DenApiError(500, "invalid_worker_payload", "Worker response was missing summary details."); + } + return worker; + }, + async getWorkerTokens(workerId: string, orgId: string): Promise { const params = new URLSearchParams(); params.set("orgId", orgId); @@ -650,6 +1202,46 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul return tokens; }, + async getWorkerRuntime(workerId: string): Promise { + const payload = await requestJson(baseUrls, `/v1/workers/${encodeURIComponent(workerId)}/runtime`, { + method: "GET", + token, + }); + const runtime = getWorkerRuntimeSnapshot(payload); + if (!runtime) { + throw new DenApiError(500, "invalid_runtime_payload", "Runtime response was missing service details."); + } + return runtime; + }, + + async upgradeWorkerRuntime(workerId: string, input: Record = {}): Promise { + const payload = await requestJson(baseUrls, `/v1/workers/${encodeURIComponent(workerId)}/runtime/upgrade`, { + method: "POST", + token, + body: input, + }); + const runtime = getWorkerRuntimeSnapshot(payload); + if (!runtime) { + throw new DenApiError(500, "invalid_runtime_payload", "Runtime upgrade response was missing service details."); + } + return runtime; + }, + + async deleteWorker(workerId: string): Promise { + const raw = await requestJsonRaw(baseUrls, `/v1/workers/${encodeURIComponent(workerId)}`, { + method: "DELETE", + token, + }); + if (raw.status === 204 || raw.ok) { + return; + } + + const payload = raw.json; + const code = isRecord(payload) && typeof payload.error === "string" ? payload.error : "request_failed"; + const message = getErrorMessage(payload, `Request failed with ${raw.status}.`); + throw new DenApiError(raw.status, code, message, isRecord(payload) ? payload.details : undefined); + }, + async getBillingStatus(options: { includeCheckout?: boolean; includePortal?: boolean; includeInvoices?: boolean } = {}): Promise { const params = new URLSearchParams(); if (options.includeCheckout) { @@ -690,5 +1282,23 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul billing, }; }, + + async getAdminOverview(options: { includeBilling?: boolean } = {}): Promise { + const params = new URLSearchParams(); + if (options.includeBilling) { + params.set("includeBilling", "1"); + } + + const path = params.size > 0 ? `/v1/admin/overview?${params.toString()}` : "/v1/admin/overview"; + const payload = await requestJson(baseUrls, path, { + method: "GET", + token, + }); + const overview = getAdminOverview(payload); + if (!overview) { + throw new DenApiError(500, "invalid_admin_payload", "Admin overview response was missing details."); + } + return overview; + }, }; } diff --git a/apps/app/src/app/pages/cloud.tsx b/apps/app/src/app/pages/cloud.tsx new file mode 100644 index 000000000..7a641580c --- /dev/null +++ b/apps/app/src/app/pages/cloud.tsx @@ -0,0 +1,945 @@ +import { + For, + Match, + Show, + Switch, + createEffect, + createMemo, + createSignal, +} from "solid-js"; +import { useLocation, useNavigate } from "@solidjs/router"; +import { + ArrowUpRight, + Cloud, + CreditCard, + LogOut, + RefreshCcw, + Server, + Settings, + Shield, + Sparkles, + UserCircle2, +} from "lucide-solid"; +import Button from "../components/button"; +import TextInput from "../components/text-input"; +import { usePlatform } from "../context/platform"; +import { + formatDenIsoDate, + formatDenMoneyMinor, + formatDenRecurringInterval, + formatDenSubscriptionStatus, + denStatusBadgeClass, + denWorkerStatusMeta, +} from "../features/den/formatters"; +import { createDenFeatureState } from "../features/den/state"; +import { getRuntimeServiceLabel } from "../lib/den"; + +export type CloudViewProps = { + developerMode: boolean; + openCloudSettings: () => void; + connectRemoteWorkspace: (input: { + openworkHostUrl?: string | null; + openworkToken?: string | null; + directory?: string | null; + displayName?: string | null; + }) => Promise; +}; + +type CloudRoute = "auth" | "dashboard" | "checkout" | "admin"; + +function routeButtonClass(active: boolean) { + return active + ? "bg-gray-12/10 text-white border-gray-6/30" + : "text-gray-10 border-gray-6/50 hover:text-gray-12 hover:bg-gray-2/40"; +} + +export default function CloudView(props: CloudViewProps) { + const location = useLocation(); + const navigate = useNavigate(); + const platform = usePlatform(); + const state = createDenFeatureState({ + developerMode: () => props.developerMode, + openLink: platform.openLink, + connectRemoteWorkspace: props.connectRemoteWorkspace, + }); + const [checkoutTokenHandled, setCheckoutTokenHandled] = createSignal( + null, + ); + const [includeBillingDetails, setIncludeBillingDetails] = createSignal(true); + + const route = createMemo(() => { + const path = location.pathname.toLowerCase(); + if (path === "/cloud/admin") return "admin"; + if (path === "/cloud/checkout") return "checkout"; + if (path === "/cloud/dashboard") return "dashboard"; + return "auth"; + }); + + const customerSessionToken = createMemo(() => { + const params = new URLSearchParams(location.search); + return params.get("customer_session_token")?.trim() ?? null; + }); + + createEffect(() => { + const path = location.pathname.toLowerCase(); + if (!path.startsWith("/cloud")) return; + if ( + path !== "/cloud" && + path !== "/cloud/dashboard" && + path !== "/cloud/checkout" && + path !== "/cloud/admin" + ) { + navigate("/cloud", { replace: true }); + } + }); + + createEffect(() => { + if (location.pathname.toLowerCase() !== "/cloud") return; + if (!state.isConfigured()) return; + if (!state.isSignedIn()) return; + if (state.desktopAuthRequested()) return; + navigate(state.resolveLandingRoute(), { replace: true }); + }); + + createEffect(() => { + const token = customerSessionToken(); + if (!token) return; + if (checkoutTokenHandled() === token) return; + setCheckoutTokenHandled(token); + void state.handleCheckoutReturn(token).then((target) => { + navigate(target, { replace: true }); + }); + }); + + const cloudTabs: Array<{ key: Exclude; label: string }> = [ + { key: "dashboard", label: "Dashboard" }, + { key: "checkout", label: "Billing" }, + { key: "admin", label: "Admin" }, + ]; + + return ( +
+
+
+
+
+
+
+
+ + OpenWork Cloud +
+
+

+ Launch, manage, and reconnect your Den workers from the main app. +

+

+ Cloud routes live inside OpenWork now. Configure a Den control plane, sign in, then launch workers and reconnect them with the same remote flow the desktop app already understands. +

+
+
+
+
+ + {state.summaryLabel()} +
+ +
+
+ + + {(value) => ( +
+ {value()} +
+ )} +
+
+ + +
+ + {(tab) => ( + + )} + +
+
+ + +
+
+
+ +
+
+
+ {state.canEditBaseUrl() ? "Unlock Cloud locally" : "Cloud is unavailable in this build"} +
+

+ {state.canEditBaseUrl() + ? "Open Cloud settings, save a Den control plane URL, and this route will unlock immediately on this device." + : "This build has no Den API URL configured. Enable developer mode or provide VITE_DEN_BASE_URL to surface Cloud features."} +

+
+ +
+
+
+
+
+ + + + +
+
+
+
+ + Cloud access +
+

+ {state.authMode() === "sign-up" + ? "Create your OpenWork Cloud account." + : "Sign in to OpenWork Cloud."} +

+

+ Direct email auth works inside the app. Social auth and browser handoff are also available when you want the full external flow. +

+
+
+ +
+
+
+ + +
+ +
+ state.setEmail(event.currentTarget.value)} + placeholder="you@example.com" + disabled={state.authBusy()} + /> + state.setPassword(event.currentTarget.value)} + placeholder="••••••••" + disabled={state.authBusy()} + /> +
+ +
+ + +
+ +
+
+ Social auth +
+
+ + +
+
+ + +
+
Finish in OpenWork
+
+ Sign in here, then bounce back into the desktop app with a one-time handoff link. +
+ + {(redirectAccessor) => ( +
+ +
{redirectAccessor()}
+
+ )} +
+ +
Preparing desktop handoff...
+
+
+
+ + + {(value) => ( +
+ {value()} +
+ )} +
+
+
+
+
+ + + +
+
Sign in to continue
+

+ Cloud dashboard routes need an active Den session. +

+ +
+
+ } + > +
+
+
+
+
+
Account
+
+ {state.user()?.name || state.user()?.email} +
+
{state.user()?.email}
+
+ +
+
+ +
+
+
+
Active org
+
+ Workers are listed from the selected org. +
+
+ +
+
+ +
+ + {(value) => ( +
+ {value()} +
+ )} +
+
+ +
+
+
+
Create Cloud worker
+
+ Launch a new cloud worker from the unified app experience. +
+
+ +
+
+ state.setWorkerName(event.currentTarget.value)} + placeholder="My Worker" + disabled={state.workerActionBusy()} + /> +
+
+ +
+
+
+
+ + Billing snapshot +
+
+ Quick access to checkout, invoices, and renewal state. +
+
+
+ + +
+
+ Billing details will appear after your first refresh.
}> + {(summaryAccessor) => { + const summary = summaryAccessor(); + return ( +
+
+ {!summary.featureGateEnabled + ? "Billing disabled" + : summary.hasActivePlan + ? "Active plan" + : "Payment required"} +
+
+ {summary.price && summary.price.amount !== null + ? `${formatDenMoneyMinor(summary.price.amount, summary.price.currency)} ${formatDenRecurringInterval(summary.price.recurringInterval, summary.price.recurringIntervalCount)}` + : "Current plan amount unavailable."} +
+
+ ); + }} + +
+
+ +
+
+
+
+
+ + Workers +
+
+ Select a worker to inspect runtime, open it in OpenWork, or manage lifecycle actions. +
+
+ +
+ + + {(value) => ( +
+ {value()} +
+ )} +
+ +
+ 0} fallback={
No Cloud workers are visible for this org yet.
}> + + {(worker) => { + const meta = createMemo(() => denWorkerStatusMeta(worker.status)); + return ( + + + +
+
+ + ); + }} + + +
+
+ + + {(workerAccessor) => { + const worker = workerAccessor(); + const meta = createMemo(() => denWorkerStatusMeta(worker.status)); + return ( +
+
+
+
{worker.workerName}
+
+ {meta().label} + {worker.provider || "Cloud worker"} + {worker.instanceUrl} +
+
+
+ + +
+
+ + + {(value) =>
{value()}
} +
+ + Load runtime details to inspect service versions and upgrades.
}> + {(runtimeAccessor) => { + const runtime = runtimeAccessor(); + return ( +
+
+ + {(service) => ( +
+
{getRuntimeServiceLabel(service.name)}
+
+ {service.running ? "Running" : service.enabled ? "Installed" : "Unavailable"} +
+
+ {service.actualVersion || service.targetVersion || "Version unavailable"} +
+
+ )} +
+
+
+ +
+
+ ); + }} +
+
+ ); + }} + +
+
+ + + + + +
+
Sign in to view billing
+

+ Cloud checkout and subscription management need an active Den session. +

+ +
+
+ } + > +
+
+
+
Cloud billing
+
+ Review plan state, open checkout or billing portal links, and manage your subscription from inside the app. +
+
+
+ + +
+
+ + + {(value) =>
{value()}
} +
+ + Load billing to inspect Cloud plan state.
}> + {(summaryAccessor) => { + const summary = summaryAccessor(); + return ( +
+
+
+
Plan status
+
+ {!summary.featureGateEnabled + ? "Billing disabled" + : summary.hasActivePlan + ? "Active plan" + : "Payment required"} +
+
+ {!summary.featureGateEnabled + ? "Cloud billing gates are disabled in this environment." + : summary.hasActivePlan + ? "This account can launch additional cloud workers right now." + : "Complete checkout to unlock additional Cloud worker launches."} +
+
+ {summary.price && summary.price.amount !== null + ? `${formatDenMoneyMinor(summary.price.amount, summary.price.currency)} ${formatDenRecurringInterval(summary.price.recurringInterval, summary.price.recurringIntervalCount)}` + : "Current plan amount is unavailable."} +
+
+ +
+
Subscription
+ No active subscription found yet.
}> + {(subscriptionAccessor) => { + const subscription = subscriptionAccessor(); + return ( + <> +
{formatDenSubscriptionStatus(subscription.status)}
+
+ {formatDenMoneyMinor(subscription.amount, subscription.currency)} {formatDenRecurringInterval(subscription.recurringInterval, subscription.recurringIntervalCount)} +
+
+ {subscription.cancelAtPeriodEnd + ? `Cancels on ${formatDenIsoDate(subscription.currentPeriodEnd)}` + : `Renews on ${formatDenIsoDate(subscription.currentPeriodEnd)}`} +
+ + ); + }} + +
+
+ +
+ + {(checkoutUrl) => ( + + )} + + + {(portalUrl) => ( + + )} + + + {(subscriptionAccessor) => { + const subscription = subscriptionAccessor(); + return ( + + ); + }} + +
+ + 0}> +
+
Invoices
+ + {(invoice) => ( +
+
+
+ {invoice.invoiceNumber || formatDenSubscriptionStatus(invoice.status)} +
+
+ {formatDenIsoDate(invoice.createdAt)} · {formatDenMoneyMinor(invoice.totalAmount, invoice.currency)} · {formatDenSubscriptionStatus(invoice.status)} +
+
+ + {(invoiceUrl) => ( + + )} + +
+ )} +
+
+
+
+ ); + }} + +
+
+ + + + +
+
Sign in to view admin data
+

+ Cloud admin screens need an authenticated Den session before they can check your access level. +

+ +
+
+ } + > +
+
+
+
+ + Cloud admin +
+
+ Inspect Cloud users, worker counts, provider usage, and billing state when your account is on the admin allowlist. +
+
+
+ + +
+
+ + + {(value) =>
{value()}
} +
+ + Load the admin overview to inspect Cloud user and worker activity.
}> + {(overviewAccessor) => { + const overview = overviewAccessor(); + return ( +
+
+
+
Users
+
{overview.summary.totalUsers}
+
{overview.summary.verifiedUsers} verified
+
+
+
Workers
+
{overview.summary.totalWorkers}
+
{overview.summary.cloudWorkers} cloud / {overview.summary.localWorkers} local
+
+
+
Paid users
+
{overview.summary.paidUsers ?? "-"}
+
Billing loaded: {overview.summary.billingLoaded ? "yes" : "no"}
+
+
+
Recent users
+
{overview.summary.recentUsers7d}
+
{overview.summary.recentUsers30d} in the last 30 days
+
+
+ +
+
+ + + + + + + + + + + + + {(entry) => ( + + + + + + + + )} + + +
UserProvidersWorkersBillingLast seen
+
{entry.name || entry.email}
+
{entry.email}
+
+
{entry.authProviders.join(", ") || "-"}
+
+
{entry.workerCount}
+
{entry.cloudWorkerCount} cloud / {entry.localWorkerCount} local
+
+
{entry.billing ? formatDenSubscriptionStatus(entry.billing.status) : "-"}
+
{entry.billing?.note || "No billing note"}
+
+ {formatDenIsoDate(entry.lastSeenAt)} +
+
+
+
+ ); + }} + +
+
+ + +
+
+ + ); +} diff --git a/apps/app/src/app/pages/settings.tsx b/apps/app/src/app/pages/settings.tsx index 2b762f119..9bc7e3c6d 100644 --- a/apps/app/src/app/pages/settings.tsx +++ b/apps/app/src/app/pages/settings.tsx @@ -6,6 +6,7 @@ import { createEffect, createMemo, createSignal, + onCleanup, onMount, } from "solid-js"; @@ -20,6 +21,10 @@ import Button from "../components/button"; import DenSettingsPanel from "../components/den-settings-panel"; import { usePlatform } from "../context/platform"; import { FEEDBACK_EMAIL_URL } from "../lib/feedback"; +import { + DEN_CONFIG_UPDATED_EVENT, + readDenFeatureGate, +} from "../lib/den-gate"; import { getOpenWorkDeployment } from "../lib/openwork-deployment"; import { ArrowUpRight, @@ -804,12 +809,38 @@ export default function SettingsView(props: SettingsViewProps) { } }; + const [denGateVersion, setDenGateVersion] = createSignal(0); + const denFeatureGate = createMemo(() => { + denGateVersion(); + return readDenFeatureGate(props.developerMode); + }); + const availableTabs = createMemo(() => { - const tabs: SettingsTab[] = ["general", "den", "model", "advanced"]; + const tabs: SettingsTab[] = ["general", "model", "advanced"]; + if ( + denFeatureGate().enabled || + denFeatureGate().canConfigureInDeveloperMode + ) { + tabs.splice(1, 0, "den"); + } if (props.developerMode) tabs.push("debug"); return tabs; }); + onMount(() => { + const handleDenConfigUpdated = () => { + setDenGateVersion((value) => value + 1); + }; + + window.addEventListener(DEN_CONFIG_UPDATED_EVENT, handleDenConfigUpdated); + onCleanup(() => { + window.removeEventListener( + DEN_CONFIG_UPDATED_EVENT, + handleDenConfigUpdated, + ); + }); + }); + const activeTab = createMemo(() => { const tabs = availableTabs(); return tabs.includes(props.settingsTab) ? props.settingsTab : "general"; diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index b1c6d2c4d..6ae6faeee 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -138,7 +138,7 @@ export type OpencodeEvent = { properties?: unknown; }; -export type View = "onboarding" | "dashboard" | "session" | "proto"; +export type View = "onboarding" | "dashboard" | "session" | "proto" | "cloud"; export type StartupPreference = "local" | "server"; diff --git a/packaging/docker/Dockerfile.den b/packaging/docker/Dockerfile.den index 51ebe7c6e..4c5461244 100644 --- a/packaging/docker/Dockerfile.den +++ b/packaging/docker/Dockerfile.den @@ -7,20 +7,20 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches -COPY packages/utils/package.json /app/packages/utils/package.json -COPY packages/den-db/package.json /app/packages/den-db/package.json -COPY services/den/package.json /app/services/den/package.json +COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json +COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json +COPY ee/apps/den-controller/package.json /app/ee/apps/den-controller/package.json RUN pnpm install --frozen-lockfile -COPY packages/utils /app/packages/utils -COPY packages/den-db /app/packages/den-db -COPY services/den /app/services/den +COPY ee/packages/utils /app/ee/packages/utils +COPY ee/packages/den-db /app/ee/packages/den-db +COPY ee/apps/den-controller /app/ee/apps/den-controller -RUN pnpm --dir /app/packages/utils run build -RUN pnpm --dir /app/packages/den-db run build -RUN pnpm --dir /app/services/den run build +RUN pnpm --dir /app/ee/packages/utils run build +RUN pnpm --dir /app/ee/packages/den-db run build +RUN pnpm --dir /app/ee/apps/den-controller run build EXPOSE 8788 -CMD ["sh", "-lc", "node services/den/dist/index.js"] +CMD ["sh", "-lc", "node ee/apps/den-controller/dist/index.js"] diff --git a/packaging/docker/Dockerfile.den-web b/packaging/docker/Dockerfile.den-web index b2fc3658d..6cd931f0b 100644 --- a/packaging/docker/Dockerfile.den-web +++ b/packaging/docker/Dockerfile.den-web @@ -1,13 +1,18 @@ FROM node:22-bookworm-slim -WORKDIR /app/packages/web +RUN corepack enable -COPY packages/web/package.json /app/packages/web/package.json +WORKDIR /app -RUN npm install --no-package-lock --no-fund --no-audit +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ +COPY .npmrc /app/.npmrc +COPY patches /app/patches +COPY ee/apps/den-web/package.json /app/ee/apps/den-web/package.json -COPY packages/web /app/packages/web +RUN pnpm install --frozen-lockfile + +COPY ee/apps/den-web /app/ee/apps/den-web EXPOSE 3005 -CMD ["npm", "run", "dev"] +CMD ["sh", "-lc", "cd /app/ee/apps/den-web && pnpm run build && pnpm run start"] diff --git a/packaging/docker/Dockerfile.den-worker-proxy b/packaging/docker/Dockerfile.den-worker-proxy index bf8dad3c8..8400453e1 100644 --- a/packaging/docker/Dockerfile.den-worker-proxy +++ b/packaging/docker/Dockerfile.den-worker-proxy @@ -7,20 +7,20 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches -COPY packages/utils/package.json /app/packages/utils/package.json -COPY packages/den-db/package.json /app/packages/den-db/package.json -COPY services/den-worker-proxy/package.json /app/services/den-worker-proxy/package.json +COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json +COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json +COPY ee/apps/den-worker-proxy/package.json /app/ee/apps/den-worker-proxy/package.json RUN pnpm install --frozen-lockfile -COPY packages/utils /app/packages/utils -COPY packages/den-db /app/packages/den-db -COPY services/den-worker-proxy /app/services/den-worker-proxy +COPY ee/packages/utils /app/ee/packages/utils +COPY ee/packages/den-db /app/ee/packages/den-db +COPY ee/apps/den-worker-proxy /app/ee/apps/den-worker-proxy -RUN pnpm --dir /app/packages/utils run build -RUN pnpm --dir /app/packages/den-db run build -RUN pnpm --dir /app/services/den-worker-proxy run build +RUN pnpm --dir /app/ee/packages/utils run build +RUN pnpm --dir /app/ee/packages/den-db run build +RUN pnpm --dir /app/ee/apps/den-worker-proxy run build EXPOSE 8789 -CMD ["sh", "-lc", "node services/den-worker-proxy/dist/server.js"] +CMD ["sh", "-lc", "node ee/apps/den-worker-proxy/dist/server.js"] diff --git a/packaging/docker/den-dev-up.sh b/packaging/docker/den-dev-up.sh index ffb10438b..ea30c030d 100755 --- a/packaging/docker/den-dev-up.sh +++ b/packaging/docker/den-dev-up.sh @@ -21,6 +21,15 @@ if ! command -v docker >/dev/null 2>&1; then exit 1 fi +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD=(docker-compose) +else + echo "docker compose or docker-compose is required" >&2 + exit 1 +fi + pick_port() { node -e " const net = require('net'); @@ -212,9 +221,10 @@ if ! DEN_API_PORT="$DEN_API_PORT" \ DAYTONA_API_KEY="${DAYTONA_API_KEY:-}" \ DAYTONA_TARGET="${DAYTONA_TARGET:-}" \ DAYTONA_SNAPSHOT="${DAYTONA_SNAPSHOT:-}" \ - docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --build --wait; then + DAYTONA_OPENWORK_VERSION="${DAYTONA_OPENWORK_VERSION:-}" \ + "${COMPOSE_CMD[@]}" -p "$PROJECT" -f "$COMPOSE_FILE" up -d --build --wait; then echo "Den Docker stack failed to start. Recent logs:" >&2 - docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --tail=200 >&2 || true + "${COMPOSE_CMD[@]}" -p "$PROJECT" -f "$COMPOSE_FILE" logs --tail=200 >&2 || true exit 1 fi @@ -248,6 +258,6 @@ echo "Health check: http://localhost:$DEN_API_PORT/health" >&2 echo "Runtime env file: $RUNTIME_FILE" >&2 echo "" >&2 echo "To stop this stack (keep DB data):" >&2 -echo " docker compose -p $PROJECT -f $COMPOSE_FILE down" >&2 +echo " ${COMPOSE_CMD[*]} -p $PROJECT -f $COMPOSE_FILE down" >&2 echo "To stop and reset the DB:" >&2 -echo " docker compose -p $PROJECT -f $COMPOSE_FILE down -v" >&2 +echo " ${COMPOSE_CMD[*]} -p $PROJECT -f $COMPOSE_FILE down -v" >&2 diff --git a/packaging/docker/docker-compose.den-dev.yml b/packaging/docker/docker-compose.den-dev.yml index 80030ed32..07fe57fea 100644 --- a/packaging/docker/docker-compose.den-dev.yml +++ b/packaging/docker/docker-compose.den-dev.yml @@ -112,7 +112,6 @@ services: build: context: ../../ dockerfile: packaging/docker/Dockerfile.den-web - command: ["sh", "-lc", "npm run build && npm run start"] depends_on: den: condition: service_healthy