mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-24 09:35:05 +00:00
feat(web): i18n (#12471)
This commit is contained in:
@@ -129,12 +129,41 @@
|
||||
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);
|
||||
|
||||
@@ -2,9 +2,12 @@ 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 (
|
||||
@@ -14,20 +17,20 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||
<nav data-component="nav-desktop">
|
||||
<div data-component="workspace-nav-items">
|
||||
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
|
||||
Zen
|
||||
{i18n.t("workspace.nav.zen")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
|
||||
API Keys
|
||||
{i18n.t("workspace.nav.apiKeys")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
|
||||
Members
|
||||
{i18n.t("workspace.nav.members")}
|
||||
</A>
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
|
||||
Billing
|
||||
{i18n.t("workspace.nav.billing")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
|
||||
Settings
|
||||
{i18n.t("workspace.nav.settings")}
|
||||
</A>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -36,26 +39,29 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||
<nav data-component="nav-mobile">
|
||||
<div data-component="workspace-nav-items">
|
||||
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
|
||||
Zen
|
||||
{i18n.t("workspace.nav.zen")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
|
||||
API Keys
|
||||
{i18n.t("workspace.nav.apiKeys")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
|
||||
Members
|
||||
{i18n.t("workspace.nav.members")}
|
||||
</A>
|
||||
<Show when={userInfo()?.isAdmin}>
|
||||
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
|
||||
Billing
|
||||
{i18n.t("workspace.nav.billing")}
|
||||
</A>
|
||||
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
|
||||
Settings
|
||||
{i18n.t("workspace.nav.settings")}
|
||||
</A>
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
<div data-component="workspace-content">{props.children}</div>
|
||||
<div data-component="workspace-content">
|
||||
<div data-component="workspace-main">{props.children}</div>
|
||||
<Legal />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { withActor } from "~/context/auth.withActor"
|
||||
import { IconCreditCard, IconStripe } 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"
|
||||
@@ -26,6 +28,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
|
||||
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)
|
||||
@@ -137,16 +140,18 @@ export function BillingSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Billing</h2>
|
||||
<h2>{i18n.t("workspace.billing.title")}</h2>
|
||||
<p>
|
||||
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
|
||||
{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">Current Balance</span>
|
||||
<span data-slot="balance-label">{i18n.t("workspace.billing.currentBalance")}</span>
|
||||
</div>
|
||||
<Show when={billingInfo()?.customerID}>
|
||||
<div data-slot="balance-right-section">
|
||||
@@ -155,7 +160,7 @@ export function BillingSection() {
|
||||
fallback={
|
||||
<div data-slot="add-balance-form-container">
|
||||
<div data-slot="add-balance-form">
|
||||
<label>Add $</label>
|
||||
<label>{i18n.t("workspace.billing.add")}</label>
|
||||
<input
|
||||
data-component="input"
|
||||
type="number"
|
||||
@@ -166,11 +171,11 @@ export function BillingSection() {
|
||||
setStore("addBalanceAmount", e.currentTarget.value)
|
||||
checkoutSubmission.clear()
|
||||
}}
|
||||
placeholder="Enter amount"
|
||||
placeholder={i18n.t("workspace.billing.enterAmount")}
|
||||
/>
|
||||
<div data-slot="form-actions">
|
||||
<button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
data-color="primary"
|
||||
@@ -178,18 +183,20 @@ export function BillingSection() {
|
||||
disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
|
||||
onClick={onClickCheckout}
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Add"}
|
||||
{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">{err()}</div>}
|
||||
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button data-color="primary" onClick={() => showAddBalanceForm()}>
|
||||
Add Balance
|
||||
{i18n.t("workspace.billing.addBalance")}
|
||||
</button>
|
||||
</Show>
|
||||
<div data-slot="credit-card">
|
||||
@@ -209,7 +216,7 @@ export function BillingSection() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={billingInfo()?.paymentMethodType === "link"}>
|
||||
<span data-slot="type">Linked to Stripe</span>
|
||||
<span data-slot="type">{i18n.t("workspace.billing.linkedToStripe")}</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
@@ -218,7 +225,9 @@ export function BillingSection() {
|
||||
disabled={sessionSubmission.pending || store.sessionRedirecting}
|
||||
onClick={onClickSession}
|
||||
>
|
||||
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
|
||||
{sessionSubmission.pending || store.sessionRedirecting
|
||||
? i18n.t("workspace.billing.loading")
|
||||
: i18n.t("workspace.billing.manage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +240,9 @@ export function BillingSection() {
|
||||
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
|
||||
onClick={onClickCheckout}
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable Billing"}
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||
? i18n.t("workspace.billing.loading")
|
||||
: i18n.t("workspace.billing.enable")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ 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"
|
||||
@@ -47,17 +49,18 @@ const querySubscription = query(async (workspaceID: string) => {
|
||||
}, workspaceID)
|
||||
}, "subscription.get")
|
||||
|
||||
function formatResetTime(seconds: number) {
|
||||
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 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
|
||||
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 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
|
||||
if (minutes === 0) return "a few seconds"
|
||||
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
|
||||
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) => {
|
||||
@@ -111,7 +114,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
|
||||
const setUseBalance = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const useBalance = form.get("useBalance")?.toString() === "true"
|
||||
|
||||
return json(
|
||||
@@ -134,6 +137,7 @@ const setUseBalance = action(async (form: FormData) => {
|
||||
|
||||
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)
|
||||
@@ -177,42 +181,50 @@ export function BlackSection() {
|
||||
{(sub) => (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Subscription</h2>
|
||||
<h2>{i18n.t("workspace.black.subscription.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>You are subscribed to OpenCode Black for ${sub().plan} per month.</p>
|
||||
<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 ? "Loading..." : "Manage Subscription"}
|
||||
{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">5-hour Usage</span>
|
||||
<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">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
|
||||
<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">Weekly Usage</span>
|
||||
<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">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
|
||||
<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>Use your available balance after reaching the usage limits</p>
|
||||
<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">
|
||||
@@ -231,19 +243,23 @@ export function BlackSection() {
|
||||
<Show when={billing()?.timeSubscriptionBooked}>
|
||||
<section class={waitlistStyles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Waitlist</h2>
|
||||
<h2>{i18n.t("workspace.black.waitlist.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>
|
||||
{billing()?.timeSubscriptionSelected
|
||||
? `We're ready to enroll you into the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`
|
||||
: `You are on the waitlist for the $${billing()?.subscriptionPlan} per month OpenCode Black plan.`}
|
||||
? 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 ? "Leaving..." : store.cancelled ? "Left" : "Leave Waitlist"}
|
||||
{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>
|
||||
@@ -255,11 +271,13 @@ export function BlackSection() {
|
||||
disabled={enrollSubmission.pending || store.enrolled}
|
||||
onClick={onClickEnroll}
|
||||
>
|
||||
{enrollSubmission.pending ? "Enrolling..." : store.enrolled ? "Enrolled" : "Enroll"}
|
||||
{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">
|
||||
When you click Enroll, your subscription starts immediately and your card will be charged.
|
||||
</p>
|
||||
<p data-slot="enroll-note">{i18n.t("workspace.black.waitlist.enrollNote")}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
@@ -5,15 +5,17 @@ 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")?.toString()
|
||||
if (!limit) return { error: "Limit is required." }
|
||||
if (!limit) return { error: formError.limitRequired }
|
||||
const numericLimit = parseInt(limit)
|
||||
if (numericLimit < 0) return { error: "Set a valid monthly limit." }
|
||||
if (numericLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required." }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
@@ -28,6 +30,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
|
||||
|
||||
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!))
|
||||
@@ -61,8 +64,8 @@ export function MonthlyLimitSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Monthly Limit</h2>
|
||||
<p>Set a monthly usage limit for your account.</p>
|
||||
<h2>{i18n.t("workspace.monthlyLimit.title")}</h2>
|
||||
<p>{i18n.t("workspace.monthlyLimit.subtitle")}</p>
|
||||
</div>
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="balance">
|
||||
@@ -81,42 +84,51 @@ export function MonthlyLimitSection() {
|
||||
data-component="input"
|
||||
name="limit"
|
||||
type="number"
|
||||
placeholder="50"
|
||||
placeholder={i18n.t("workspace.monthlyLimit.placeholder")}
|
||||
/>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
{(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()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? "Setting..." : "Set"}
|
||||
{submission.pending
|
||||
? i18n.t("workspace.monthlyLimit.setting")
|
||||
: i18n.t("workspace.monthlyLimit.set")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
{billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
|
||||
{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">No usage limit set.</p>}>
|
||||
<Show
|
||||
when={billingInfo()?.monthlyLimit}
|
||||
fallback={<p data-slot="usage-status">{i18n.t("workspace.monthlyLimit.noLimit")}</p>}
|
||||
>
|
||||
<p data-slot="usage-status">
|
||||
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
|
||||
{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("en-US", {
|
||||
const current = new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
|
||||
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
|
||||
const getPaymentsInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
@@ -19,6 +20,7 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) =>
|
||||
|
||||
export function PaymentSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const payments = createAsync(() => getPaymentsInfo(params.id!))
|
||||
const downloadReceiptAction = useAction(downloadReceipt)
|
||||
|
||||
@@ -60,17 +62,17 @@ export function PaymentSection() {
|
||||
<Show when={payments() && payments()!.length > 0}>
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Payments History</h2>
|
||||
<p>Recent payment transactions.</p>
|
||||
<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>Date</th>
|
||||
<th>Payment ID</th>
|
||||
<th>Amount</th>
|
||||
<th>Receipt</th>
|
||||
<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>
|
||||
@@ -88,8 +90,13 @@ export function PaymentSection() {
|
||||
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
|
||||
${((amount ?? 0) / 100000000).toFixed(2)}
|
||||
<Switch>
|
||||
<Match when={payment.enrichment?.type === "credit"}> (credit)</Match>
|
||||
<Match when={payment.enrichment?.type === "subscription"}> (subscription)</Match>
|
||||
<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">
|
||||
@@ -103,7 +110,7 @@ export function PaymentSection() {
|
||||
}}
|
||||
data-slot="receipt-button"
|
||||
>
|
||||
View
|
||||
{i18n.t("workspace.payments.view")}
|
||||
</button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
|
||||
@@ -7,11 +7,13 @@ 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")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Billing.reload(), workspaceID), {
|
||||
revalidate: queryBillingInfo.key,
|
||||
})
|
||||
@@ -20,7 +22,7 @@ const reload = action(async (form: FormData) => {
|
||||
const setReload = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const reloadValue = form.get("reload")?.toString() === "true"
|
||||
const amountStr = form.get("reloadAmount")?.toString()
|
||||
const triggerStr = form.get("reloadTrigger")?.toString()
|
||||
@@ -30,9 +32,9 @@ const setReload = action(async (form: FormData) => {
|
||||
|
||||
if (reloadValue) {
|
||||
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
|
||||
return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
|
||||
return { error: formErrorReloadAmountMin(Billing.RELOAD_AMOUNT_MIN) }
|
||||
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
|
||||
return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
|
||||
return { error: formErrorReloadTriggerMin(Billing.RELOAD_TRIGGER_MIN) }
|
||||
}
|
||||
|
||||
return json(
|
||||
@@ -58,6 +60,7 @@ const setReload = action(async (form: FormData) => {
|
||||
|
||||
export function ReloadSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const setReloadSubmission = useSubmission(setReload)
|
||||
const reloadSubmission = useSubmission(reload)
|
||||
@@ -99,23 +102,26 @@ export function ReloadSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Auto Reload</h2>
|
||||
<h2>{i18n.t("workspace.reload.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<Show
|
||||
when={billingInfo()?.reload}
|
||||
fallback={
|
||||
<p>
|
||||
Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
|
||||
{i18n.t("workspace.reload.disabled.before")} <b>{i18n.t("workspace.reload.disabled.state")}</b>.{" "}
|
||||
{i18n.t("workspace.reload.disabled.after")}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
|
||||
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
|
||||
{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 ? "Edit" : "Enable"}
|
||||
{billingInfo()?.reload ? i18n.t("workspace.reload.edit") : i18n.t("workspace.reload.enable")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +129,7 @@ export function ReloadSection() {
|
||||
<form action={setReload} method="post" data-slot="create-form">
|
||||
<div data-slot="form-field">
|
||||
<label>
|
||||
<span data-slot="field-label">Enable Auto Reload</span>
|
||||
<span data-slot="field-label">{i18n.t("workspace.reload.enableAutoReload")}</span>
|
||||
<div data-slot="toggle-container">
|
||||
<label data-slot="model-toggle-label">
|
||||
<input
|
||||
@@ -141,7 +147,7 @@ export function ReloadSection() {
|
||||
|
||||
<div data-slot="input-row">
|
||||
<div data-slot="input-field">
|
||||
<p>Reload $</p>
|
||||
<p>{i18n.t("workspace.reload.reloadAmount")}</p>
|
||||
<input
|
||||
data-component="input"
|
||||
name="reloadAmount"
|
||||
@@ -155,7 +161,7 @@ export function ReloadSection() {
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>When balance reaches $</p>
|
||||
<p>{i18n.t("workspace.reload.whenBalanceReaches")}</p>
|
||||
<input
|
||||
data-component="input"
|
||||
name="reloadTrigger"
|
||||
@@ -171,15 +177,15 @@ export function ReloadSection() {
|
||||
</div>
|
||||
|
||||
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
|
||||
{(err: any) => <div data-slot="form-error">{err()}</div>}
|
||||
{(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()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
|
||||
{setReloadSubmission.pending ? "Saving..." : "Save"}
|
||||
{setReloadSubmission.pending ? i18n.t("workspace.reload.saving") : i18n.t("workspace.reload.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -188,21 +194,21 @@ export function ReloadSection() {
|
||||
<div data-slot="section-content">
|
||||
<div data-slot="reload-error">
|
||||
<p>
|
||||
Reload failed at{" "}
|
||||
{billingInfo()?.timeReloadError!.toLocaleString("en-US", {
|
||||
{i18n.t("workspace.reload.failedAt")}{" "}
|
||||
{billingInfo()?.timeReloadError!.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
. Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
|
||||
again.
|
||||
. {i18n.t("workspace.reload.reason")} {billingInfo()?.reloadError?.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 ? "Retrying..." : "Retry"}
|
||||
{reloadSubmission.pending ? i18n.t("workspace.reload.retrying") : i18n.t("workspace.reload.retry")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Legend,
|
||||
type ChartConfiguration,
|
||||
} from "chart.js"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
|
||||
|
||||
@@ -90,10 +91,8 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
||||
usage: usageData,
|
||||
keys: keysData.map((key) => ({
|
||||
id: key.keyId,
|
||||
displayName:
|
||||
key.timeDeleted !== null
|
||||
? `${key.userEmail} - ${key.keyName} (deleted)`
|
||||
: `${key.userEmail} - ${key.keyName}`,
|
||||
displayName: `${key.userEmail} - ${key.keyName}`,
|
||||
deleted: key.timeDeleted !== null,
|
||||
})),
|
||||
}
|
||||
}, workspaceID)
|
||||
@@ -132,7 +131,7 @@ function formatDateLabel(dateStr: string): string {
|
||||
date.setMonth(m - 1)
|
||||
date.setDate(d)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
const month = date.toLocaleDateString("en-US", { month: "short" })
|
||||
const month = date.toLocaleDateString(undefined, { month: "short" })
|
||||
const day = date.getUTCDate().toString().padStart(2, "0")
|
||||
return `${month} ${day}`
|
||||
}
|
||||
@@ -152,6 +151,7 @@ export function GraphSection() {
|
||||
let canvasRef: HTMLCanvasElement | undefined
|
||||
let chartInstance: Chart | undefined
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const now = new Date()
|
||||
const [store, setStore] = createStore({
|
||||
data: null as Awaited<ReturnType<typeof getCosts>> | null,
|
||||
@@ -193,13 +193,14 @@ export function GraphSection() {
|
||||
})
|
||||
|
||||
const getKeyName = (keyID: string | null): string => {
|
||||
if (!keyID || !store.data?.keys) return "All Keys"
|
||||
if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys")
|
||||
const found = store.data.keys.find((k) => k.id === keyID)
|
||||
return found?.displayName ?? "All Keys"
|
||||
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("en-US", { month: "long", year: "numeric" })
|
||||
new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" })
|
||||
|
||||
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
|
||||
|
||||
@@ -216,6 +217,7 @@ export function GraphSection() {
|
||||
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 dailyDataSub = new Map<string, Map<string, number>>()
|
||||
const dailyDataNonSub = new Map<string, Map<string, number>>()
|
||||
@@ -255,7 +257,7 @@ export function GraphSection() {
|
||||
.map((model) => {
|
||||
const color = getModelColor(model)
|
||||
return {
|
||||
label: `${model} (sub)`,
|
||||
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),
|
||||
@@ -344,8 +346,8 @@ export function GraphSection() {
|
||||
chart.data.datasets?.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(" (sub)")
|
||||
const model = isSub ? label.slice(0, -6) : label
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
const model = isSub ? label.slice(0, -subSuffix.length) : label
|
||||
const baseColor = getModelColor(model)
|
||||
const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
|
||||
const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
|
||||
@@ -360,8 +362,8 @@ export function GraphSection() {
|
||||
chart.data.datasets?.forEach((dataset, i) => {
|
||||
const meta = chart.getDatasetMeta(i)
|
||||
const label = dataset.label || ""
|
||||
const isSub = label.endsWith(" (sub)")
|
||||
const model = isSub ? label.slice(0, -6) : label
|
||||
const isSub = label.endsWith(subSuffix)
|
||||
const model = isSub ? label.slice(0, -subSuffix.length) : label
|
||||
const baseColor = getModelColor(model)
|
||||
const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
|
||||
meta.data.forEach((bar: any) => {
|
||||
@@ -406,8 +408,8 @@ export function GraphSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Cost</h2>
|
||||
<p>Usage costs broken down by model.</p>
|
||||
<h2>{i18n.t("workspace.cost.title")}</h2>
|
||||
<p>{i18n.t("workspace.cost.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div data-slot="filter-container">
|
||||
@@ -421,13 +423,13 @@ export function GraphSection() {
|
||||
</button>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={store.model === null ? "All Models" : store.model}
|
||||
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>All Models</span>
|
||||
<span>{i18n.t("workspace.cost.allModels")}</span>
|
||||
</button>
|
||||
<For each={getModels()}>
|
||||
{(model) => (
|
||||
@@ -445,12 +447,14 @@ export function GraphSection() {
|
||||
>
|
||||
<>
|
||||
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
|
||||
<span>All Keys</span>
|
||||
<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.displayName}</span>
|
||||
<span>
|
||||
{key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -462,7 +466,7 @@ export function GraphSection() {
|
||||
when={chartConfig()}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>No usage data available for the selected period.</p>
|
||||
<p>{i18n.t("workspace.cost.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,11 @@ import { ProviderSection } from "./provider-section"
|
||||
import { GraphSection } from "./graph-section"
|
||||
import { IconLogo } from "~/component/icon"
|
||||
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
|
||||
const checkoutAction = useAction(createCheckoutUrl)
|
||||
@@ -35,9 +37,9 @@ export default function () {
|
||||
<IconLogo />
|
||||
<p>
|
||||
<span>
|
||||
Reliable optimized models for coding agents.{" "}
|
||||
{i18n.t("workspace.home.banner.beforeLink")}{" "}
|
||||
<a target="_blank" href="/docs/zen">
|
||||
Learn more
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
@@ -52,12 +54,14 @@ export default function () {
|
||||
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
|
||||
onClick={onClickCheckout}
|
||||
>
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
|
||||
{checkoutSubmission.pending || store.checkoutRedirecting
|
||||
? i18n.t("workspace.home.billing.loading")
|
||||
: i18n.t("workspace.home.billing.enable")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<span data-slot="balance">
|
||||
Current balance <b>${balance()}</b>
|
||||
{i18n.t("workspace.home.billing.currentBalance")} <b>${balance()}</b>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
|
||||
@@ -7,22 +7,24 @@ 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")?.toString()
|
||||
if (!id) return { error: "ID is required" }
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
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")?.toString().trim()
|
||||
if (!name) return { error: "Name is required" }
|
||||
if (!name) return { error: formError.nameRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
@@ -45,6 +47,7 @@ const listKeys = query(async (workspaceID: string) => {
|
||||
|
||||
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 })
|
||||
@@ -73,11 +76,11 @@ export function KeySection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>API Keys</h2>
|
||||
<h2>{i18n.t("workspace.keys.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>Manage your API keys for accessing opencode services.</p>
|
||||
<p>{i18n.t("workspace.keys.subtitle")}</p>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
Create API Key
|
||||
{i18n.t("workspace.keys.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,19 +92,19 @@ export function KeySection() {
|
||||
data-component="input"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Enter key name"
|
||||
placeholder={i18n.t("workspace.keys.placeholder")}
|
||||
/>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
{(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()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? "Creating..." : "Create"}
|
||||
{submission.pending ? i18n.t("common.creating") : i18n.t("common.create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -111,17 +114,17 @@ export function KeySection() {
|
||||
when={keys()?.length}
|
||||
fallback={
|
||||
<div data-component="empty-state">
|
||||
<p>Create an opencode Gateway API key</p>
|
||||
<p>{i18n.t("workspace.keys.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="api-keys-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Created By</th>
|
||||
<th>Last Used</th>
|
||||
<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>
|
||||
@@ -143,7 +146,7 @@ export function KeySection() {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
}}
|
||||
title="Copy API key"
|
||||
title={i18n.t("workspace.keys.copyApiKey")}
|
||||
>
|
||||
<span>{key.keyDisplay}</span>
|
||||
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
|
||||
@@ -160,7 +163,7 @@ export function KeySection() {
|
||||
<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">Delete</button>
|
||||
<button data-color="ghost">{i18n.t("workspace.keys.delete")}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 { formError, localizeError } from "~/lib/form-error"
|
||||
|
||||
const listMembers = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
@@ -22,14 +24,14 @@ const listMembers = query(async (workspaceID: string) => {
|
||||
const inviteMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const email = form.get("email")?.toString().trim()
|
||||
if (!email) return { error: "Email is required" }
|
||||
if (!email) return { error: formError.emailRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
if (!role) return { error: "Role is required" }
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit")?.toString()
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
@@ -45,9 +47,9 @@ const inviteMember = action(async (form: FormData) => {
|
||||
const removeMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const id = form.get("id")?.toString()
|
||||
if (!id) return { error: "ID is required" }
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
@@ -64,14 +66,14 @@ const updateMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
|
||||
const id = form.get("id")?.toString()
|
||||
if (!id) return { error: "ID is required" }
|
||||
if (!id) return { error: formError.idRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const role = form.get("role")?.toString() as (typeof UserRole)[number]
|
||||
if (!role) return { error: "Role is required" }
|
||||
if (!role) return { error: formError.roleRequired }
|
||||
const limit = form.get("limit")?.toString()
|
||||
const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" }
|
||||
if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid }
|
||||
|
||||
return json(
|
||||
await withActor(
|
||||
@@ -85,7 +87,14 @@ const updateMember = action(async (form: FormData) => {
|
||||
)
|
||||
}, "member.update")
|
||||
|
||||
function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
|
||||
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"
|
||||
@@ -120,12 +129,12 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
const dateLastUsed = props.member.timeMonthlyUsageUpdated
|
||||
if (!dateLastUsed) return 0
|
||||
|
||||
const current = new Date().toLocaleDateString("en-US", {
|
||||
const current = new Date().toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
|
||||
const lastUsed = dateLastUsed.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
@@ -133,18 +142,22 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
return current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
|
||||
})()
|
||||
|
||||
const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
|
||||
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>{props.member.role}</span>}>
|
||||
<Show when={store.editing && !isCurrentUser()} fallback={<span>{roleLabel(props.member.role)}</span>}>
|
||||
<RoleDropdown
|
||||
value={store.selectedRole}
|
||||
options={roleOptions}
|
||||
options={props.roleOptions}
|
||||
onChange={(value) => setStore("selectedRole", value as (typeof UserRole)[number])}
|
||||
/>
|
||||
</Show>
|
||||
@@ -156,12 +169,12 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
type="number"
|
||||
value={store.limit}
|
||||
onInput={(e) => setStore("limit", e.currentTarget.value)}
|
||||
placeholder="No limit"
|
||||
placeholder={i18n.t("workspace.members.noLimit")}
|
||||
min="0"
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
|
||||
<td data-slot="member-joined">{props.member.timeSeen ? "" : i18n.t("workspace.members.invited")}</td>
|
||||
<Show when={isAdmin()}>
|
||||
<td data-slot="member-actions">
|
||||
<Show
|
||||
@@ -169,13 +182,13 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
fallback={
|
||||
<>
|
||||
<button data-color="ghost" onClick={() => show()}>
|
||||
Edit
|
||||
{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">Delete</button>
|
||||
<button data-color="ghost">{i18n.t("workspace.members.delete")}</button>
|
||||
</form>
|
||||
</Show>
|
||||
</>
|
||||
@@ -187,11 +200,11 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
<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 ? "Saving..." : "Save"}
|
||||
{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()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
</form>
|
||||
@@ -202,13 +215,9 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
|
||||
)
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: "admin", description: "Can manage models, members, and billing" },
|
||||
{ value: "member", description: "Can only generate API keys for themselves" },
|
||||
]
|
||||
|
||||
export function MemberSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const data = createAsync(() => listMembers(params.id!))
|
||||
const submission = useSubmission(inviteMember)
|
||||
const [store, setStore] = createStore({
|
||||
@@ -219,6 +228,19 @@ export function MemberSection() {
|
||||
|
||||
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)
|
||||
@@ -243,20 +265,20 @@ export function MemberSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Members</h2>
|
||||
<h2>{i18n.t("workspace.members.title")}</h2>
|
||||
<div data-slot="title-row">
|
||||
<p>Manage workspace members and their permissions.</p>
|
||||
<p>{i18n.t("workspace.members.subtitle")}</p>
|
||||
<Show when={data()?.actorRole === "admin"}>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
Invite Member
|
||||
{i18n.t("workspace.members.invite")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="beta-notice">
|
||||
Workspaces are free for teams during the beta.{" "}
|
||||
{i18n.t("workspace.members.beta.beforeLink")}{" "}
|
||||
<a href="/docs/zen/#for-teams" target="_blank" rel="noopener noreferrer">
|
||||
Learn more
|
||||
{i18n.t("common.learnMore")}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
@@ -264,17 +286,17 @@ export function MemberSection() {
|
||||
<form action={inviteMember} method="post" data-slot="create-form">
|
||||
<div data-slot="input-row">
|
||||
<div data-slot="input-field">
|
||||
<p>Invitee</p>
|
||||
<p>{i18n.t("workspace.members.form.invitee")}</p>
|
||||
<input
|
||||
ref={(r) => (input = r)}
|
||||
data-component="input"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="Enter email"
|
||||
placeholder={i18n.t("workspace.members.form.emailPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>Role</p>
|
||||
<p>{i18n.t("workspace.members.form.role")}</p>
|
||||
<RoleDropdown
|
||||
value={store.selectedRole}
|
||||
options={roleOptions}
|
||||
@@ -282,12 +304,12 @@ export function MemberSection() {
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="input-field">
|
||||
<p>Monthly spending limit</p>
|
||||
<p>{i18n.t("workspace.members.form.monthlyLimit")}</p>
|
||||
<input
|
||||
data-component="input"
|
||||
name="limit"
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
placeholder={i18n.t("workspace.members.noLimit")}
|
||||
value={store.limit}
|
||||
onInput={(e) => setStore("limit", e.currentTarget.value)}
|
||||
min="0"
|
||||
@@ -295,16 +317,16 @@ export function MemberSection() {
|
||||
</div>
|
||||
</div>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
{(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()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||
{submission.pending ? "Inviting..." : "Invite"}
|
||||
{submission.pending ? i18n.t("workspace.members.inviting") : i18n.t("workspace.members.invite")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -313,9 +335,9 @@ export function MemberSection() {
|
||||
<table data-slot="members-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Month limit</th>
|
||||
<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>
|
||||
@@ -331,6 +353,7 @@ export function MemberSection() {
|
||||
workspaceID={params.id!}
|
||||
actorID={data()!.actorID}
|
||||
actorRole={data()!.actorRole}
|
||||
roleOptions={roleOptions}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./role-dropdown.css"
|
||||
|
||||
interface RoleOption {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ interface RoleDropdownProps {
|
||||
|
||||
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)
|
||||
@@ -22,7 +24,7 @@ export function RoleDropdown(props: RoleDropdownProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown trigger={props.value} open={open()} onOpenChange={setOpen} class="role-dropdown">
|
||||
<Dropdown trigger={selected()} open={open()} onOpenChange={setOpen} class="role-dropdown">
|
||||
<>
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
@@ -32,7 +34,7 @@ export function RoleDropdown(props: RoleDropdownProps) {
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<div>
|
||||
<strong>{option.value}</strong>
|
||||
<strong>{option.label}</strong>
|
||||
<p>{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
IconXai,
|
||||
IconZai,
|
||||
} from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
import { formError } from "~/lib/form-error"
|
||||
|
||||
const getModelLab = (modelId: string) => {
|
||||
if (modelId.startsWith("claude")) return "Anthropic"
|
||||
@@ -59,9 +61,9 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
||||
const updateModel = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const model = form.get("model")?.toString()
|
||||
if (!model) return { error: "Model is required" }
|
||||
if (!model) return { error: formError.modelRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
const enabled = form.get("enabled")?.toString() === "true"
|
||||
return json(
|
||||
withActor(async () => {
|
||||
@@ -77,6 +79,7 @@ const updateModel = action(async (form: FormData) => {
|
||||
|
||||
export function ModelSection() {
|
||||
const params = useParams()
|
||||
const i18n = useI18n()
|
||||
const modelsInfo = createAsync(() => getModelsInfo(params.id!))
|
||||
const userInfo = createAsync(() => querySessionInfo(params.id!))
|
||||
|
||||
@@ -91,9 +94,10 @@ export function ModelSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Models</h2>
|
||||
<h2>{i18n.t("workspace.models.title")}</h2>
|
||||
<p>
|
||||
Manage which models workspace members can access. <a href="/docs/zen#pricing ">Learn more</a>.
|
||||
{i18n.t("workspace.models.subtitle.beforeLink")} <a href="/docs/zen#pricing ">{i18n.t("common.learnMore")}</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div data-slot="models-list">
|
||||
@@ -102,9 +106,9 @@ export function ModelSection() {
|
||||
<table data-slot="models-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>{i18n.t("workspace.models.table.model")}</th>
|
||||
<th></th>
|
||||
<th>Enabled</th>
|
||||
<th>{i18n.t("workspace.models.table.enabled")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -20,6 +21,7 @@ const listKeys = query(async (workspaceID: string) => {
|
||||
|
||||
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!))
|
||||
@@ -42,16 +44,16 @@ export function NewUserSection() {
|
||||
<div class={styles.root}>
|
||||
<div data-component="feature-grid">
|
||||
<div data-slot="feature">
|
||||
<h3>Tested & Verified Models</h3>
|
||||
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
|
||||
<h3>{i18n.t("workspace.newUser.feature.tested.title")}</h3>
|
||||
<p>{i18n.t("workspace.newUser.feature.tested.body")}</p>
|
||||
</div>
|
||||
<div data-slot="feature">
|
||||
<h3>Highest Quality</h3>
|
||||
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
|
||||
<h3>{i18n.t("workspace.newUser.feature.quality.title")}</h3>
|
||||
<p>{i18n.t("workspace.newUser.feature.quality.body")}</p>
|
||||
</div>
|
||||
<div data-slot="feature">
|
||||
<h3>No Lock-in</h3>
|
||||
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
|
||||
<h3>{i18n.t("workspace.newUser.feature.lockin.title")}</h3>
|
||||
<p>{i18n.t("workspace.newUser.feature.lockin.body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,17 +70,17 @@ export function NewUserSection() {
|
||||
setCopiedKey(true)
|
||||
setTimeout(() => setCopiedKey(false), 2000)
|
||||
}}
|
||||
title="Copy API key"
|
||||
title={i18n.t("workspace.newUser.copyApiKey")}
|
||||
>
|
||||
<Show
|
||||
when={copiedKey()}
|
||||
fallback={
|
||||
<>
|
||||
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
|
||||
<IconCopy style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copyKey")}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
|
||||
<IconCheck style={{ width: "16px", height: "16px" }} /> {i18n.t("workspace.newUser.copied")}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
@@ -88,13 +90,15 @@ export function NewUserSection() {
|
||||
|
||||
<div data-component="next-steps">
|
||||
<ol>
|
||||
<li>Enable billing</li>
|
||||
<li>{i18n.t("workspace.newUser.step.enableBilling")}</li>
|
||||
<li>
|
||||
Run <code>opencode auth login</code> and select opencode
|
||||
{i18n.t("workspace.newUser.step.login.before")} <code>opencode auth login</code>{" "}
|
||||
{i18n.t("workspace.newUser.step.login.after")}
|
||||
</li>
|
||||
<li>Paste your API key</li>
|
||||
<li>{i18n.t("workspace.newUser.step.pasteKey")}</li>
|
||||
<li>
|
||||
Start opencode and run <code>/models</code> to select a model
|
||||
{i18n.t("workspace.newUser.step.models.before")} <code>/models</code>{" "}
|
||||
{i18n.t("workspace.newUser.step.models.after")}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ 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-" },
|
||||
@@ -20,9 +22,9 @@ function maskCredentials(credentials: string) {
|
||||
const removeProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider")?.toString()
|
||||
if (!provider) return { error: "Provider is required" }
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
|
||||
revalidate: listProviders.key,
|
||||
})
|
||||
@@ -32,10 +34,10 @@ const saveProvider = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const provider = form.get("provider")?.toString()
|
||||
const credentials = form.get("credentials")?.toString()
|
||||
if (!provider) return { error: "Provider is required" }
|
||||
if (!credentials) return { error: "API key is required" }
|
||||
if (!provider) return { error: formError.providerRequired }
|
||||
if (!credentials) return { error: formError.apiKeyRequired }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
@@ -55,6 +57,7 @@ const listProviders = query(async (workspaceID: string) => {
|
||||
|
||||
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")?.toString() === props.provider.key)
|
||||
const removeSubmission = useSubmission(
|
||||
@@ -100,13 +103,16 @@ function ProviderRow(props: { provider: Provider }) {
|
||||
ref={(r) => (input = r)}
|
||||
name="credentials"
|
||||
type="text"
|
||||
placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`}
|
||||
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">{err()}</div>}
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
<input type="hidden" name="provider" value={props.provider.key} />
|
||||
@@ -122,19 +128,19 @@ function ProviderRow(props: { provider: Provider }) {
|
||||
when={!!providerData()}
|
||||
fallback={
|
||||
<button data-color="ghost" onClick={() => show()}>
|
||||
Configure
|
||||
{i18n.t("workspace.providers.configure")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div data-slot="configured-actions">
|
||||
<button data-color="ghost" onClick={() => show()}>
|
||||
Edit
|
||||
{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}>
|
||||
Delete
|
||||
{i18n.t("workspace.providers.delete")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -148,11 +154,11 @@ function ProviderRow(props: { provider: Provider }) {
|
||||
disabled={saveSubmission.pending}
|
||||
form={`provider-form-${props.provider.key}`}
|
||||
>
|
||||
{saveSubmission.pending ? "Saving..." : "Save"}
|
||||
{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()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -163,18 +169,20 @@ function ProviderRow(props: { provider: Provider }) {
|
||||
}
|
||||
|
||||
export function ProviderSection() {
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Bring Your Own Key</h2>
|
||||
<p>Configure your own API keys from AI providers.</p>
|
||||
<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>Provider</th>
|
||||
<th>API Key</th>
|
||||
<th>{i18n.t("workspace.providers.table.provider")}</th>
|
||||
<th>{i18n.t("workspace.providers.table.apiKey")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -6,6 +6,8 @@ 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"
|
||||
@@ -29,10 +31,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
|
||||
const updateWorkspace = action(async (form: FormData) => {
|
||||
"use server"
|
||||
const name = form.get("name")?.toString().trim()
|
||||
if (!name) return { error: "Workspace name is required." }
|
||||
if (name.length > 255) return { error: "Name must be 255 characters or less." }
|
||||
if (!name) return { error: formError.workspaceNameRequired }
|
||||
if (name.length > 255) return { error: formError.nameTooLong }
|
||||
const workspaceID = form.get("workspaceID")?.toString()
|
||||
if (!workspaceID) return { error: "Workspace ID is required." }
|
||||
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||
return json(
|
||||
await withActor(
|
||||
() =>
|
||||
@@ -46,6 +48,7 @@ const updateWorkspace = action(async (form: FormData) => {
|
||||
|
||||
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 })
|
||||
@@ -74,12 +77,12 @@ export function SettingsSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Settings</h2>
|
||||
<p>Update your workspace name and preferences.</p>
|
||||
<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>Workspace name</p>
|
||||
<p>{i18n.t("workspace.settings.workspaceName")}</p>
|
||||
<Show
|
||||
when={!store.show}
|
||||
fallback={
|
||||
@@ -91,19 +94,19 @@ export function SettingsSection() {
|
||||
data-component="input"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Workspace name"
|
||||
value={workspaceInfo()?.name ?? "Default"}
|
||||
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 ? "Updating..." : "Save"}
|
||||
{submission.pending ? i18n.t("workspace.settings.updating") : i18n.t("workspace.settings.save")}
|
||||
</button>
|
||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||
Cancel
|
||||
{i18n.t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||
</Show>
|
||||
</form>
|
||||
}
|
||||
@@ -111,7 +114,7 @@ export function SettingsSection() {
|
||||
<div data-slot="value-with-action">
|
||||
<p data-slot="current-value">{workspaceInfo()?.name}</p>
|
||||
<button data-color="primary" onClick={() => show()}>
|
||||
Edit
|
||||
{i18n.t("workspace.settings.edit")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
|
||||
@@ -20,6 +21,7 @@ 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)
|
||||
@@ -72,26 +74,26 @@ export function UsageSection() {
|
||||
return (
|
||||
<section class={styles.root}>
|
||||
<div data-slot="section-title">
|
||||
<h2>Usage History</h2>
|
||||
<p>Recent API usage and costs.</p>
|
||||
<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>Make your first API call to get started.</p>
|
||||
<p>{i18n.t("workspace.usage.empty")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="usage-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Model</th>
|
||||
<th>Input</th>
|
||||
<th>Output</th>
|
||||
<th>Cost</th>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -126,16 +128,18 @@ export function UsageSection() {
|
||||
<Show when={isInputOpen()}>
|
||||
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">Input</span>
|
||||
<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">Cache Read</span>
|
||||
<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">Cache Write</span>
|
||||
<span data-slot="breakdown-label">
|
||||
{i18n.t("workspace.usage.breakdown.cacheWrite")}
|
||||
</span>
|
||||
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -158,11 +162,11 @@ export function UsageSection() {
|
||||
<Show when={isOutputOpen()}>
|
||||
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
||||
<div data-slot="breakdown-row">
|
||||
<span data-slot="breakdown-label">Output</span>
|
||||
<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">Reasoning</span>
|
||||
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
|
||||
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +178,9 @@ export function UsageSection() {
|
||||
when={usage.enrichment?.plan === "sub"}
|
||||
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
|
||||
>
|
||||
subscription (${((usage.cost ?? 0) / 100000000).toFixed(4)})
|
||||
{i18n.t("workspace.usage.subscription", {
|
||||
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
||||
})}
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function formatDateUTC(date: Date) {
|
||||
timeZoneName: "short",
|
||||
timeZone: "UTC",
|
||||
}
|
||||
return date.toLocaleDateString("en-US", options)
|
||||
return date.toLocaleDateString(undefined, options)
|
||||
}
|
||||
|
||||
export function formatBalance(amount: number) {
|
||||
|
||||
Reference in New Issue
Block a user