Просмотр исходного кода

Merge branch 'console-workspaces' into dev

Frank 4 месяцев назад
Родитель
Сommit
2d35b78333
50 измененных файлов с 2070 добавлено и 1176 удалено
  1. 83 20
      packages/console/app/src/component/icon.tsx
  2. 66 0
      packages/console/app/src/component/modal.css
  3. 24 0
      packages/console/app/src/component/modal.tsx
  4. 0 7
      packages/console/app/src/lib/beta.ts
  5. 68 0
      packages/console/app/src/routes/user-menu.css
  6. 63 0
      packages/console/app/src/routes/user-menu.tsx
  7. 27 100
      packages/console/app/src/routes/workspace-picker.css
  8. 27 23
      packages/console/app/src/routes/workspace-picker.tsx
  9. 4 24
      packages/console/app/src/routes/workspace.css
  10. 14 36
      packages/console/app/src/routes/workspace.tsx
  11. 117 7
      packages/console/app/src/routes/workspace/[id].css
  12. 30 63
      packages/console/app/src/routes/workspace/[id].tsx
  13. 6 9
      packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css
  14. 1 5
      packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx
  15. 23 0
      packages/console/app/src/routes/workspace/[id]/billing/index.tsx
  16. 1 7
      packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css
  17. 0 0
      packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx
  18. 0 0
      packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css
  19. 4 6
      packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx
  20. 71 0
      packages/console/app/src/routes/workspace/[id]/index.tsx
  21. 11 0
      packages/console/app/src/routes/workspace/[id]/keys/index.tsx
  22. 31 3
      packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css
  23. 36 45
      packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx
  24. 11 0
      packages/console/app/src/routes/workspace/[id]/members/index.tsx
  25. 439 0
      packages/console/app/src/routes/workspace/[id]/members/member-section.module.css
  26. 445 0
      packages/console/app/src/routes/workspace/[id]/members/member-section.tsx
  27. 170 0
      packages/console/app/src/routes/workspace/[id]/model-section.module.css
  28. 21 14
      packages/console/app/src/routes/workspace/[id]/model-section.tsx
  29. 1 21
      packages/console/app/src/routes/workspace/[id]/new-user-section.module.css
  30. 0 0
      packages/console/app/src/routes/workspace/[id]/new-user-section.tsx
  31. 44 12
      packages/console/app/src/routes/workspace/[id]/provider-section.module.css
  32. 53 31
      packages/console/app/src/routes/workspace/[id]/provider-section.tsx
  33. 11 0
      packages/console/app/src/routes/workspace/[id]/settings/index.tsx
  34. 43 44
      packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css
  35. 14 16
      packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx
  36. 0 0
      packages/console/app/src/routes/workspace/[id]/usage-section.module.css
  37. 1 1
      packages/console/app/src/routes/workspace/[id]/usage-section.tsx
  38. 26 0
      packages/console/app/src/routes/workspace/common.tsx
  39. 0 0
      packages/console/app/src/routes/workspace/index.tsx
  40. 0 179
      packages/console/app/src/routes/workspace/member-section.module.css
  41. 0 328
      packages/console/app/src/routes/workspace/member-section.tsx
  42. 0 122
      packages/console/app/src/routes/workspace/model-section.module.css
  43. 5 0
      packages/console/core/src/actor.ts
  44. 3 2
      packages/console/core/src/model.ts
  45. 17 9
      packages/console/core/src/provider.ts
  46. 26 10
      packages/console/core/src/user.ts
  47. 1 0
      packages/console/core/src/workspace.ts
  48. 4 0
      packages/console/mail/emails/components.tsx
  49. 28 32
      packages/console/mail/emails/templates/InviteEmail.tsx
  50. BIN
      packages/console/mail/emails/templates/static/logo.png

+ 83 - 20
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>
+  )
+}

+ 66 - 0
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);
+  }
+}

+ 24 - 0
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>
+  )
+}

+ 0 - 7
packages/console/app/src/lib/beta.ts

@@ -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")

+ 68 - 0
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;
+  }
+}

+ 63 - 0
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>
+  )
+}

+ 27 - 100
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;
-    }
-  }
 }

+ 27 - 23
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>
   )
 }

+ 4 - 24
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;
-    }
   }
-}
+}

+ 14 - 36
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>

+ 117 - 7
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);
+  }
+}

+ 30 - 63
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>
   )
 }

+ 6 - 9
packages/console/app/src/routes/workspace/billing-section.module.css → 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;
       }
     }
   }
-}
+}

+ 1 - 5
packages/console/app/src/routes/workspace/billing-section.tsx → 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"

+ 23 - 0
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>
+  )
+}

+ 1 - 7
packages/console/app/src/routes/workspace/monthly-limit-section.module.css → 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;
   }
-}
+}

+ 0 - 0
packages/console/app/src/routes/workspace/monthly-limit-section.tsx → packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx


+ 0 - 0
packages/console/app/src/routes/workspace/payment-section.module.css → packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css


+ 4 - 6
packages/console/app/src/routes/workspace/payment-section.tsx → 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>
   )
 }

+ 71 - 0
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>
+  )
+}

+ 11 - 0
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>
+  )
+}

+ 31 - 3
packages/console/app/src/routes/workspace/key-section.module.css → 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;
         }
       }
     }
   }
-}
+}

+ 36 - 45
packages/console/app/src/routes/workspace/key-section.tsx → 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,69 +53,59 @@ 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() {
     setStore("show", false)
   }
 
-  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}

+ 11 - 0
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>
+  )
+}

+ 439 - 0
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;
+        }
+      }
+    }
+  }
+}

+ 445 - 0
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>
+  )
+}

+ 170 - 0
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;
+      }
+    }
+  }
+}

+ 21 - 14
packages/console/app/src/routes/workspace/model-section.tsx → 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>

+ 1 - 21
packages/console/app/src/routes/workspace/new-user-section.module.css → 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 @@
       }
     }
   }
-}
+}

+ 0 - 0
packages/console/app/src/routes/workspace/new-user-section.tsx → packages/console/app/src/routes/workspace/[id]/new-user-section.tsx


+ 44 - 12
packages/console/app/src/routes/workspace/provider-section.module.css → 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 {

+ 53 - 31
packages/console/app/src/routes/workspace/provider-section.tsx → 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>

+ 11 - 0
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>
+  )
+}

+ 43 - 44
packages/console/app/src/routes/workspace/settings-section.module.css → 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);
     }
   }
-}
+}

+ 14 - 16
packages/console/app/src/routes/workspace/settings-section.tsx → 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>

+ 0 - 0
packages/console/app/src/routes/workspace/usage-section.module.css → packages/console/app/src/routes/workspace/[id]/usage-section.module.css


+ 1 - 1
packages/console/app/src/routes/workspace/usage-section.tsx → 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"
 

+ 26 - 0
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")

+ 0 - 0
packages/console/app/src/routes/workspace/index.tsx


+ 0 - 179
packages/console/app/src/routes/workspace/member-section.module.css

@@ -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;
-        }
-      }
-    }
-  }
-}

+ 0 - 328
packages/console/app/src/routes/workspace/member-section.tsx

@@ -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>
-  )
-}

+ 0 - 122
packages/console/app/src/routes/workspace/model-section.module.css

@@ -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;
-}

+ 5 - 0
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) {

+ 3 - 2
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)

+ 17 - 9
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()))),
+      )
+    },
   )
 }

+ 26 - 10
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) =>

+ 1 - 0
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

+ 4 - 0
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)
 }

+ 28 - 32
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>

BIN
packages/console/mail/emails/templates/static/logo.png