diff options
| author | Frank <[email protected]> | 2025-10-10 14:54:49 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-10 14:54:49 -0400 |
| commit | 4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4 (patch) | |
| tree | a72d46d1edae9dbd437039141a6b4e175d1284e5 /packages | |
| parent | ee846235f2c375560ab6095d2481351032f55a0b (diff) | |
| download | opencode-4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4.tar.gz opencode-4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4.zip | |
wip: zen
Diffstat (limited to 'packages')
3 files changed, 216 insertions, 64 deletions
diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css index 8fd866536..4d142c486 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css @@ -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; + } } } } diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx index f18311569..89c0ac957 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx +++ b/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()}> diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts index 1580783fd..63877150e 100644 --- a/packages/console/core/src/user.ts +++ b/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, }, }), |
