Frank 4 месяцев назад
Родитель
Сommit
03d5089436

+ 2 - 12
packages/console/app/src/routes/workspace/[id]/index.tsx

@@ -1,18 +1,10 @@
 import "./index.css"
 import { NewUserSection } from "./new-user-section"
 import { UsageSection } from "./usage-section"
-import { MemberSection } from "./members/member-section"
-import { SettingsSection } from "./settings/settings-section"
 import { ModelSection } from "./model-section"
 import { ProviderSection } from "./provider-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]">
       <section data-component="title-section">
@@ -28,10 +20,8 @@ export default function () {
 
       <div data-slot="sections">
         <NewUserSection />
-        <Show when={userInfo()?.isAdmin}>
-          <ModelSection />
-          <ProviderSection />
-        </Show>
+        <ModelSection />
+        <ProviderSection />
         <UsageSection />
       </div>
     </div>

+ 83 - 12
packages/console/app/src/routes/workspace/[id]/model-section.module.css

@@ -1,5 +1,3 @@
-.root {}
-
 [data-slot="section-title"] {
   display: flex;
   flex-direction: column;
@@ -62,28 +60,101 @@
       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;
+    [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;
     }
   }
+}
 
-  @media (max-width: 40rem) {
+@media (max-width: 40rem) {
+  [data-slot="models-table-element"] {
 
     th,
     td {

+ 20 - 6
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,11 +40,15 @@ 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()}>
@@ -52,8 +57,7 @@ export function ModelSection() {
               <thead>
                 <tr>
                   <th>Model</th>
-                  <th>Status</th>
-                  <th>Action</th>
+                  <th>Enabled</th>
                 </tr>
               </thead>
               <tbody>
@@ -61,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">
                         <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>

+ 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(`Expected admin user, got ${userRole()}`)
+  }
+
   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()))),
+      )
+    },
   )
 }

+ 3 - 8
packages/console/core/src/user.ts

@@ -13,11 +13,6 @@ import { Key } from "./key"
 import { KeyTable } from "./schema/key.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`)
@@ -65,7 +60,7 @@ export namespace User {
       role: z.enum(UserRole),
     }),
     async ({ email, role }) => {
-      assertAdmin()
+      Actor.assertAdmin()
       const workspaceID = Actor.workspace()
 
       // create user
@@ -176,7 +171,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 +183,7 @@ export namespace User {
   )
 
   export const remove = fn(z.string(), async (id) => {
-    assertAdmin()
+    Actor.assertAdmin()
     assertNotSelf(id)
 
     return await Database.use((tx) =>