mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-19 08:44:22 +01:00
wip: zen
This commit is contained in:
@@ -30,14 +30,28 @@
|
||||
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);
|
||||
}
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: var(--space-2);
|
||||
p {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -48,65 +62,118 @@
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
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 {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
margin-top: var(--space-1);
|
||||
&[data-selected="true"] {
|
||||
background-color: var(--color-accent-alpha);
|
||||
}
|
||||
|
||||
div {
|
||||
flex: 1;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-sans);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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>Can manage models, members, and billing</p>
|
||||
<p>{roleLabels.admin.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="role" value="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>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
<Show when={submission.result && submission.result.error}>
|
||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
<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()}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user