summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-10 21:24:05 -0400
committerFrank <[email protected]>2025-10-10 21:24:05 -0400
commit2d35b783335cae9898ec80362934bab892fcf973 (patch)
tree146ba002b1bd58044bcac15c5ced977c086a9bd1 /packages
parent07645e070525e627cda5cd4ad7f001f70cbc57dc (diff)
parentc7dfbbeed0e7b5a7421b4b0d8c115a24f5ba7534 (diff)
downloadopencode-2d35b783335cae9898ec80362934bab892fcf973.tar.gz
opencode-2d35b783335cae9898ec80362934bab892fcf973.zip
Merge branch 'console-workspaces' into dev
Diffstat (limited to 'packages')
-rw-r--r--packages/console/app/src/component/icon.tsx103
-rw-r--r--packages/console/app/src/component/modal.css66
-rw-r--r--packages/console/app/src/component/modal.tsx24
-rw-r--r--packages/console/app/src/lib/beta.ts7
-rw-r--r--packages/console/app/src/routes/user-menu.css68
-rw-r--r--packages/console/app/src/routes/user-menu.tsx63
-rw-r--r--packages/console/app/src/routes/workspace-picker.css127
-rw-r--r--packages/console/app/src/routes/workspace-picker.tsx50
-rw-r--r--packages/console/app/src/routes/workspace.css28
-rw-r--r--packages/console/app/src/routes/workspace.tsx50
-rw-r--r--packages/console/app/src/routes/workspace/[id].css124
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx93
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css (renamed from packages/console/app/src/routes/workspace/billing-section.module.css)15
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx (renamed from packages/console/app/src/routes/workspace/billing-section.tsx)6
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/index.tsx23
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css (renamed from packages/console/app/src/routes/workspace/monthly-limit-section.module.css)8
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx (renamed from packages/console/app/src/routes/workspace/monthly-limit-section.tsx)0
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css (renamed from packages/console/app/src/routes/workspace/payment-section.module.css)0
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx (renamed from packages/console/app/src/routes/workspace/payment-section.tsx)10
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx71
-rw-r--r--packages/console/app/src/routes/workspace/[id]/keys/index.tsx11
-rw-r--r--packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css (renamed from packages/console/app/src/routes/workspace/key-section.module.css)34
-rw-r--r--packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx (renamed from packages/console/app/src/routes/workspace/key-section.tsx)81
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/index.tsx11
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.module.css439
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.tsx445
-rw-r--r--packages/console/app/src/routes/workspace/[id]/model-section.module.css170
-rw-r--r--packages/console/app/src/routes/workspace/[id]/model-section.tsx (renamed from packages/console/app/src/routes/workspace/model-section.tsx)35
-rw-r--r--packages/console/app/src/routes/workspace/[id]/new-user-section.module.css (renamed from packages/console/app/src/routes/workspace/new-user-section.module.css)22
-rw-r--r--packages/console/app/src/routes/workspace/[id]/new-user-section.tsx (renamed from packages/console/app/src/routes/workspace/new-user-section.tsx)0
-rw-r--r--packages/console/app/src/routes/workspace/[id]/provider-section.module.css (renamed from packages/console/app/src/routes/workspace/provider-section.module.css)56
-rw-r--r--packages/console/app/src/routes/workspace/[id]/provider-section.tsx (renamed from packages/console/app/src/routes/workspace/provider-section.tsx)84
-rw-r--r--packages/console/app/src/routes/workspace/[id]/settings/index.tsx11
-rw-r--r--packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css (renamed from packages/console/app/src/routes/workspace/settings-section.module.css)87
-rw-r--r--packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx (renamed from packages/console/app/src/routes/workspace/settings-section.tsx)30
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.module.css (renamed from packages/console/app/src/routes/workspace/usage-section.module.css)0
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.tsx (renamed from packages/console/app/src/routes/workspace/usage-section.tsx)2
-rw-r--r--packages/console/app/src/routes/workspace/common.tsx26
-rw-r--r--packages/console/app/src/routes/workspace/index.tsx0
-rw-r--r--packages/console/app/src/routes/workspace/member-section.module.css179
-rw-r--r--packages/console/app/src/routes/workspace/member-section.tsx328
-rw-r--r--packages/console/app/src/routes/workspace/model-section.module.css122
-rw-r--r--packages/console/core/src/actor.ts5
-rw-r--r--packages/console/core/src/model.ts5
-rw-r--r--packages/console/core/src/provider.ts26
-rw-r--r--packages/console/core/src/user.ts36
-rw-r--r--packages/console/core/src/workspace.ts1
-rw-r--r--packages/console/mail/emails/components.tsx4
-rw-r--r--packages/console/mail/emails/templates/InviteEmail.tsx60
-rw-r--r--packages/console/mail/emails/templates/static/logo.pngbin0 -> 1726 bytes
50 files changed, 2070 insertions, 1176 deletions
diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx
index 2b2dbe411..4d3865c87 100644
--- a/packages/console/app/src/component/icon.tsx
+++ b/packages/console/app/src/component/icon.tsx
@@ -2,26 +2,70 @@ import { JSX } from "solid-js"
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
- <svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none"
- xmlns="http://www.w3.org/2000/svg">
- <path fill-rule="evenodd" clip-rule="evenodd"
- d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z" fill="currentColor"/>
- <path fill-rule="evenodd" clip-rule="evenodd"
- d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor"/>
- <path fill-rule="evenodd" clip-rule="evenodd"
- d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="currentColor"/>
- <path d="M108 12H96V36H90V6H108V12Z" fill="currentColor"/>
- <path d="M114 36H108V12H114V36Z" fill="currentColor"/>
- <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor"/>
- <path fill-rule="evenodd" clip-rule="evenodd"
- d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z" fill="currentColor"/>
- <path fill-rule="evenodd" clip-rule="evenodd"
- d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z" fill="currentColor"/>
- <path fill-rule="evenodd" clip-rule="evenodd"
- d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z" fill="currentColor"/>
- </svg>
-
-)
+ <svg width="64" height="32" viewBox="0 0 64 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M0 9.14333V4.5719H4.57143V9.14333H0Z" fill="currentColor" />
+ <path d="M4.57178 9.14333V4.5719H9.14321V9.14333H4.57178Z" fill="currentColor" />
+ <path d="M9.1438 9.14333V4.5719H13.7152V9.14333H9.1438Z" fill="currentColor" />
+ <path d="M13.7124 9.14333V4.5719H18.2838V9.14333H13.7124Z" fill="currentColor" />
+ <path d="M13.7124 13.7136V9.14221H18.2838V13.7136H13.7124Z" fill="currentColor" />
+ <path d="M0 18.2857V13.7142H4.57143V18.2857H0Z" fill="currentColor" fill-opacity="0.2" />
+ <rect width="4.57143" height="4.57143" transform="translate(4.57178 13.7141)" fill="currentColor" />
+ <path d="M4.57178 18.2855V13.7141H9.14321V18.2855H4.57178Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M9.1438 18.2855V13.7141H13.7152V18.2855H9.1438Z" fill="currentColor" />
+ <path d="M13.7156 18.2855V13.7141H18.287V18.2855H13.7156Z" fill="currentColor" fill-opacity="0.2" />
+ <rect width="4.57143" height="4.57143" transform="translate(0 18.2859)" fill="currentColor" />
+ <path d="M0 22.8572V18.2858H4.57143V22.8572H0Z" fill="currentColor" fill-opacity="0.2" />
+ <rect
+ width="4.57143"
+ height="4.57143"
+ transform="translate(4.57178 18.2859)"
+ fill="currentColor"
+ fill-opacity="0.2"
+ />
+ <path d="M4.57178 22.8573V18.2859H9.14321V22.8573H4.57178Z" fill="currentColor" />
+ <path d="M9.1438 22.8573V18.2859H13.7152V22.8573H9.1438Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M13.7156 22.8573V18.2859H18.287V22.8573H13.7156Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M0 27.4292V22.8578H4.57143V27.4292H0Z" fill="currentColor" />
+ <path d="M4.57178 27.4292V22.8578H9.14321V27.4292H4.57178Z" fill="currentColor" />
+ <path d="M9.1438 27.4276V22.8562H13.7152V27.4276H9.1438Z" fill="currentColor" />
+ <path d="M13.7124 27.4292V22.8578H18.2838V27.4292H13.7124Z" fill="currentColor" />
+ <path d="M22.8572 9.14333V4.5719H27.4286V9.14333H22.8572Z" fill="currentColor" />
+ <path d="M27.426 9.14333V4.5719H31.9975V9.14333H27.426Z" fill="currentColor" />
+ <path d="M32.001 9.14333V4.5719H36.5724V9.14333H32.001Z" fill="currentColor" />
+ <path d="M36.5698 9.14333V4.5719H41.1413V9.14333H36.5698Z" fill="currentColor" />
+ <path d="M22.8572 13.7152V9.1438H27.4286V13.7152H22.8572Z" fill="currentColor" />
+ <path d="M36.5698 13.7152V9.1438H41.1413V13.7152H36.5698Z" fill="currentColor" />
+ <path d="M22.8572 18.2855V13.7141H27.4286V18.2855H22.8572Z" fill="currentColor" />
+ <path d="M27.4292 18.2855V13.7141H32.0006V18.2855H27.4292Z" fill="currentColor" />
+ <path d="M32.001 18.2855V13.7141H36.5724V18.2855H32.001Z" fill="currentColor" />
+ <path d="M36.5698 18.2855V13.7141H41.1413V18.2855H36.5698Z" fill="currentColor" />
+ <path d="M22.8572 22.8573V18.2859H27.4286V22.8573H22.8572Z" fill="currentColor" />
+ <path d="M27.4292 22.8573V18.2859H32.0006V22.8573H27.4292Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M32.001 22.8573V18.2859H36.5724V22.8573H32.001Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M36.5698 22.8573V18.2859H41.1413V22.8573H36.5698Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M22.8572 27.4292V22.8578H27.4286V27.4292H22.8572Z" fill="currentColor" />
+ <path d="M27.4292 27.4276V22.8562H32.0006V27.4276H27.4292Z" fill="currentColor" />
+ <path d="M32.001 27.4276V22.8562H36.5724V27.4276H32.001Z" fill="currentColor" />
+ <path d="M36.5698 27.4292V22.8578H41.1413V27.4292H36.5698Z" fill="currentColor" />
+ <path d="M45.7144 9.14333V4.5719H50.2858V9.14333H45.7144Z" fill="currentColor" />
+ <path d="M50.2861 9.14333V4.5719H54.8576V9.14333H50.2861Z" fill="currentColor" />
+ <path d="M54.855 9.14333V4.5719H59.4264V9.14333H54.855Z" fill="currentColor" />
+ <path d="M45.7144 13.7136V9.14221H50.2858V13.7136H45.7144Z" fill="currentColor" />
+ <path d="M59.4299 13.7152V9.1438H64.0014V13.7152H59.4299Z" fill="currentColor" />
+ <path d="M45.7144 18.2855V13.7141H50.2858V18.2855H45.7144Z" fill="currentColor" />
+ <path d="M50.2861 18.2857V13.7142H54.8576V18.2857H50.2861Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M54.8579 18.2855V13.7141H59.4293V18.2855H54.8579Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M59.4299 18.2855V13.7141H64.0014V18.2855H59.4299Z" fill="currentColor" />
+ <path d="M45.7144 22.8573V18.2859H50.2858V22.8573H45.7144Z" fill="currentColor" />
+ <path d="M50.2861 22.8572V18.2858H54.8576V22.8572H50.2861Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M54.8579 22.8573V18.2859H59.4293V22.8573H54.8579Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M59.4299 22.8573V18.2859H64.0014V22.8573H59.4299Z" fill="currentColor" />
+ <path d="M45.7144 27.4292V22.8578H50.2858V27.4292H45.7144Z" fill="currentColor" />
+ <path d="M50.2861 27.4286V22.8572H54.8576V27.4286H50.2861Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M54.8579 27.4285V22.8571H59.4293V27.4285H54.8579Z" fill="currentColor" fill-opacity="0.2" />
+ <path d="M59.4299 27.4292V22.8578H64.0014V27.4292H59.4299Z" fill="currentColor" />
+ </svg>
+ )
}
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
@@ -55,3 +99,22 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
+
+export function IconChevron(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill="currentColor"
+ d="M4.00024 5.04041L7.37401 1.66663L6.66691 0.959525L4.00024 3.62619L1.33357 0.959525L0.626465 1.66663L4.00024 5.04041Z"
+ />
+ </svg>
+ )
+}
+
+export function IconWorkspaceLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} width="24" height="30" viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z" fill="currentColor" />
+ </svg>
+ )
+}
diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css
new file mode 100644
index 000000000..23b6831c9
--- /dev/null
+++ b/packages/console/app/src/component/modal.css
@@ -0,0 +1,66 @@
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+[data-component="modal"][data-slot="overlay"] {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: rgba(0, 0, 0, 0.5);
+ animation: fadeIn 0.2s ease;
+
+ @media (prefers-color-scheme: dark) {
+ background-color: rgba(0, 0, 0, 0.7);
+ }
+
+ [data-slot="content"] {
+ background-color: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: var(--space-6);
+ min-width: 400px;
+ max-width: 90vw;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
+ animation: slideUp 0.2s ease;
+
+ @media (max-width: 30rem) {
+ min-width: 300px;
+ padding: var(--space-4);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
+ }
+ }
+
+ [data-slot="title"] {
+ margin: 0 0 var(--space-4) 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--color-text);
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/component/modal.tsx b/packages/console/app/src/component/modal.tsx
new file mode 100644
index 000000000..d6dc8a3de
--- /dev/null
+++ b/packages/console/app/src/component/modal.tsx
@@ -0,0 +1,24 @@
+import { JSX, Show } from "solid-js"
+import "./modal.css"
+
+interface ModalProps {
+ open: boolean
+ onClose: () => void
+ title?: string
+ children: JSX.Element
+}
+
+export function Modal(props: ModalProps) {
+ return (
+ <Show when={props.open}>
+ <div data-component="modal" data-slot="overlay" onClick={props.onClose}>
+ <div data-slot="content" onClick={(e) => e.stopPropagation()}>
+ <Show when={props.title}>
+ <h2 data-slot="title">{props.title}</h2>
+ </Show>
+ {props.children}
+ </div>
+ </div>
+ </Show>
+ )
+}
diff --git a/packages/console/app/src/lib/beta.ts b/packages/console/app/src/lib/beta.ts
deleted file mode 100644
index d60a735ee..000000000
--- a/packages/console/app/src/lib/beta.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { query } from "@solidjs/router"
-import { Resource } from "@opencode-ai/console-resource"
-
-export const beta = query(async (workspaceID?: string) => {
- "use server"
- return Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true
-}, "beta")
diff --git a/packages/console/app/src/routes/user-menu.css b/packages/console/app/src/routes/user-menu.css
new file mode 100644
index 000000000..28c7937f5
--- /dev/null
+++ b/packages/console/app/src/routes/user-menu.css
@@ -0,0 +1,68 @@
+[data-component="user-menu"] {
+ position: relative;
+
+ [data-slot="trigger"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border: none;
+ border-radius: var(--border-radius-sm);
+ background-color: transparent;
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-sans);
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--color-surface-hover);
+ }
+
+ span {
+ flex: 1;
+ text-align: left;
+ font-weight: 500;
+ color: var(--color-text-muted);
+ }
+ }
+
+ [data-slot="chevron"] {
+ flex-shrink: 0;
+ color: var(--color-text-secondary);
+ }
+
+ [data-slot="dropdown"] {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ z-index: 1000;
+ margin-top: var(--space-1);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ min-width: 160px;
+
+ @media (prefers-color-scheme: dark) {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ }
+
+ form {
+ width: 100%;
+ }
+ }
+
+ [data-slot="item"],
+ [data-slot="create-item"] {
+ width: 100%;
+ padding: var(--space-2-5) var(--space-3);
+ border: none;
+ background: none;
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-sans);
+ text-align: left;
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx
new file mode 100644
index 000000000..8c011fc0b
--- /dev/null
+++ b/packages/console/app/src/routes/user-menu.tsx
@@ -0,0 +1,63 @@
+import { Show, onCleanup, createEffect } from "solid-js"
+import { createStore } from "solid-js/store"
+import { action, redirect } from "@solidjs/router"
+import { getRequestEvent } from "solid-js/web"
+import { useAuthSession } from "~/context/auth.session"
+import { IconChevron } from "~/component/icon"
+import "./user-menu.css"
+
+const logout = action(async () => {
+ "use server"
+ const auth = await useAuthSession()
+ const event = getRequestEvent()
+ const current = auth.data.current
+ if (current)
+ await auth.update((val) => {
+ delete val.account?.[current]
+ const first = Object.keys(val.account ?? {})[0]
+ val.current = first
+ event!.locals.actor = undefined
+ return val
+ })
+ throw redirect("/zen")
+})
+
+export function UserMenu(props: { email: string | null | undefined }) {
+ const [store, setStore] = createStore({
+ showDropdown: false,
+ })
+ let dropdownRef: HTMLDivElement | undefined
+
+ createEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
+ setStore("showDropdown", false)
+ }
+ }
+
+ document.addEventListener("click", handleClickOutside)
+
+ onCleanup(() => document.removeEventListener("click", handleClickOutside))
+ })
+
+ return (
+ <div data-component="user-menu">
+ <div ref={dropdownRef}>
+ <button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
+ <span>{props.email}</span>
+ <IconChevron data-slot="chevron" />
+ </button>
+
+ <Show when={store.showDropdown}>
+ <div data-slot="dropdown">
+ <form action={logout} method="post">
+ <button type="submit" formaction={logout} data-slot="item">
+ Logout
+ </button>
+ </form>
+ </div>
+ </Show>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css
index c22ced867..dec482286 100644
--- a/packages/console/app/src/routes/workspace-picker.css
+++ b/packages/console/app/src/routes/workspace-picker.css
@@ -1,33 +1,38 @@
[data-component="workspace-picker"] {
position: relative;
- /* Override blue accent colors with neutral colors */
- --color-accent: var(--color-border);
- --color-accent-hover: var(--color-border);
- --color-accent-active: var(--color-border);
- --color-primary: var(--color-border);
- --color-primary-hover: var(--color-border);
- --color-primary-active: var(--color-border);
- --color-primary-alpha-20: transparent;
[data-slot="trigger"] {
+ /* Override blue accent colors with neutral colors for dropdown trigger */
+ --color-accent: var(--color-border);
+ --color-accent-hover: var(--color-border);
+ --color-accent-active: var(--color-border);
+ --color-primary: var(--color-border);
+ --color-primary-hover: var(--color-border);
+ --color-primary-active: var(--color-border);
+ --color-primary-alpha-20: transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
- border: 1px solid var(--color-border);
+ border: none;
border-radius: var(--border-radius-sm);
- background-color: var(--color-bg);
+ background-color: transparent;
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-sans);
cursor: pointer;
- min-width: 200px;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: var(--color-surface-hover);
+ }
span {
flex: 1;
text-align: left;
font-weight: 500;
+ color: var(--color-text);
}
}
@@ -36,20 +41,10 @@
color: var(--color-text-secondary);
}
- [data-slot="dropdown"] button {
- text-decoration: none !important;
- }
-
- /* Ensure text inside buttons has no underline */
- [data-slot="dropdown"] button * {
- text-decoration: none !important;
- }
-
[data-slot="dropdown"] {
position: absolute;
top: 100%;
left: 0;
- right: 0;
z-index: 1000;
margin-top: var(--space-1);
border: 1px solid var(--color-border);
@@ -58,14 +53,15 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-height: 240px;
overflow-y: auto;
+ min-width: 200px;
@media (prefers-color-scheme: dark) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
- [data-slot="option"],
- [data-slot="create-option"] {
+ [data-slot="item"],
+ [data-slot="create-item"] {
width: 100%;
padding: var(--space-2-5) var(--space-3);
border: none;
@@ -74,60 +70,22 @@
font-size: var(--font-size-sm);
font-family: var(--font-sans);
text-align: left;
- cursor: pointer;
- text-decoration: none;
-
- &:hover {
- background-color: var(--color-surface);
- text-decoration: none;
- }
-
- &:focus {
- text-decoration: none;
- }
-
- &:active {
- text-decoration: none;
- }
-
- &:first-child {
- border-top-left-radius: var(--border-radius-sm);
- border-top-right-radius: var(--border-radius-sm);
- }
-
- &:last-child {
- border-bottom-left-radius: var(--border-radius-sm);
- border-bottom-right-radius: var(--border-radius-sm);
- }
- }
-
- [data-slot="option"][data-selected="true"] {
- background-color: transparent;
- color: var(--color-text);
- }
-
- [data-slot="create-option"] {
- color: var(--color-text-secondary);
- font-weight: 500;
}
[data-slot="create-form"] {
- margin-top: var(--space-4);
- padding: var(--space-4);
- border: 1px solid var(--color-border);
- border-radius: var(--border-radius-sm);
- background-color: var(--color-surface);
+ width: 100%;
}
[data-slot="create-input-group"] {
display: flex;
- gap: var(--space-2);
- align-items: center;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
- @media (max-width: 30rem) {
- flex-direction: column;
- align-items: stretch;
- }
+ [data-slot="button-group"] {
+ display: flex;
+ gap: var(--space-2);
+ justify-content: flex-end;
}
[data-slot="create-input"] {
@@ -150,35 +108,4 @@
color: var(--color-text-muted);
}
}
-
- button[type="submit"],
- button[type="button"] {
- padding: var(--space-2-5) var(--space-4);
- background-color: var(--color-bg);
- color: var(--color-text);
- font-size: var(--font-size-sm);
- font-family: var(--font-sans);
- font-weight: 500;
- cursor: pointer;
- white-space: nowrap;
-
- &:focus {
- outline: none;
- box-shadow: none;
- }
-
- &:active {
- transform: translateY(1px);
- }
-
- &[data-color="primary"] {
- background-color: var(--color-text-secondary);
- border-color: var(--color-text-secondary);
- color: var(--color-bg);
- }
-
- @media (max-width: 30rem) {
- flex: 1;
- }
- }
} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx
index 181826335..34a544973 100644
--- a/packages/console/app/src/routes/workspace-picker.tsx
+++ b/packages/console/app/src/routes/workspace-picker.tsx
@@ -1,4 +1,4 @@
-import { query, useParams, action, createAsync, redirect } from "@solidjs/router"
+import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
import { For, Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
@@ -7,6 +7,8 @@ import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/ind
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
+import { IconChevron } from "~/component/icon"
+import { Modal } from "~/component/modal"
import "./workspace-picker.css"
const getWorkspaces = query(async () => {
@@ -40,11 +42,13 @@ const createWorkspace = action(async (form: FormData) => {
export function WorkspacePicker() {
const params = useParams()
const workspaces = createAsync(() => getWorkspaces())
+ const submission = useSubmission(createWorkspace)
const [store, setStore] = createStore({
showForm: false,
showDropdown: false,
})
let dropdownRef: HTMLDivElement | undefined
+ let inputRef: HTMLInputElement | undefined
const currentWorkspace = () => {
const ws = workspaces()?.find((w) => w.id === params.id)
@@ -55,6 +59,12 @@ export function WorkspacePicker() {
setStore({ showForm: true, showDropdown: false })
}
+ createEffect(() => {
+ if (store.showForm && inputRef) {
+ setTimeout(() => inputRef?.focus(), 0)
+ }
+ })
+
const handleSelectWorkspace = (workspaceID: string) => {
if (workspaceID === params.id) {
setStore("showDropdown", false)
@@ -85,25 +95,17 @@ export function WorkspacePicker() {
return (
<div data-component="workspace-picker">
<div ref={dropdownRef}>
- <div data-slot="trigger" onClick={() => setStore("showDropdown", !store.showDropdown)}>
+ <button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
<span>{currentWorkspace()}</span>
- <svg data-slot="chevron" width="12" height="8" viewBox="0 0 12 8" fill="none">
- <path
- d="M1 1L6 6L11 1"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
- </svg>
- </div>
+ <IconChevron data-slot="chevron" />
+ </button>
<Show when={store.showDropdown}>
<div data-slot="dropdown">
<For each={workspaces()}>
{(workspace) => (
<button
- data-slot="option"
+ data-slot="item"
data-selected={workspace.id === params.id}
type="button"
onClick={() => handleSelectWorkspace(workspace.id)}
@@ -112,33 +114,35 @@ export function WorkspacePicker() {
</button>
)}
</For>
- <button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}>
+ <button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
+ Create New Workspace
</button>
</div>
</Show>
</div>
- <Show when={store.showForm}>
+ <Modal open={store.showForm} onClose={() => setStore("showForm", false)} title="Create New Workspace">
<form data-slot="create-form" action={createWorkspace} method="post">
<div data-slot="create-input-group">
<input
+ ref={inputRef}
data-slot="create-input"
type="text"
name="workspaceName"
placeholder="Enter workspace name"
required
- autofocus
/>
- <button type="submit" data-color="primary">
- Create
- </button>
- <button type="button" onClick={() => setStore("showForm", false)}>
- Cancel
- </button>
+ <div data-slot="button-group">
+ <button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Creating..." : "Create"}
+ </button>
+ </div>
</div>
</form>
- </Show>
+ </Modal>
</div>
)
}
diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css
index ed94365f0..e8f12796e 100644
--- a/packages/console/app/src/routes/workspace.css
+++ b/packages/console/app/src/routes/workspace.css
@@ -11,7 +11,6 @@
font-size: var(--font-size-sm);
font-family: var(--font-sans);
font-weight: 500;
- text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
@@ -55,9 +54,6 @@
a {
color: var(--color-text);
- text-decoration: underline;
- text-underline-offset: var(--space-0-75);
- text-decoration-thickness: 1px;
}
/* Workspace Header */
@@ -80,16 +76,14 @@
[data-slot="header-brand"] {
flex: 0 0 auto;
padding-top: 4px;
-
- svg {
- width: 138px;
- }
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
[data-component="site-title"] {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
- text-decoration: none;
letter-spacing: -0.02em;
}
}
@@ -109,19 +103,5 @@
display: none;
}
}
-
- a,
- button {
- appearance: none;
- background: none;
- border: none;
- cursor: pointer;
- padding: 0;
- color: var(--color-text);
- text-decoration: underline;
- text-underline-offset: var(--space-0-75);
- text-decoration-thickness: 1px;
- text-transform: uppercase;
- }
}
-}
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx
index ac394f585..2ac629f52 100644
--- a/packages/console/app/src/routes/workspace.tsx
+++ b/packages/console/app/src/routes/workspace.tsx
@@ -1,62 +1,40 @@
import { Show } from "solid-js"
-import { getRequestEvent } from "solid-js/web"
-import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
+import { query, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import "./workspace.css"
-import { useAuthSession } from "~/context/auth.session"
-import { IconLogo } from "../component/icon"
+import { IconWorkspaceLogo } from "../component/icon"
import { WorkspacePicker } from "./workspace-picker"
+import { UserMenu } from "./user-menu"
import { withActor } from "~/context/auth.withActor"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
-import { beta } from "~/lib/beta"
+import { querySessionInfo } from "./workspace/common"
-const getUserInfo = query(async (workspaceID: string) => {
+const getUserEmail = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const actor = Actor.assert("user")
const email = await User.getAccountEmail(actor.properties.userID)
- return { email }
+ return email
}, workspaceID)
-}, "userInfo")
-
-const logout = action(async () => {
- "use server"
- const auth = await useAuthSession()
- const event = getRequestEvent()
- const current = auth.data.current
- if (current)
- await auth.update((val) => {
- delete val.account?.[current]
- const first = Object.keys(val.account ?? {})[0]
- val.current = first
- event!.locals.actor = undefined
- return val
- })
- throw redirect("/zen")
-})
+}, "userEmail")
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
- const userInfo = createAsync(() => getUserInfo(params.id))
- const isBeta = createAsync(() => beta(params.id))
+ const userEmail = createAsync(() => getUserEmail(params.id))
+ const sessionInfo = createAsync(() => querySessionInfo(params.id))
return (
<main data-page="workspace">
<header data-component="workspace-header">
<div data-slot="header-brand">
<A href="/" data-component="site-title">
- <IconLogo />
+ <IconWorkspaceLogo />
</A>
- </div>
- <div data-slot="header-actions">
- <Show when={isBeta()}>
+ <Show when={sessionInfo()?.isBeta}>
<WorkspacePicker />
</Show>
- <span data-slot="user">{userInfo()?.email}</span>
- <form action={logout} method="post">
- <button type="submit" formaction={logout}>
- Logout
- </button>
- </form>
+ </div>
+ <div data-slot="header-actions">
+ <UserMenu email={userEmail()} />
</div>
</header>
<div>{props.children}</div>
diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css
index 8b318a19f..e2aa0774c 100644
--- a/packages/console/app/src/routes/workspace/[id].css
+++ b/packages/console/app/src/routes/workspace/[id].css
@@ -1,7 +1,72 @@
+[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;
+}
+
+[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;
+ }
+ }
+ }
+}
+
+[data-component="workspace-content"] {
+ flex: 1;
+ padding: var(--space-6) var(--space-8);
+ overflow-y: auto;
+
+ @media (max-width: 48rem) {
+ padding: var(--space-6) var(--space-4);
+ }
+}
+
[data-page="workspace-[id]"] {
max-width: 64rem;
- padding: var(--space-10) var(--space-4);
- margin: 0 auto;
+ padding: var(--space-2) var(--space-4);
+ margin: 0;
width: 100%;
display: flex;
flex-direction: column;
@@ -32,7 +97,6 @@
gap: var(--space-6);
}
- /* Section titles */
[data-slot="section-title"] {
display: flex;
flex-direction: column;
@@ -44,8 +108,7 @@
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
- color: var(--color-text-secondary);
- text-transform: uppercase;
+ color: var(--color-text);
@media (max-width: 30rem) {
font-size: var(--font-size-md);
@@ -66,7 +129,15 @@
}
}
}
+
+ [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);
@@ -78,7 +149,7 @@
}
/* Title section */
- [data-component="title-section"] {
+ [data-component="header-section"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
@@ -105,11 +176,50 @@
p {
line-height: 1.5;
font-size: var(--font-size-md);
- color: var(--color-text-muted);
+ 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);
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
index 15aeb57a0..8347cd49c 100644
--- a/packages/console/app/src/routes/workspace/[id].tsx
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -1,70 +1,37 @@
-import "./[id].css"
-import { MonthlyLimitSection } from "./monthly-limit-section"
-import { NewUserSection } from "./new-user-section"
-import { BillingSection } from "./billing-section"
-import { PaymentSection } from "./payment-section"
-import { UsageSection } from "./usage-section"
-import { KeySection } from "./key-section"
-import { MemberSection } from "./member-section"
-import { SettingsSection } from "./settings-section"
-import { ModelSection } from "./model-section"
-import { ProviderSection } from "./provider-section"
import { Show } from "solid-js"
-import { createAsync, query, useParams } from "@solidjs/router"
-import { Actor } from "@opencode-ai/console-core/actor.js"
-import { withActor } from "~/context/auth.withActor"
-import { User } from "@opencode-ai/console-core/user.js"
-import { beta } from "~/lib/beta"
-
-const getUserInfo = query(async (workspaceID: string) => {
- "use server"
- return withActor(async () => {
- const actor = Actor.assert("user")
- const user = await User.fromID(actor.properties.userID)
- return {
- isAdmin: user?.role === "admin",
- }
- }, workspaceID)
-}, "user.get")
+import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
+import { querySessionInfo } from "./common"
+import "./[id].css"
-export default function () {
+export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
- const userInfo = createAsync(() => getUserInfo(params.id))
- const isBeta = createAsync(() => beta(params.id))
-
+ const userInfo = createAsync(() => querySessionInfo(params.id))
return (
- <div data-page="workspace-[id]">
- <section data-component="title-section">
- <h1>Zen</h1>
- <p>
- Curated list of models provided by opencode.{" "}
- <a target="_blank" href="/docs/zen">
- Learn more
- </a>
- .
- </p>
- </section>
-
- <div data-slot="sections">
- <NewUserSection />
- <KeySection />
- <Show when={isBeta()}>
- <MemberSection />
- </Show>
- <Show when={userInfo()?.isAdmin}>
- <Show when={isBeta()}>
- <SettingsSection />
- <ModelSection />
- <ProviderSection />
- </Show>
- <BillingSection />
- <MonthlyLimitSection />
- </Show>
- <UsageSection />
- <Show when={userInfo()?.isAdmin}>
- <PaymentSection />
- </Show>
+ <main data-page="workspace">
+ <div data-component="workspace-container">
+ <nav data-component="workspace-nav">
+ <div data-component="workspace-nav-items">
+ <A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
+ Zen
+ </A>
+ <A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
+ API Keys
+ </A>
+ <A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
+ Members
+ </A>
+ <Show when={userInfo()?.isAdmin}>
+ <A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
+ Billing
+ </A>
+ <A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
+ Settings
+ </A>
+ </Show>
+ </div>
+ </nav>
+ <div data-component="workspace-content">{props.children}</div>
</div>
- </div>
+ </main>
)
}
diff --git a/packages/console/app/src/routes/workspace/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
index 0bb5709cb..123bb1c86 100644
--- a/packages/console/app/src/routes/workspace/billing-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
@@ -1,10 +1,4 @@
.root {
- [data-slot="section-content"] {
- display: flex;
- flex-direction: column;
- gap: var(--space-3);
- }
-
[data-slot="reload-error"] {
display: flex;
align-items: center;
@@ -29,6 +23,7 @@
flex-shrink: 0;
}
}
+
[data-slot="payment"] {
display: flex;
flex-direction: column;
@@ -86,7 +81,7 @@
@media (max-width: 30rem) {
flex-direction: column;
- > button {
+ >button {
width: 100%;
}
}
@@ -96,19 +91,21 @@
}
/* Make Enable Billing button full width when it's the only button */
- > button {
+ >button {
flex: 1;
}
}
}
+
[data-slot="usage"] {
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
+
b {
font-weight: 600;
}
}
}
-}
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
index 295ad3396..f9084bbf1 100644
--- a/packages/console/app/src/routes/workspace/billing-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
@@ -6,11 +6,7 @@ import { IconCreditCard } from "~/component/icon"
import styles from "./billing-section.module.css"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
-
-const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
-}, "checkoutUrl")
+import { createCheckoutUrl } from "../../common"
const reload = action(async (form: FormData) => {
"use server"
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
new file mode 100644
index 000000000..a6d4825bf
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
@@ -0,0 +1,23 @@
+import { MonthlyLimitSection } from "./monthly-limit-section"
+import { BillingSection } from "./billing-section"
+import { PaymentSection } from "./payment-section"
+import { Show } from "solid-js"
+import { createAsync, useParams } from "@solidjs/router"
+import { querySessionInfo } from "../../common"
+
+export default function () {
+ const params = useParams()
+ const userInfo = createAsync(() => querySessionInfo(params.id))
+
+ return (
+ <div data-page="workspace-[id]">
+ <div data-slot="sections">
+ <Show when={userInfo()?.isAdmin}>
+ <BillingSection />
+ <MonthlyLimitSection />
+ <PaymentSection />
+ </Show>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/monthly-limit-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css
index 02de058e4..4f0f8b2e6 100644
--- a/packages/console/app/src/routes/workspace/monthly-limit-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css
@@ -1,10 +1,4 @@
.root {
- [data-slot="section-content"] {
- display: flex;
- flex-direction: column;
- gap: var(--space-3);
- }
-
[data-slot="balance"] {
display: flex;
flex-direction: column;
@@ -99,4 +93,4 @@
margin: 0;
line-height: 1.4;
}
-}
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
index dbeda115c..dbeda115c 100644
--- a/packages/console/app/src/routes/workspace/monthly-limit-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
diff --git a/packages/console/app/src/routes/workspace/payment-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css
index 2e1afe78b..2e1afe78b 100644
--- a/packages/console/app/src/routes/workspace/payment-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css
diff --git a/packages/console/app/src/routes/workspace/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
index c35a50660..c830cee8a 100644
--- a/packages/console/app/src/routes/workspace/payment-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
@@ -1,8 +1,8 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
-import { For } from "solid-js"
+import { For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
-import { formatDateUTC, formatDateForTable } from "./common"
+import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
const getPaymentsInfo = query(async (workspaceID: string) => {
@@ -19,7 +19,6 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) =>
export function PaymentSection() {
const params = useParams()
- // ORIGINAL CODE - COMMENTED OUT FOR TESTING
const payments = createAsync(() => getPaymentsInfo(params.id))
const downloadReceiptAction = useAction(downloadReceipt)
@@ -58,8 +57,7 @@ export function PaymentSection() {
// ]
return (
- payments() &&
- payments()!.length > 0 && (
+ <Show when={payments() && payments()!.length > 0}>
<section class={styles.root}>
<div data-slot="section-title">
<h2>Payments History</h2>
@@ -109,6 +107,6 @@ export function PaymentSection() {
</table>
</div>
</section>
- )
+ </Show>
)
}
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
new file mode 100644
index 000000000..7f196e45d
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -0,0 +1,71 @@
+import { NewUserSection } from "./new-user-section"
+import { UsageSection } from "./usage-section"
+import { ModelSection } from "./model-section"
+import { ProviderSection } from "./provider-section"
+import { IconLogo } from "~/component/icon"
+import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
+import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common"
+import { Show, createMemo } from "solid-js"
+
+export default function () {
+ const params = useParams()
+ const userInfo = createAsync(() => querySessionInfo(params.id))
+ const billingInfo = createAsync(() => queryBillingInfo(params.id))
+ const createCheckoutUrlAction = useAction(createCheckoutUrl)
+ const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
+
+ const balanceAmount = createMemo(() => {
+ return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ })
+
+ return (
+ <div data-page="workspace-[id]">
+ <section data-component="header-section">
+ <IconLogo />
+ <p>
+ <span>
+ Reliable optimized models for coding agents.{" "}
+ <a target="_blank" href="/docs/zen">
+ Learn more
+ </a>
+ .
+ </span>
+ <span data-slot="billing-info">
+ <Show
+ when={billingInfo()?.reload}
+ fallback={
+ <button
+ data-color="primary"
+ data-size="sm"
+ disabled={createCheckoutUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl
+ }
+ }}
+ >
+ {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
+ </button>
+ }
+ >
+ <span data-slot="balance">
+ Current balance: <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+ </span>
+ </Show>
+ </span>
+ </p>
+ </section>
+
+ <div data-slot="sections">
+ <NewUserSection />
+ <ModelSection />
+ <Show when={userInfo()?.isAdmin}>
+ <ProviderSection />
+ </Show>
+ <UsageSection />
+ </div>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/keys/index.tsx b/packages/console/app/src/routes/workspace/[id]/keys/index.tsx
new file mode 100644
index 000000000..367c4e476
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/keys/index.tsx
@@ -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>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/key-section.module.css b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css
index 6a1d0c85f..1066b7f09 100644
--- a/packages/console/app/src/routes/workspace/key-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css
@@ -1,4 +1,11 @@
.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;
@@ -107,6 +114,7 @@
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;
@@ -140,16 +148,30 @@
&[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);
@@ -157,16 +179,22 @@
}
th {
- &:nth-child(3) /* Date */ {
+ &:nth-child(3)
+
+ /* Date */
+ {
display: none;
}
}
td {
- &:nth-child(3) /* Date */ {
+ &:nth-child(3)
+
+ /* Date */
+ {
display: none;
}
}
}
}
-}
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx
index 3b7e399aa..565981c7f 100644
--- a/packages/console/app/src/routes/workspace/key-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx
@@ -4,7 +4,7 @@ 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 { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
@@ -43,8 +43,9 @@ const listKeys = query(async (workspaceID: string) => {
return withActor(() => Key.list(), workspaceID)
}, "key.list")
-export function KeyCreateForm() {
+export function KeySection() {
const params = useParams()
+ const keys = createAsync(() => listKeys(params.id))
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
@@ -52,22 +53,17 @@ export function KeyCreateForm() {
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
- hide()
+ setStore("show", false)
}
})
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()
+ setTimeout(() => input?.focus(), 0)
}
function hide() {
@@ -75,46 +71,41 @@ export function KeyCreateForm() {
}
return (
- <Show
- when={store.show}
- fallback={
- <button data-color="primary" onClick={() => show()}>
- Create API Key
- </button>
- }
- >
- <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="Enter key name" />
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{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
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Creating..." : "Create"}
- </button>
- </div>
- </form>
- </Show>
- )
-}
-
-export function KeySection() {
- const params = useParams()
- const keys = createAsync(() => listKeys(params.id))
-
- return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>API Keys</h2>
- <p>Manage your API keys for accessing opencode services.</p>
+ <div data-slot="title-row">
+ <p>Manage your API keys for accessing opencode services.</p>
+ <button data-color="primary" onClick={() => show()}>
+ Create API Key
+ </button>
+ </div>
</div>
- <KeyCreateForm />
+ <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="Enter key name"
+ />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{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
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Creating..." : "Create"}
+ </button>
+ </div>
+ </form>
+ </Show>
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
diff --git a/packages/console/app/src/routes/workspace/[id]/members/index.tsx b/packages/console/app/src/routes/workspace/[id]/members/index.tsx
new file mode 100644
index 000000000..51e07b715
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/members/index.tsx
@@ -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>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css
new file mode 100644
index 000000000..d67a29eba
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css
@@ -0,0 +1,439 @@
+.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-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="role-selector"] {
+ position: relative;
+
+ [data-slot="trigger"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ 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;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &: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;
+ transition: transform 0.15s ease;
+ }
+ }
+
+ [data-slot="dropdown"] {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ margin-top: var(--space-1);
+ padding: var(--space-1);
+ background-color: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ min-width: 280px;
+ width: max-content;
+
+ [data-slot="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);
+ }
+
+ p {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ margin: 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ [data-slot="members-table"] {
+ overflow-x: auto;
+ }
+
+ [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);
+
+ [data-slot="role-selector"] {
+ position: relative;
+
+ [data-slot="trigger"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ 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;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-family: var(--font-sans);
+
+ &: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;
+ transition: transform 0.15s ease;
+ }
+ }
+
+ [data-slot="dropdown"] {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ margin-top: var(--space-1);
+ padding: var(--space-1);
+ background-color: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ min-width: 280px;
+ width: max-content;
+
+ [data-slot="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);
+ }
+
+ p {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ margin: 0;
+ }
+ }
+ }
+ }
+ }
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) 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="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;
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
new file mode 100644
index 000000000..e60049a7e
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
@@ -0,0 +1,445 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, createSignal, For, Show, onCleanup } 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 { IconChevron } from "~/component/icon"
+
+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")?.toString().trim()
+ if (!email) return { error: "Email is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ const role = form.get("role")?.toString() as (typeof UserRole)[number]
+ if (!role) return { error: "Role is required" }
+ 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" }
+ 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")?.toString()
+ if (!id) return { error: "ID is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ 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")?.toString()
+ if (!id) return { error: "ID is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ const role = form.get("role")?.toString() as (typeof UserRole)[number]
+ if (!role) return { error: "Role is required" }
+ 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" }
+
+ 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 }) {
+ 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],
+ showRoleDropdown: false,
+ limit: "",
+ })
+
+ let roleDropdownRef: HTMLDivElement | undefined
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ setStore("editing", false)
+ }
+ })
+
+ createEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (roleDropdownRef && !roleDropdownRef.contains(event.target as Node)) {
+ setStore("showRoleDropdown", false)
+ }
+ }
+
+ document.addEventListener("click", handleClickOutside)
+ onCleanup(() => document.removeEventListener("click", handleClickOutside))
+ })
+
+ function show() {
+ while (true) {
+ submission.clear()
+ if (!submission.result) break
+ }
+ setStore("editing", true)
+ setStore("selectedRole", props.member.role)
+ setStore("limit", props.member.monthlyLimit?.toString() ?? "")
+ }
+
+ function hide() {
+ setStore("editing", false)
+ setStore("showRoleDropdown", false)
+ }
+
+ function getUsageDisplay() {
+ const currentUsage = (() => {
+ const dateLastUsed = props.member.timeMonthlyUsageUpdated
+ if (!dateLastUsed) return 0
+
+ const current = new Date().toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ const usage = current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
+ return (usage / 100000000).toFixed(2)
+ })()
+
+ const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
+ return `$${currentUsage} / ${limit}`
+ }
+
+ const roleLabels = {
+ admin: { title: "Admin", description: "Can manage models, members, and billing" },
+ member: { title: "Member", description: "Can only generate API keys for themselves" },
+ }
+
+ return (
+ <tr>
+ <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
+ <td data-slot="member-role">
+ <Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
+ <div data-slot="role-selector" ref={roleDropdownRef}>
+ <button
+ data-slot="trigger"
+ type="button"
+ onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
+ >
+ <span>{roleLabels[store.selectedRole].title}</span>
+ <IconChevron data-slot="chevron" />
+ </button>
+ <Show when={store.showRoleDropdown}>
+ <div data-slot="dropdown">
+ <button
+ data-slot="item"
+ data-selected={store.selectedRole === "admin"}
+ type="button"
+ onClick={() => {
+ setStore("selectedRole", "admin")
+ setStore("showRoleDropdown", false)
+ }}
+ >
+ <div>
+ <strong>Admin</strong>
+ <p>{roleLabels.admin.description}</p>
+ </div>
+ </button>
+ <button
+ data-slot="item"
+ data-selected={store.selectedRole === "member"}
+ type="button"
+ onClick={() => {
+ setStore("selectedRole", "member")
+ setStore("showRoleDropdown", false)
+ }}
+ >
+ <div>
+ <strong>{roleLabels.member.title}</strong>
+ <p>{roleLabels.member.description}</p>
+ </div>
+ </button>
+ </div>
+ </Show>
+ </div>
+ </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="No limit"
+ min="0"
+ />
+ </Show>
+ </td>
+ <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
+ <Show when={isAdmin()}>
+ <td data-slot="member-actions">
+ <Show
+ when={store.editing}
+ fallback={
+ <>
+ <button data-color="ghost" onClick={() => show()}>
+ 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>
+ </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 ? "Saving..." : "Save"}
+ </button>
+ <Show when={!submission.pending}>
+ <button type="button" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ </Show>
+ </form>
+ </Show>
+ </td>
+ </Show>
+ </tr>
+ )
+}
+
+export function MemberSection() {
+ const params = useParams()
+ const data = createAsync(() => listMembers(params.id))
+ const submission = useSubmission(inviteMember)
+ const [store, setStore] = createStore({
+ show: false,
+ selectedRole: "member" as (typeof UserRole)[number],
+ showRoleDropdown: false,
+ limit: "",
+ })
+
+ let input: HTMLInputElement
+ let roleDropdownRef: HTMLDivElement | undefined
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ setStore("show", false)
+ }
+ })
+
+ createEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (roleDropdownRef && !roleDropdownRef.contains(event.target as Node)) {
+ setStore("showRoleDropdown", false)
+ }
+ }
+
+ document.addEventListener("click", handleClickOutside)
+ onCleanup(() => document.removeEventListener("click", handleClickOutside))
+ })
+
+ 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)
+ setStore("showRoleDropdown", false)
+ }
+
+ const roleLabels = {
+ admin: { title: "Admin", description: "Can manage models, members, and billing" },
+ member: { title: "Member", description: "Can only generate API keys for themselves" },
+ }
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>Members</h2>
+ <div data-slot="title-row">
+ <p>Manage workspace members and their permissions.</p>
+ <Show when={data()?.actorRole === "admin"}>
+ <button data-color="primary" onClick={() => show()}>
+ Invite Member
+ </button>
+ </Show>
+ </div>
+ </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>Invitee</p>
+ <input
+ ref={(r) => (input = r)}
+ data-component="input"
+ name="email"
+ type="text"
+ placeholder="Enter email"
+ />
+ </div>
+ <div data-slot="input-field">
+ <p>Role</p>
+ <div data-slot="role-selector" ref={roleDropdownRef}>
+ <button
+ data-slot="trigger"
+ type="button"
+ onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
+ >
+ <span>{roleLabels[store.selectedRole].title}</span>
+ <IconChevron data-slot="chevron" />
+ </button>
+ <Show when={store.showRoleDropdown}>
+ <div data-slot="dropdown">
+ <button
+ data-slot="item"
+ data-selected={store.selectedRole === "admin"}
+ type="button"
+ onClick={() => {
+ setStore("selectedRole", "admin")
+ setStore("showRoleDropdown", false)
+ }}
+ >
+ <div>
+ <strong>Admin</strong>
+ <p>{roleLabels.admin.description}</p>
+ </div>
+ </button>
+ <button
+ data-slot="item"
+ data-selected={store.selectedRole === "member"}
+ type="button"
+ onClick={() => {
+ setStore("selectedRole", "member")
+ setStore("showRoleDropdown", false)
+ }}
+ >
+ <div>
+ <strong>{roleLabels.member.title}</strong>
+ <p>{roleLabels.member.description}</p>
+ </div>
+ </button>
+ </div>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="input-field">
+ <p>Monthly spending limit</p>
+ <input
+ data-component="input"
+ name="limit"
+ type="number"
+ placeholder="No limit"
+ 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">{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
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Inviting..." : "Invite"}
+ </button>
+ </div>
+ </form>
+ </Show>
+ <div data-slot="members-table">
+ <table data-slot="members-table-element">
+ <thead>
+ <tr>
+ <th>Email</th>
+ <th>Role</th>
+ <th>Month limit</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}
+ />
+ )}
+ </For>
+ </Show>
+ </tbody>
+ </table>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.module.css b/packages/console/app/src/routes/workspace/[id]/model-section.module.css
new file mode 100644
index 000000000..420545670
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/model-section.module.css
@@ -0,0 +1,170 @@
+[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;
+ }
+
+ &[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;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
index 4128b4a2c..96d6950c9 100644
--- a/packages/console/app/src/routes/workspace/model-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx
@@ -4,6 +4,7 @@ import { createMemo, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { ZenModel } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
+import { querySessionInfo } from "../common"
const getModelsInfo = query(async (workspaceID: string) => {
"use server"
@@ -39,28 +40,24 @@ const updateModel = action(async (form: FormData) => {
export function ModelSection() {
const params = useParams()
const modelsInfo = createAsync(() => getModelsInfo(params.id))
+ const userInfo = createAsync(() => querySessionInfo(params.id))
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Models</h2>
- <p>Manage models for your workspace.</p>
+ <p>
+ Manage which models workspace members can access. Requests will fail if a member tries to use a disabled
+ model.{userInfo()?.isAdmin ? "" : " To use a disabled model, contact your workspace’s admin."}
+ </p>
</div>
<div data-slot="models-list">
- <Show
- when={modelsInfo()}
- fallback={
- <div data-component="empty-state">
- <p>Loading models...</p>
- </div>
- }
- >
+ <Show when={modelsInfo()}>
<div data-slot="models-table">
<table data-slot="models-table-element">
<thead>
<tr>
<th>Model</th>
- <th>Status</th>
- <th>Action</th>
+ <th>Enabled</th>
</tr>
</thead>
<tbody>
@@ -68,15 +65,25 @@ export function ModelSection() {
{(modelId) => {
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId))
return (
- <tr data-slot="model-row" data-enabled={isEnabled()}>
+ <tr data-slot="model-row" data-disabled={!isEnabled()}>
<td data-slot="model-name">{modelId}</td>
- <td data-slot="model-status">{isEnabled() ? "Enabled" : "Disabled"}</td>
<td data-slot="model-toggle">
<form action={updateModel} method="post">
<input type="hidden" name="model" value={modelId} />
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="enabled" value={isEnabled().toString()} />
- <button data-color="ghost">{isEnabled() ? "Disable" : "Enable"}</button>
+ <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>
diff --git a/packages/console/app/src/routes/workspace/new-user-section.module.css b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css
index 2edc7cc14..aaad823ab 100644
--- a/packages/console/app/src/routes/workspace/new-user-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css
@@ -53,26 +53,6 @@
flex-direction: column;
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-secondary);
- text-transform: uppercase;
-
- @media (max-width: 30rem) {
- font-size: var(--font-size-md);
- }
- }
- }
-
[data-slot="key-display"] {
display: flex;
flex-direction: column;
@@ -160,4 +140,4 @@
}
}
}
-}
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/new-user-section.tsx b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx
index b694801cc..b694801cc 100644
--- a/packages/console/app/src/routes/workspace/new-user-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx
diff --git a/packages/console/app/src/routes/workspace/provider-section.module.css b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css
index 5f18862f5..1a450d3dc 100644
--- a/packages/console/app/src/routes/workspace/provider-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css
@@ -18,6 +18,14 @@
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
+
+ &:nth-child(1) {
+ width: 180px;
+ }
+
+ &:nth-child(3) {
+ width: 200px;
+ }
}
td {
@@ -32,24 +40,21 @@
font-weight: 500;
}
- &[data-slot="provider-status"] {
- text-align: left;
- color: var(--color-text);
- }
-
- &[data-slot="provider-toggle"] {
+ &[data-slot="provider-key"] {
text-align: left;
- font-family: var(--font-sans);
+ 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);
@@ -59,6 +64,8 @@
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
+ width: 100%;
+ box-sizing: border-box;
&:focus {
outline: none;
@@ -76,18 +83,43 @@
line-height: 1.4;
}
}
+ }
+ }
- [data-slot="form-actions"] {
- display: flex;
- gap: var(--space-2);
+ &[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 {
- &[data-enabled="false"] {
- opacity: 0.6;
+ &:hover {
+ [data-slot="provider-action"] [data-slot="delete-form"] {
+ opacity: 1;
+ pointer-events: auto;
+ }
}
&:last-child td {
diff --git a/packages/console/app/src/routes/workspace/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
index 856b3a6a2..6ec8477b4 100644
--- a/packages/console/app/src/routes/workspace/provider-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx
@@ -12,6 +12,10 @@ const PROVIDERS = [
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")?.toString()
@@ -58,7 +62,7 @@ function ProviderRow(props: { provider: Provider }) {
let input: HTMLInputElement
- const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key)
+ const providerData = () => providers()?.find((p) => p.provider === props.provider.key)
createEffect(() => {
if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
@@ -80,32 +84,14 @@ function ProviderRow(props: { provider: Provider }) {
}
return (
- <tr data-slot="provider-row" data-enabled={isEnabled()}>
+ <tr data-slot="provider-row">
<td data-slot="provider-name">{props.provider.name}</td>
- <td data-slot="provider-status">{isEnabled() ? "Configured" : "Not Configured"}</td>
- <td data-slot="provider-toggle">
+ <td data-slot="provider-key">
<Show
when={store.editing}
- fallback={
- <Show
- when={isEnabled()}
- fallback={
- <button data-color="ghost" onClick={() => show()}>
- Configure
- </button>
- }
- >
- <form action={removeProvider} method="post">
- <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}>
- Disable
- </button>
- </form>
- </Show>
- }
+ fallback={<span>{providerData() ? maskCredentials(providerData()!.credentials) : "-"}</span>}
>
- <form action={saveProvider} method="post" data-slot="edit-form">
+ <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)}
@@ -122,15 +108,51 @@ function ProviderRow(props: { provider: Provider }) {
</div>
<input type="hidden" name="provider" value={props.provider.key} />
<input type="hidden" name="workspaceID" value={params.id} />
- <div data-slot="form-actions">
+ </form>
+ </Show>
+ </td>
+ <td data-slot="provider-action">
+ <Show
+ when={store.editing}
+ fallback={
+ <Show
+ when={!!providerData()}
+ fallback={
+ <button data-color="ghost" onClick={() => show()}>
+ Configure
+ </button>
+ }
+ >
+ <div data-slot="configured-actions">
+ <button data-color="ghost" onClick={() => show()}>
+ 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
+ </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 ? "Saving..." : "Save"}
+ </button>
+ <Show when={!saveSubmission.pending}>
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
- <button type="submit" data-color="ghost" disabled={saveSubmission.pending}>
- {saveSubmission.pending ? "Saving..." : "Save"}
- </button>
- </div>
- </form>
+ </Show>
+ </div>
</Show>
</td>
</tr>
@@ -149,8 +171,8 @@ export function ProviderSection() {
<thead>
<tr>
<th>Provider</th>
- <th>Status</th>
- <th>Action</th>
+ <th>API Key</th>
+ <th></th>
</tr>
</thead>
<tbody>
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/index.tsx b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx
new file mode 100644
index 000000000..7c8f1fd17
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx
@@ -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>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/settings-section.module.css b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
index e3a5ad508..058fbe301 100644
--- a/packages/console/app/src/routes/workspace/settings-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
@@ -1,63 +1,61 @@
.root {
- [data-slot="section-content"] {
- display: flex;
- flex-direction: column;
- gap: var(--space-4);
- }
+ max-width: 40rem;
[data-slot="setting"] {
display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: var(--space-4);
- padding: var(--space-4);
- border: 1px solid var(--color-border);
- border-radius: var(--border-radius-sm);
-
- @media (max-width: 30rem) {
- flex-direction: column;
- gap: var(--space-3);
- }
- }
-
- [data-slot="setting-info"] {
- flex: 1;
- display: flex;
flex-direction: column;
- gap: var(--space-1);
+ gap: var(--space-3);
- h3 {
- font-size: var(--font-size-md);
- font-weight: 500;
+ p {
line-height: 1.2;
margin: 0;
- color: var(--color-text);
+ 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"] {
- font-size: var(--font-size-sm);
- color: var(--color-text-muted);
+ 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-3);
- min-width: 15rem;
- width: fit-content;
-
- @media (max-width: 30rem) {
- width: 100%;
- min-width: auto;
- }
+ gap: var(--space-2);
[data-slot="input-container"] {
display: flex;
- flex-direction: column;
- gap: var(--space-1);
+ 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 {
@@ -68,11 +66,13 @@
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
- font-family: var(--font-mono);
+ 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 {
@@ -80,16 +80,15 @@
}
}
- [data-slot="form-actions"] {
- display: flex;
- gap: var(--space-2);
- justify-content: flex-end;
+ >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);
}
}
-}
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
index 0fc0158da..828f1be7e 100644
--- a/packages/console/app/src/routes/workspace/settings-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
@@ -79,10 +79,7 @@ export function SettingsSection() {
</div>
<div data-slot="section-content">
<div data-slot="setting">
- <div data-slot="setting-info">
- <h3>Workspace Name</h3>
- <p data-slot="current-value">{workspaceInfo()?.name}</p>
- </div>
+ <p>Workspace name</p>
<Show
when={!store.show}
fallback={
@@ -97,25 +94,26 @@ export function SettingsSection() {
placeholder="Workspace name"
value={workspaceInfo()?.name ?? "Default"}
/>
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
- </Show>
- </div>
- <input type="hidden" name="workspaceID" value={params.id} />
- <div data-slot="form-actions">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Updating..." : "Save"}
+ </button>
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Updating..." : "Update"}
- </button>
</div>
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
</form>
}
>
- <button data-color="primary" onClick={() => show()}>
- Edit Name
- </button>
+ <div data-slot="value-with-action">
+ <p data-slot="current-value">{workspaceInfo()?.name}</p>
+ <button data-color="primary" onClick={() => show()}>
+ Edit
+ </button>
+ </div>
</Show>
</div>
</div>
diff --git a/packages/console/app/src/routes/workspace/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
index 1a772ba87..1a772ba87 100644
--- a/packages/console/app/src/routes/workspace/usage-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
diff --git a/packages/console/app/src/routes/workspace/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
index 9f65fe5f7..47a2e43f7 100644
--- a/packages/console/app/src/routes/workspace/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
@@ -1,7 +1,7 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
-import { formatDateUTC, formatDateForTable } from "./common"
+import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import styles from "./usage-section.module.css"
diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx
index f85fd8423..fef1b3cd9 100644
--- a/packages/console/app/src/routes/workspace/common.tsx
+++ b/packages/console/app/src/routes/workspace/common.tsx
@@ -1,3 +1,9 @@
+import { Resource } from "@opencode-ai/console-resource"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { action, query } from "@solidjs/router"
+import { withActor } from "~/context/auth.withActor"
+import { Billing } from "@opencode-ai/console-core/billing.js"
+
export function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
@@ -23,3 +29,23 @@ export function formatDateUTC(date: Date) {
}
return date.toLocaleDateString("en-US", options)
}
+
+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, successUrl: string, cancelUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
+}, "checkoutUrl")
+
+export const queryBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(() => Billing.get(), workspaceID)
+}, "billing.get")
diff --git a/packages/console/app/src/routes/workspace/index.tsx b/packages/console/app/src/routes/workspace/index.tsx
deleted file mode 100644
index e69de29bb..000000000
--- a/packages/console/app/src/routes/workspace/index.tsx
+++ /dev/null
diff --git a/packages/console/app/src/routes/workspace/member-section.module.css b/packages/console/app/src/routes/workspace/member-section.module.css
deleted file mode 100644
index 16b6ff8d2..000000000
--- a/packages/console/app/src/routes/workspace/member-section.module.css
+++ /dev/null
@@ -1,179 +0,0 @@
-.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);
- 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="members-table"] {
- overflow-x: auto;
- }
-
- [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;
- }
-
- 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);
-
- button {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- padding: var(--space-2) 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="member-date"] {
- color: var(--color-text);
- }
-
- &[data-slot="member-actions"] {
- font-family: var(--font-sans);
- }
- }
-
- 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(3)
-
- /* Date */
- {
- display: none;
- }
- }
-
- td {
- &:nth-child(3)
-
- /* Date */
- {
- display: none;
- }
- }
- }
- }
-} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace/member-section.tsx b/packages/console/app/src/routes/workspace/member-section.tsx
deleted file mode 100644
index b13e8e5ed..000000000
--- a/packages/console/app/src/routes/workspace/member-section.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { createEffect, createSignal, 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"
-
-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")?.toString().trim()
- if (!email) return { error: "Email is required" }
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- const role = form.get("role")?.toString() as (typeof UserRole)[number]
- if (!role) return { error: "Role is required" }
- return json(
- await withActor(
- () =>
- User.invite({ email, role })
- .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")?.toString()
- if (!id) return { error: "ID is required" }
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- 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")?.toString()
- if (!id) return { error: "ID is required" }
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- const role = form.get("role")?.toString() as (typeof UserRole)[number]
- if (!role) return { error: "Role is required" }
- 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" }
-
- 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")
-
-export function MemberCreateForm() {
- const params = useParams()
- const submission = useSubmission(inviteMember)
- const [store, setStore] = createStore({ show: false })
-
- 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 (
- <Show
- when={store.show}
- fallback={
- <button data-color="primary" onClick={() => show()}>
- Invite Member
- </button>
- }
- >
- <form action={inviteMember} method="post" data-slot="create-form">
- <div data-slot="input-container">
- <input ref={(r) => (input = r)} data-component="input" name="email" type="text" placeholder="Enter email" />
- <div data-slot="role-selector">
- <label>
- <input type="radio" name="role" value="admin" checked />
- <div>
- <strong>Admin</strong>
- <p>Can manage models, members, and billing</p>
- </div>
- </label>
- <label>
- <input type="radio" name="role" value="member" />
- <div>
- <strong>Member</strong>
- <p>Can only generate API keys for themselves</p>
- </div>
- </label>
- </div>
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{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
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Inviting..." : "Invite"}
- </button>
- </div>
- </form>
- </Show>
- )
-}
-
-function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
- const [editing, setEditing] = createSignal(false)
- const submission = useSubmission(updateMember)
- const isCurrentUser = () => props.actorID === props.member.id
- const isAdmin = () => props.actorRole === "admin"
-
- createEffect(() => {
- if (!submission.pending && submission.result && !submission.result.error) {
- setEditing(false)
- }
- })
-
- function getUsageDisplay() {
- const currentUsage = (() => {
- const dateLastUsed = props.member.timeMonthlyUsageUpdated
- if (!dateLastUsed) return 0
-
- const current = new Date().toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- timeZone: "UTC",
- })
- const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- timeZone: "UTC",
- })
- if (current !== lastUsed) return 0
- return ((props.member.monthlyUsage ?? 0) / 100000000).toFixed(2)
- })()
-
- const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
- return `$${currentUsage} / ${limit}`
- }
-
- return (
- <Show
- when={editing()}
- fallback={
- <tr>
- <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
- <td data-slot="member-role">{props.member.role}</td>
- <td data-slot="member-usage">{getUsageDisplay()}</td>
- <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
- <td data-slot="member-actions">
- <Show when={isAdmin()}>
- <button data-color="ghost" onClick={() => setEditing(true)}>
- 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>
- </form>
- </Show>
- </Show>
- </td>
- </tr>
- }
- >
- <tr>
- <td colspan="5">
- <form action={updateMember} method="post">
- <div data-slot="edit-member-email">{props.member.accountEmail ?? props.member.email}</div>
- <input type="hidden" name="id" value={props.member.id} />
- <input type="hidden" name="workspaceID" value={props.workspaceID} />
-
- <Show
- when={!isCurrentUser()}
- fallback={
- <>
- <div data-slot="current-user-role">Role: {props.member.role}</div>
- <input type="hidden" name="role" value={props.member.role} />
- </>
- }
- >
- <div data-slot="role-selector">
- <label>
- <input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
- <div>
- <strong>Admin</strong>
- <p>Can manage models, members, and billing</p>
- </div>
- </label>
- <label>
- <input type="radio" name="role" value="member" checked={props.member.role === "member"} />
- <div>
- <strong>Member</strong>
- <p>Can only generate API keys for themselves</p>
- </div>
- </label>
- </div>
- </Show>
-
- <div data-slot="limit-selector">
- <label>
- <strong>Monthly Limit</strong>
- <input
- type="number"
- name="limit"
- value={props.member.monthlyLimit ?? ""}
- placeholder="No limit"
- min="0"
- />
- <p>Set a monthly spending limit for this user</p>
- </label>
- </div>
-
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
- </Show>
-
- <div data-slot="form-actions">
- <button type="button" data-color="ghost" onClick={() => setEditing(false)}>
- Cancel
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Saving..." : "Save"}
- </button>
- </div>
- </form>
- </td>
- </tr>
- </Show>
- )
-}
-
-export function MemberSection() {
- const params = useParams()
- const data = createAsync(() => listMembers(params.id))
-
- return (
- <section class={styles.root}>
- <div data-slot="section-title">
- <h2>Members</h2>
- </div>
- <Show when={data()?.actorRole === "admin"}>
- <MemberCreateForm />
- </Show>
- <div data-slot="members-table">
- <table data-slot="members-table-element">
- <thead>
- <tr>
- <th>Email</th>
- <th>Role</th>
- <th>Usage</th>
- <th></th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- <For each={data()?.members || []}>
- {(member) => (
- <MemberRow
- member={member}
- workspaceID={params.id}
- actorID={data()!.actorID}
- actorRole={data()!.actorRole}
- />
- )}
- </For>
- </tbody>
- </table>
- </div>
- </section>
- )
-}
diff --git a/packages/console/app/src/routes/workspace/model-section.module.css b/packages/console/app/src/routes/workspace/model-section.module.css
deleted file mode 100644
index 5a98c9b15..000000000
--- a/packages/console/app/src/routes/workspace/model-section.module.css
+++ /dev/null
@@ -1,122 +0,0 @@
-.root {}
-
-[data-slot="section-title"] {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-[data-slot="section-title"] h2 {
- margin: 0;
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--color-text);
-}
-
-[data-slot="section-title"] p {
- margin: 0;
- color: var(--color-text-secondary);
- font-size: 0.875rem;
-}
-
-[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;
- }
-
- &[data-slot="training-data"] {
- text-align: center;
- color: var(--color-text);
- }
-
- &[data-slot="model-status"] {
- text-align: left;
- color: var(--color-text);
- }
-
- &[data-slot="model-toggle"] {
- text-align: left;
- font-family: var(--font-sans);
- }
- }
-
- tbody tr {
- &[data-enabled="false"] {
- opacity: 0.6;
- }
-
- &: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)
-
- /* Training Data */
- {
- display: none;
- }
- }
-
- td {
- &:nth-child(2)
-
- /* Training Data */
- {
- display: none;
- }
- }
- }
-}
-
-
-[data-component="empty-state"] {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 3rem;
- color: var(--color-text-secondary);
- font-size: 0.875rem;
-} \ No newline at end of file
diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts
index 88c5e4b51..48f4a6366 100644
--- a/packages/console/core/src/actor.ts
+++ b/packages/console/core/src/actor.ts
@@ -67,6 +67,11 @@ export namespace Actor {
return actor as Extract<Info, { type: T }>
}
+ export const assertAdmin = () => {
+ if (userRole() === "admin") return
+ throw new Error(`Action not allowed. Ask your workspace admin to perform this action.`)
+ }
+
export function workspace() {
const actor = use()
if ("workspaceID" in actor.properties) {
diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts
index ae636c4f3..48d7e16c5 100644
--- a/packages/console/core/src/model.ts
+++ b/packages/console/core/src/model.ts
@@ -40,13 +40,14 @@ export namespace ZenModel {
export namespace Model {
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
- const workspaceID = Actor.workspace()
+ Actor.assertAdmin()
return Database.use((db) =>
- db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, workspaceID), eq(ModelTable.model, model))),
+ db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
)
})
export const disable = fn(z.object({ model: z.string() }), ({ model }) => {
+ Actor.assertAdmin()
return Database.use((db) =>
db
.insert(ModelTable)
diff --git a/packages/console/core/src/provider.ts b/packages/console/core/src/provider.ts
index 1f8c07b9f..cf2040b59 100644
--- a/packages/console/core/src/provider.ts
+++ b/packages/console/core/src/provider.ts
@@ -20,8 +20,9 @@ export namespace Provider {
provider: z.string().min(1).max(64),
credentials: z.string(),
}),
- ({ provider, credentials }) =>
- Database.use((tx) =>
+ async ({ provider, credentials }) => {
+ Actor.assertAdmin()
+ return Database.use((tx) =>
tx
.insert(ProviderTable)
.values({
@@ -36,14 +37,21 @@ export namespace Provider {
timeDeleted: null,
},
}),
- ),
+ )
+ },
)
- export const remove = fn(z.object({ provider: z.string() }), ({ provider }) =>
- Database.transaction((tx) =>
- tx
- .delete(ProviderTable)
- .where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),
- ),
+ export const remove = fn(
+ z.object({
+ provider: z.string(),
+ }),
+ async ({ provider }) => {
+ Actor.assertAdmin()
+ return Database.transaction((tx) =>
+ tx
+ .delete(ProviderTable)
+ .where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),
+ )
+ },
)
}
diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts
index 38c8e5e3a..40d74f93d 100644
--- a/packages/console/core/src/user.ts
+++ b/packages/console/core/src/user.ts
@@ -11,13 +11,9 @@ import { Account } from "./account"
import { AccountTable } from "./schema/account.sql"
import { Key } from "./key"
import { KeyTable } from "./schema/key.sql"
+import { WorkspaceTable } from "./schema/workspace.sql"
export namespace User {
- const assertAdmin = () => {
- if (Actor.userRole() === "admin") return
- throw new Error(`Expected admin user, got ${Actor.userRole()}`)
- }
-
const assertNotSelf = (id: string) => {
if (Actor.userID() !== id) return
throw new Error(`Expected not self actor, got self actor`)
@@ -63,9 +59,10 @@ export namespace User {
z.object({
email: z.string(),
role: z.enum(UserRole),
+ monthlyLimit: z.number().nullable().optional(),
}),
- async ({ email, role }) => {
- assertAdmin()
+ async ({ email, role, monthlyLimit }) => {
+ Actor.assertAdmin()
const workspaceID = Actor.workspace()
// create user
@@ -85,10 +82,12 @@ export namespace User {
}),
workspaceID,
role,
+ monthlyLimit,
})
.onDuplicateKeyUpdate({
set: {
role,
+ monthlyLimit,
timeDeleted: null,
},
}),
@@ -117,6 +116,21 @@ export namespace User {
// send email, ignore errors
try {
+ const emailInfo = await Database.use((tx) =>
+ tx
+ .select({
+ email: AccountTable.email,
+ workspaceName: WorkspaceTable.name,
+ })
+ .from(UserTable)
+ .innerJoin(AccountTable, eq(UserTable.accountID, AccountTable.id))
+ .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, workspaceID))
+ .where(
+ and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, Actor.assert("user").properties.userID)),
+ )
+ .then((rows) => rows[0]),
+ )
+
const { InviteEmail } = await import("@opencode-ai/console-mail/InviteEmail.jsx")
await AWS.sendEmail({
to: email,
@@ -124,8 +138,10 @@ export namespace User {
body: render(
// @ts-ignore
InviteEmail({
+ inviter: emailInfo.email,
assetsUrl: `https://opencode.ai/email`,
- workspace: workspaceID,
+ workspaceID: workspaceID,
+ workspaceName: emailInfo.workspaceName,
}),
),
})
@@ -176,7 +192,7 @@ export namespace User {
monthlyLimit: z.number().nullable(),
}),
async ({ id, role, monthlyLimit }) => {
- assertAdmin()
+ Actor.assertAdmin()
if (role === "member") assertNotSelf(id)
return await Database.use((tx) =>
tx
@@ -188,7 +204,7 @@ export namespace User {
)
export const remove = fn(z.string(), async (id) => {
- assertAdmin()
+ Actor.assertAdmin()
assertNotSelf(id)
return await Database.use((tx) =>
diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts
index 7a742e896..655112ae2 100644
--- a/packages/console/core/src/workspace.ts
+++ b/packages/console/core/src/workspace.ts
@@ -52,6 +52,7 @@ export namespace Workspace {
name: z.string().min(1).max(255),
}),
async ({ name }) => {
+ Actor.assertAdmin()
const workspaceID = Actor.workspace()
return await Database.use((tx) =>
tx
diff --git a/packages/console/mail/emails/components.tsx b/packages/console/mail/emails/components.tsx
index d030b6cbf..ff845c8f4 100644
--- a/packages/console/mail/emails/components.tsx
+++ b/packages/console/mail/emails/components.tsx
@@ -31,6 +31,10 @@ export function A({ children, ...props }: AProps) {
return React.createElement("a", props, children)
}
+export function B({ children, ...props }: AProps) {
+ return React.createElement("b", props, children)
+}
+
export function Span({ children, ...props }: SpanProps) {
return React.createElement("span", props, children)
}
diff --git a/packages/console/mail/emails/templates/InviteEmail.tsx b/packages/console/mail/emails/templates/InviteEmail.tsx
index 978080a9c..5c9630224 100644
--- a/packages/console/mail/emails/templates/InviteEmail.tsx
+++ b/packages/console/mail/emails/templates/InviteEmail.tsx
@@ -1,7 +1,7 @@
// @ts-nocheck
import React from "react"
import { Img, Row, Html, Link, Body, Head, Button, Column, Preview, Section, Container } from "@jsx-email/all"
-import { Hr, Text, Fonts, SplitString, Title, A, Span } from "../components"
+import { Hr, Text, Fonts, SplitString, Title, A, Span, B } from "../components"
import {
unit,
body,
@@ -23,17 +23,24 @@ const CONSOLE_URL = "https://opencode.ai/"
const DOC_URL = "https://opencode.ai/docs/zen"
interface InviteEmailProps {
- workspace: string
+ inviter: string
+ workspaceID: string
+ workspaceName: string
assetsUrl: string
}
-export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteEmailProps) => {
- const subject = `Join the ${workspace} workspace`
- const messagePlain = `You've been invited to join the ${workspace} workspace in the OpenCode Zen Console.`
- const url = `${CONSOLE_URL}workspace/${workspace}`
+export const InviteEmail = ({
+ inviter = "[email protected]",
+ workspaceID = "wrk_01K6XFY7V53T8XN0A7X8G9BTN3",
+ workspaceName = "anomaly",
+ assetsUrl = LOCAL_ASSETS_URL,
+}: InviteEmailProps) => {
+ const subject = `You were invited to the OpenCode Console`
+ const messagePlain = `${inviter} invited you to join the ${workspaceName} workspace (${workspaceID}).`
+ const url = `${CONSOLE_URL}workspace/${workspaceID}`
return (
<Html lang="en">
<Head>
- <Title>{`OpenCode Zen — ${messagePlain}`}</Title>
+ <Title>{`OpenCode — ${messagePlain}`}</Title>
</Head>
<Fonts assetsUrl={assetsUrl} />
<Preview>{messagePlain}</Preview>
@@ -42,15 +49,10 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
<Section style={frame}>
<Row>
<Column>
- <A href={CONSOLE_URL}>
- <Img height="32" alt="OpenCode Zen Logo" src={`${assetsUrl}/zen-logo.png`} />
+ <A href={`${CONSOLE_URL}zen`}>
+ <Img height="32" alt="OpenCode Logo" src={`${assetsUrl}/logo.png`} />
</A>
</Column>
- <Column align="right">
- <Button style={buttonPrimary} href={url}>
- <Span style={code}>Join Workspace</Span>
- </Button>
- </Column>
</Row>
<Row style={headingHr}>
@@ -59,32 +61,26 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
</Column>
</Row>
- <Section>
- <Text style={{ ...compactText, ...breadcrumb }}>
- <Span>OpenCode Zen</Span>
- <Span style={{ ...code, ...breadcrumbColonSeparator }}>:</Span>
- <Span>{workspace}</Span>
- </Text>
- <Text style={{ ...heading, ...compactText }}>
- <Link href={url}>
- <SplitString text={subject} split={40} />
- </Link>
- </Text>
- </Section>
<Section style={{ padding: `${unit}px 0 0 0` }}>
<Text style={{ ...compactText }}>
- You've been invited to join the{" "}
+ <B>{inviter}</B> invited you to join the{" "}
<Link style={medium} href={url}>
- {workspace}
+ <B>{workspaceName}</B>
</Link>{" "}
- workspace in the{" "}
- <Link style={medium} href={CONSOLE_URL}>
- OpenCode Zen Console
+ workspace ({workspaceID}) in the{" "}
+ <Link style={medium} href={`${CONSOLE_URL}zen`}>
+ OpenCode Console
</Link>
.
</Text>
</Section>
+ <Section style={{ padding: `${unit}px 0 0 0` }}>
+ <Button style={buttonPrimary} href={url}>
+ <Span style={code}>Join Workspace</Span>
+ </Button>
+ </Section>
+
<Row style={headingHr}>
<Column>
<Hr />
@@ -93,7 +89,7 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
<Row>
<Column>
- <Link href={CONSOLE_URL} style={footerLink}>
+ <Link href={`${CONSOLE_URL}zen`} style={footerLink}>
Console
</Link>
</Column>
diff --git a/packages/console/mail/emails/templates/static/logo.png b/packages/console/mail/emails/templates/static/logo.png
new file mode 100644
index 000000000..1d4a39639
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/logo.png
Binary files differ