Frank 4 months ago
parent
commit
4227b89ebc

+ 107 - 40
packages/console/app/src/routes/workspace/[id]/members/member-section.module.css

@@ -30,83 +30,150 @@
     border: 1px solid var(--color-border);
     border-radius: var(--border-radius-sm);
 
-    [data-slot="input-container"] {
+    [data-slot="input-row"] {
       display: flex;
-      flex-direction: column;
-      gap: var(--space-1);
-    }
+      flex-direction: row;
+      gap: var(--space-3);
 
-    @media (max-width: 30rem) {
-      gap: var(--space-2);
+      @media (max-width: 40rem) {
+        flex-direction: column;
+        gap: var(--space-2);
+      }
     }
 
-    input {
+    [data-slot="input-field"] {
+      display: flex;
+      flex-direction: column;
+      gap: var(--space-1);
       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);
+      p {
+        line-height: 1.2;
+        margin: 0;
+        color: var(--color-text-muted);
+        font-size: var(--font-size-sm);
       }
 
-      &::placeholder {
-        color: var(--color-text-disabled);
+      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);
-      margin-top: var(--space-1);
       line-height: 1.4;
+      margin-top: calc(var(--space-1) * -1);
     }
 
     [data-slot="role-selector"] {
-      display: flex;
-      flex-direction: column;
-      gap: var(--space-2);
+      position: relative;
 
-      label {
+      [data-slot="trigger"] {
         display: flex;
-        gap: var(--space-3);
-        padding: var(--space-3);
+        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 {
-          background-color: var(--color-bg-surface);
+          border-color: var(--color-accent);
         }
 
-        input[type="radio"] {
-          margin-top: var(--space-1);
+        &:focus {
+          outline: none;
+          border-color: var(--color-accent);
+          box-shadow: 0 0 0 3px var(--color-accent-alpha);
         }
 
-        div {
-          flex: 1;
+        [data-slot="chevron"] {
+          opacity: 0.6;
+          transition: transform 0.15s ease;
+        }
+      }
 
-          strong {
-            display: block;
-            color: var(--color-text);
-            font-family: var(--font-sans);
-            margin-bottom: var(--space-1);
+      [data-slot="dropdown"] {
+        position: absolute;
+        top: 100%;
+        left: 0;
+        right: 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);
+
+        [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);
           }
 
-          p {
-            font-size: var(--font-size-xs);
-            color: var(--color-text-muted);
-            font-family: var(--font-sans);
+          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;
+            }
           }
         }
       }

+ 105 - 23
packages/console/app/src/routes/workspace/[id]/members/member-section.tsx

@@ -1,11 +1,12 @@
 import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { createEffect, createSignal, For, Show } from "solid-js"
+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"
@@ -26,10 +27,13 @@ const inviteMember = action(async (form: FormData) => {
   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 })
+        User.invite({ email, role, monthlyLimit })
           .then((data) => ({ error: undefined, data }))
           .catch((e) => ({ error: e.message as string })),
       workspaceID,
@@ -213,9 +217,15 @@ export function MemberSection() {
   const params = useParams()
   const data = createAsync(() => listMembers(params.id))
   const submission = useSubmission(inviteMember)
-  const [store, setStore] = createStore({ show: false })
+  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) {
@@ -223,17 +233,36 @@ export function MemberSection() {
     }
   })
 
+  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 (
@@ -251,28 +280,81 @@ export function MemberSection() {
       </div>
       <Show when={store.show}>
         <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 data-slot="input-row">
+            <div data-slot="input-field">
+              <p>Email</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>
-            <Show when={submission.result && submission.result.error}>
-              {(err) => <div data-slot="form-error">{err()}</div>}
-            </Show>
           </div>
+          <div data-slot="input-row">
+            <div data-slot="input-field">
+              <p>Usage 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()}>

+ 4 - 1
packages/console/core/src/user.ts

@@ -58,8 +58,9 @@ export namespace User {
     z.object({
       email: z.string(),
       role: z.enum(UserRole),
+      monthlyLimit: z.number().nullable().optional(),
     }),
-    async ({ email, role }) => {
+    async ({ email, role, monthlyLimit }) => {
       Actor.assertAdmin()
       const workspaceID = Actor.workspace()
 
@@ -80,10 +81,12 @@ export namespace User {
                 }),
             workspaceID,
             role,
+            monthlyLimit,
           })
           .onDuplicateKeyUpdate({
             set: {
               role,
+              monthlyLimit,
               timeDeleted: null,
             },
           }),