summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-10 16:34:07 -0400
committerFrank <[email protected]>2025-10-10 16:34:07 -0400
commitd8b3aa9382e98eba9609cb1344846c4354d0d4b6 (patch)
treef6201b63ab16cc2fbdd36a4fb2a78d4e518849ed /packages
parentea9b5b8d769b08e1caeed3c9ce618d88762b29ad (diff)
downloadopencode-d8b3aa9382e98eba9609cb1344846c4354d0d4b6.tar.gz
opencode-d8b3aa9382e98eba9609cb1344846c4354d0d4b6.zip
wip: zen
Diffstat (limited to 'packages')
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.module.css137
-rw-r--r--packages/console/app/src/routes/workspace/[id]/members/member-section.tsx209
2 files changed, 262 insertions, 84 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 4d142c486..d67a29eba 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
@@ -132,7 +132,6 @@
position: absolute;
top: 100%;
left: 0;
- right: 0;
z-index: 10;
margin-top: var(--space-1);
padding: var(--space-1);
@@ -140,6 +139,8 @@
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ min-width: 280px;
+ width: max-content;
[data-slot="item"] {
display: block;
@@ -199,6 +200,14 @@
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
+
+ &:nth-child(2) {
+ width: 180px;
+ }
+
+ &:nth-child(3) {
+ width: 200px;
+ }
}
td {
@@ -216,6 +225,94 @@
&[data-slot="member-role"] {
font-family: var(--font-mono);
+ [data-slot="role-selector"] {
+ position: relative;
+
+ [data-slot="trigger"] {
+ display: flex;
+ 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;
+ font-family: var(--font-sans);
+
+ &:hover {
+ border-color: var(--color-accent);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+
+ [data-slot="chevron"] {
+ opacity: 0.6;
+ transition: transform 0.15s ease;
+ }
+ }
+
+ [data-slot="dropdown"] {
+ position: absolute;
+ top: 100%;
+ left: 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);
+ min-width: 280px;
+ width: max-content;
+
+ [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);
+ }
+
+ 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;
+ }
+ }
+ }
+ }
+ }
+
button {
display: flex;
align-items: center;
@@ -248,6 +345,30 @@
}
}
+ &[data-slot="member-usage"] {
+ input {
+ 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;
+ font-family: var(--font-mono);
+
+ &: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="member-date"] {
color: var(--color-text);
}
@@ -257,7 +378,17 @@
display: flex;
gap: var(--space-2);
- form button {
+ [data-slot="inline-edit-form"] {
+ display: flex;
+ gap: var(--space-2);
+
+ button {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ }
+
+ form:not([data-slot="inline-edit-form"]) button {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
@@ -267,7 +398,7 @@
tbody tr {
&:hover {
- [data-slot="member-actions"] form button {
+ [data-slot="member-actions"] form:not([data-slot="inline-edit-form"]) button {
opacity: 1;
pointer-events: auto;
}
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 8cbff5032..99408d51e 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
@@ -86,17 +86,50 @@ const updateMember = action(async (form: FormData) => {
}, "member.update")
function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
- const [editing, setEditing] = createSignal(false)
const submission = useSubmission(updateMember)
const isCurrentUser = () => props.actorID === props.member.id
const isAdmin = () => props.actorRole === "admin"
+ const [store, setStore] = createStore({
+ editing: false,
+ selectedRole: props.member.role as (typeof UserRole)[number],
+ showRoleDropdown: false,
+ limit: "",
+ })
+
+ let roleDropdownRef: HTMLDivElement | undefined
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
- setEditing(false)
+ setStore("editing", false)
}
})
+ 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("editing", true)
+ setStore("selectedRole", props.member.role)
+ setStore("limit", props.member.monthlyLimit?.toString() ?? "")
+ }
+
+ function hide() {
+ setStore("editing", false)
+ setStore("showRoleDropdown", false)
+ }
+
function getUsageDisplay() {
const currentUsage = (() => {
const dateLastUsed = props.member.timeMonthlyUsageUpdated
@@ -120,96 +153,110 @@ function MemberRow(props: { member: any; workspaceID: string; actorID: string; a
return `$${currentUsage} / ${limit}`
}
- return (
- <Show
- when={editing()}
- fallback={
- <tr>
- <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
- <td data-slot="member-role">{props.member.role}</td>
- <td data-slot="member-usage">{getUsageDisplay()}</td>
- <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
- <Show when={isAdmin()}>
- <td data-slot="member-actions">
- <button data-color="ghost" onClick={() => setEditing(true)}>
- Edit
- </button>
- <Show when={!isCurrentUser()}>
- <form action={removeMember} method="post">
- <input type="hidden" name="id" value={props.member.id} />
- <input type="hidden" name="workspaceID" value={props.workspaceID} />
- <button data-color="ghost">Delete</button>
- </form>
- </Show>
- </td>
- </Show>
- </tr>
- }
- >
- <tr>
- <td colspan={isAdmin() ? 5 : 4}>
- <form action={updateMember} method="post">
- <div data-slot="edit-member-email">{props.member.accountEmail ?? props.member.email}</div>
- <input type="hidden" name="id" value={props.member.id} />
- <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ const roleLabels = {
+ admin: { title: "Admin", description: "Can manage models, members, and billing" },
+ member: { title: "Member", description: "Can only generate API keys for themselves" },
+ }
- <Show
- when={!isCurrentUser()}
- fallback={
- <>
- <div data-slot="current-user-role">Role: {props.member.role}</div>
- <input type="hidden" name="role" value={props.member.role} />
- </>
- }
+ return (
+ <tr>
+ <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
+ <td data-slot="member-role">
+ <Show when={store.editing && !isCurrentUser()} fallback={<span>{props.member.role}</span>}>
+ <div data-slot="role-selector" ref={roleDropdownRef}>
+ <button
+ data-slot="trigger"
+ type="button"
+ onClick={() => setStore("showRoleDropdown", !store.showRoleDropdown)}
>
- <div data-slot="role-selector">
- <label>
- <input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
+ <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>Can manage models, members, and billing</p>
+ <p>{roleLabels.admin.description}</p>
</div>
- </label>
- <label>
- <input type="radio" name="role" value="member" checked={props.member.role === "member"} />
+ </button>
+ <button
+ data-slot="item"
+ data-selected={store.selectedRole === "member"}
+ type="button"
+ onClick={() => {
+ setStore("selectedRole", "member")
+ setStore("showRoleDropdown", false)
+ }}
+ >
<div>
- <strong>Member</strong>
- <p>Can only generate API keys for themselves</p>
+ <strong>{roleLabels.member.title}</strong>
+ <p>{roleLabels.member.description}</p>
</div>
- </label>
+ </button>
</div>
</Show>
-
- <div data-slot="limit-selector">
- <label>
- <strong>Monthly Limit</strong>
- <input
- type="number"
- name="limit"
- value={props.member.monthlyLimit ?? ""}
- placeholder="No limit"
- min="0"
- />
- <p>Set a monthly spending limit for this user</p>
- </label>
- </div>
-
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
- </Show>
-
- <div data-slot="form-actions">
- <button type="button" data-color="ghost" onClick={() => setEditing(false)}>
- Cancel
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
+ </div>
+ </Show>
+ </td>
+ <td data-slot="member-usage">
+ <Show when={store.editing} fallback={<span>{getUsageDisplay()}</span>}>
+ <input
+ data-component="input"
+ type="number"
+ value={store.limit}
+ onInput={(e) => setStore("limit", e.currentTarget.value)}
+ placeholder="No limit"
+ min="0"
+ />
+ </Show>
+ </td>
+ <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
+ <Show when={isAdmin()}>
+ <td data-slot="member-actions">
+ <Show
+ when={store.editing}
+ fallback={
+ <>
+ <button data-color="ghost" onClick={() => show()}>
+ Edit
+ </button>
+ <Show when={!isCurrentUser()}>
+ <form action={removeMember} method="post">
+ <input type="hidden" name="id" value={props.member.id} />
+ <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ <button data-color="ghost">Delete</button>
+ </form>
+ </Show>
+ </>
+ }
+ >
+ <form action={updateMember} method="post" data-slot="inline-edit-form">
+ <input type="hidden" name="id" value={props.member.id} />
+ <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ <input type="hidden" name="role" value={store.selectedRole} />
+ <input type="hidden" name="limit" value={store.limit} />
+ <button type="submit" data-color="ghost" disabled={submission.pending}>
{submission.pending ? "Saving..." : "Save"}
</button>
- </div>
- </form>
+ <Show when={!submission.pending}>
+ <button type="button" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ </Show>
+ </form>
+ </Show>
</td>
- </tr>
- </Show>
+ </Show>
+ </tr>
)
}
@@ -370,7 +417,7 @@ export function MemberSection() {
<tr>
<th>Email</th>
<th>Role</th>
- <th>Usage</th>
+ <th>Limit</th>
<th></th>
<Show when={data()?.actorRole === "admin"}>
<th></th>