This commit is contained in:
Frank
2025-10-10 16:34:07 -04:00
parent ea9b5b8d76
commit d8b3aa9382
2 changed files with 262 additions and 84 deletions

View File

@@ -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;
}

View File

@@ -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>