summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-10 14:54:49 -0400
committerFrank <[email protected]>2025-10-10 14:54:49 -0400
commit4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4 (patch)
treea72d46d1edae9dbd437039141a6b4e175d1284e5 /packages
parentee846235f2c375560ab6095d2481351032f55a0b (diff)
downloadopencode-4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4.tar.gz
opencode-4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4.zip
wip: zen
Diffstat (limited to 'packages')
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.module.css147
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.tsx128
-rw-r--r--packages/console/core/src/user.ts5
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,
},
}),