refine den-web cloud dashboard and onboarding

This commit is contained in:
Benjamin Shafii
2026-03-26 08:01:46 -07:00
parent 14024643e6
commit 6061f50d1f
19 changed files with 603 additions and 335 deletions

View File

@@ -95,14 +95,14 @@ export function AuthScreen() {
const panelTitle = verificationRequired
? "Verify your email."
: authMode === "sign-up"
? "Create your Den account."
: "Sign in to Den.";
? "Create your Cloud account."
: "Sign in to Cloud.";
const panelCopy = verificationRequired
? "Enter the six-digit code from your inbox to finish setup."
: authMode === "sign-up"
? "Start with email, GitHub, or Google."
: "Welcome back. Pick up where your workers left off.";
: "Welcome back. Keep your team setup in sync across Cloud and desktop.";
return (
<section className="den-page flex w-full items-center py-4 lg:min-h-[calc(100vh-2.5rem)]">
@@ -110,28 +110,27 @@ export function AuthScreen() {
<div className="grid w-full gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)]">
<div className="den-frame-soft flex flex-col justify-between gap-10 p-7 md:p-10 lg:p-12">
<div className="grid gap-6">
<span className="den-kicker w-fit">OpenWork Den</span>
<span className="den-kicker w-fit">OpenWork Cloud</span>
<div className="grid gap-4">
<h1 className="den-title-xl max-w-[10ch]">Run workers that stay on.</h1>
<h1 className="den-title-xl max-w-[12ch]">Share your OpenWork setup with your team.</h1>
<p className="den-copy max-w-[40rem]">
Launch hosted workers, share the same setup with your team,
and reconnect from desktop or web when you need it.
Provision shared setups, invite your org, and keep background agents available when you need them.
</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-3 lg:grid-cols-1">
<div className="grid gap-1 border-t border-gray-200 pt-4">
<p className="text-base font-medium text-[var(--dls-text-primary)]">Hosted workers</p>
<p className="den-copy text-sm">Keep automations live after your laptop closes.</p>
<p className="text-base font-medium text-[var(--dls-text-primary)]">Share setup across your team and org</p>
<p className="den-copy text-sm">Package skills, MCPs, plugins, and config once.</p>
</div>
<div className="grid gap-1 border-t border-gray-200 pt-4">
<p className="text-base font-medium text-[var(--dls-text-primary)]">Shared setup</p>
<p className="den-copy text-sm">Bring the same skills, MCPs, and config into Den.</p>
<p className="text-base font-medium text-[var(--dls-text-primary)]">Background agents</p>
<p className="den-copy text-sm">Keep selected workflows running in the cloud. Alpha.</p>
</div>
<div className="grid gap-1 border-t border-gray-200 pt-4">
<p className="text-base font-medium text-[var(--dls-text-primary)]">Open anywhere</p>
<p className="den-copy text-sm">Reconnect from the browser or the OpenWork app.</p>
<p className="text-base font-medium text-[var(--dls-text-primary)]">Custom LLM providers</p>
<p className="den-copy text-sm">Standardize provider access for your team. Coming soon.</p>
</div>
</div>
</div>
@@ -176,8 +175,7 @@ export function AuthScreen() {
</div>
<p className="text-xs leading-5 opacity-80">
If OpenWork does not open automatically, copy the sign-in
link or one-time code and paste it into OpenWork Cloud
settings.
link or one-time code and paste it into the OpenWork desktop app.
</p>
</div>
) : null}
@@ -278,8 +276,8 @@ export function AuthScreen() {
: verificationRequired
? "Verify email"
: authMode === "sign-in"
? "Sign in"
: "Create account"}
? "Sign in to Cloud"
: "Create Cloud account"}
</button>
{verificationRequired ? (

View File

@@ -135,39 +135,41 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
return (
<section className="den-page grid gap-6 py-4 lg:py-6">
<div className="den-frame grid gap-6 p-6 md:p-8 lg:p-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex flex-col gap-4 lg:max-w-3xl">
<div className="grid gap-3">
<p className="den-eyebrow">{onboardingPending ? "Finish setup" : "Billing"}</p>
<h1 className="den-title-xl max-w-[10ch]">Choose how to run Den.</h1>
<p className="den-eyebrow">OpenWork Cloud</p>
<h1 className="den-title-xl max-w-[12ch]">Provision shared setups for your team.</h1>
<p className="den-copy max-w-2xl">
Start with hosted workers for your team, or stay local and add
Cloud later.
Share your setup across your org, launch background agents in alpha, and prepare for team-wide provider provisioning.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<span className={`den-status-pill ${billingSummary?.hasActivePlan ? "is-positive" : "is-neutral"}`}>
{billingSummary?.hasActivePlan ? "Active plan" : "Trial ready"}
</span>
<span className="den-kicker">{user?.email ?? "Signed in"}</span>
<div className="flex flex-wrap gap-3">
{checkoutHref ? (
<a href={checkoutHref} rel="noreferrer" className="den-button-primary w-full sm:w-auto">
Start free trial
</a>
) : (
<button
type="button"
className="den-button-primary w-full sm:w-auto"
onClick={() => void refreshBilling({ includeCheckout: true, quiet: false })}
disabled={billingBusy || billingCheckoutBusy}
>
Refresh trial link
</button>
)}
<a href="https://openworklabs.com/download" className="den-button-secondary w-full sm:w-auto">
Use desktop only
</a>
</div>
</div>
<div className="den-stat-grid">
<div className="den-stat-card">
<p className="den-stat-label">Cloud plan</p>
<p className="den-stat-value">{planAmountLabel}</p>
<p className="den-stat-copy">Hosted workers, team access, and billing in one place.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Subscription</p>
<p className="den-stat-value">{subscriptionStatus}</p>
<p className="den-stat-copy">{billingSummary?.hasActivePlan ? "Your workspace is ready to keep running." : `${TRIAL_DAYS}-day free trial before billing starts.`}</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Invoices</p>
<p className="den-stat-value">{billingSummary?.invoices.length ?? 0}</p>
<p className="den-stat-copy">Past billing history appears here as soon as your plan is active.</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-[var(--dls-text-secondary)]">
<span>{TRIAL_DAYS}-day free trial</span>
<span aria-hidden="true"></span>
<span>{planAmountLabel} after trial</span>
<span aria-hidden="true"></span>
<span>{user?.email ?? "Signed in"}</span>
</div>
</div>
</div>
@@ -176,93 +178,72 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
{showLoading ? <div className="den-frame-soft px-5 py-4 text-sm text-[var(--dls-text-secondary)]">Refreshing access state...</div> : null}
{billingSummary ? (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_360px]">
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_320px]">
<div className="grid gap-6">
<article className="den-frame grid gap-6 p-6 md:p-7">
<div className="grid gap-3">
<span className="den-kicker w-fit">Hosted workers</span>
<h2 className="den-title-lg">Den Cloud</h2>
<span className="den-kicker w-fit">OpenWork Cloud</span>
<h2 className="den-title-lg">Share your setup across your team.</h2>
<p className="den-copy">
Run workers that stay on, share access with your team, and
manage billing without leaving Den.
Manage your team&apos;s setup, invite teammates, and keep everything in sync.
</p>
</div>
<div className="grid gap-3 text-sm text-[var(--dls-text-secondary)]">
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />{TRIAL_DAYS}-day free trial</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />{planAmountLabel} after trial</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Hosted workers, team access, and billing controls</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Share setup across your team and org</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Background agents in alpha for selected workflows</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Custom LLM providers for teams, coming soon</div>
</div>
{checkoutHref ? (
<div className="mt-auto flex flex-wrap gap-3 pt-2">
<a
href={checkoutHref}
rel="noreferrer"
className="den-button-primary w-full sm:w-auto"
>
Start free trial
</a>
{billingSummary.portalUrl ? (
<a href={billingSummary.portalUrl} rel="noreferrer" target="_blank" className="den-button-secondary w-full sm:w-auto">
Open billing portal
</a>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="den-frame-inset rounded-[1.5rem] p-4">
<p className="den-stat-label">Background agents</p>
<p className="mt-3 text-sm text-[var(--dls-text-secondary)]">
Keep selected workflows running in the background. Alpha.
</p>
</div>
) : (
<div className="mt-auto grid gap-3 pt-2">
<div className="den-frame-inset rounded-[1.5rem] px-4 py-3 text-sm text-[var(--dls-text-secondary)]">
We are still preparing your trial link.
</div>
<button
type="button"
className="den-button-secondary w-full sm:w-auto"
onClick={() => void refreshBilling({ includeCheckout: true, quiet: false })}
disabled={billingBusy || billingCheckoutBusy}
>
Refresh trial link
</button>
<div className="den-frame-inset rounded-[1.5rem] p-4">
<p className="den-stat-label">Custom LLM providers</p>
<p className="mt-3 text-sm text-[var(--dls-text-secondary)]">
Standardize provider access for your team. Coming soon.
</p>
</div>
)}
</div>
</article>
<article className="den-frame-soft grid gap-6 p-6 md:p-7">
<article className="den-frame-soft grid gap-5 p-6 md:p-7">
<div className="grid gap-3">
<span className="den-kicker w-fit">Local-first</span>
<h2 className="den-title-lg">Desktop App</h2>
<span className="den-kicker w-fit">Desktop app</span>
<h2 className="den-title-lg">Stay local when you need to.</h2>
<p className="den-copy">
Run locally for free, keep your data on your machine, and add
Cloud when your team needs it.
Run locally for free, keep your data on your machine, and add OpenWork Cloud when your team is ready.
</p>
</div>
<div className="grid gap-3 text-sm text-[var(--dls-text-secondary)]">
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Run locally for free</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Keep data on your machine</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Add Den Cloud whenever you are ready</div>
<div className="flex gap-3"><span className="mt-2 h-1.5 w-1.5 rounded-full bg-slate-300" />Move into OpenWork Cloud later</div>
</div>
<div className="mt-auto pt-2">
<a href="/" className="den-button-secondary w-full sm:w-auto">
Download app
<a href="https://openworklabs.com/download" className="den-button-secondary w-full sm:w-auto">
Use desktop only
</a>
</div>
</article>
</div>
<aside className="den-frame-soft grid h-fit gap-5 p-6 md:p-7">
<aside className="den-frame-soft grid h-fit gap-4 p-5 md:p-6">
<div className="grid gap-2">
<p className="den-eyebrow">Billing snapshot</p>
<h2 className="den-title-lg">Keep billing tidy.</h2>
<p className="den-copy text-sm">
Track plan status, open the billing portal, and review invoices
from one place.
</p>
<p className="den-eyebrow">Billing status</p>
<h2 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">{subscriptionStatus}</h2>
<p className="den-copy text-sm">{billingSummary.hasActivePlan ? "Your Cloud plan is active." : `${TRIAL_DAYS}-day free trial before billing starts.`}</p>
</div>
<div className="den-frame-inset grid gap-3 rounded-[1.5rem] px-4 py-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-[var(--dls-text-primary)]">Plan status</span>
<span className="text-sm font-medium text-[var(--dls-text-primary)]">Plan</span>
<span className={`den-status-pill ${billingSummary.hasActivePlan ? "is-positive" : "is-neutral"}`}>
{subscriptionStatus}
</span>
@@ -278,6 +259,11 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
</div>
<div className="grid gap-3">
{checkoutHref && !billingSummary.hasActivePlan ? (
<a href={checkoutHref} rel="noreferrer" className="den-button-primary w-full">
Start free trial
</a>
) : null}
{billingSummary.portalUrl ? (
<a href={billingSummary.portalUrl} rel="noreferrer" target="_blank" className="den-button-secondary w-full">
Open billing portal
@@ -293,38 +279,14 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
</button>
</div>
<div className="den-list-shell">
<div className="px-5 py-4">
<p className="den-eyebrow">Invoices</p>
</div>
{billingSummary.invoices.length === 0 ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">
Invoices will appear here once your Cloud plan begins billing.
</div>
) : (
billingSummary.invoices.slice(0, 5).map((invoice) => (
<div key={invoice.id} className="den-list-row">
<div className="grid gap-1">
<p className="text-sm font-medium text-[var(--dls-text-primary)]">
{invoice.invoiceNumber ?? "Invoice"}
</p>
<p className="text-xs text-[var(--dls-text-secondary)]">
{invoice.createdAt ? new Date(invoice.createdAt).toLocaleDateString() : "Recent"}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--dls-text-secondary)]">
{invoice.totalAmount !== null ? formatMoneyMinor(invoice.totalAmount, invoice.currency) : "Pending"}
</span>
{invoice.invoiceUrl ? (
<a href={invoice.invoiceUrl} rel="noreferrer" target="_blank" className="den-button-ghost text-sm">
View
</a>
) : null}
</div>
</div>
))
)}
<div className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-[var(--dls-text-secondary)]">
{billingSummary.portalUrl ? (
<a href={billingSummary.portalUrl} rel="noreferrer" target="_blank" className="font-medium text-[var(--dls-text-primary)] transition hover:opacity-70">
Billing portal
</a>
) : null}
<span>Invoices {billingSummary.invoices.length > 0 ? `(${billingSummary.invoices.length})` : ""}</span>
<span>Cancel anytime</span>
</div>
</aside>
</div>

View File

@@ -558,7 +558,7 @@ export function DashboardScreen({ showSidebar = true }: { showSidebar?: boolean
<p className="text-[15px] leading-relaxed text-[var(--dls-text-secondary)]">
{billingSummary?.featureGateEnabled
? billingSummary.hasActivePlan
? "Your account has an active Den Cloud plan."
? "Your account has an active OpenWork Cloud plan."
: "Your account needs billing before the next launch."
: "Billing gates are disabled in this environment."}
</p>

View File

@@ -144,6 +144,22 @@ export function getManageMembersRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/manage-members`;
}
export function getMembersRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/members`;
}
export function getSharedSetupsRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/shared-setups`;
}
export function getBackgroundAgentsRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/background-agents`;
}
export function getCustomLlmProvidersRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/custom-llm-providers`;
}
export function parseOrgListPayload(payload: unknown): {
orgs: DenOrgSummary[];
activeOrgId: string | null;

View File

@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { getSharedSetupsRoute } from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
const EXAMPLE_AGENTS = [
{
name: "Sales follow-up agent",
status: "Active",
detail: "Source: SDR outreach setup",
},
{
name: "Renewal reminder agent",
status: "Paused",
detail: "Source: Customer success setup",
},
];
export function BackgroundAgentsScreen() {
const { orgSlug } = useOrgDashboard();
return (
<section className="den-page flex max-w-6xl flex-col gap-6 py-4 md:py-8">
<div className="den-frame grid gap-6 p-6 md:p-8 lg:p-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="grid gap-3">
<div className="flex items-center gap-3">
<p className="den-eyebrow">OpenWork Cloud</p>
<span className="den-status-pill is-neutral">Alpha</span>
</div>
<h1 className="den-title-xl max-w-[12ch]">Background agents</h1>
<p className="den-copy max-w-2xl">
Keep selected workflows running in the background.
</p>
</div>
<Link href={getSharedSetupsRoute(orgSlug)} className="den-button-secondary">
Open shared setups
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="den-stat-card md:col-span-2">
<p className="den-stat-label">How this fits</p>
<p className="den-stat-copy mt-3">
Use shared setups as the source of truth, then keep selected workflows available without asking each teammate to run them locally.
</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Status</p>
<p className="den-stat-value text-[1.5rem] md:text-[1.7rem]">Alpha</p>
<p className="den-stat-copy">Available for selected workflows while the product continues to evolve.</p>
</div>
</div>
</div>
<div className="den-list-shell">
<div className="px-5 py-5">
<p className="den-eyebrow">Example workflows</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">
Background workflows
</h2>
</div>
{EXAMPLE_AGENTS.map((agent) => (
<article key={agent.name} className="den-list-row">
<div className="grid gap-1">
<h3 className="text-base font-semibold text-[var(--dls-text-primary)]">{agent.name}</h3>
<p className="text-sm text-[var(--dls-text-secondary)]">{agent.detail}</p>
</div>
<span className="den-status-pill is-neutral">{agent.status}</span>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { OPENWORK_DOCS_URL } from "./shared-setup-data";
const UPCOMING_BENEFITS = [
"Standardize provider access across your team.",
"Keep model choices consistent across shared setups.",
"Control rollout without reconfiguring every teammate by hand.",
];
export function CustomLlmProvidersScreen() {
return (
<section className="den-page flex max-w-6xl flex-col gap-6 py-4 md:py-8">
<div className="den-frame grid gap-6 p-6 md:p-8 lg:p-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="grid gap-3">
<div className="flex items-center gap-3">
<p className="den-eyebrow">OpenWork Cloud</p>
<span className="den-status-pill is-neutral">Coming soon</span>
</div>
<h1 className="den-title-xl max-w-[12ch]">Custom LLM providers</h1>
<p className="den-copy max-w-2xl">
Standardize provider access for your team.
</p>
</div>
<a href={OPENWORK_DOCS_URL} target="_blank" rel="noreferrer" className="den-button-secondary">
Learn more
</a>
</div>
<div className="grid gap-4 md:grid-cols-3">
{UPCOMING_BENEFITS.map((benefit) => (
<div key={benefit} className="den-stat-card">
<p className="den-stat-label">Coming soon</p>
<p className="den-stat-copy mt-3">{benefit}</p>
</div>
))}
</div>
</div>
<div className="den-frame-soft grid gap-4 p-5 md:p-6">
<h2 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">What to expect</h2>
<p className="den-copy text-sm">
This page stays intentionally light for now. The goal is to make provider access easier to manage across shared setups once the feature is ready.
</p>
<div>
<Link href="/checkout" className="den-button-secondary">
Review billing
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import Link from "next/link";
import {
getBackgroundAgentsRoute,
getCustomLlmProvidersRoute,
getMembersRoute,
getSharedSetupsRoute,
} from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import { OPENWORK_DOCS_URL, useOrgTemplates } from "./shared-setup-data";
export function DashboardOverviewScreen() {
const { orgSlug, activeOrg, orgContext } = useOrgDashboard();
const { templates, busy, error } = useOrgTemplates(orgSlug);
const pendingInvitations = (orgContext?.invitations ?? []).filter((invitation) => invitation.status === "pending");
return (
<section className="den-page flex max-w-6xl flex-col gap-6 py-4 md:py-8">
<div className="den-frame grid gap-6 p-6 md:p-8 lg:p-10">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="grid gap-3">
<p className="den-eyebrow">OpenWork Cloud</p>
<h1 className="den-title-xl max-w-[12ch]">{activeOrg?.name ?? "OpenWork Cloud"}</h1>
<p className="den-copy max-w-2xl">
Manage your team&apos;s setup, invite teammates, and keep everything in sync.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={getMembersRoute(orgSlug)} className="den-button-secondary">
Add member
</Link>
<a href={OPENWORK_DOCS_URL} target="_blank" rel="noreferrer" className="den-button-secondary">
Learn how
</a>
<Link href="/checkout" className="den-button-primary">
Billing
</Link>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="den-stat-card">
<p className="den-stat-label">Members</p>
<p className="den-stat-value">{orgContext?.members.length ?? 0}</p>
<p className="den-stat-copy">People who can access this shared setup.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Shared setups</p>
<p className="den-stat-value">{templates.length}</p>
<p className="den-stat-copy">Reusable templates your team can open right away.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Pending invites</p>
<p className="den-stat-value">{pendingInvitations.length}</p>
<p className="den-stat-copy">Invitations waiting for teammates to join.</p>
</div>
</div>
</div>
{error ? <div className="den-notice is-error">{error}</div> : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="den-list-shell">
<div className="flex flex-col gap-2 px-5 py-5 md:flex-row md:items-end md:justify-between">
<div>
<p className="den-eyebrow">Recent shared setups</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">
Shared setups
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-[var(--dls-text-secondary)] md:text-right">
Create and update shared templates your team can use right away.
</p>
</div>
{busy ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">Loading shared setups...</div>
) : templates.length === 0 ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">
No shared setups yet. Create one from the OpenWork desktop app and it will appear here.
</div>
) : (
templates.slice(0, 4).map((template) => (
<article key={template.id} className="den-list-row">
<div className="grid gap-1">
<h3 className="text-base font-semibold text-[var(--dls-text-primary)]">{template.name}</h3>
<p className="text-sm text-[var(--dls-text-secondary)]">Created by {template.creator.name}</p>
<p className="text-xs text-[var(--dls-text-secondary)]">
{template.createdAt ? `Updated ${new Date(template.createdAt).toLocaleDateString()}` : "Updated recently"}
</p>
</div>
<Link href={getSharedSetupsRoute(orgSlug)} className="den-button-secondary shrink-0">
Open
</Link>
</article>
))
)}
</div>
<div className="grid h-fit gap-4">
<article className="den-frame-soft grid gap-3 p-5 md:p-6">
<div className="flex items-center justify-between gap-3">
<p className="den-eyebrow">Background agents</p>
<span className="den-status-pill is-neutral">Alpha</span>
</div>
<p className="den-copy text-sm">Keep selected workflows running in the background.</p>
<Link href={getBackgroundAgentsRoute(orgSlug)} className="den-button-secondary w-full">
Open background agents
</Link>
</article>
<article className="den-frame-soft grid gap-3 p-5 md:p-6">
<div className="flex items-center justify-between gap-3">
<p className="den-eyebrow">Custom LLM providers</p>
<span className="den-status-pill is-neutral">Soon</span>
</div>
<p className="den-copy text-sm">Standardize provider access for your team.</p>
<Link href={getCustomLlmProvidersRoute(orgSlug)} className="den-button-secondary w-full">
Learn more
</Link>
</article>
</div>
</div>
</section>
);
}

View File

@@ -180,13 +180,12 @@ export function ManageMembersScreen() {
<div className="den-frame p-6 md:p-8 lg:p-10">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<div>
<p className="den-eyebrow">Manage members</p>
<p className="den-eyebrow">OpenWork Cloud</p>
<h1 className="mt-2 text-[2.4rem] font-semibold leading-[0.95] tracking-[-0.06em] text-[var(--dls-text-primary)]">
{activeOrg.name}
Members
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-[var(--dls-text-secondary)]">
Invite people, adjust roles, and keep access clean without turning
the page into an admin maze.
Invite teammates, adjust roles, and keep access clean without turning this into an admin maze.
</p>
</div>
<div className="den-frame-inset rounded-[1.25rem] px-4 py-3 text-sm text-[var(--dls-text-secondary)]">

View File

@@ -6,8 +6,11 @@ import { useMemo, useState, type ReactNode } from "react";
import { useDenFlow } from "../../../../_providers/den-flow-provider";
import {
formatRoleLabel,
getManageMembersRoute,
getBackgroundAgentsRoute,
getCustomLlmProvidersRoute,
getMembersRoute,
getOrgDashboardRoute,
getSharedSetupsRoute,
} from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
@@ -59,7 +62,10 @@ export function OrgDashboardShell({ children }: { children: ReactNode }) {
const navItems = [
{ href: activeOrg ? getOrgDashboardRoute(activeOrg.slug) : "#", label: "Dashboard" },
{ href: activeOrg ? getManageMembersRoute(activeOrg.slug) : "#", label: "Manage members" },
{ href: activeOrg ? getSharedSetupsRoute(activeOrg.slug) : "#", label: "Shared setups" },
{ href: activeOrg ? getMembersRoute(activeOrg.slug) : "#", label: "Members" },
{ href: activeOrg ? getBackgroundAgentsRoute(activeOrg.slug) : "#", label: "Background agents", badge: "Alpha" },
{ href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#", label: "Custom LLM providers", badge: "Soon" },
{ href: "/checkout", label: "Billing" },
];
@@ -69,8 +75,8 @@ export function OrgDashboardShell({ children }: { children: ReactNode }) {
<div className="flex h-full flex-col gap-5 p-4 md:p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="den-eyebrow">OpenWork Den</p>
<p className="mt-1 text-lg font-semibold tracking-tight text-[var(--dls-text-primary)]">Workspace</p>
<p className="den-eyebrow">OpenWork Cloud</p>
<p className="mt-1 text-lg font-semibold tracking-tight text-[var(--dls-text-primary)]">Team workspace</p>
</div>
{orgBusy ? <span className="text-xs text-[var(--dls-text-secondary)]">Refreshing...</span> : null}
</div>
@@ -171,25 +177,36 @@ export function OrgDashboardShell({ children }: { children: ReactNode }) {
</div>
<nav className="grid gap-1.5">
{navItems.map((item) => {
const selected = item.href !== "#" && pathname === item.href;
const selected = item.href !== "#" && (pathname === item.href || pathname.startsWith(`${item.href}/`));
return (
<Link
key={item.label}
href={item.href}
className={`rounded-full px-4 py-3 text-sm font-medium transition ${
className={`flex items-center justify-between gap-3 rounded-full px-4 py-3 text-sm font-medium transition ${
selected
? "bg-white text-[var(--dls-text-primary)] shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_2px_0_rgba(0,0,0,0.04)]"
: "text-[var(--dls-text-secondary)] hover:text-[var(--dls-text-primary)]"
}`}
>
{item.label}
<span>{item.label}</span>
{item.badge ? <span className="den-status-pill is-neutral">{item.badge}</span> : null}
</Link>
);
})}
</nav>
</div>
<div className="mt-auto den-frame-soft grid gap-3 p-4">
<div className="mt-auto grid gap-3">
<a
href="https://openworklabs.com/docs"
target="_blank"
rel="noreferrer"
className="den-button-secondary w-full"
>
Learn how
</a>
<div className="den-frame-soft grid gap-3 p-4">
<div>
<p className="den-eyebrow">Signed in as</p>
<p className="mt-2 break-words text-sm font-medium text-[var(--dls-text-primary)]">
@@ -204,6 +221,7 @@ export function OrgDashboardShell({ children }: { children: ReactNode }) {
>
Log out
</button>
</div>
</div>
</div>
</aside>

View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
export const OPENWORK_DOCS_URL = "https://openworklabs.com/docs";
export type TemplateCard = {
id: string;
name: string;
createdAt: string | null;
creator: {
name: string;
email: string;
};
};
function asTemplateCard(value: unknown): TemplateCard | null {
if (!value || typeof value !== "object") {
return null;
}
const entry = value as Record<string, unknown>;
const creator = entry.creator && typeof entry.creator === "object"
? (entry.creator as Record<string, unknown>)
: null;
if (
typeof entry.id !== "string" ||
typeof entry.name !== "string" ||
!creator ||
typeof creator.name !== "string" ||
typeof creator.email !== "string"
) {
return null;
}
return {
id: entry.id,
name: entry.name,
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : null,
creator: {
name: creator.name,
email: creator.email,
},
};
}
export function useOrgTemplates(orgSlug: string) {
const [templates, setTemplates] = useState<TemplateCard[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function loadTemplates() {
setBusy(true);
setError(null);
try {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates`,
{ method: "GET" },
12000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load templates (${response.status}).`));
}
const list =
payload && typeof payload === "object" && Array.isArray((payload as { templates?: unknown[] }).templates)
? (payload as { templates: unknown[] }).templates
: [];
setTemplates(list.map(asTemplateCard).filter((entry): entry is TemplateCard => entry !== null));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load templates.");
} finally {
setBusy(false);
}
}
useEffect(() => {
void loadTemplates();
}, [orgSlug]);
return {
templates,
busy,
error,
reloadTemplates: loadTemplates,
};
}

View File

@@ -1,95 +1,23 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
import { getManageMembersRoute } from "../../../../_lib/den-org";
import { useState } from "react";
import { requestJson, getErrorMessage } from "../../../../_lib/den-flow";
import { getMembersRoute } from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import { OPENWORK_DOCS_URL, useOrgTemplates } from "./shared-setup-data";
type TemplateCard = {
id: string;
name: string;
createdAt: string | null;
creator: {
name: string;
email: string;
};
};
function asTemplateCard(value: unknown): TemplateCard | null {
if (!value || typeof value !== "object") {
return null;
}
const entry = value as Record<string, unknown>;
const creator = entry.creator && typeof entry.creator === "object"
? (entry.creator as Record<string, unknown>)
: null;
if (
typeof entry.id !== "string" ||
typeof entry.name !== "string" ||
!creator ||
typeof creator.name !== "string" ||
typeof creator.email !== "string"
) {
return null;
}
return {
id: entry.id,
name: entry.name,
createdAt: typeof entry.createdAt === "string" ? entry.createdAt : null,
creator: {
name: creator.name,
email: creator.email,
},
};
}
export function TemplatesDashboardScreen() {
export function SharedSetupsScreen() {
const { orgSlug, activeOrg, orgContext } = useOrgDashboard();
const [templates, setTemplates] = useState<TemplateCard[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { templates, busy, error, reloadTemplates } = useOrgTemplates(orgSlug);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const canDelete = orgContext?.currentMember.isOwner ?? false;
const pendingInvitations = useMemo(
() => (orgContext?.invitations ?? []).filter((invitation) => invitation.status === "pending"),
[orgContext?.invitations],
);
async function loadTemplates() {
setBusy(true);
setError(null);
try {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates`,
{ method: "GET" },
12000,
);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load templates (${response.status}).`));
}
const list =
payload && typeof payload === "object" && Array.isArray((payload as { templates?: unknown[] }).templates)
? (payload as { templates: unknown[] }).templates
: [];
setTemplates(list.map(asTemplateCard).filter((entry): entry is TemplateCard => entry !== null));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load templates.");
} finally {
setBusy(false);
}
}
async function deleteTemplate(templateId: string) {
setDeletingId(templateId);
setError(null);
setDeleteError(null);
try {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates/${encodeURIComponent(templateId)}`,
@@ -101,134 +29,106 @@ export function TemplatesDashboardScreen() {
throw new Error(getErrorMessage(payload, `Failed to delete template (${response.status}).`));
}
await loadTemplates();
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Failed to delete template.");
await reloadTemplates();
} catch (nextError) {
setDeleteError(nextError instanceof Error ? nextError.message : "Failed to delete template.");
} finally {
setDeletingId(null);
}
}
useEffect(() => {
void loadTemplates();
}, [orgSlug]);
return (
<section className="den-page flex max-w-6xl flex-col gap-6 py-4 md:py-8">
<div className="den-frame grid gap-6 p-6 md:p-8 lg:p-10">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="grid gap-3">
<p className="den-eyebrow">Den dashboard</p>
<h1 className="den-title-xl max-w-[11ch]">{activeOrg?.name ?? "OpenWork"}</h1>
<p className="den-eyebrow">OpenWork Cloud</p>
<h1 className="den-title-xl max-w-[12ch]">Shared setups</h1>
<p className="den-copy max-w-2xl">
Share setup once, keep team access tidy, and manage the links your
organization actually uses.
Create and update shared templates your team can use right away.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={getManageMembersRoute(orgSlug)} className="den-button-secondary">
Manage members
</Link>
<Link href="/checkout" className="den-button-primary">
Billing
<a href={OPENWORK_DOCS_URL} target="_blank" rel="noreferrer" className="den-button-secondary">
Learn how
</a>
<Link href={getMembersRoute(orgSlug)} className="den-button-secondary">
Members
</Link>
<a href="https://openworklabs.com/download" className="den-button-primary">
Use desktop app
</a>
</div>
</div>
<div className="den-stat-grid">
<div className="grid gap-4 md:grid-cols-3">
<div className="den-stat-card">
<p className="den-stat-label">Organization</p>
<p className="den-stat-value text-[1.4rem] md:text-[1.6rem]">{activeOrg?.name ?? "OpenWork"}</p>
<p className="den-stat-copy">Shared templates stay available to this team.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Templates</p>
<p className="den-stat-value">{templates.length}</p>
<p className="den-stat-copy">Current shared setups created from the desktop app.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Members</p>
<p className="den-stat-value">{orgContext?.members.length ?? 0}</p>
<p className="den-stat-copy">Everyone who can access this workspace.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Pending invites</p>
<p className="den-stat-value">{pendingInvitations.length}</p>
<p className="den-stat-copy">Invites waiting to be accepted.</p>
</div>
<div className="den-stat-card">
<p className="den-stat-label">Shared links</p>
<p className="den-stat-value">{templates.length}</p>
<p className="den-stat-copy">Setup packages created from the desktop app.</p>
<p className="den-stat-copy">Teammates who can use these shared templates.</p>
</div>
</div>
</div>
{error ? <div className="den-notice is-error">{error}</div> : null}
{deleteError ? <div className="den-notice is-error">{deleteError}</div> : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="den-list-shell">
<div className="flex flex-col gap-2 px-5 py-5 md:flex-row md:items-end md:justify-between">
<div>
<p className="den-eyebrow">Shared setup links</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">
Current workspace templates
</h2>
</div>
<p className="max-w-sm text-sm leading-relaxed text-[var(--dls-text-secondary)] md:text-right">
Create new links from the OpenWork desktop app. Keep only the
setups your team still needs.
</p>
<div className="den-list-shell">
<div className="flex flex-col gap-2 px-5 py-5 md:flex-row md:items-end md:justify-between">
<div>
<p className="den-eyebrow">Template library</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">
Team templates
</h2>
</div>
{busy ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">Loading templates...</div>
) : templates.length === 0 ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">
No shared links yet. Create one from the desktop app and it will
appear here.
</div>
) : (
templates.map((template) => (
<article key={template.id} className="den-list-row">
<div className="grid gap-1">
<h3 className="text-base font-semibold text-[var(--dls-text-primary)]">{template.name}</h3>
<p className="text-sm text-[var(--dls-text-secondary)]">
Created by {template.creator.name} · {template.creator.email}
</p>
<p className="text-xs text-[var(--dls-text-secondary)]">
{template.createdAt ? `Created ${new Date(template.createdAt).toLocaleString()}` : "Created recently"}
</p>
</div>
{canDelete ? (
<button
type="button"
className="den-button-danger shrink-0"
onClick={() => void deleteTemplate(template.id)}
disabled={deletingId === template.id}
>
{deletingId === template.id ? "Deleting..." : "Delete"}
</button>
) : null}
</article>
))
)}
<p className="max-w-sm text-sm leading-relaxed text-[var(--dls-text-secondary)] md:text-right">
Review what your team is sharing now, and remove templates that are no longer current.
</p>
</div>
<aside className="den-frame-soft grid h-fit gap-4 p-5 md:p-6">
<div className="grid gap-2">
<p className="den-eyebrow">Quick actions</p>
<h2 className="text-2xl font-semibold tracking-tight text-[var(--dls-text-primary)]">
Keep the workspace moving.
</h2>
<p className="den-copy text-sm">
Invite teammates, review billing, or clean up old links without
leaving this area.
</p>
{busy ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">Loading shared setups...</div>
) : templates.length === 0 ? (
<div className="den-list-row text-sm text-[var(--dls-text-secondary)]">
No shared setups yet. Create one from the OpenWork desktop app and it will appear here.
</div>
) : (
templates.map((template) => (
<article key={template.id} className="den-list-row">
<div className="grid gap-1">
<h3 className="text-base font-semibold text-[var(--dls-text-primary)]">{template.name}</h3>
<p className="text-sm text-[var(--dls-text-secondary)]">
Created by {template.creator.name} · {template.creator.email}
</p>
<p className="text-xs text-[var(--dls-text-secondary)]">
{template.createdAt ? `Updated ${new Date(template.createdAt).toLocaleString()}` : "Updated recently"}
</p>
</div>
<Link href={getManageMembersRoute(orgSlug)} className="den-button-secondary w-full">
Add or edit members
</Link>
<Link href="/checkout" className="den-button-secondary w-full">
Open billing
</Link>
<a href="https://openworklabs.com/download" className="den-button-secondary w-full">
Download desktop app
</a>
</aside>
{canDelete ? (
<button
type="button"
className="den-button-danger shrink-0"
onClick={() => void deleteTemplate(template.id)}
disabled={deletingId === template.id}
>
{deletingId === template.id ? "Deleting..." : "Delete"}
</button>
) : null}
</article>
))
)}
</div>
</section>
);

View File

@@ -0,0 +1,5 @@
import { BackgroundAgentsScreen } from "../_components/background-agents-screen";
export default function BackgroundAgentsPage() {
return <BackgroundAgentsScreen />;
}

View File

@@ -0,0 +1,5 @@
import { CustomLlmProvidersScreen } from "../_components/custom-llm-providers-screen";
export default function CustomLlmProvidersPage() {
return <CustomLlmProvidersScreen />;
}

View File

@@ -1,5 +1,9 @@
import { ManageMembersScreen } from "../_components/manage-members-screen";
import { redirect } from "next/navigation";
export default function ManageMembersPage() {
return <ManageMembersScreen />;
export default function ManageMembersRedirectPage({
params,
}: {
params: { orgSlug: string };
}) {
redirect(`/o/${encodeURIComponent(params.orgSlug)}/dashboard/members`);
}

View File

@@ -0,0 +1,5 @@
import { ManageMembersScreen } from "../_components/manage-members-screen";
export default function MembersPage() {
return <ManageMembersScreen />;
}

View File

@@ -1,7 +1,5 @@
// import { DashboardScreen } from "../../../_components/dashboard-screen";
import { TemplatesDashboardScreen } from "./_components/templates-dashboard-screen";
import { DashboardOverviewScreen } from "./_components/dashboard-overview-screen";
export default function OrgDashboardPage() {
// return <DashboardScreen showSidebar={false} />;
return <TemplatesDashboardScreen />;
return <DashboardOverviewScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SharedSetupsScreen } from "../_components/templates-dashboard-screen";
export default function SharedSetupsPage() {
return <SharedSetupsScreen />;
}

View File

@@ -18,20 +18,20 @@ const ibmPlexMono = IBM_Plex_Mono({
export const metadata: Metadata = {
metadataBase: new URL("https://app.openworklabs.com"),
title: "Den Cloud Workers",
title: "OpenWork Cloud",
description:
"Launch OpenWork cloud workers, manage Polar checkout flows, and run Den from app.openworklabs.com with the same positioning as the landing page.",
"Share your OpenWork setup with your team, manage billing, and use OpenWork Cloud from app.openworklabs.com.",
openGraph: {
title: "Den Cloud Workers",
title: "OpenWork Cloud",
description:
"Always-on AI workers for you and your team, launched from app.openworklabs.com.",
"Share your OpenWork setup with your team and keep selected workflows available in OpenWork Cloud.",
images: ["/opengraph-image"]
},
twitter: {
card: "summary_large_image",
title: "Den Cloud Workers",
title: "OpenWork Cloud",
description:
"Launch OpenWork cloud workers, manage Polar checkout flows, and operate Den from app.openworklabs.com.",
"Share your OpenWork setup with your team and manage OpenWork Cloud from app.openworklabs.com.",
images: ["/opengraph-image"]
},
icons: {

View File

@@ -1,6 +1,6 @@
import { ImageResponse } from "next/og";
export const alt = "Den Cloud Workers";
export const alt = "OpenWork Cloud";
export const size = {
width: 1200,
height: 630
@@ -74,19 +74,19 @@ export default function OpenGraphImage() {
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ fontSize: 16, fontWeight: 700, textTransform: "uppercase", letterSpacing: 3, color: "#64748b" }}>
OpenWork hosted
OpenWork Cloud
</div>
<div style={{ fontSize: 24, fontWeight: 600 }}>Den Cloud Workers</div>
<div style={{ fontSize: 24, fontWeight: 600 }}>OpenWork Cloud</div>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10, fontSize: 64, fontWeight: 600, letterSpacing: -2.8, lineHeight: 0.98 }}>
<div>Always-on AI workers</div>
<div>for you and your team.</div>
<div>Share your setup</div>
<div>with your team.</div>
</div>
<div style={{ fontSize: 24, lineHeight: 1.45, color: "#475569", display: "flex", maxWidth: 520 }}>
Launch cloud workers, manage Polar checkout, and operate Den directly from app.openworklabs.com.
Share setups across your org, keep selected workflows available, and manage OpenWork Cloud from app.openworklabs.com.
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>