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 8fd86653..4d142c48 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: 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); - } - - @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); + 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); } - p { - font-size: var(--font-size-xs); - color: var(--color-text-muted); - font-family: var(--font-sans); + &[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; + } } } } 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 f1831156..89c0ac95 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() {
-
- (input = r)} data-component="input" name="email" type="text" placeholder="Enter email" /> -
- - +
+
+

Email

+ (input = r)} + data-component="input" + name="email" + type="text" + placeholder="Enter email" + /> +
+
+

Role

+
+ + +
+ + +
+
+
- - {(err) =>
{err()}
} -
+
+
+

Usage limit

+ setStore("limit", e.currentTarget.value)} + min="0" + /> +
+
+ + {(err) =>
{err()}
} +
+