chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,337 @@
[data-page="workspace"] {
line-height: 1;
}
/* Workspace Layout */
[data-component="workspace-container"] {
display: flex;
height: calc(100vh - 73px);
}
[data-component="workspace-nav"] {
width: 240px;
flex-shrink: 0;
padding: var(--space-6) var(--space-4);
display: flex;
justify-content: flex-end;
}
/* Desktop Navigation */
[data-component="nav-desktop"] {
display: block;
@media (max-width: 48rem) {
display: none;
}
[data-component="workspace-nav-items"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-nav-button] {
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-sm);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.15s ease;
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-text);
font-weight: 700;
position: relative;
&::before {
content: "";
position: absolute;
left: calc(-1 * var(--space-0-5));
top: 0;
bottom: 0;
width: 2px;
background-color: var(--color-text);
border-radius: 0 2px 2px 0;
}
}
}
}
}
/* Mobile Navigation */
[data-component="nav-mobile"] {
display: none;
@media (max-width: 48rem) {
display: flex;
align-items: stretch;
justify-content: flex-start;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-component="workspace-nav-items"] {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-1);
min-width: max-content;
height: 100%;
[data-nav-button] {
padding: var(--space-2) var(--space-3);
padding-bottom: calc(var(--space-2) + 4px);
border-radius: var(--border-radius-sm);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
position: relative;
&:hover {
color: var(--color-text);
}
&.active {
color: var(--color-text);
font-weight: 700;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--color-text);
border-radius: 2px 2px 0 0;
}
}
}
}
}
[data-component="workspace-content"] {
flex: 1;
padding: var(--space-6) var(--space-8);
overflow-y: auto;
display: flex;
flex-direction: column;
@media (max-width: 48rem) {
padding: var(--space-6) var(--space-4);
}
}
[data-component="workspace-main"] {
flex: 1;
}
[data-component="workspace-content"] > [data-component="legal"] {
margin-top: var(--space-16);
padding-top: var(--space-8);
border-top: 1px solid var(--color-border);
color: var(--color-text-weak);
display: flex;
gap: var(--space-8);
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
@media (max-width: 48rem) {
gap: var(--space-4);
}
}
[data-page="workspace-[id]"] {
max-width: 64rem;
padding: var(--space-2) var(--space-4);
margin: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-10);
@media (max-width: 30rem) {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
gap: var(--space-8);
}
[data-slot="sections"] {
display: flex;
flex-direction: column;
gap: var(--space-16);
@media (max-width: 30rem) {
gap: var(--space-8);
}
section {
display: flex;
flex-direction: column;
gap: var(--space-8);
@media (max-width: 30rem) {
gap: var(--space-6);
}
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text);
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
p {
line-height: 1.5;
font-size: var(--font-size-md);
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
}
@media (max-width: 30rem) {
font-size: var(--font-size-sm);
}
}
}
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-8);
}
}
section:not(:last-child) {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-16);
@media (max-width: 30rem) {
padding-bottom: var(--space-8);
}
}
}
/* Title section */
[data-component="header-section"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border);
@media (max-width: 30rem) {
padding-bottom: var(--space-6);
}
h1 {
font-size: var(--font-size-2xl);
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-xl);
}
}
p {
line-height: 1.5;
font-size: var(--font-size-md);
color: var(--color-text);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
@media (max-width: 48rem) {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
a {
color: var(--color-text-muted);
}
[data-slot="billing-info"] {
flex-shrink: 0;
margin-left: auto;
}
[data-slot="balance"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
b {
font-weight: 600;
color: var(--color-text);
}
}
}
}
}
@media (max-width: 48rem) {
[data-component="workspace-container"] {
flex-direction: column;
}
[data-component="workspace-nav"] {
width: 100%;
flex-direction: row;
border-right: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-4);
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
min-height: fit-content;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
}

View File

@@ -0,0 +1,80 @@
import { Show } from "solid-js"
import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import { querySessionInfo } from "./common"
import "./[id].css"
import { useI18n } from "~/context/i18n"
import { Legal } from "~/component/legal"
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const i18n = useI18n()
const userInfo = createAsync(() => querySessionInfo(params.id!))
return (
<main data-page="workspace">
<div data-component="workspace-container">
<nav data-component="workspace-nav">
<nav data-component="nav-desktop">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
{i18n.t("workspace.nav.zen")}
</A>
<A href={`/workspace/${params.id}/go`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.go")}
</A>
<A href={`/workspace/${params.id}/usage`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.usage")}
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.apiKeys")}
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.members")}
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.billing")}
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.settings")}
</A>
</Show>
</div>
</nav>
<nav data-component="nav-mobile">
<div data-component="workspace-nav-items">
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
{i18n.t("workspace.nav.zen")}
</A>
<A href={`/workspace/${params.id}/go`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.go")}
</A>
<A href={`/workspace/${params.id}/usage`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.usage")}
</A>
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.apiKeys")}
</A>
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.members")}
</A>
<Show when={userInfo()?.isAdmin}>
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.billing")}
</A>
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
{i18n.t("workspace.nav.settings")}
</A>
</Show>
</div>
</nav>
</nav>
<div data-component="workspace-content">
<div data-component="workspace-main">{props.children}</div>
<Legal />
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,185 @@
.root {
[data-slot="reload-error"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
p {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin: 0;
flex: 1;
}
[data-slot="create-form"] {
display: flex;
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
}
}
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="balance-display"] {
display: flex;
align-items: flex-start;
gap: var(--space-3);
@media (max-width: 30rem) {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
[data-slot="balance-amount"] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
align-self: stretch;
[data-slot="balance-label"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin-top: var(--space-2);
font-weight: 400;
}
[data-slot="balance-value"] {
font-size: var(--font-size-2xl);
font-weight: 600;
color: var(--color-text);
}
}
[data-slot="balance-right-section"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
flex: 1;
}
[data-slot="add-balance-form-container"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="add-balance-form"] {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-3);
label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
white-space: nowrap;
}
input[data-component="input"] {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
[data-slot="credit-card"] {
padding: var(--space-2) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 150px;
align-self: flex-start;
[data-slot="card-icon"] {
display: flex;
align-items: center;
color: var(--color-text-muted);
}
[data-slot="card-details"] {
display: flex;
align-items: baseline;
gap: var(--space-1);
flex: 1;
justify-content: flex-end;
[data-slot="secret"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="number"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
}
[data-slot="type"] {
font-size: var(--font-size-sm);
font-weight: 400;
color: var(--color-text-muted);
}
}
button {
white-space: nowrap;
flex-shrink: 0;
}
}
button {
align-self: flex-start;
white-space: nowrap;
flex-shrink: 0;
}
}
[data-slot="enable-billing-button"] {
align-self: flex-start;
padding: var(--space-4);
min-width: 150px;
}
}

View File

@@ -0,0 +1,266 @@
import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router"
import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { localizeError } from "~/lib/form-error"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)
}, "sessionUrl")
export function BillingSection() {
const params = useParams()
const i18n = useI18n()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
const checkoutSubmission = useSubmission(createCheckoutUrl)
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const [store, setStore] = createStore({
showAddBalanceForm: false,
addBalanceAmount: billingInfo()?.reloadAmount.toString() ?? "",
checkoutRedirecting: false,
sessionRedirecting: false,
})
createEffect(() => {
const info = billingInfo()
if (info) {
setStore("addBalanceAmount", info.reloadAmount.toString())
}
})
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
async function onClickCheckout() {
const amount = parseInt(store.addBalanceAmount)
const baseUrl = window.location.href
const checkout = await checkoutAction(params.id!, amount, baseUrl, baseUrl)
if (checkout && checkout.data) {
setStore("checkoutRedirecting", true)
window.location.href = checkout.data
}
}
async function onClickSession() {
const baseUrl = window.location.href
const sessionUrl = await sessionAction(params.id!, baseUrl)
if (sessionUrl && sessionUrl.data) {
setStore("sessionRedirecting", true)
window.location.href = sessionUrl.data
}
}
function showAddBalanceForm() {
while (true) {
checkoutSubmission.clear()
if (!checkoutSubmission.result) break
}
setStore({
showAddBalanceForm: true,
})
}
function hideAddBalanceForm() {
setStore("showAddBalanceForm", false)
checkoutSubmission.clear()
}
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
// Scenario 1: User has not added billing details and has no balance
// const balanceInfo = () => ({
// balance: 0,
// paymentMethodType: null as string | null,
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
// timeReloadError: null as Date | null,
// })
// Scenario 2: User has not added billing details but has a balance
// const balanceInfo = () => ({
// balance: 1500000000, // $15.00
// paymentMethodType: null as string | null,
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
// Scenario 3: User has added billing details (reload enabled)
// const balanceInfo = () => ({
// balance: 750000000, // $7.50
// paymentMethodType: "card",
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
// Scenario 4: User has billing details but reload failed
// const balanceInfo = () => ({
// balance: 250000000, // $2.50
// paymentMethodType: "card",
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: "Your card was declined." as string,
// timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
// })
// Scenario 5: User has Link payment method
// const balanceInfo = () => ({
// balance: 500000000, // $5.00
// paymentMethodType: "link",
// paymentMethodLast4: null as string | null,
// reload: true,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.billing.title")}</h2>
<p>
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
{i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
<div data-slot="section-content">
<div data-slot="balance-display">
<div data-slot="balance-amount">
<span data-slot="balance-value">${balance()}</span>
<span data-slot="balance-label">{i18n.t("workspace.billing.currentBalance")}</span>
</div>
<Show when={billingInfo()?.customerID}>
<div data-slot="balance-right-section">
<Show
when={!store.showAddBalanceForm}
fallback={
<div data-slot="add-balance-form-container">
<div data-slot="add-balance-form">
<label>{i18n.t("workspace.billing.add")}</label>
<input
data-component="input"
type="number"
min={billingInfo()?.reloadAmountMin.toString()}
step="1"
value={store.addBalanceAmount}
onInput={(e) => {
setStore("addBalanceAmount", e.currentTarget.value)
checkoutSubmission.clear()
}}
placeholder={i18n.t("workspace.billing.enterAmount")}
/>
<div data-slot="form-actions">
<button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
{i18n.t("common.cancel")}
</button>
<button
data-color="primary"
type="button"
disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting
? i18n.t("workspace.billing.loading")
: i18n.t("workspace.billing.addAction")}
</button>
</div>
</div>
<Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}>
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
}
>
<button data-color="primary" onClick={() => showAddBalanceForm()}>
{i18n.t("workspace.billing.addBalance")}
</button>
</Show>
<div data-slot="credit-card">
<div data-slot="card-icon">
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
<Match when={billingInfo()?.paymentMethodType === "link"}>
<IconStripe style={{ width: "24px", height: "24px" }} />
</Match>
<Match when={billingInfo()?.paymentMethodType === "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Match>
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<IconWechat style={{ width: "24px", height: "24px" }} />
</Match>
<Match when={billingInfo()?.paymentMethodType === "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">
<Switch>
<Match when={billingInfo()?.paymentMethodType === "card"}>
<Show when={billingInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
</Show>
</Match>
<Match when={billingInfo()?.paymentMethodType === "link"}>
<span data-slot="type">{i18n.t("workspace.billing.linkedToStripe")}</span>
</Match>
<Match when={billingInfo()?.paymentMethodType === "alipay"}>
<span data-slot="type">{i18n.t("workspace.billing.alipay")}</span>
</Match>
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<span data-slot="type">{i18n.t("workspace.billing.wechat")}</span>
</Match>
</Switch>
</div>
<button
data-color="ghost"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting
? i18n.t("workspace.billing.loading")
: i18n.t("workspace.billing.manage")}
</button>
</div>
</div>
</Show>
</div>
<Show when={!billingInfo()?.customerID}>
<button
data-slot="enable-billing-button"
data-color="primary"
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting
? i18n.t("workspace.billing.loading")
: i18n.t("workspace.billing.enable")}
</button>
</Show>
</div>
</section>
)
}

View File

@@ -0,0 +1,142 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-slot="usage"] {
display: flex;
gap: var(--space-6);
margin-top: var(--space-4);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-4);
}
}
[data-slot="usage-item"] {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-header"] {
display: flex;
justify-content: space-between;
align-items: baseline;
}
[data-slot="usage-label"] {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text);
}
[data-slot="usage-value"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="progress"] {
height: 8px;
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
[data-slot="progress-bar"] {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width 0.3s ease;
}
[data-slot="reset-time"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="setting-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
margin: 0;
}
}
[data-slot="toggle-label"] {
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -0,0 +1,290 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { Show } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./black-section.module.css"
import waitlistStyles from "./black-waitlist-section.module.css"
import { useI18n } from "~/context/i18n"
import { formError } from "~/lib/form-error"
const querySubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const row = await Database.use((tx) =>
tx
.select({
rollingUsage: SubscriptionTable.rollingUsage,
fixedUsage: SubscriptionTable.fixedUsage,
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
subscription: BillingTable.subscription,
})
.from(BillingTable)
.innerJoin(SubscriptionTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID))
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
.then((r) => r[0]),
)
if (!row?.subscription) return null
const blackData = BlackData.getLimits({ plan: row.subscription.plan })
return {
plan: row.subscription.plan,
useBalance: row.subscription.useBalance ?? false,
rollingUsage: Subscription.analyzeRollingUsage({
limit: blackData.rollingLimit,
window: blackData.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weeklyUsage: Subscription.analyzeWeeklyUsage({
limit: blackData.fixedLimit,
usage: row.fixedUsage ?? 0,
timeUpdated: row.timeFixedUpdated ?? new Date(),
}),
}
}, workspaceID)
}, "subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.black.time.day") : i18n.t("workspace.black.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.black.time.hour") : i18n.t("workspace.black.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.black.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.black.time.minute") : i18n.t("workspace.black.time.minutes")}`
}
const cancelWaitlist = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscriptionPlan: null,
timeSubscriptionBooked: null,
timeSubscriptionSelected: null,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "cancelWaitlist")
const enroll = action(async (workspaceID: string) => {
"use server"
return json(
await withActor(async () => {
await Billing.subscribeBlack({ seats: 1 })
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "enroll")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "sessionUrl")
const setUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = (form.get("useBalance") as string | null) === "true"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
subscription: useBalance
? sql`JSON_SET(subscription, '$.useBalance', true)`
: sql`JSON_REMOVE(subscription, '$.useBalance')`,
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, querySubscription.key] },
)
}, "setUseBalance")
export function BlackSection() {
const params = useParams()
const i18n = useI18n()
const billing = createAsync(() => queryBillingInfo(params.id!))
const subscription = createAsync(() => querySubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const cancelAction = useAction(cancelWaitlist)
const cancelSubmission = useSubmission(cancelWaitlist)
const enrollAction = useAction(enroll)
const enrollSubmission = useSubmission(enroll)
const useBalanceSubmission = useSubmission(setUseBalance)
const [store, setStore] = createStore({
sessionRedirecting: false,
cancelled: false,
enrolled: false,
})
async function onClickSession() {
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("sessionRedirecting", true)
window.location.href = result.data
}
}
async function onClickCancel() {
const result = await cancelAction(params.id!)
if (!result.error) {
setStore("cancelled", true)
}
}
async function onClickEnroll() {
const result = await enrollAction(params.id!)
if (!result.error) {
setStore("enrolled", true)
}
}
return (
<>
<Show when={subscription()}>
{(sub) => (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.black.subscription.title")}</h2>
<div data-slot="title-row">
<p>{i18n.t("workspace.black.subscription.message", { plan: sub().plan })}</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.sessionRedirecting
? i18n.t("workspace.black.loading")
: i18n.t("workspace.black.subscription.manage")}
</button>
</div>
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.black.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.black.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.black.subscription.resetsIn")}{" "}
{formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
</div>
<form action={setUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.black.subscription.useBalance")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().useBalance}
disabled={useBalanceSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</section>
)}
</Show>
<Show when={billing()?.timeSubscriptionBooked}>
<section class={waitlistStyles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.black.waitlist.title")}</h2>
<div data-slot="title-row">
<p>
{billing()?.timeSubscriptionSelected
? i18n.t("workspace.black.waitlist.ready", { plan: billing()?.subscriptionPlan ?? "" })
: i18n.t("workspace.black.waitlist.joined", { plan: billing()?.subscriptionPlan ?? "" })}
</p>
<button
data-color="danger"
disabled={cancelSubmission.pending || store.cancelled}
onClick={onClickCancel}
>
{cancelSubmission.pending
? i18n.t("workspace.black.waitlist.leaving")
: store.cancelled
? i18n.t("workspace.black.waitlist.left")
: i18n.t("workspace.black.waitlist.leave")}
</button>
</div>
</div>
<Show when={billing()?.timeSubscriptionSelected}>
<div data-slot="enroll-section">
<button
data-slot="enroll-button"
data-color="primary"
disabled={enrollSubmission.pending || store.enrolled}
onClick={onClickEnroll}
>
{enrollSubmission.pending
? i18n.t("workspace.black.waitlist.enrolling")
: store.enrolled
? i18n.t("workspace.black.waitlist.enrolled")
: i18n.t("workspace.black.waitlist.enroll")}
</button>
<p data-slot="enroll-note">{i18n.t("workspace.black.waitlist.enrollNote")}</p>
</div>
</Show>
</section>
</Show>
</>
)
}

View File

@@ -0,0 +1,23 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-slot="enroll-section"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="enroll-button"] {
align-self: flex-start;
}
[data-slot="enroll-note"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}

View File

@@ -0,0 +1,35 @@
import { MonthlyLimitSection } from "./monthly-limit-section"
import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
import { RedeemSection } from "./redeem-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const sessionInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={sessionInfo()?.isAdmin}>
<Show when={isBlack()}>
<BlackSection />
</Show>
<BillingSection />
<RedeemSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />
<MonthlyLimitSection />
<PaymentSection />
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
.root {
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 15rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
[data-slot="amount"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: baseline;
gap: var(--space-1);
justify-content: flex-end;
[data-slot="currency"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="value"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-1);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}
[data-slot="usage-status"] {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.4;
}
}

View File

@@ -0,0 +1,145 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import styles from "./monthly-limit-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit") as string | null
if (!limit) return { error: formError.limitRequired }
const numericLimit = parseInt(limit)
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
Billing.setMonthlyLimit(numericLimit)
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)
}, "billing.setMonthlyLimit")
export function MonthlyLimitSection() {
const params = useParams()
const i18n = useI18n()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.monthlyLimit.title")}</h2>
<p>{i18n.t("workspace.monthlyLimit.subtitle")}</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
{billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
fallback={
<form action={setMonthlyLimit} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
required
ref={(r) => (input = r)}
data-component="input"
name="limit"
type="number"
placeholder={i18n.t("workspace.monthlyLimit.placeholder")}
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending
? i18n.t("workspace.monthlyLimit.setting")
: i18n.t("workspace.monthlyLimit.set")}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
{billingInfo()?.monthlyLimit
? i18n.t("workspace.monthlyLimit.edit")
: i18n.t("workspace.monthlyLimit.set")}
</button>
</Show>
</div>
<Show
when={billingInfo()?.monthlyLimit}
fallback={<p data-slot="usage-status">{i18n.t("workspace.monthlyLimit.noLimit")}</p>}
>
<p data-slot="usage-status">
{i18n.t("workspace.monthlyLimit.currentUsage.beforeMonth")}{" "}
{new Date().toLocaleDateString(undefined, { month: "long", timeZone: "UTC" })}{" "}
{i18n.t("workspace.monthlyLimit.currentUsage.beforeAmount")}
{(() => {
const dateLastUsed = billingInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
</Show>
</div>
</section>
)
}

View File

@@ -0,0 +1,93 @@
.root {
[data-slot="payments-table"] {
overflow-x: auto;
}
[data-slot="payments-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="payment-date"] {
color: var(--color-text);
}
&[data-slot="payment-id"] {
font-family: var(--font-mono);
font-weight: 400;
color: var(--color-text-muted);
max-width: 200px;
word-break: break-word;
}
&[data-slot="payment-amount"] {
color: var(--color-text);
&[data-refunded="true"] {
text-decoration: line-through;
}
}
&[data-slot="payment-receipt"] {
span {
display: inline-block;
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
line-height: 1.5;
}
button {
font-size: var(--font-size-sm);
}
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2)
/* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2)
/* Payment ID */ {
display: none;
}
}
}
}
}

View File

@@ -0,0 +1,141 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
import { For, Match, Show, Switch } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
function money(amount: number, currency?: string) {
const formatter =
currency === "inr"
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
return formatter.format(amount / 100_000_000)
}
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.payments()
}, workspaceID)
}, "payment.list")
const downloadReceipt = action(async (workspaceID: string, paymentID: string) => {
"use server"
return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID)
}, "receipt.download")
export function PaymentSection() {
const params = useParams()
const i18n = useI18n()
const payments = createAsync(() => getPaymentsInfo(params.id!))
const downloadReceiptAction = useAction(downloadReceipt)
// DUMMY DATA FOR TESTING
// const payments = () => [
// {
// id: "pi_3QK1x2FT9vXn4A6r1234567890",
// paymentID: "pi_3QK1x2FT9vXn4A6r1234567890",
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago
// amount: 2100000000, // $21.00 ($20 + $1 fee)
// },
// {
// id: "pi_3QJ8k7FT9vXn4A6r0987654321",
// paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321",
// timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QI5m1FT9vXn4A6r5678901234",
// paymentID: "pi_3QI5m1FT9vXn4A6r5678901234",
// timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QH2n9FT9vXn4A6r3456789012",
// paymentID: "pi_3QH2n9FT9vXn4A6r3456789012",
// timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QG7p4FT9vXn4A6r7890123456",
// paymentID: "pi_3QG7p4FT9vXn4A6r7890123456",
// timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago
// amount: 2100000000, // $21.00
// },
// ]
return (
<Show when={payments() && payments()!.length > 0}>
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.payments.title")}</h2>
<p>{i18n.t("workspace.payments.subtitle")}</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.payments.table.date")}</th>
<th>{i18n.t("workspace.payments.table.paymentId")}</th>
<th>{i18n.t("workspace.payments.table.amount")}</th>
<th>{i18n.t("workspace.payments.table.receipt")}</th>
</tr>
</thead>
<tbody>
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
const currency =
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
? payment.enrichment.currency
: undefined
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
{money(amount, currency)}
<Switch>
<Match when={payment.enrichment?.type === "credit"}>
{" "}
({i18n.t("workspace.payments.type.credit")})
</Match>
<Match when={payment.enrichment?.type === "subscription"}>
({i18n.t("workspace.payments.type.subscription")})
</Match>
</Switch>
</td>
<td data-slot="payment-receipt">
{payment.paymentID ? (
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
>
{i18n.t("workspace.payments.view")}
</button>
) : (
<span>-</span>
)}
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</section>
</Show>
)
}

View File

@@ -0,0 +1,61 @@
.root {
[data-slot="redeem-container"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
min-width: 20rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
}
[data-slot="redeem-form"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-slot="input-row"] {
display: flex;
gap: var(--space-2);
align-items: stretch;
@media (max-width: 30rem) {
flex-direction: column;
}
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
[data-slot="form-success"] {
color: var(--color-success, var(--color-accent));
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}

View File

@@ -0,0 +1,71 @@
import { json, action, useParams, useSubmission } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./redeem-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const redeem = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const code = (form.get("code") as string | null)?.trim().toUpperCase()
if (!code) return { error: "Coupon code is required." }
if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
return json(
await withActor(async () => {
const actor = Actor.assert("user")
const email = await User.getAuthEmail(actor.properties.userID)
if (!email) return { error: "No email on account." }
return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
.then(() => ({ error: undefined, data: true }))
.catch((e) => ({ error: e.message as string }))
}, workspaceID),
{ revalidate: queryBillingInfo.key },
)
}, "billing.redeemCoupon")
export function RedeemSection() {
const params = useParams()
const i18n = useI18n()
const submission = useSubmission(redeem)
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.redeem.title")}</h2>
<p>{i18n.t("workspace.redeem.subtitle")}</p>
</div>
<div data-slot="redeem-container">
<form action={redeem} method="post" data-slot="redeem-form">
<div data-slot="input-row">
<input
required
data-component="input"
name="code"
type="text"
autocomplete="off"
placeholder={i18n.t("workspace.redeem.placeholder")}
/>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
</button>
</div>
<Show when={submission.result && (submission.result as any).error}>
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
<div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
</form>
</div>
</section>
)
}

View File

@@ -0,0 +1,261 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="setting-row"] {
display: flex;
align-items: center;
gap: var(--space-3);
p {
flex: 1;
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
margin: 0;
b {
font-weight: 600;
}
}
[data-slot="create-form"] {
margin: 0;
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
margin-top: var(--space-4);
[data-slot="form-field"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
label {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="field-label"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
}
[data-slot="toggle-container"] {
display: flex;
align-items: center;
}
input[data-component="input"] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
}
[data-slot="input-row"] {
display: flex;
flex-direction: row;
gap: var(--space-3);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-2);
}
}
[data-slot="input-field"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
p {
line-height: 1.2;
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
input[data-component="input"] {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
min-width: 0;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::placeholder {
color: var(--color-text-disabled);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--color-bg-surface);
}
}
[data-slot="field-with-connector"] {
display: flex;
align-items: center;
gap: var(--space-2);
[data-slot="field-connector"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
white-space: nowrap;
}
input[data-component="input"] {
flex: 1;
min-width: 80px;
}
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
margin-top: var(--space-1);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin-top: calc(var(--space-1) * -1);
}
[data-slot="model-toggle-label"] {
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
}
[data-slot="reload-error"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-top: var(--space-4);
p {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin: 0;
flex: 1;
}
[data-slot="create-form"] {
display: flex;
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
padding: 0;
border: none;
}
}
}

View File

@@ -0,0 +1,220 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./reload-section.module.css"
import { queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localizeError } from "~/lib/form-error"
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Billing.reload(), workspaceID), {
revalidate: queryBillingInfo.key,
})
}, "billing.reload")
const setReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const reloadValue = (form.get("reload") as string | null) === "true"
const amountStr = form.get("reloadAmount") as string | null
const triggerStr = form.get("reloadTrigger") as string | null
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
if (reloadValue) {
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
return { error: formErrorReloadAmountMin(Billing.RELOAD_AMOUNT_MIN) }
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
return { error: formErrorReloadTriggerMin(Billing.RELOAD_TRIGGER_MIN) }
}
return json(
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: reloadValue,
...(reloadAmount !== null ? { reloadAmount } : {}),
...(reloadTrigger !== null ? { reloadTrigger } : {}),
...(reloadValue
? {
reloadError: null,
timeReloadError: null,
}
: {}),
})
.where(eq(BillingTable.workspaceID, workspaceID)),
),
{ revalidate: queryBillingInfo.key },
)
}, "billing.setReload")
export function ReloadSection() {
const params = useParams()
const i18n = useI18n()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
const [store, setStore] = createStore({
show: false,
reload: false,
reloadAmount: "",
reloadTrigger: "",
})
const processingFee = createMemo(() => {
const reloadAmount = billingInfo()?.reloadAmount
if (!reloadAmount) return "0.00"
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
})
createEffect(() => {
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
setStore("show", false)
}
})
function show() {
while (true) {
setReloadSubmission.clear()
if (!setReloadSubmission.result) break
}
const info = billingInfo()!
setStore("show", true)
setStore("reload", true)
setStore("reloadAmount", String(info.reloadAmount))
setStore("reloadTrigger", String(info.reloadTrigger))
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.reload.title")}</h2>
<div data-slot="title-row">
<Show
when={billingInfo()?.reload}
fallback={
<p>
{i18n.t("workspace.reload.disabled.before")} <b>{i18n.t("workspace.reload.disabled.state")}</b>.{" "}
{i18n.t("workspace.reload.disabled.after")}
</p>
}
>
<p>
{i18n.t("workspace.reload.enabled.before")} <b>{i18n.t("workspace.reload.enabled.state")}</b>.{" "}
{i18n.t("workspace.reload.enabled.middle")} <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
{i18n.t("workspace.reload.processingFee")}) {i18n.t("workspace.reload.enabled.after")}{" "}
<b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>
{billingInfo()?.reload ? i18n.t("workspace.reload.edit") : i18n.t("workspace.reload.enable")}
</button>
</div>
</div>
<Show when={store.show}>
<form action={setReload} method="post" data-slot="create-form">
<div data-slot="form-field">
<label>
<span data-slot="field-label">{i18n.t("workspace.reload.enableAutoReload")}</span>
<div data-slot="toggle-container">
<label data-slot="model-toggle-label">
<input
type="checkbox"
name="reload"
value="true"
checked={store.reload}
onChange={(e) => setStore("reload", e.currentTarget.checked)}
/>
<span></span>
</label>
</div>
</label>
</div>
<div data-slot="input-row">
<div data-slot="input-field">
<p>{i18n.t("workspace.reload.reloadAmount")}</p>
<input
data-component="input"
name="reloadAmount"
type="number"
min={String(billingInfo()?.reloadAmountMin ?? "")}
step="1"
value={store.reloadAmount}
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
placeholder={String(billingInfo()?.reloadAmount ?? "")}
disabled={!store.reload}
/>
</div>
<div data-slot="input-field">
<p>{i18n.t("workspace.reload.whenBalanceReaches")}</p>
<input
data-component="input"
name="reloadTrigger"
type="number"
min={String(billingInfo()?.reloadTriggerMin ?? "")}
step="1"
value={store.reloadTrigger}
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
placeholder={String(billingInfo()?.reloadTrigger ?? "")}
disabled={!store.reload}
/>
</div>
</div>
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="button" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
{setReloadSubmission.pending ? i18n.t("workspace.reload.saving") : i18n.t("workspace.reload.save")}
</button>
</div>
</form>
</Show>
<Show when={billingInfo()?.reloadError}>
<div data-slot="section-content">
<div data-slot="reload-error">
<p>
{i18n.t("workspace.reload.failedAt")}{" "}
{billingInfo()?.timeReloadError!.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. {i18n.t("workspace.reload.reason")}{" "}
{localizeError(i18n.t, billingInfo()?.reloadError ?? undefined).replace(/\.$/, "")}.{" "}
{i18n.t("workspace.reload.updatePaymentMethod")}
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={reloadSubmission.pending}>
{reloadSubmission.pending ? i18n.t("workspace.reload.retrying") : i18n.t("workspace.reload.retry")}
</button>
</form>
</div>
</div>
</Show>
</section>
)
}

View File

@@ -0,0 +1,30 @@
import { IconGo } from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LiteSection } from "./lite-section"
export default function () {
const i18n = useI18n()
const language = useLanguage()
return (
<div data-page="workspace-[id]">
<section data-component="header-section">
<IconGo />
<p>
<span>
{i18n.t("workspace.lite.banner.beforeLink")}{" "}
<a target="_blank" href={language.route("/docs/go")}>
{i18n.t("common.learnMore")}
</a>
.
</span>
</p>
</section>
<div data-slot="sections">
<LiteSection />
</div>
</div>
)
}

View File

@@ -0,0 +1,232 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-slot="usage"] {
display: flex;
gap: var(--space-6);
margin-top: var(--space-4);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-4);
}
}
[data-slot="usage-item"] {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="usage-header"] {
display: flex;
justify-content: space-between;
align-items: baseline;
}
[data-slot="usage-label"] {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-text);
}
[data-slot="usage-value"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="progress"] {
height: 8px;
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
[data-slot="progress-bar"] {
height: 100%;
background-color: var(--color-accent);
border-radius: var(--border-radius-sm);
transition: width 0.3s ease;
}
[data-slot="reset-time"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
[data-slot="setting-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-4);
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
margin: 0;
}
}
[data-slot="toggle-label"] {
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
[data-slot="beta-notice"] {
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin-top: var(--space-3);
a {
color: var(--color-accent);
text-decoration: none;
}
}
[data-slot="other-message"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
line-height: 1.5;
}
[data-slot="promo-description"] {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
line-height: 1.5;
margin-top: var(--space-2);
strong {
color: var(--color-text);
font-weight: 600;
}
}
[data-slot="promo-models-title"] {
font-size: var(--font-size-md);
font-weight: 600;
margin-top: var(--space-4);
}
[data-slot="promo-models"] {
margin: var(--space-2) 0 0 var(--space-4);
padding: 0;
font-size: var(--font-size-md);
color: var(--color-text-secondary);
line-height: 1.4;
}
[data-slot="subscribe-actions"] {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
[data-slot="subscribe-button"] {
align-self: stretch;
}
[data-slot="other-methods"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
[data-slot="other-methods-icons"] {
display: inline-flex;
align-items: center;
gap: 4px;
}
[data-slot="modal-actions"] {
display: flex;
gap: var(--space-3);
margin-top: var(--space-4);
button {
flex: 1;
}
}
[data-slot="method-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
height: 48px;
}
}

View File

@@ -0,0 +1,366 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { withActor } from "~/context/auth.withActor"
import { queryBillingInfo } from "../../common"
import styles from "./lite-section.module.css"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { IconAlipay, IconUpi } from "~/component/icon"
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const row = await Database.use((tx) =>
tx
.select({
userID: LiteTable.userID,
rollingUsage: LiteTable.rollingUsage,
weeklyUsage: LiteTable.weeklyUsage,
monthlyUsage: LiteTable.monthlyUsage,
timeRollingUpdated: LiteTable.timeRollingUpdated,
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
lite: BillingTable.lite,
})
.from(BillingTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
.then((r) => r[0]),
)
if (!row) return null
const limits = LiteData.getLimits()
const mine = row.userID === Actor.userID()
return {
mine,
useBalance: row.lite?.useBalance ?? false,
rollingUsage: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
usage: row.rollingUsage ?? 0,
timeUpdated: row.timeRollingUpdated ?? new Date(),
}),
weeklyUsage: Subscription.analyzeWeeklyUsage({
limit: limits.weeklyLimit,
usage: row.weeklyUsage ?? 0,
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
}),
monthlyUsage: Subscription.analyzeMonthlyUsage({
limit: limits.monthlyLimit,
usage: row.monthlyUsage ?? 0,
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
timeSubscribed: row.timeCreated,
}),
}
}, workspaceID)
}, "lite.subscription.get")
function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
const days = Math.floor(seconds / 86400)
if (days >= 1) {
const hours = Math.floor((seconds % 86400) / 3600)
return `${days} ${days === 1 ? i18n.t("workspace.lite.time.day") : i18n.t("workspace.lite.time.days")} ${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")}`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours >= 1)
return `${hours} ${hours === 1 ? i18n.t("workspace.lite.time.hour") : i18n.t("workspace.lite.time.hours")} ${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
if (minutes === 0) return i18n.t("workspace.lite.time.fewSeconds")
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
"use server"
return json(
await withActor(
() =>
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
},
"liteCheckoutUrl",
)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "liteSessionUrl")
const setLiteUseBalance = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useBalance = (form.get("useBalance") as string | null) === "true"
return json(
await withActor(async () => {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
lite: useBalance ? { useBalance: true } : {},
})
.where(eq(BillingTable.workspaceID, workspaceID)),
)
return { error: undefined }
}, workspaceID).catch((e) => ({ error: e.message as string })),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "setLiteUseBalance")
export function LiteSection() {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked)
const lite = createAsync(() => queryLiteSubscription(params.id!))
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const checkoutAction = useAction(createLiteCheckoutUrl)
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
})
const busy = createMemo(() => !!store.loading)
async function onClickSession() {
setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
window.location.href = result.data
return
}
setStore("loading", undefined)
}
async function onClickSubscribe(method?: "alipay" | "upi") {
setStore("loading", method ?? "checkout")
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) {
window.location.href = result.data
return
}
setStore("loading", undefined)
}
return (
<>
<Show when={isBlack()}>
<section class={styles.root}>
<p data-slot="other-message">{i18n.t("workspace.lite.black.message")}</p>
</section>
</Show>
<Show when={!isBlack() && lite() && lite()!.mine && lite()!}>
{(sub) => (
<section class={styles.root}>
<div data-slot="section-title">
<div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p>
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
{store.loading === "session"
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
</button>
</div>
</div>
<div data-slot="beta-notice">
{i18n.t("workspace.lite.subscription.selectProvider")}{" "}
<a href={language.route("/docs/providers/#opencode-go")} target="_blank" rel="noopener noreferrer">
{i18n.t("common.learnMore")}
</a>
.
</div>
<div data-slot="usage">
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.rollingUsage")}</span>
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().rollingUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.weeklyUsage")}</span>
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")} {formatResetTime(sub().weeklyUsage.resetInSec, i18n)}
</span>
</div>
<div data-slot="usage-item">
<div data-slot="usage-header">
<span data-slot="usage-label">{i18n.t("workspace.lite.subscription.monthlyUsage")}</span>
<span data-slot="usage-value">{sub().monthlyUsage.usagePercent}%</span>
</div>
<div data-slot="progress">
<div data-slot="progress-bar" style={{ width: `${sub().monthlyUsage.usagePercent}%` }} />
</div>
<span data-slot="reset-time">
{i18n.t("workspace.lite.subscription.resetsIn")}{" "}
{formatResetTime(sub().monthlyUsage.resetInSec, i18n)}
</span>
</div>
</div>
<form action={setLiteUseBalance} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.subscription.useBalance")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useBalance" value={sub().useBalance ? "false" : "true"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().useBalance}
disabled={useBalanceSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</section>
)}
</Show>
<Show when={!isBlack() && lite() && !lite()!.mine}>
<section class={styles.root}>
<p data-slot="other-message">{i18n.t("workspace.lite.other.message")}</p>
</section>
</Show>
<Show when={!isBlack() && lite() === null}>
<section class={styles.root}>
<p data-slot="promo-description">
<For
each={i18n
.t("workspace.lite.promo.description")
.split(/(\{\{price\}\})/g)
.filter(Boolean)}
>
{(part) => {
if (part === "{{price}}") return <strong>{i18n.t("workspace.lite.promo.price")}</strong>
return part
}}
</For>
</p>
<h3 data-slot="promo-models-title">{i18n.t("workspace.lite.promo.modelsTitle")}</h3>
<ul data-slot="promo-models">
<li>Kimi K2.5</li>
<li>Kimi K2.6</li>
<li>GLM-5</li>
<li>GLM-5.1</li>
<li>MiMo-V2-Pro</li>
<li>MiMo-V2-Omni</li>
<li>MiMo-V2.5-Pro</li>
<li>MiMo-V2.5</li>
<li>MiniMax M2.5</li>
<li>MiniMax M2.7</li>
<li>Qwen3.5 Plus</li>
<li>Qwen3.6 Plus</li>
<li>DeepSeek V4 Pro</li>
<li>DeepSeek V4 Flash</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<div data-slot="subscribe-actions">
<button
data-slot="subscribe-button"
data-color="primary"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe()}
>
{store.loading === "checkout"
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
<button
type="button"
data-slot="other-methods"
data-color="ghost"
onClick={() => setStore("showModal", true)}
>
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
<span data-slot="other-methods-icons">
<span> </span>
<IconAlipay style={{ width: "16px", height: "16px" }} />
<span> </span>
<IconUpi style={{ width: "auto", height: "10px" }} />
</span>
</button>
</div>
<Modal
open={store.showModal}
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
>
<div data-slot="modal-actions">
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("alipay")}
>
<Show when={store.loading !== "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Show>
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
</button>
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("upi")}
>
<Show when={store.loading !== "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Show>
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
</button>
</div>
</Modal>
</section>
</Show>
</>
)
}

View File

@@ -0,0 +1,81 @@
import { Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
import { IconZen } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
export default function () {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
const checkoutSubmission = useSubmission(createCheckoutUrl)
const [store, setStore] = createStore({
checkoutRedirecting: false,
})
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
async function onClickCheckout() {
const baseUrl = window.location.href
const checkout = await checkoutAction(params.id!, billingInfo()!.reloadAmount, baseUrl, baseUrl)
if (checkout && checkout.data) {
setStore("checkoutRedirecting", true)
window.location.href = checkout.data
}
}
return (
<div data-page="workspace-[id]">
<section data-component="header-section">
<IconZen />
<p>
<span>
{i18n.t("workspace.home.banner.beforeLink")}{" "}
<a target="_blank" href={language.route("/docs/zen")}>
{i18n.t("common.learnMore")}
</a>
.
</span>
<Show when={userInfo()?.isAdmin}>
<span data-slot="billing-info">
<Show
when={billingInfo()?.customerID}
fallback={
<button
data-color="primary"
data-size="sm"
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting
? i18n.t("workspace.home.billing.loading")
: i18n.t("workspace.home.billing.enable")}
</button>
}
>
<span data-slot="balance">
{i18n.t("workspace.home.billing.currentBalance")} <b>${balance()}</b>
</span>
</Show>
</span>
</Show>
</p>
</section>
<div data-slot="sections">
<NewUserSection />
<ModelSection />
<Show when={userInfo()?.isAdmin}>
<ProviderSection />
</Show>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { KeySection } from "./key-section"
export default function () {
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<KeySection />
</div>
</div>
)
}

View File

@@ -0,0 +1,197 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
[data-slot="api-keys-table"] {
overflow-x: auto;
}
[data-slot="api-keys-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="key-name"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="key-value"] {
font-family: var(--font-mono);
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-left: calc(-1 * var(--space-3));
font-size: var(--font-size-sm);
font-weight: 400;
border: none;
background-color: transparent;
color: var(--color-text-muted);
font-family: var(--font-mono);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.15s ease;
text-transform: none;
&:hover:not(:disabled) {
background-color: var(--color-bg-surface);
color: var(--color-text);
}
&:disabled {
cursor: default;
color: var(--color-text);
}
span {
font-family: inherit;
}
}
}
&[data-slot="key-date"] {
color: var(--color-text);
}
&[data-slot="key-actions"] {
font-family: var(--font-sans);
button {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
}
}
tbody tr {
&:hover {
[data-slot="key-actions"] button {
opacity: 1;
pointer-events: auto;
}
}
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3)
/* Date */ {
display: none;
}
}
td {
&:nth-child(3)
/* Date */ {
display: none;
}
}
}
}
}

View File

@@ -0,0 +1,179 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, createSignal, For, Show } from "solid-js"
import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode-ai/console-core/key.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
const createKey = action(async (form: FormData) => {
"use server"
const name = (form.get("name") as string | null)?.trim()
if (!name) return { error: formError.nameRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
Key.create({
userID: Actor.assert("user").properties.userID,
name,
})
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listKeys.key },
)
}, "key.create")
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
export function KeySection() {
const params = useParams()
const i18n = useI18n()
const keys = createAsync(() => listKeys(params.id!))
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
setStore("show", false)
}
})
function show() {
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
setTimeout(() => input?.focus(), 0)
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.keys.title")}</h2>
<div data-slot="title-row">
<p>{i18n.t("workspace.keys.subtitle")}</p>
<button data-color="primary" onClick={() => show()}>
{i18n.t("workspace.keys.create")}
</button>
</div>
</div>
<Show when={store.show}>
<form action={createKey} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
ref={(r) => (input = r)}
data-component="input"
name="name"
type="text"
placeholder={i18n.t("workspace.keys.placeholder")}
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
</button>
</div>
</form>
</Show>
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
fallback={
<div data-component="empty-state">
<p>{i18n.t("workspace.keys.empty")}</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.keys.table.name")}</th>
<th>{i18n.t("workspace.keys.table.key")}</th>
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={keys()!}>
{(key) => {
const [copied, setCopied] = createSignal(false)
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
return (
<tr>
<td data-slot="key-name">{key.name}</td>
<td data-slot="key-value">
<Show when={key.key} fallback={<span>{key.keyDisplay}</span>}>
<button
data-color="ghost"
disabled={copied()}
onClick={async () => {
await navigator.clipboard.writeText(key.key!)
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
title={i18n.t("workspace.keys.copyApiKey")}
>
<span>{key.keyDisplay}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
<IconCheck style={{ width: "14px", height: "14px" }} />
</Show>
</button>
</Show>
</td>
<td data-slot="key-user-email">{key.email}</td>
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost">{i18n.t("workspace.keys.delete")}</button>
</form>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}

View File

@@ -0,0 +1,11 @@
import { MemberSection } from "./member-section"
export default function () {
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<MemberSection />
</div>
</div>
)
}

View File

@@ -0,0 +1,249 @@
.root {
[data-slot="title-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="beta-notice"] {
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg-surface);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
margin-bottom: var(--space-3);
a {
color: var(--color-accent);
text-decoration: none;
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
[data-slot="input-row"] {
display: flex;
flex-direction: row;
gap: var(--space-3);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-2);
}
}
[data-slot="input-field"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
p {
line-height: 1.2;
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
min-width: 0;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
> button[type="reset"] {
align-self: flex-start;
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin-top: calc(var(--space-1) * -1);
}
}
[data-slot="members-table"] {
overflow-x: auto;
padding-bottom: 200px;
margin-bottom: -200px;
}
[data-slot="members-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
&:nth-child(2) {
width: 180px;
}
&:nth-child(3) {
width: 200px;
}
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="member-email"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="member-role"] {
font-family: var(--font-mono);
text-transform: capitalize;
}
&[data-slot="member-usage"] {
input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
}
&[data-slot="member-date"] {
color: var(--color-text);
}
&[data-slot="member-actions"] {
font-family: var(--font-sans);
display: flex;
gap: var(--space-2);
[data-slot="inline-edit-form"] {
display: flex;
gap: var(--space-2);
button {
opacity: 1;
pointer-events: auto;
}
}
form:not([data-slot="inline-edit-form"]) button {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
}
}
tbody tr {
&:hover {
[data-slot="member-actions"] form:not([data-slot="inline-edit-form"]) button {
opacity: 1;
pointer-events: auto;
}
}
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3)
/* Date */ {
display: none;
}
}
td {
&:nth-child(3)
/* Date */ {
display: none;
}
}
}
}
}

View File

@@ -0,0 +1,368 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import styles from "./member-section.module.css"
import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { User } from "@opencode-ai/console-core/user.js"
import { RoleDropdown } from "./role-dropdown"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError, localizeError } from "~/lib/form-error"
const listMembers = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return {
members: await User.list(),
actorID: Actor.userID(),
actorRole: Actor.userRole(),
}
}, workspaceID)
}, "member.list")
const inviteMember = action(async (form: FormData) => {
"use server"
const email = (form.get("email") as string | null)?.trim()
if (!email) return { error: formError.emailRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role") as (typeof UserRole)[number] | null
if (!role) return { error: formError.roleRequired }
const limit = form.get("limit") as string | null
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
await withActor(
() =>
User.invite({ email, role, monthlyLimit })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listMembers.key },
)
}, "member.create")
const removeMember = action(async (form: FormData) => {
"use server"
const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
User.remove(id)
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listMembers.key },
)
}, "member.remove")
const updateMember = action(async (form: FormData) => {
"use server"
const id = form.get("id") as string | null
if (!id) return { error: formError.idRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const role = form.get("role") as (typeof UserRole)[number] | null
if (!role) return { error: formError.roleRequired }
const limit = form.get("limit") as string | null
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
return json(
await withActor(
() =>
User.update({ id, role, monthlyLimit })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listMembers.key },
)
}, "member.update")
function MemberRow(props: {
member: any
workspaceID: string
actorID: string
actorRole: string
roleOptions: { value: string; label: string; description: string }[]
}) {
const i18n = useI18n()
const submission = useSubmission(updateMember)
const isCurrentUser = () => props.actorID === props.member.id
const isAdmin = () => props.actorRole === "admin"
const [store, setStore] = createStore({
editing: false,
selectedRole: props.member.role as (typeof UserRole)[number],
limit: "",
})
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
setStore("editing", false)
}
})
function show() {
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("editing", true)
setStore("selectedRole", props.member.role)
setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "")
}
function hide() {
setStore("editing", false)
}
function getUsageDisplay() {
const currentUsage = (() => {
const dateLastUsed = props.member.timeMonthlyUsageUpdated
if (!dateLastUsed) return 0
const current = new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
})
return current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
})()
const limit = props.member.monthlyLimit
? `$${props.member.monthlyLimit}`
: i18n.t("workspace.members.noLimitLowercase")
return `$${(currentUsage / 100000000).toFixed(2)} / ${limit}`
}
const roleLabel = (value: string) => props.roleOptions.find((option) => option.value === value)?.label ?? value
return (
<tr>
<td data-slot="member-email">{props.member.authEmail ?? props.member.email}</td>
<td data-slot="member-role">
<Show when={store.editing && !isCurrentUser()} fallback={<span>{roleLabel(props.member.role)}</span>}>
<RoleDropdown
value={store.selectedRole}
options={props.roleOptions}
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
/>
</Show>
</td>
<td data-slot="member-usage">
<Show when={store.editing} fallback={<span>{getUsageDisplay()}</span>}>
<input
data-component="input"
type="number"
value={store.limit}
onInput={(e) => setStore("limit", e.currentTarget.value)}
placeholder={i18n.t("workspace.members.noLimit")}
min="0"
/>
</Show>
</td>
<td data-slot="member-joined">{props.member.timeSeen ? "" : i18n.t("workspace.members.invited")}</td>
<Show when={isAdmin()}>
<td data-slot="member-actions">
<Show
when={store.editing}
fallback={
<>
<button data-color="ghost" onClick={() => show()}>
{i18n.t("workspace.members.edit")}
</button>
<Show when={!isCurrentUser()}>
<form action={removeMember} method="post">
<input type="hidden" name="id" value={props.member.id} />
<input type="hidden" name="workspaceID" value={props.workspaceID} />
<button data-color="ghost">{i18n.t("workspace.members.delete")}</button>
</form>
</Show>
</>
}
>
<form action={updateMember} method="post" data-slot="inline-edit-form">
<input type="hidden" name="id" value={props.member.id} />
<input type="hidden" name="workspaceID" value={props.workspaceID} />
<input type="hidden" name="role" value={store.selectedRole} />
<input type="hidden" name="limit" value={store.limit} />
<button type="submit" data-color="ghost" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.members.saving") : i18n.t("workspace.members.save")}
</button>
<Show when={!submission.pending}>
<button type="button" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
</Show>
</form>
</Show>
</td>
</Show>
</tr>
)
}
export function MemberSection() {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const data = createAsync(() => listMembers(params.id!))
const submission = useSubmission(inviteMember)
const [store, setStore] = createStore({
show: false,
selectedRole: "member" as (typeof UserRole)[number],
limit: "",
})
let input: HTMLInputElement
const roleOptions = [
{
value: "admin",
label: i18n.t("workspace.members.role.admin"),
description: i18n.t("workspace.members.role.adminDescription"),
},
{
value: "member",
label: i18n.t("workspace.members.role.member"),
description: i18n.t("workspace.members.role.memberDescription"),
},
]
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
setStore("show", false)
}
})
function show() {
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
setStore("selectedRole", "member")
setStore("limit", "")
setTimeout(() => input?.focus(), 0)
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.members.title")}</h2>
<div data-slot="title-row">
<p>{i18n.t("workspace.members.subtitle")}</p>
<Show when={data()?.actorRole === "admin"}>
<button data-color="primary" onClick={() => show()}>
{i18n.t("workspace.members.invite")}
</button>
</Show>
</div>
</div>
<div data-slot="beta-notice">
{i18n.t("workspace.members.beta.beforeLink")}{" "}
<a href={language.route("/docs/zen/#for-teams")} target="_blank" rel="noopener noreferrer">
{i18n.t("common.learnMore")}
</a>
.
</div>
<Show when={store.show}>
<form action={inviteMember} method="post" data-slot="create-form">
<div data-slot="input-row">
<div data-slot="input-field">
<p>{i18n.t("workspace.members.form.invitee")}</p>
<input
ref={(r) => (input = r)}
data-component="input"
name="email"
type="text"
placeholder={i18n.t("workspace.members.form.emailPlaceholder")}
/>
</div>
<div data-slot="input-field">
<p>{i18n.t("workspace.members.form.role")}</p>
<RoleDropdown
value={store.selectedRole}
options={roleOptions}
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
/>
</div>
<div data-slot="input-field">
<p>{i18n.t("workspace.members.form.monthlyLimit")}</p>
<input
data-component="input"
name="limit"
type="number"
placeholder={i18n.t("workspace.members.noLimit")}
value={store.limit}
onInput={(e) => setStore("limit", e.currentTarget.value)}
min="0"
/>
</div>
</div>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<input type="hidden" name="role" value={store.selectedRole} />
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.members.inviting") : i18n.t("workspace.members.invite")}
</button>
</div>
</form>
</Show>
<div data-slot="members-table">
<table data-slot="members-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.members.table.email")}</th>
<th>{i18n.t("workspace.members.table.role")}</th>
<th>{i18n.t("workspace.members.table.monthLimit")}</th>
<th></th>
<Show when={data()?.actorRole === "admin"}>
<th></th>
</Show>
</tr>
</thead>
<tbody>
<Show when={data() && data()!.members.length > 0}>
<For each={data()!.members}>
{(member) => (
<MemberRow
member={member}
workspaceID={params.id!}
actorID={data()!.actorID}
actorRole={data()!.actorRole}
roleOptions={roleOptions}
/>
)}
</For>
</Show>
</tbody>
</table>
</div>
</section>
)
}

View File

@@ -0,0 +1,72 @@
.role-dropdown {
[data-slot="trigger"] {
border: 1px solid var(--color-border);
background-color: var(--color-bg);
width: 100%;
text-transform: capitalize;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
min-width: 0;
&:hover {
border-color: var(--color-accent);
background-color: var(--color-bg);
}
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
}
[data-slot="chevron"] {
opacity: 0.6;
}
[data-slot="dropdown"] {
padding: var(--space-1);
min-width: 280px;
width: max-content;
}
[data-slot="role-item"] {
display: block;
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background-color: transparent;
color: var(--color-text);
font-size: var(--font-size-sm);
text-align: left;
cursor: pointer;
border-radius: var(--border-radius-sm);
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-bg-surface);
}
&[data-selected="true"] {
background-color: var(--color-accent-alpha);
}
div {
strong {
display: block;
color: var(--color-text);
margin-bottom: var(--space-1);
text-transform: capitalize;
}
p {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin: 0;
}
}
}
}

View File

@@ -0,0 +1,45 @@
import { createSignal } from "solid-js"
import { Dropdown } from "~/component/dropdown"
import "./role-dropdown.css"
interface RoleOption {
value: string
label: string
description: string
}
interface RoleDropdownProps {
value: string
options: RoleOption[]
onChange: (value: string) => void
}
export function RoleDropdown(props: RoleDropdownProps) {
const [open, setOpen] = createSignal(false)
const selected = () => props.options.find((option) => option.value === props.value)?.label ?? props.value
const handleSelect = (value: string) => {
props.onChange(value)
setOpen(false)
}
return (
<Dropdown trigger={selected()} open={open()} onOpenChange={setOpen} class="role-dropdown">
<>
{props.options.map((option) => (
<button
data-slot="role-item"
data-selected={props.value === option.value}
type="button"
onClick={() => handleSelect(option.value)}
>
<div>
<strong>{option.label}</strong>
<p>{option.description}</p>
</div>
</button>
))}
</>
</Dropdown>
)
}

View File

@@ -0,0 +1,173 @@
[data-slot="models-list"] {
display: flex;
flex-direction: column;
}
[data-slot="models-table"] {
overflow-x: auto;
}
[data-slot="models-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="model-name"] {
color: var(--color-text);
font-family: var(--font-mono);
font-weight: 500;
div {
display: flex;
align-items: center;
gap: 8px;
}
}
&[data-slot="training-data"] {
text-align: center;
color: var(--color-text);
}
&[data-slot="model-toggle"] {
text-align: left;
font-family: var(--font-sans);
}
[data-slot="model-toggle-label"] {
/* Toggle container */
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
/* Hidden checkbox input */
input {
opacity: 0;
width: 0;
height: 0;
}
/* Toggle track (background) */
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
/* Toggle handle (slider) */
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
/* Checked state - track */
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
/* Checked state - handle */
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
/* Hover states */
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
/* Disabled state */
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
input:disabled:checked + span {
opacity: 0.5;
}
input:disabled ~ span:hover {
box-shadow: none;
}
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
&[data-disabled="true"] {
td[data-slot="model-name"] {
color: var(--color-text-muted);
}
}
}
}
@media (max-width: 40rem) {
[data-slot="models-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2)
/* Training Data */ {
display: none;
}
}
td {
&:nth-child(2)
/* Training Data */ {
display: none;
}
}
}
}

View File

@@ -0,0 +1,192 @@
import { Model } from "@opencode-ai/console-core/model.js"
import { query, action, useParams, createAsync, json } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import {
IconAlibaba,
IconAnthropic,
IconArcee,
IconGemini,
IconMiniMax,
IconMoonshotAI,
IconNvidia,
IconOpenAI,
IconStealth,
IconXai,
IconXiaomi,
IconZai,
} from "~/component/icon"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba"
if (modelId.startsWith("minimax")) return "MiniMax"
if (modelId.startsWith("grok")) return "xAI"
if (modelId.startsWith("mimo")) return "Xiaomi"
if (modelId.startsWith("nemotron")) return "NVIDIA"
if (modelId.startsWith("trinity")) return "Arcee"
return "Stealth"
}
const getModelsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return {
all: Object.entries(ZenData.list("full").models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("alpha-"))
.sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"]
const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index
}
const pA = getPriority(idA)
const pB = getPriority(idB)
if (pA !== pB) return pA - pB
const modelAName = Array.isArray(modelA) ? modelA[0].name : modelA.name
const modelBName = Array.isArray(modelB) ? modelB[0].name : modelB.name
return modelAName.localeCompare(modelBName)
})
.map(([id, model]) => ({ id, name: Array.isArray(model) ? model[0].name : model.name })),
disabled: await Model.listDisabled(),
}
}, workspaceID)
}, "model.info")
const updateModel = action(async (form: FormData) => {
"use server"
const model = form.get("model") as string | null
if (!model) return { error: formError.modelRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const enabled = (form.get("enabled") as string | null) === "true"
return json(
withActor(async () => {
if (enabled) {
await Model.disable({ model })
} else {
await Model.enable({ model })
}
}, workspaceID),
{ revalidate: getModelsInfo.key },
)
}, "model.toggle")
export function ModelSection() {
const params = useParams()
const i18n = useI18n()
const language = useLanguage()
const modelsInfo = createAsync(() => getModelsInfo(params.id!))
const userInfo = createAsync(() => querySessionInfo(params.id!))
const modelsWithLab = createMemo(() => {
const info = modelsInfo()
if (!info) return []
return info.all.map((model) => ({
...model,
lab: getModelLab(model.id),
}))
})
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.models.title")}</h2>
<p>
{i18n.t("workspace.models.subtitle.beforeLink")}{" "}
<a href={language.route("/docs/zen#pricing")}>{i18n.t("common.learnMore")}</a>.
</p>
</div>
<div data-slot="models-list">
<Show when={modelsInfo()}>
<div data-slot="models-table">
<table data-slot="models-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.models.table.model")}</th>
<th></th>
<th>{i18n.t("workspace.models.table.enabled")}</th>
</tr>
</thead>
<tbody>
<For each={modelsWithLab()}>
{({ id, name, lab }) => {
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(id))
return (
<tr data-slot="model-row" data-disabled={!isEnabled()}>
<td data-slot="model-name">
<div>
{(() => {
switch (lab) {
case "OpenAI":
return <IconOpenAI width={16} height={16} />
case "Anthropic":
return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGemini width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":
return <IconZai width={16} height={16} />
case "Alibaba":
return <IconAlibaba width={16} height={16} />
case "xAI":
return <IconXai width={16} height={16} />
case "MiniMax":
return <IconMiniMax width={16} height={16} />
case "Xiaomi":
return <IconXiaomi width={16} height={16} />
case "NVIDIA":
return <IconNvidia width={16} height={16} />
case "Arcee":
return <IconArcee width={16} height={16} />
default:
return <IconStealth width={16} height={16} />
}
})()}
<span>{name}</span>
</div>
</td>
<td data-slot="model-lab">{lab}</td>
<td data-slot="model-toggle">
<form action={updateModel} method="post">
<input type="hidden" name="model" value={id} />
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="enabled" value={String(isEnabled())} />
<label data-slot="model-toggle-label">
<input
type="checkbox"
checked={isEnabled()}
disabled={!userInfo()?.isAdmin}
onChange={(e) => {
const form = e.currentTarget.closest("form")
if (form) form.requestSubmit()
}}
/>
<span></span>
</label>
</form>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</section>
)
}

View File

@@ -0,0 +1,143 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
background-color: var(--color-bg-surface);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
[data-component="feature-grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
@media (max-width: 30rem) {
grid-template-columns: 1fr;
gap: var(--space-4);
}
[data-slot="feature"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
h3 {
font-size: var(--font-size-sm);
font-weight: 600;
margin: 0;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: -0.025rem;
}
p {
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
color: var(--color-text-muted);
}
}
}
[data-component="api-key-highlight"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
[data-slot="key-display"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
[data-slot="key-container"] {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-accent);
border-radius: var(--border-radius-sm);
align-items: center;
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-3);
align-items: stretch;
}
[data-slot="key-value"] {
flex: 1;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--color-text);
background-color: var(--color-bg);
padding: var(--space-3);
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
word-break: break-all;
line-height: 1.4;
@media (max-width: 40rem) {
font-size: var(--font-size-xs);
padding: var(--space-2-5);
}
}
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
white-space: nowrap;
min-width: 130px;
@media (max-width: 40rem) {
justify-content: center;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
min-width: 96px;
}
}
}
}
}
[data-component="next-steps"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
ol {
margin: 0;
padding-left: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style-position: inside;
li {
font-size: var(--font-size-md);
line-height: 1.5;
color: var(--color-text-secondary);
code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, createSignal, Show } from "solid-js"
import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode-ai/console-core/key.js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import styles from "./new-user-section.module.css"
import { useI18n } from "~/context/i18n"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
export function NewUserSection() {
const params = useParams()
const i18n = useI18n()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id!))
const usage = createAsync(() => getUsageInfo(params.id!))
const isNew = createMemo(() => {
const keysList = keys()
const usageList = usage()
return keysList?.length === 1 && (!usageList || usageList.length === 0)
})
const defaultKey = createMemo(() => {
const key = keys()?.at(-1)?.key
if (!key) return undefined
return {
actual: key,
masked: key.slice(0, 8) + "*".repeat(key.length - 12) + key.slice(-4),
}
})
return (
<Show when={isNew()}>
<div class={styles.root}>
<div data-component="feature-grid">
<div data-slot="feature">
<h3>{i18n.t("workspace.newUser.feature.tested.title")}</h3>
<p>{i18n.t("workspace.newUser.feature.tested.body")}</p>
</div>
<div data-slot="feature">
<h3>{i18n.t("workspace.newUser.feature.quality.title")}</h3>
<p>{i18n.t("workspace.newUser.feature.quality.body")}</p>
</div>
<div data-slot="feature">
<h3>{i18n.t("workspace.newUser.feature.lockin.title")}</h3>
<p>{i18n.t("workspace.newUser.feature.lockin.body")}</p>
</div>
</div>
<div data-component="api-key-highlight">
<Show when={defaultKey()}>
<div data-slot="key-display">
<div data-slot="key-container">
<code data-slot="key-value">{defaultKey()?.masked}</code>
<button
data-color="primary"
disabled={copiedKey()}
onClick={async () => {
await navigator.clipboard.writeText(defaultKey()?.actual ?? "")
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
title={i18n.t("workspace.newUser.copyApiKey")}
>
<Show
when={copiedKey()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copyKey")}
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copied")}
</Show>
</button>
</div>
</div>
</Show>
</div>
<div data-component="next-steps">
<ol>
<li>{i18n.t("workspace.newUser.step.enableBilling")}</li>
<li>
{i18n.t("workspace.newUser.step.login.before")} <code>opencode auth login</code>{" "}
{i18n.t("workspace.newUser.step.login.after")}
</li>
<li>{i18n.t("workspace.newUser.step.pasteKey")}</li>
<li>
{i18n.t("workspace.newUser.step.models.before")} <code>/models</code>{" "}
{i18n.t("workspace.newUser.step.models.after")}
</li>
</ol>
</div>
</div>
</Show>
)
}

View File

@@ -0,0 +1,138 @@
.root {
[data-slot="providers-table"] {
overflow-x: auto;
}
[data-slot="providers-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
&:nth-child(1) {
width: 180px;
}
&:nth-child(3) {
width: 200px;
}
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="provider-name"] {
color: var(--color-text);
font-family: var(--font-mono);
font-weight: 500;
}
&[data-slot="provider-key"] {
text-align: left;
color: var(--color-text-secondary);
[data-slot="edit-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 100%;
[data-slot="input-wrapper"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-width: 100%;
input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
width: 100%;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}
}
&[data-slot="provider-action"] {
text-align: left;
font-family: var(--font-sans);
white-space: nowrap;
[data-slot="configured-actions"] {
display: flex;
gap: var(--space-2);
[data-slot="delete-form"] {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
&:hover [data-slot="delete-form"] {
opacity: 1;
pointer-events: auto;
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
}
}
tbody tr {
&:hover {
[data-slot="provider-action"] [data-slot="delete-form"] {
opacity: 1;
pointer-events: auto;
}
}
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
}
}
}

View File

@@ -0,0 +1,199 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, For, Show } from "solid-js"
import { Provider } from "@opencode-ai/console-core/provider.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import styles from "./provider-section.module.css"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const PROVIDERS = [
{ name: "OpenAI", key: "openai", prefix: "sk-" },
{ name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
{ name: "Google Gemini", key: "google", prefix: "AI" },
] as const
type Provider = (typeof PROVIDERS)[number]
function maskCredentials(credentials: string) {
return `${credentials.slice(0, 8)}...${credentials.slice(-8)}`
}
const removeProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider") as string | null
if (!provider) return { error: formError.providerRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
revalidate: listProviders.key,
})
}, "provider.remove")
const saveProvider = action(async (form: FormData) => {
"use server"
const provider = form.get("provider") as string | null
const credentials = form.get("credentials") as string | null
if (!provider) return { error: formError.providerRequired }
if (!credentials) return { error: formError.apiKeyRequired }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
Provider.create({ provider, credentials })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listProviders.key },
)
}, "provider.save")
const listProviders = query(async (workspaceID: string) => {
"use server"
return withActor(() => Provider.list(), workspaceID)
}, "provider.list")
function ProviderRow(props: { provider: Provider }) {
const params = useParams()
const i18n = useI18n()
const providers = createAsync(() => listProviders(params.id!))
const saveSubmission = useSubmission(
saveProvider,
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
)
const removeSubmission = useSubmission(
removeProvider,
([fd]) => (fd.get("provider") as string | null) === props.provider.key,
)
const [store, setStore] = createStore({ editing: false })
let input: HTMLInputElement
const providerData = () => providers()?.find((p) => p.provider === props.provider.key)
createEffect(() => {
if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
hide()
}
})
function show() {
while (true) {
saveSubmission.clear()
if (!saveSubmission.result) break
}
setStore("editing", true)
setTimeout(() => input?.focus(), 0)
}
function hide() {
setStore("editing", false)
}
return (
<tr data-slot="provider-row">
<td data-slot="provider-name">{props.provider.name}</td>
<td data-slot="provider-key">
<Show
when={store.editing}
fallback={<span>{providerData() ? maskCredentials(providerData()!.credentials) : "-"}</span>}
>
<form id={`provider-form-${props.provider.key}`} action={saveProvider} method="post" data-slot="edit-form">
<div data-slot="input-wrapper">
<input
ref={(r) => (input = r)}
name="credentials"
type="text"
placeholder={i18n.t("workspace.providers.placeholder", {
provider: props.provider.name,
prefix: props.provider.prefix,
})}
autocomplete="off"
data-form-type="other"
data-lpignore="true"
/>
<Show when={saveSubmission.result && saveSubmission.result.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</div>
<input type="hidden" name="provider" value={props.provider.key} />
<input type="hidden" name="workspaceID" value={params.id} />
</form>
</Show>
</td>
<td data-slot="provider-action">
<Show
when={store.editing}
fallback={
<Show
when={!!providerData()}
fallback={
<button data-color="ghost" onClick={() => show()}>
{i18n.t("workspace.providers.configure")}
</button>
}
>
<div data-slot="configured-actions">
<button data-color="ghost" onClick={() => show()}>
{i18n.t("workspace.providers.edit")}
</button>
<form action={removeProvider} method="post" data-slot="delete-form">
<input type="hidden" name="provider" value={props.provider.key} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
{i18n.t("workspace.providers.delete")}
</button>
</form>
</div>
</Show>
}
>
<div data-slot="form-actions">
<button
type="submit"
data-color="ghost"
disabled={saveSubmission.pending}
form={`provider-form-${props.provider.key}`}
>
{saveSubmission.pending ? i18n.t("workspace.providers.saving") : i18n.t("workspace.providers.save")}
</button>
<Show when={!saveSubmission.pending}>
<button type="reset" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
</Show>
</div>
</Show>
</td>
</tr>
)
}
export function ProviderSection() {
const i18n = useI18n()
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.providers.title")}</h2>
<p>{i18n.t("workspace.providers.subtitle")}</p>
</div>
<div data-slot="providers-table">
<table data-slot="providers-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.providers.table.provider")}</th>
<th>{i18n.t("workspace.providers.table.apiKey")}</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={PROVIDERS}>{(provider) => <ProviderRow provider={provider} />}</For>
</tbody>
</table>
</div>
</section>
)
}

View File

@@ -0,0 +1,11 @@
import { SettingsSection } from "./settings-section"
export default function () {
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<SettingsSection />
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
.root {
max-width: 40rem;
[data-slot="setting"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
p {
line-height: 1.2;
margin: 0;
color: var(--color-text-muted);
}
[data-slot="value-with-action"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
@media (max-width: 30rem) {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
}
[data-slot="current-value"] {
color: var(--color-text);
line-height: 1.4;
margin: 0;
}
> button {
align-self: flex-start;
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
[data-slot="input-container"] {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-2);
@media (max-width: 30rem) {
flex-direction: column;
align-items: stretch;
}
button {
white-space: nowrap;
flex-shrink: 0;
}
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
min-width: 0;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
> button[type="reset"] {
align-self: flex-start;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin-top: calc(var(--space-1) * -1);
}
}
}

View File

@@ -0,0 +1,125 @@
import { json, action, useParams, useSubmission, createAsync, query } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import styles from "./settings-section.module.css"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
const getWorkspaceInfo = query(async (workspaceID: string) => {
"use server"
return withActor(
() =>
Database.use((tx) =>
tx
.select({
id: WorkspaceTable.id,
name: WorkspaceTable.name,
slug: WorkspaceTable.slug,
})
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0] || null),
),
workspaceID,
)
}, "workspace.get")
const updateWorkspace = action(async (form: FormData) => {
"use server"
const name = (form.get("name") as string | null)?.trim()
if (!name) return { error: formError.workspaceNameRequired }
if (name.length > 255) return { error: formError.nameTooLong }
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
return json(
await withActor(
() =>
Workspace.update({ name })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
)
}, "workspace.update")
export function SettingsSection() {
const params = useParams()
const i18n = useI18n()
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id!))
const submission = useSubmission(updateWorkspace)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.settings.title")}</h2>
<p>{i18n.t("workspace.settings.subtitle")}</p>
</div>
<div data-slot="section-content">
<div data-slot="setting">
<p>{i18n.t("workspace.settings.workspaceName")}</p>
<Show
when={!store.show}
fallback={
<form action={updateWorkspace} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
required
ref={(r) => (input = r)}
data-component="input"
name="name"
type="text"
placeholder={i18n.t("workspace.settings.workspaceName")}
value={workspaceInfo()?.name ?? i18n.t("workspace.settings.defaultName")}
/>
<input type="hidden" name="workspaceID" value={params.id} />
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? i18n.t("workspace.settings.updating") : i18n.t("workspace.settings.save")}
</button>
<button type="reset" data-color="ghost" onClick={() => hide()}>
{i18n.t("common.cancel")}
</button>
</div>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
</form>
}
>
<div data-slot="value-with-action">
<p data-slot="current-value">{workspaceInfo()?.name}</p>
<button data-color="primary" onClick={() => show()}>
{i18n.t("workspace.settings.edit")}
</button>
</div>
</Show>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,145 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
display: flex;
align-items: center;
justify-content: center;
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="filter-container"] {
margin-bottom: 0;
display: flex;
align-items: center;
gap: var(--space-3);
[data-component="dropdown"] {
[data-slot="trigger"] {
border: 1px solid var(--color-border);
background-color: var(--color-bg);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
&:hover {
border-color: var(--color-accent);
}
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
}
[data-slot="chevron"] {
opacity: 0.6;
}
[data-slot="dropdown"] {
min-width: 200px;
max-height: 300px;
overflow-y: auto;
padding: var(--space-1);
}
}
}
[data-slot="month-picker"] {
display: flex;
align-items: center;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: 0;
}
[data-slot="month-button"] {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none !important;
color: var(--color-text);
cursor: pointer;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
line-height: 1;
&:hover {
background-color: var(--color-bg-hover);
}
svg {
display: block;
width: 16px;
height: 16px;
stroke-width: 2;
}
}
[data-slot="month-label"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
line-height: 1.5;
min-width: 140px;
text-align: center;
white-space: nowrap;
}
[data-slot="model-item"] {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: background-color 0.2s;
font-size: var(--font-size-sm);
color: var(--color-text);
border: none !important;
background: none;
width: 100%;
text-align: left;
white-space: nowrap;
&:hover {
background: var(--color-bg-hover);
}
span {
flex: 1;
user-select: none;
}
}
[data-slot="chart-container"] {
padding: var(--space-6);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
}
@media (max-width: 40rem) {
[data-slot="chart-container"] {
height: 300px;
padding: var(--space-4);
}
[data-component="empty-state"] {
height: 300px;
}
}
}

View File

@@ -0,0 +1,556 @@
import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Dropdown } from "~/component/dropdown"
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
import styles from "./graph-section.module.css"
import {
Chart,
BarController,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
type ChartConfiguration,
} from "chart.js"
import { useI18n } from "~/context/i18n"
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
async function getCosts(workspaceID: string, year: number, month: number, tzOffset: string) {
"use server"
return withActor(async () => {
const timezoneOffset = (() => {
const m = /^([+-])(\d{2}):(\d{2})$/.exec(tzOffset)
if (!m) return 0
const sign = m[1] === "-" ? -1 : 1
return sign * (Number(m[2]) * 60 + Number(m[3])) * 60_000
})()
const monthStartUTC = new Date(Date.UTC(year, month, 1, 0, 0, 0) - timezoneOffset)
const monthEndUTC = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0) - timezoneOffset)
const dateExpr = sql<string>`DATE(CONVERT_TZ(${UsageTable.timeCreated}, '+00:00', ${tzOffset}))`
const usageData = await Database.use((tx) =>
tx
.select({
date: dateExpr,
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspaceID),
gte(UsageTable.timeCreated, monthStartUTC),
lt(UsageTable.timeCreated, monthEndUTC),
),
)
.groupBy(dateExpr, UsageTable.model, UsageTable.keyID, sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
plan: r.plan as "sub" | "lite" | "byok" | null,
})),
),
)
// Get unique key IDs from usage
const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
// Second query: get all existing keys plus any keys from usage
const keysData = await Database.use((tx) =>
tx
.select({
keyId: KeyTable.id,
keyName: KeyTable.name,
userEmail: AuthTable.subject,
timeDeleted: KeyTable.timeDeleted,
})
.from(KeyTable)
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(
and(
eq(KeyTable.workspaceID, workspaceID),
usageKeyIds.size > 0
? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
: isNull(KeyTable.timeDeleted),
),
)
.orderBy(AuthTable.subject, KeyTable.name),
)
return {
usage: usageData,
keys: keysData.map((key) => ({
id: key.keyId,
displayName: `${key.userEmail} - ${key.keyName}`,
deleted: key.timeDeleted !== null,
})),
}
}, workspaceID)
}
const MODEL_COLORS: Record<string, string> = {
"claude-sonnet-4-5": "#D4745C",
"claude-sonnet-4": "#E8B4A4",
"claude-opus-4": "#C8A098",
"claude-haiku-4-5": "#F0D8D0",
"claude-3-5-haiku": "#F8E8E0",
"gpt-5.1": "#4A90E2",
"gpt-5.1-codex": "#6BA8F0",
"gpt-5": "#7DB8F8",
"gpt-5-codex": "#9FCAFF",
"gpt-5-nano": "#B8D8FF",
"grok-code": "#8B5CF6",
"big-pickle": "#10B981",
"kimi-k2": "#F59E0B",
"qwen3-coder": "#EC4899",
"glm-4.6": "#14B8A6",
}
function getModelColor(model: string): string {
if (MODEL_COLORS[model]) return MODEL_COLORS[model]
const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 50%, 65%)`
}
function formatDateLabel(dateStr: string): string {
const [, m, d] = dateStr.split("-").map(Number)
const month = new Date(2000, m - 1, 1).toLocaleDateString(undefined, { month: "short" })
return `${month} ${d.toString().padStart(2, "0")}`
}
// Compute the UTC offset (in MySQL CONVERT_TZ format like "+05:30") for the
// given IANA timezone at the given instant. Honors DST.
function getTimezoneOffset(timezone: string, at: Date): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
.formatToParts(at)
.reduce<Record<string, string>>((acc, p) => {
if (p.type !== "literal") acc[p.type] = p.value
return acc
}, {})
const asUTC = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
)
const diffMinutes = Math.round((asUTC - at.getTime()) / 60_000)
const sign = diffMinutes < 0 ? "-" : "+"
const abs = Math.abs(diffMinutes)
const hh = Math.floor(abs / 60)
.toString()
.padStart(2, "0")
const mm = (abs % 60).toString().padStart(2, "0")
return `${sign}${hh}:${mm}`
}
function addOpacityToColor(color: string, opacity: number): string {
if (color.startsWith("#")) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
return color
}
export function GraphSection() {
let canvasRef: HTMLCanvasElement | undefined
let chartInstance: Chart | undefined
const params = useParams()
const i18n = useI18n()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const now = new Date()
const [store, setStore] = createStore({
data: null as Awaited<ReturnType<typeof getCosts>> | null,
year: now.getFullYear(),
month: now.getMonth(),
key: null as string | null,
model: null as string | null,
modelDropdownOpen: false,
keyDropdownOpen: false,
colorScheme: "light" as "light" | "dark",
})
const onPreviousMonth = async () => {
const month = store.month === 0 ? 11 : store.month - 1
const year = store.month === 0 ? store.year - 1 : store.year
setStore({ month, year })
}
const onNextMonth = async () => {
const month = store.month === 11 ? 0 : store.month + 1
const year = store.month === 11 ? store.year + 1 : store.year
setStore({ month, year })
}
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getModels = createMemo(() => {
if (!store.data?.usage) return []
return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => {
// Number of days in the month is independent of timezone.
const daysInMonth = new Date(Date.UTC(store.year, store.month + 1, 0)).getUTCDate()
const yyyy = store.year.toString().padStart(4, "0")
const mm = (store.month + 1).toString().padStart(2, "0")
return Array.from({ length: daysInMonth }, (_, i) => {
const dd = (i + 1).toString().padStart(2, "0")
return `${yyyy}-${mm}-${dd}`
})
})
const getKeyName = (keyID: string | null): string => {
if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys")
const found = store.data.keys.find((k) => k.id === keyID)
if (!found) return i18n.t("workspace.cost.allKeys")
return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName
}
const formatMonthYear = () =>
new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" })
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => {
const data = store.data
const dates = getDates()
if (!data?.usage?.length) return null
store.colorScheme
const styles = getComputedStyle(document.documentElement)
const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim()
const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim()
const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim()
const colorText = styles.getPropertyValue("--color-text").trim()
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
const liteSuffix = " (go)"
const dailyDataRegular = new Map<string, Map<string, number>>()
const dailyDataSub = new Map<string, Map<string, number>>()
const dailyDataLite = new Map<string, Map<string, number>>()
for (const dateKey of dates) {
dailyDataRegular.set(dateKey, new Map())
dailyDataSub.set(dateKey, new Map())
dailyDataLite.set(dateKey, new Map())
}
data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
const dayMap = targetMap.get(row.date)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
})
const filteredModels = store.model === null ? getModels() : [store.model]
// Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
const datasets = [
...filteredModels
.filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0))
.map((model) => {
const color = getModelColor(model)
return {
label: model,
data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
stack: "usage",
}
}),
...filteredModels
.filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0))
.map((model) => {
const color = getModelColor(model)
return {
label: `${model}${subSuffix}`,
data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: addOpacityToColor(color, 0.5),
hoverBackgroundColor: addOpacityToColor(color, 0.7),
borderWidth: 1,
borderColor: color,
stack: "subscription",
}
}),
...filteredModels
.filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0))
.map((model) => {
const color = getModelColor(model)
return {
label: `${model}${liteSuffix}`,
data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: addOpacityToColor(color, 0.35),
hoverBackgroundColor: addOpacityToColor(color, 0.55),
borderWidth: 1,
borderColor: addOpacityToColor(color, 0.7),
borderDash: [4, 2],
stack: "lite",
}
}),
]
return {
type: "bar",
data: {
labels: dates.map(formatDateLabel),
datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
maxRotation: 0,
autoSkipPadding: 20,
color: colorTextMuted,
font: {
family: "monospace",
size: 11,
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: colorBorderMuted,
},
ticks: {
color: colorTextMuted,
font: {
family: "monospace",
size: 11,
},
callback: (value) => {
const num = Number(value)
return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
},
},
},
},
plugins: {
tooltip: {
mode: "index",
intersect: false,
backgroundColor: colorBgElevated,
titleColor: colorText,
bodyColor: colorTextSecondary,
borderColor: colorBorder,
borderWidth: 1,
padding: 12,
displayColors: true,
filter: (item) => (item.parsed.y ?? 0) > 0,
callbacks: {
label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`,
},
},
legend: {
display: true,
position: "bottom",
labels: {
color: colorTextSecondary,
font: {
size: 12,
},
padding: 16,
boxWidth: 16,
boxHeight: 16,
usePointStyle: false,
},
onHover: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(subSuffix)
const isLite = label.endsWith(liteSuffix)
const model = isSub
? label.slice(0, -subSuffix.length)
: isLite
? label.slice(0, -liteSuffix.length)
: label
const baseColor = getModelColor(model)
const originalColor = isSub
? addOpacityToColor(baseColor, 0.5)
: isLite
? addOpacityToColor(baseColor, 0.35)
: baseColor
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})
})
chart.update("none")
},
onLeave: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const label = dataset.label || ""
const isSub = label.endsWith(subSuffix)
const isLite = label.endsWith(liteSuffix)
const model = isSub
? label.slice(0, -subSuffix.length)
: isLite
? label.slice(0, -liteSuffix.length)
: label
const baseColor = getModelColor(model)
const color = isSub
? addOpacityToColor(baseColor, 0.5)
: isLite
? addOpacityToColor(baseColor, 0.35)
: baseColor
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})
})
chart.update("none")
},
},
},
},
}
})
createEffect(async () => {
// Compute the offset for mid-month so DST transitions don't bias to the
// wrong side.
const midMonth = new Date(Date.UTC(store.year, store.month, 15, 12, 0, 0))
const tzOffset = getTimezoneOffset(timezone, midMonth)
const data = await getCosts(params.id!, store.year, store.month, tzOffset)
setStore({ data })
})
createEffect(() => {
const config = chartConfig()
if (!config || !canvasRef) return
if (chartInstance) chartInstance.destroy()
chartInstance = new Chart(canvasRef, config)
onCleanup(() => chartInstance?.destroy())
})
createEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" })
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
setStore({ colorScheme: e.matches ? "dark" : "light" })
}
mediaQuery.addEventListener("change", handleColorSchemeChange)
onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange))
})
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.cost.title")}</h2>
<p>{i18n.t("workspace.cost.subtitle")}</p>
</div>
<div data-slot="filter-container">
<div data-slot="month-picker">
<button data-slot="month-button" onClick={onPreviousMonth}>
<IconChevronLeft />
</button>
<span data-slot="month-label">{formatMonthYear()}</span>
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
<IconChevronRight />
</button>
</div>
<Dropdown
trigger={store.model === null ? i18n.t("workspace.cost.allModels") : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>{i18n.t("workspace.cost.allModels")}</span>
</button>
<For each={getModels()}>
{(model) => (
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
<span>{model}</span>
</button>
)}
</For>
</>
</Dropdown>
<Dropdown
trigger={getKeyName(store.key)}
open={store.keyDropdownOpen}
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>{i18n.t("workspace.cost.allKeys")}</span>
</button>
<For each={store.data?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>
{key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName}
</span>
</button>
)}
</For>
</>
</Dropdown>
</div>
<Show
when={chartConfig()}
fallback={
<div data-component="empty-state">
<p>{i18n.t("workspace.cost.empty")}</p>
</div>
}
>
<div data-slot="chart-container">
<canvas ref={canvasRef} />
</div>
</Show>
</section>
)
}

View File

@@ -0,0 +1,21 @@
import { Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { GraphSection } from "./graph-section"
import { UsageSection } from "./usage-section"
import { querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const user = createAsync(() => querySessionInfo(params.id!))
return (
<div data-page="workspace-[id]">
<div data-slot="sections">
<Show when={user()?.isAdmin}>
<GraphSection />
</Show>
<UsageSection />
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
.root {
/* Empty state */
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
/* Table container */
[data-slot="usage-table"] {
overflow-x: auto;
}
/* Table element */
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text-muted);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text-muted);
}
[data-slot="tokens-with-breakdown"] {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-slot="breakdown-button"] {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--color-text);
}
svg {
width: 16px;
height: 16px;
}
}
[data-slot="breakdown-popup"] {
position: absolute;
left: 0;
top: 100%;
margin-top: var(--space-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
z-index: 10;
min-width: 180px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: var(--font-size-xs);
@media (prefers-color-scheme: dark) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
tbody tr:last-child td {
border-bottom: none;
}
}
/* Pagination */
[data-slot="pagination"] {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
button {
padding: var(--space-2) var(--space-4);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
/* Mobile responsive */
@media (max-width: 40rem) {
[data-slot="usage-table-element"] {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
/* Hide Model column on mobile */
th:nth-child(2),
td:nth-child(2) {
display: none;
}
}
}
/* Breakdown popup content */
[data-slot="breakdown-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-1) 0;
}
[data-slot="breakdown-label"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="breakdown-value"] {
color: var(--color-text);
font-weight: 500;
font-size: var(--font-size-xs);
}
}

View File

@@ -0,0 +1,217 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../../common"
import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
import { useI18n } from "~/context/i18n"
const PAGE_SIZE = 50
async function getUsageInfo(workspaceID: string, page: number) {
"use server"
return withActor(async () => {
return await Billing.usages(page, PAGE_SIZE)
}, workspaceID)
}
const queryUsageInfo = query(getUsageInfo, "usage.list")
export function UsageSection() {
const params = useParams()
const i18n = useI18n()
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
createEffect(() => {
setStore({ usage: usage() })
}, [usage])
createEffect(() => {
if (!openBreakdownId()) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
setOpenBreakdownId(null)
}
}
document.addEventListener("click", handleClickOutside)
return () => document.removeEventListener("click", handleClickOutside)
})
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
}
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.outputTokens + (u.reasoningTokens ?? 0)
}
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
page: store.page - 1,
usage,
})
}
const goNext = async () => {
const usage = await getUsageInfo(params.id!, store.page + 1)
setStore({
page: store.page + 1,
usage,
})
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>{i18n.t("workspace.usage.title")}</h2>
<p>{i18n.t("workspace.usage.subtitle")}</p>
</div>
<div data-slot="usage-table">
<Show
when={hasResults()}
fallback={
<div data-component="empty-state">
<p>{i18n.t("workspace.usage.empty")}</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>{i18n.t("workspace.usage.table.date")}</th>
<th>{i18n.t("workspace.usage.table.model")}</th>
<th>{i18n.t("workspace.usage.table.input")}</th>
<th>{i18n.t("workspace.usage.table.output")}</th>
<th>{i18n.t("workspace.usage.table.cost")}</th>
<th>{i18n.t("workspace.usage.table.session")}</th>
</tr>
</thead>
<tbody>
<For each={store.usage}>
{(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated))
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage))
const inputBreakdownId = `input-breakdown-${index()}`
const outputBreakdownId = `output-breakdown-${index()}`
const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId)
const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId)
const isClaude = usage.model.toLowerCase().includes("claude")
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
<Show when={isInputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">
{i18n.t("workspace.usage.breakdown.cacheWrite")}
</span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
</div>
</Show>
</div>
</td>
<td data-slot="usage-tokens">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span>
<Show when={isOutputOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span>
<span data-slot="breakdown-value">{usage.outputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
</div>
</div>
</Show>
</div>
</td>
<td data-slot="usage-cost">
<Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}>
<Match when={usage.enrichment?.plan === "sub"}>
{i18n.t("workspace.usage.subscription", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Match>
<Match when={usage.enrichment?.plan === "lite"}>
{i18n.t("workspace.usage.lite", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Match>
<Match when={usage.enrichment?.plan === "byok"}>
{i18n.t("workspace.usage.byok", {
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
})}
</Match>
</Switch>
</td>
<td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
</tr>
)
}}
</For>
</tbody>
</table>
<Show when={canGoPrev() || canGoNext()}>
<div data-slot="pagination">
<button disabled={!canGoPrev()} onClick={goPrev}>
<IconChevronLeft />
</button>
<button disabled={!canGoNext()} onClick={goNext}>
<IconChevronRight />
</button>
</div>
</Show>
</Show>
</div>
</section>
)
}

View File

@@ -0,0 +1,122 @@
import { Resource } from "@opencode-ai/console-resource"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { action, json, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
export function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString(undefined, options).replace(",", ",")
}
export function formatDateUTC(date: Date) {
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString(undefined, options)
}
export function formatBalance(amount: number) {
const balance = ((amount ?? 0) / 100000000).toFixed(2)
return balance === "-0.00" ? "0.00" : balance
}
export async function getLastSeenWorkspaceID() {
"use server"
return withActor(async () => {
const actor = Actor.assert("account")
return Database.use(async (tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(
and(
eq(UserTable.accountID, actor.properties.accountID),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
)
.orderBy(desc(UserTable.timeSeen))
.limit(1)
.then((x) => x[0]?.id),
)
})
}
export const querySessionInfo = query(async (workspaceID: string) => {
"use server"
return withActor(() => {
return {
isAdmin: Actor.userRole() === "admin",
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
}
}, workspaceID)
}, "session.get")
export const createCheckoutUrl = action(
async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
)
},
"checkoutUrl",
)
export const queryBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const billing = await Billing.get()
return {
customerID: billing.customerID,
paymentMethodID: billing.paymentMethodID,
paymentMethodType: billing.paymentMethodType,
paymentMethodLast4: billing.paymentMethodLast4,
balance: billing.balance,
reload: billing.reload,
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
monthlyLimit: billing.monthlyLimit,
monthlyUsage: billing.monthlyUsage,
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
reloadError: billing.reloadError,
timeReloadError: billing.timeReloadError,
subscription: billing.subscription,
subscriptionID: billing.subscriptionID,
subscriptionPlan: billing.subscriptionPlan,
timeSubscriptionBooked: billing.timeSubscriptionBooked,
timeSubscriptionSelected: billing.timeSubscriptionSelected,
lite: billing.lite,
liteSubscriptionID: billing.liteSubscriptionID,
}
}, workspaceID)
}, "billing.get")