mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 09:44:21 +01:00
Merge branch 'console-workspaces' into dev
This commit is contained in:
@@ -2,26 +2,70 @@ import { JSX } from "solid-js"
|
|||||||
|
|
||||||
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none"
|
<svg width="64" height="32" viewBox="0 0 64 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
<path d="M0 9.14333V4.5719H4.57143V9.14333H0Z" fill="currentColor" />
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
<path d="M4.57178 9.14333V4.5719H9.14321V9.14333H4.57178Z" fill="currentColor" />
|
||||||
d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z" fill="currentColor"/>
|
<path d="M9.1438 9.14333V4.5719H13.7152V9.14333H9.1438Z" fill="currentColor" />
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
<path d="M13.7124 9.14333V4.5719H18.2838V9.14333H13.7124Z" fill="currentColor" />
|
||||||
d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor"/>
|
<path d="M13.7124 13.7136V9.14221H18.2838V13.7136H13.7124Z" fill="currentColor" />
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
<path d="M0 18.2857V13.7142H4.57143V18.2857H0Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="currentColor"/>
|
<rect width="4.57143" height="4.57143" transform="translate(4.57178 13.7141)" fill="currentColor" />
|
||||||
<path d="M108 12H96V36H90V6H108V12Z" fill="currentColor"/>
|
<path d="M4.57178 18.2855V13.7141H9.14321V18.2855H4.57178Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
<path d="M114 36H108V12H114V36Z" fill="currentColor"/>
|
<path d="M9.1438 18.2855V13.7141H13.7152V18.2855H9.1438Z" fill="currentColor" />
|
||||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor"/>
|
<path d="M13.7156 18.2855V13.7141H18.287V18.2855H13.7156Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
<rect width="4.57143" height="4.57143" transform="translate(0 18.2859)" fill="currentColor" />
|
||||||
d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z" fill="currentColor"/>
|
<path d="M0 22.8572V18.2858H4.57143V22.8572H0Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
<rect
|
||||||
d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z" fill="currentColor"/>
|
width="4.57143"
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
height="4.57143"
|
||||||
d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z" fill="currentColor"/>
|
transform="translate(4.57178 18.2859)"
|
||||||
</svg>
|
fill="currentColor"
|
||||||
|
fill-opacity="0.2"
|
||||||
)
|
/>
|
||||||
|
<path d="M4.57178 22.8573V18.2859H9.14321V22.8573H4.57178Z" fill="currentColor" />
|
||||||
|
<path d="M9.1438 22.8573V18.2859H13.7152V22.8573H9.1438Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M13.7156 22.8573V18.2859H18.287V22.8573H13.7156Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M0 27.4292V22.8578H4.57143V27.4292H0Z" fill="currentColor" />
|
||||||
|
<path d="M4.57178 27.4292V22.8578H9.14321V27.4292H4.57178Z" fill="currentColor" />
|
||||||
|
<path d="M9.1438 27.4276V22.8562H13.7152V27.4276H9.1438Z" fill="currentColor" />
|
||||||
|
<path d="M13.7124 27.4292V22.8578H18.2838V27.4292H13.7124Z" fill="currentColor" />
|
||||||
|
<path d="M22.8572 9.14333V4.5719H27.4286V9.14333H22.8572Z" fill="currentColor" />
|
||||||
|
<path d="M27.426 9.14333V4.5719H31.9975V9.14333H27.426Z" fill="currentColor" />
|
||||||
|
<path d="M32.001 9.14333V4.5719H36.5724V9.14333H32.001Z" fill="currentColor" />
|
||||||
|
<path d="M36.5698 9.14333V4.5719H41.1413V9.14333H36.5698Z" fill="currentColor" />
|
||||||
|
<path d="M22.8572 13.7152V9.1438H27.4286V13.7152H22.8572Z" fill="currentColor" />
|
||||||
|
<path d="M36.5698 13.7152V9.1438H41.1413V13.7152H36.5698Z" fill="currentColor" />
|
||||||
|
<path d="M22.8572 18.2855V13.7141H27.4286V18.2855H22.8572Z" fill="currentColor" />
|
||||||
|
<path d="M27.4292 18.2855V13.7141H32.0006V18.2855H27.4292Z" fill="currentColor" />
|
||||||
|
<path d="M32.001 18.2855V13.7141H36.5724V18.2855H32.001Z" fill="currentColor" />
|
||||||
|
<path d="M36.5698 18.2855V13.7141H41.1413V18.2855H36.5698Z" fill="currentColor" />
|
||||||
|
<path d="M22.8572 22.8573V18.2859H27.4286V22.8573H22.8572Z" fill="currentColor" />
|
||||||
|
<path d="M27.4292 22.8573V18.2859H32.0006V22.8573H27.4292Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M32.001 22.8573V18.2859H36.5724V22.8573H32.001Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M36.5698 22.8573V18.2859H41.1413V22.8573H36.5698Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M22.8572 27.4292V22.8578H27.4286V27.4292H22.8572Z" fill="currentColor" />
|
||||||
|
<path d="M27.4292 27.4276V22.8562H32.0006V27.4276H27.4292Z" fill="currentColor" />
|
||||||
|
<path d="M32.001 27.4276V22.8562H36.5724V27.4276H32.001Z" fill="currentColor" />
|
||||||
|
<path d="M36.5698 27.4292V22.8578H41.1413V27.4292H36.5698Z" fill="currentColor" />
|
||||||
|
<path d="M45.7144 9.14333V4.5719H50.2858V9.14333H45.7144Z" fill="currentColor" />
|
||||||
|
<path d="M50.2861 9.14333V4.5719H54.8576V9.14333H50.2861Z" fill="currentColor" />
|
||||||
|
<path d="M54.855 9.14333V4.5719H59.4264V9.14333H54.855Z" fill="currentColor" />
|
||||||
|
<path d="M45.7144 13.7136V9.14221H50.2858V13.7136H45.7144Z" fill="currentColor" />
|
||||||
|
<path d="M59.4299 13.7152V9.1438H64.0014V13.7152H59.4299Z" fill="currentColor" />
|
||||||
|
<path d="M45.7144 18.2855V13.7141H50.2858V18.2855H45.7144Z" fill="currentColor" />
|
||||||
|
<path d="M50.2861 18.2857V13.7142H54.8576V18.2857H50.2861Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M54.8579 18.2855V13.7141H59.4293V18.2855H54.8579Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M59.4299 18.2855V13.7141H64.0014V18.2855H59.4299Z" fill="currentColor" />
|
||||||
|
<path d="M45.7144 22.8573V18.2859H50.2858V22.8573H45.7144Z" fill="currentColor" />
|
||||||
|
<path d="M50.2861 22.8572V18.2858H54.8576V22.8572H50.2861Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M54.8579 22.8573V18.2859H59.4293V22.8573H54.8579Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M59.4299 22.8573V18.2859H64.0014V22.8573H59.4299Z" fill="currentColor" />
|
||||||
|
<path d="M45.7144 27.4292V22.8578H50.2858V27.4292H45.7144Z" fill="currentColor" />
|
||||||
|
<path d="M50.2861 27.4286V22.8572H54.8576V27.4286H50.2861Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M54.8579 27.4285V22.8571H59.4293V27.4285H54.8579Z" fill="currentColor" fill-opacity="0.2" />
|
||||||
|
<path d="M59.4299 27.4292V22.8578H64.0014V27.4292H59.4299Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
@@ -55,3 +99,22 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconChevron(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} width="8" height="6" viewBox="0 0 8 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4.00024 5.04041L7.37401 1.66663L6.66691 0.959525L4.00024 3.62619L1.33357 0.959525L0.626465 1.66663L4.00024 5.04041Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconWorkspaceLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} width="24" height="30" viewBox="0 0 24 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
66
packages/console/app/src/component/modal.css
Normal file
66
packages/console/app/src/component/modal.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="modal"][data-slot="overlay"] {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="content"] {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
min-width: 300px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="title"] {
|
||||||
|
margin: 0 0 var(--space-4) 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/console/app/src/component/modal.tsx
Normal file
24
packages/console/app/src/component/modal.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { JSX, Show } from "solid-js"
|
||||||
|
import "./modal.css"
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal(props: ModalProps) {
|
||||||
|
return (
|
||||||
|
<Show when={props.open}>
|
||||||
|
<div data-component="modal" data-slot="overlay" onClick={props.onClose}>
|
||||||
|
<div data-slot="content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Show when={props.title}>
|
||||||
|
<h2 data-slot="title">{props.title}</h2>
|
||||||
|
</Show>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { query } from "@solidjs/router"
|
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
|
||||||
|
|
||||||
export const beta = query(async (workspaceID?: string) => {
|
|
||||||
"use server"
|
|
||||||
return Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true
|
|
||||||
}, "beta")
|
|
||||||
68
packages/console/app/src/routes/user-menu.css
Normal file
68
packages/console/app/src/routes/user-menu.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
[data-component="user-menu"] {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
[data-slot="trigger"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="chevron"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dropdown"] {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 160px;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="item"],
|
||||||
|
[data-slot="create-item"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2-5) var(--space-3);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
packages/console/app/src/routes/user-menu.tsx
Normal file
63
packages/console/app/src/routes/user-menu.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Show, onCleanup, createEffect } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { action, redirect } from "@solidjs/router"
|
||||||
|
import { getRequestEvent } from "solid-js/web"
|
||||||
|
import { useAuthSession } from "~/context/auth.session"
|
||||||
|
import { IconChevron } from "~/component/icon"
|
||||||
|
import "./user-menu.css"
|
||||||
|
|
||||||
|
const logout = action(async () => {
|
||||||
|
"use server"
|
||||||
|
const auth = await useAuthSession()
|
||||||
|
const event = getRequestEvent()
|
||||||
|
const current = auth.data.current
|
||||||
|
if (current)
|
||||||
|
await auth.update((val) => {
|
||||||
|
delete val.account?.[current]
|
||||||
|
const first = Object.keys(val.account ?? {})[0]
|
||||||
|
val.current = first
|
||||||
|
event!.locals.actor = undefined
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
throw redirect("/zen")
|
||||||
|
})
|
||||||
|
|
||||||
|
export function UserMenu(props: { email: string | null | undefined }) {
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
showDropdown: false,
|
||||||
|
})
|
||||||
|
let dropdownRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||||
|
setStore("showDropdown", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", handleClickOutside)
|
||||||
|
|
||||||
|
onCleanup(() => document.removeEventListener("click", handleClickOutside))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-component="user-menu">
|
||||||
|
<div ref={dropdownRef}>
|
||||||
|
<button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
|
||||||
|
<span>{props.email}</span>
|
||||||
|
<IconChevron data-slot="chevron" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={store.showDropdown}>
|
||||||
|
<div data-slot="dropdown">
|
||||||
|
<form action={logout} method="post">
|
||||||
|
<button type="submit" formaction={logout} data-slot="item">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,33 +1,38 @@
|
|||||||
[data-component="workspace-picker"] {
|
[data-component="workspace-picker"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Override blue accent colors with neutral colors */
|
|
||||||
--color-accent: var(--color-border);
|
|
||||||
--color-accent-hover: var(--color-border);
|
|
||||||
--color-accent-active: var(--color-border);
|
|
||||||
--color-primary: var(--color-border);
|
|
||||||
--color-primary-hover: var(--color-border);
|
|
||||||
--color-primary-active: var(--color-border);
|
|
||||||
--color-primary-alpha-20: transparent;
|
|
||||||
|
|
||||||
[data-slot="trigger"] {
|
[data-slot="trigger"] {
|
||||||
|
/* Override blue accent colors with neutral colors for dropdown trigger */
|
||||||
|
--color-accent: var(--color-border);
|
||||||
|
--color-accent-hover: var(--color-border);
|
||||||
|
--color-accent-active: var(--color-border);
|
||||||
|
--color-primary: var(--color-border);
|
||||||
|
--color-primary-hover: var(--color-border);
|
||||||
|
--color-primary-active: var(--color-border);
|
||||||
|
--color-primary-alpha-20: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
background-color: var(--color-bg);
|
background-color: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 200px;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,20 +41,10 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="dropdown"] button {
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure text inside buttons has no underline */
|
|
||||||
[data-slot="dropdown"] button * {
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="dropdown"] {
|
[data-slot="dropdown"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -58,14 +53,15 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="option"],
|
[data-slot="item"],
|
||||||
[data-slot="create-option"] {
|
[data-slot="create-item"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-2-5) var(--space-3);
|
padding: var(--space-2-5) var(--space-3);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -74,60 +70,22 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
border-top-left-radius: var(--border-radius-sm);
|
|
||||||
border-top-right-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom-left-radius: var(--border-radius-sm);
|
|
||||||
border-bottom-right-radius: var(--border-radius-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="option"][data-selected="true"] {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="create-option"] {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="create-form"] {
|
[data-slot="create-form"] {
|
||||||
margin-top: var(--space-4);
|
width: 100%;
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="create-input-group"] {
|
[data-slot="create-input-group"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 30rem) {
|
[data-slot="button-group"] {
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
align-items: stretch;
|
gap: var(--space-2);
|
||||||
}
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="create-input"] {
|
[data-slot="create-input"] {
|
||||||
@@ -150,35 +108,4 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type="submit"],
|
|
||||||
button[type="button"] {
|
|
||||||
padding: var(--space-2-5) var(--space-4);
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-color="primary"] {
|
|
||||||
background-color: var(--color-text-secondary);
|
|
||||||
border-color: var(--color-text-secondary);
|
|
||||||
color: var(--color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 30rem) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { query, useParams, action, createAsync, redirect } from "@solidjs/router"
|
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
|
||||||
import { For, Show, createEffect, onCleanup } from "solid-js"
|
import { For, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
@@ -7,6 +7,8 @@ import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/ind
|
|||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
import { Workspace } from "@opencode-ai/console-core/workspace.js"
|
import { Workspace } from "@opencode-ai/console-core/workspace.js"
|
||||||
|
import { IconChevron } from "~/component/icon"
|
||||||
|
import { Modal } from "~/component/modal"
|
||||||
import "./workspace-picker.css"
|
import "./workspace-picker.css"
|
||||||
|
|
||||||
const getWorkspaces = query(async () => {
|
const getWorkspaces = query(async () => {
|
||||||
@@ -40,11 +42,13 @@ const createWorkspace = action(async (form: FormData) => {
|
|||||||
export function WorkspacePicker() {
|
export function WorkspacePicker() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaces = createAsync(() => getWorkspaces())
|
const workspaces = createAsync(() => getWorkspaces())
|
||||||
|
const submission = useSubmission(createWorkspace)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
showForm: false,
|
showForm: false,
|
||||||
showDropdown: false,
|
showDropdown: false,
|
||||||
})
|
})
|
||||||
let dropdownRef: HTMLDivElement | undefined
|
let dropdownRef: HTMLDivElement | undefined
|
||||||
|
let inputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
const currentWorkspace = () => {
|
const currentWorkspace = () => {
|
||||||
const ws = workspaces()?.find((w) => w.id === params.id)
|
const ws = workspaces()?.find((w) => w.id === params.id)
|
||||||
@@ -55,6 +59,12 @@ export function WorkspacePicker() {
|
|||||||
setStore({ showForm: true, showDropdown: false })
|
setStore({ showForm: true, showDropdown: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (store.showForm && inputRef) {
|
||||||
|
setTimeout(() => inputRef?.focus(), 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handleSelectWorkspace = (workspaceID: string) => {
|
const handleSelectWorkspace = (workspaceID: string) => {
|
||||||
if (workspaceID === params.id) {
|
if (workspaceID === params.id) {
|
||||||
setStore("showDropdown", false)
|
setStore("showDropdown", false)
|
||||||
@@ -85,25 +95,17 @@ export function WorkspacePicker() {
|
|||||||
return (
|
return (
|
||||||
<div data-component="workspace-picker">
|
<div data-component="workspace-picker">
|
||||||
<div ref={dropdownRef}>
|
<div ref={dropdownRef}>
|
||||||
<div data-slot="trigger" onClick={() => setStore("showDropdown", !store.showDropdown)}>
|
<button data-slot="trigger" type="button" onClick={() => setStore("showDropdown", !store.showDropdown)}>
|
||||||
<span>{currentWorkspace()}</span>
|
<span>{currentWorkspace()}</span>
|
||||||
<svg data-slot="chevron" width="12" height="8" viewBox="0 0 12 8" fill="none">
|
<IconChevron data-slot="chevron" />
|
||||||
<path
|
</button>
|
||||||
d="M1 1L6 6L11 1"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={store.showDropdown}>
|
<Show when={store.showDropdown}>
|
||||||
<div data-slot="dropdown">
|
<div data-slot="dropdown">
|
||||||
<For each={workspaces()}>
|
<For each={workspaces()}>
|
||||||
{(workspace) => (
|
{(workspace) => (
|
||||||
<button
|
<button
|
||||||
data-slot="option"
|
data-slot="item"
|
||||||
data-selected={workspace.id === params.id}
|
data-selected={workspace.id === params.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectWorkspace(workspace.id)}
|
onClick={() => handleSelectWorkspace(workspace.id)}
|
||||||
@@ -112,33 +114,35 @@ export function WorkspacePicker() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}>
|
<button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}>
|
||||||
+ Create New Workspace
|
+ Create New Workspace
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={store.showForm}>
|
<Modal open={store.showForm} onClose={() => setStore("showForm", false)} title="Create New Workspace">
|
||||||
<form data-slot="create-form" action={createWorkspace} method="post">
|
<form data-slot="create-form" action={createWorkspace} method="post">
|
||||||
<div data-slot="create-input-group">
|
<div data-slot="create-input-group">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
data-slot="create-input"
|
data-slot="create-input"
|
||||||
type="text"
|
type="text"
|
||||||
name="workspaceName"
|
name="workspaceName"
|
||||||
placeholder="Enter workspace name"
|
placeholder="Enter workspace name"
|
||||||
required
|
required
|
||||||
autofocus
|
|
||||||
/>
|
/>
|
||||||
<button type="submit" data-color="primary">
|
<div data-slot="button-group">
|
||||||
Create
|
<button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}>
|
||||||
</button>
|
Cancel
|
||||||
<button type="button" onClick={() => setStore("showForm", false)}>
|
</button>
|
||||||
Cancel
|
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||||
</button>
|
{submission.pending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Show>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
@@ -55,9 +54,6 @@
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: var(--space-0-75);
|
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Workspace Header */
|
/* Workspace Header */
|
||||||
@@ -80,16 +76,14 @@
|
|||||||
[data-slot="header-brand"] {
|
[data-slot="header-brand"] {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
|
display: flex;
|
||||||
svg {
|
align-items: center;
|
||||||
width: 138px;
|
gap: var(--space-4);
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="site-title"] {
|
[data-component="site-title"] {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,19 +103,5 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
|
||||||
button {
|
|
||||||
appearance: none;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: var(--space-0-75);
|
|
||||||
text-decoration-thickness: 1px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,62 +1,40 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { getRequestEvent } from "solid-js/web"
|
import { query, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
|
||||||
import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
|
|
||||||
import "./workspace.css"
|
import "./workspace.css"
|
||||||
import { useAuthSession } from "~/context/auth.session"
|
import { IconWorkspaceLogo } from "../component/icon"
|
||||||
import { IconLogo } from "../component/icon"
|
|
||||||
import { WorkspacePicker } from "./workspace-picker"
|
import { WorkspacePicker } from "./workspace-picker"
|
||||||
|
import { UserMenu } from "./user-menu"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { User } from "@opencode-ai/console-core/user.js"
|
import { User } from "@opencode-ai/console-core/user.js"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { beta } from "~/lib/beta"
|
import { querySessionInfo } from "./workspace/common"
|
||||||
|
|
||||||
const getUserInfo = query(async (workspaceID: string) => {
|
const getUserEmail = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
const actor = Actor.assert("user")
|
const actor = Actor.assert("user")
|
||||||
const email = await User.getAccountEmail(actor.properties.userID)
|
const email = await User.getAccountEmail(actor.properties.userID)
|
||||||
return { email }
|
return email
|
||||||
}, workspaceID)
|
}, workspaceID)
|
||||||
}, "userInfo")
|
}, "userEmail")
|
||||||
|
|
||||||
const logout = action(async () => {
|
|
||||||
"use server"
|
|
||||||
const auth = await useAuthSession()
|
|
||||||
const event = getRequestEvent()
|
|
||||||
const current = auth.data.current
|
|
||||||
if (current)
|
|
||||||
await auth.update((val) => {
|
|
||||||
delete val.account?.[current]
|
|
||||||
const first = Object.keys(val.account ?? {})[0]
|
|
||||||
val.current = first
|
|
||||||
event!.locals.actor = undefined
|
|
||||||
return val
|
|
||||||
})
|
|
||||||
throw redirect("/zen")
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function WorkspaceLayout(props: RouteSectionProps) {
|
export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const userInfo = createAsync(() => getUserInfo(params.id))
|
const userEmail = createAsync(() => getUserEmail(params.id))
|
||||||
const isBeta = createAsync(() => beta(params.id))
|
const sessionInfo = createAsync(() => querySessionInfo(params.id))
|
||||||
return (
|
return (
|
||||||
<main data-page="workspace">
|
<main data-page="workspace">
|
||||||
<header data-component="workspace-header">
|
<header data-component="workspace-header">
|
||||||
<div data-slot="header-brand">
|
<div data-slot="header-brand">
|
||||||
<A href="/" data-component="site-title">
|
<A href="/" data-component="site-title">
|
||||||
<IconLogo />
|
<IconWorkspaceLogo />
|
||||||
</A>
|
</A>
|
||||||
</div>
|
<Show when={sessionInfo()?.isBeta}>
|
||||||
<div data-slot="header-actions">
|
|
||||||
<Show when={isBeta()}>
|
|
||||||
<WorkspacePicker />
|
<WorkspacePicker />
|
||||||
</Show>
|
</Show>
|
||||||
<span data-slot="user">{userInfo()?.email}</span>
|
</div>
|
||||||
<form action={logout} method="post">
|
<div data-slot="header-actions">
|
||||||
<button type="submit" formaction={logout}>
|
<UserMenu email={userEmail()} />
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div>{props.children}</div>
|
<div>{props.children}</div>
|
||||||
|
|||||||
@@ -1,7 +1,72 @@
|
|||||||
|
[data-page="workspace"] {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace Layout */
|
||||||
|
[data-component="workspace-container"] {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 73px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="workspace-nav"] {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="workspace-nav-items"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
[data-nav-button] {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(-1 * var(--space-0-5));
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background-color: var(--color-text);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="workspace-content"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-6) var(--space-8);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-page="workspace-[id]"] {
|
[data-page="workspace-[id]"] {
|
||||||
max-width: 64rem;
|
max-width: 64rem;
|
||||||
padding: var(--space-10) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -32,7 +97,6 @@
|
|||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section titles */
|
|
||||||
[data-slot="section-title"] {
|
[data-slot="section-title"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -44,8 +108,7 @@
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.03125rem;
|
letter-spacing: -0.03125rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text);
|
||||||
text-transform: uppercase;
|
|
||||||
|
|
||||||
@media (max-width: 30rem) {
|
@media (max-width: 30rem) {
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
@@ -66,7 +129,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="section-content"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
section:not(:last-child) {
|
section:not(:last-child) {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
padding-bottom: var(--space-16);
|
padding-bottom: var(--space-16);
|
||||||
@@ -78,7 +149,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Title section */
|
/* Title section */
|
||||||
[data-component="title-section"] {
|
[data-component="header-section"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
@@ -105,11 +176,50 @@
|
|||||||
p {
|
p {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="billing-info"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="balance"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
[data-component="workspace-container"] {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="workspace-nav"] {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +1,37 @@
|
|||||||
import "./[id].css"
|
|
||||||
import { MonthlyLimitSection } from "./monthly-limit-section"
|
|
||||||
import { NewUserSection } from "./new-user-section"
|
|
||||||
import { BillingSection } from "./billing-section"
|
|
||||||
import { PaymentSection } from "./payment-section"
|
|
||||||
import { UsageSection } from "./usage-section"
|
|
||||||
import { KeySection } from "./key-section"
|
|
||||||
import { MemberSection } from "./member-section"
|
|
||||||
import { SettingsSection } from "./settings-section"
|
|
||||||
import { ModelSection } from "./model-section"
|
|
||||||
import { ProviderSection } from "./provider-section"
|
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import { createAsync, query, useParams } from "@solidjs/router"
|
import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { querySessionInfo } from "./common"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import "./[id].css"
|
||||||
import { User } from "@opencode-ai/console-core/user.js"
|
|
||||||
import { beta } from "~/lib/beta"
|
|
||||||
|
|
||||||
const getUserInfo = query(async (workspaceID: string) => {
|
export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||||
"use server"
|
|
||||||
return withActor(async () => {
|
|
||||||
const actor = Actor.assert("user")
|
|
||||||
const user = await User.fromID(actor.properties.userID)
|
|
||||||
return {
|
|
||||||
isAdmin: user?.role === "admin",
|
|
||||||
}
|
|
||||||
}, workspaceID)
|
|
||||||
}, "user.get")
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const userInfo = createAsync(() => getUserInfo(params.id))
|
const userInfo = createAsync(() => querySessionInfo(params.id))
|
||||||
const isBeta = createAsync(() => beta(params.id))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-page="workspace-[id]">
|
<main data-page="workspace">
|
||||||
<section data-component="title-section">
|
<div data-component="workspace-container">
|
||||||
<h1>Zen</h1>
|
<nav data-component="workspace-nav">
|
||||||
<p>
|
<div data-component="workspace-nav-items">
|
||||||
Curated list of models provided by opencode.{" "}
|
<A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button>
|
||||||
<a target="_blank" href="/docs/zen">
|
Zen
|
||||||
Learn more
|
</A>
|
||||||
</a>
|
<A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button>
|
||||||
.
|
API Keys
|
||||||
</p>
|
</A>
|
||||||
</section>
|
<A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button>
|
||||||
|
Members
|
||||||
<div data-slot="sections">
|
</A>
|
||||||
<NewUserSection />
|
<Show when={userInfo()?.isAdmin}>
|
||||||
<KeySection />
|
<A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button>
|
||||||
<Show when={isBeta()}>
|
Billing
|
||||||
<MemberSection />
|
</A>
|
||||||
</Show>
|
<A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button>
|
||||||
<Show when={userInfo()?.isAdmin}>
|
Settings
|
||||||
<Show when={isBeta()}>
|
</A>
|
||||||
<SettingsSection />
|
</Show>
|
||||||
<ModelSection />
|
</div>
|
||||||
<ProviderSection />
|
</nav>
|
||||||
</Show>
|
<div data-component="workspace-content">{props.children}</div>
|
||||||
<BillingSection />
|
|
||||||
<MonthlyLimitSection />
|
|
||||||
</Show>
|
|
||||||
<UsageSection />
|
|
||||||
<Show when={userInfo()?.isAdmin}>
|
|
||||||
<PaymentSection />
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
.root {
|
.root {
|
||||||
[data-slot="section-content"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="reload-error"] {
|
[data-slot="reload-error"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -29,6 +23,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="payment"] {
|
[data-slot="payment"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -86,7 +81,7 @@
|
|||||||
@media (max-width: 30rem) {
|
@media (max-width: 30rem) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
> button {
|
>button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,19 +91,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Make Enable Billing button full width when it's the only button */
|
/* Make Enable Billing button full width when it's the only button */
|
||||||
> button {
|
>button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="usage"] {
|
[data-slot="usage"] {
|
||||||
p {
|
p {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
b {
|
b {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,7 @@ import { IconCreditCard } from "~/component/icon"
|
|||||||
import styles from "./billing-section.module.css"
|
import styles from "./billing-section.module.css"
|
||||||
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import { createCheckoutUrl } from "../../common"
|
||||||
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
|
||||||
"use server"
|
|
||||||
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
|
|
||||||
}, "checkoutUrl")
|
|
||||||
|
|
||||||
const reload = action(async (form: FormData) => {
|
const reload = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MonthlyLimitSection } from "./monthly-limit-section"
|
||||||
|
import { BillingSection } from "./billing-section"
|
||||||
|
import { PaymentSection } from "./payment-section"
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { createAsync, useParams } from "@solidjs/router"
|
||||||
|
import { querySessionInfo } from "../../common"
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const params = useParams()
|
||||||
|
const userInfo = createAsync(() => querySessionInfo(params.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-page="workspace-[id]">
|
||||||
|
<div data-slot="sections">
|
||||||
|
<Show when={userInfo()?.isAdmin}>
|
||||||
|
<BillingSection />
|
||||||
|
<MonthlyLimitSection />
|
||||||
|
<PaymentSection />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
.root {
|
.root {
|
||||||
[data-slot="section-content"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="balance"] {
|
[data-slot="balance"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -99,4 +93,4 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||||
import { For } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { formatDateUTC, formatDateForTable } from "./common"
|
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||||
import styles from "./payment-section.module.css"
|
import styles from "./payment-section.module.css"
|
||||||
|
|
||||||
const getPaymentsInfo = query(async (workspaceID: string) => {
|
const getPaymentsInfo = query(async (workspaceID: string) => {
|
||||||
@@ -19,7 +19,6 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) =>
|
|||||||
|
|
||||||
export function PaymentSection() {
|
export function PaymentSection() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
|
|
||||||
const payments = createAsync(() => getPaymentsInfo(params.id))
|
const payments = createAsync(() => getPaymentsInfo(params.id))
|
||||||
const downloadReceiptAction = useAction(downloadReceipt)
|
const downloadReceiptAction = useAction(downloadReceipt)
|
||||||
|
|
||||||
@@ -58,8 +57,7 @@ export function PaymentSection() {
|
|||||||
// ]
|
// ]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
payments() &&
|
<Show when={payments() && payments()!.length > 0}>
|
||||||
payments()!.length > 0 && (
|
|
||||||
<section class={styles.root}>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>Payments History</h2>
|
<h2>Payments History</h2>
|
||||||
@@ -109,6 +107,6 @@ export function PaymentSection() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
71
packages/console/app/src/routes/workspace/[id]/index.tsx
Normal file
71
packages/console/app/src/routes/workspace/[id]/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NewUserSection } from "./new-user-section"
|
||||||
|
import { UsageSection } from "./usage-section"
|
||||||
|
import { ModelSection } from "./model-section"
|
||||||
|
import { ProviderSection } from "./provider-section"
|
||||||
|
import { IconLogo } from "~/component/icon"
|
||||||
|
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
|
||||||
|
import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common"
|
||||||
|
import { Show, createMemo } from "solid-js"
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const params = useParams()
|
||||||
|
const userInfo = createAsync(() => querySessionInfo(params.id))
|
||||||
|
const billingInfo = createAsync(() => queryBillingInfo(params.id))
|
||||||
|
const createCheckoutUrlAction = useAction(createCheckoutUrl)
|
||||||
|
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
||||||
|
|
||||||
|
const balanceAmount = createMemo(() => {
|
||||||
|
return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-page="workspace-[id]">
|
||||||
|
<section data-component="header-section">
|
||||||
|
<IconLogo />
|
||||||
|
<p>
|
||||||
|
<span>
|
||||||
|
Reliable optimized models for coding agents.{" "}
|
||||||
|
<a target="_blank" href="/docs/zen">
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<span data-slot="billing-info">
|
||||||
|
<Show
|
||||||
|
when={billingInfo()?.reload}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
data-color="primary"
|
||||||
|
data-size="sm"
|
||||||
|
disabled={createCheckoutUrlSubmission.pending}
|
||||||
|
onClick={async () => {
|
||||||
|
const baseUrl = window.location.href
|
||||||
|
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
|
||||||
|
if (checkoutUrl) {
|
||||||
|
window.location.href = checkoutUrl
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span data-slot="balance">
|
||||||
|
Current balance: <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div data-slot="sections">
|
||||||
|
<NewUserSection />
|
||||||
|
<ModelSection />
|
||||||
|
<Show when={userInfo()?.isAdmin}>
|
||||||
|
<ProviderSection />
|
||||||
|
</Show>
|
||||||
|
<UsageSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { KeySection } from "./key-section"
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return (
|
||||||
|
<div data-page="workspace-[id]">
|
||||||
|
<div data-slot="sections">
|
||||||
|
<KeySection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
.root {
|
.root {
|
||||||
|
[data-slot="title-row"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="empty-state"] {
|
[data-component="empty-state"] {
|
||||||
padding: var(--space-20) var(--space-6);
|
padding: var(--space-20) var(--space-6);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -107,6 +114,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-left: calc(-1 * var(--space-3));
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -140,16 +148,30 @@
|
|||||||
|
|
||||||
&[data-slot="key-actions"] {
|
&[data-slot="key-actions"] {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|
||||||
|
button {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
|
&:hover {
|
||||||
|
[data-slot="key-actions"] button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:last-child td {
|
&:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 40rem) {
|
@media (max-width: 40rem) {
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
@@ -157,16 +179,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
&:nth-child(3) /* Date */ {
|
&:nth-child(3)
|
||||||
|
|
||||||
|
/* Date */
|
||||||
|
{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
&:nth-child(3) /* Date */ {
|
&:nth-child(3)
|
||||||
|
|
||||||
|
/* Date */
|
||||||
|
{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { IconCopy, IconCheck } from "~/component/icon"
|
|||||||
import { Key } from "@opencode-ai/console-core/key.js"
|
import { Key } from "@opencode-ai/console-core/key.js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { formatDateUTC, formatDateForTable } from "./common"
|
import { formatDateUTC, formatDateForTable } from "../../common"
|
||||||
import styles from "./key-section.module.css"
|
import styles from "./key-section.module.css"
|
||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
|
|
||||||
@@ -43,8 +43,9 @@ const listKeys = query(async (workspaceID: string) => {
|
|||||||
return withActor(() => Key.list(), workspaceID)
|
return withActor(() => Key.list(), workspaceID)
|
||||||
}, "key.list")
|
}, "key.list")
|
||||||
|
|
||||||
export function KeyCreateForm() {
|
export function KeySection() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const keys = createAsync(() => listKeys(params.id))
|
||||||
const submission = useSubmission(createKey)
|
const submission = useSubmission(createKey)
|
||||||
const [store, setStore] = createStore({ show: false })
|
const [store, setStore] = createStore({ show: false })
|
||||||
|
|
||||||
@@ -52,69 +53,59 @@ export function KeyCreateForm() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!submission.pending && submission.result && !submission.result.error) {
|
if (!submission.pending && submission.result && !submission.result.error) {
|
||||||
hide()
|
setStore("show", false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
// submission.clear() does not clear the result in some cases, ie.
|
|
||||||
// 1. Create key with empty name => error shows
|
|
||||||
// 2. Put in a key name and creates the key => form hides
|
|
||||||
// 3. Click add key button again => form shows with the same error if
|
|
||||||
// submission.clear() is called only once
|
|
||||||
while (true) {
|
while (true) {
|
||||||
submission.clear()
|
submission.clear()
|
||||||
if (!submission.result) break
|
if (!submission.result) break
|
||||||
}
|
}
|
||||||
setStore("show", true)
|
setStore("show", true)
|
||||||
input.focus()
|
setTimeout(() => input?.focus(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
setStore("show", false)
|
setStore("show", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={store.show}
|
|
||||||
fallback={
|
|
||||||
<button data-color="primary" onClick={() => show()}>
|
|
||||||
Create API Key
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form action={createKey} method="post" data-slot="create-form">
|
|
||||||
<div data-slot="input-container">
|
|
||||||
<input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
|
|
||||||
<Show when={submission.result && submission.result.error}>
|
|
||||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
|
||||||
<div data-slot="form-actions">
|
|
||||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
|
||||||
{submission.pending ? "Creating..." : "Create"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeySection() {
|
|
||||||
const params = useParams()
|
|
||||||
const keys = createAsync(() => listKeys(params.id))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class={styles.root}>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>API Keys</h2>
|
<h2>API Keys</h2>
|
||||||
<p>Manage your API keys for accessing opencode services.</p>
|
<div data-slot="title-row">
|
||||||
|
<p>Manage your API keys for accessing opencode services.</p>
|
||||||
|
<button data-color="primary" onClick={() => show()}>
|
||||||
|
Create API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<KeyCreateForm />
|
<Show when={store.show}>
|
||||||
|
<form action={createKey} method="post" data-slot="create-form">
|
||||||
|
<div data-slot="input-container">
|
||||||
|
<input
|
||||||
|
ref={(r) => (input = r)}
|
||||||
|
data-component="input"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter key name"
|
||||||
|
/>
|
||||||
|
<Show when={submission.result && submission.result.error}>
|
||||||
|
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
|
<div data-slot="form-actions">
|
||||||
|
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||||
|
{submission.pending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
<div data-slot="api-keys-table">
|
<div data-slot="api-keys-table">
|
||||||
<Show
|
<Show
|
||||||
when={keys()?.length}
|
when={keys()?.length}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { MemberSection } from "./member-section"
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return (
|
||||||
|
<div data-page="workspace-[id]">
|
||||||
|
<div data-slot="sections">
|
||||||
|
<MemberSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
.root {
|
||||||
|
[data-slot="title-row"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="empty-state"] {
|
||||||
|
padding: var(--space-20) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="create-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
|
||||||
|
[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;
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: calc(var(--space-1) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="members-table"] {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="members-table-element"] {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
thead {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
&[data-slot="member-email"] {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[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;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-transform: none;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-slot="member-actions"] {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
&:hover {
|
||||||
|
[data-slot="member-actions"] form:not([data-slot="inline-edit-form"]) button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
&:nth-child(3)
|
||||||
|
|
||||||
|
/* Date */
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
&:nth-child(3)
|
||||||
|
|
||||||
|
/* Date */
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||||
|
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"
|
||||||
|
return withActor(async () => {
|
||||||
|
return {
|
||||||
|
members: await User.list(),
|
||||||
|
actorID: Actor.userID(),
|
||||||
|
actorRole: Actor.userRole(),
|
||||||
|
}
|
||||||
|
}, workspaceID)
|
||||||
|
}, "member.list")
|
||||||
|
|
||||||
|
const inviteMember = action(async (form: FormData) => {
|
||||||
|
"use server"
|
||||||
|
const email = form.get("email")?.toString().trim()
|
||||||
|
if (!email) return { error: "Email is required" }
|
||||||
|
const workspaceID = form.get("workspaceID")?.toString()
|
||||||
|
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, monthlyLimit })
|
||||||
|
.then((data) => ({ error: undefined, data }))
|
||||||
|
.catch((e) => ({ error: e.message as string })),
|
||||||
|
workspaceID,
|
||||||
|
),
|
||||||
|
{ revalidate: listMembers.key },
|
||||||
|
)
|
||||||
|
}, "member.create")
|
||||||
|
|
||||||
|
const removeMember = action(async (form: FormData) => {
|
||||||
|
"use server"
|
||||||
|
const id = form.get("id")?.toString()
|
||||||
|
if (!id) return { error: "ID is required" }
|
||||||
|
const workspaceID = form.get("workspaceID")?.toString()
|
||||||
|
if (!workspaceID) return { error: "Workspace ID is required" }
|
||||||
|
return json(
|
||||||
|
await withActor(
|
||||||
|
() =>
|
||||||
|
User.remove(id)
|
||||||
|
.then((data) => ({ error: undefined, data }))
|
||||||
|
.catch((e) => ({ error: e.message as string })),
|
||||||
|
workspaceID,
|
||||||
|
),
|
||||||
|
{ revalidate: listMembers.key },
|
||||||
|
)
|
||||||
|
}, "member.remove")
|
||||||
|
|
||||||
|
const updateMember = action(async (form: FormData) => {
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
const id = form.get("id")?.toString()
|
||||||
|
if (!id) return { error: "ID is required" }
|
||||||
|
const workspaceID = form.get("workspaceID")?.toString()
|
||||||
|
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.update({ id, role, monthlyLimit })
|
||||||
|
.then((data) => ({ error: undefined, data }))
|
||||||
|
.catch((e) => ({ error: e.message as string })),
|
||||||
|
workspaceID,
|
||||||
|
),
|
||||||
|
{ revalidate: listMembers.key },
|
||||||
|
)
|
||||||
|
}, "member.update")
|
||||||
|
|
||||||
|
function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
if (!dateLastUsed) return 0
|
||||||
|
|
||||||
|
const current = new Date().toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
timeZone: "UTC",
|
||||||
|
})
|
||||||
|
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
timeZone: "UTC",
|
||||||
|
})
|
||||||
|
const usage = current === lastUsed ? (props.member.monthlyUsage ?? 0) : 0
|
||||||
|
return (usage / 100000000).toFixed(2)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
|
||||||
|
return `$${currentUsage} / ${limit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
admin: { title: "Admin", description: "Can manage models, members, and billing" },
|
||||||
|
member: { title: "Member", description: "Can only generate API keys for themselves" },
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<Show when={!submission.pending}>
|
||||||
|
<button type="button" data-color="ghost" onClick={() => hide()}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberSection() {
|
||||||
|
const params = useParams()
|
||||||
|
const data = createAsync(() => listMembers(params.id))
|
||||||
|
const submission = useSubmission(inviteMember)
|
||||||
|
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) {
|
||||||
|
setStore("show", 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("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 (
|
||||||
|
<section class={styles.root}>
|
||||||
|
<div data-slot="section-title">
|
||||||
|
<h2>Members</h2>
|
||||||
|
<div data-slot="title-row">
|
||||||
|
<p>Manage workspace members and their permissions.</p>
|
||||||
|
<Show when={data()?.actorRole === "admin"}>
|
||||||
|
<button data-color="primary" onClick={() => show()}>
|
||||||
|
Invite Member
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={store.show}>
|
||||||
|
<form action={inviteMember} method="post" data-slot="create-form">
|
||||||
|
<div data-slot="input-row">
|
||||||
|
<div data-slot="input-field">
|
||||||
|
<p>Invitee</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>
|
||||||
|
<div data-slot="input-field">
|
||||||
|
<p>Monthly spending 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()}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||||
|
{submission.pending ? "Inviting..." : "Invite"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
|
<div data-slot="members-table">
|
||||||
|
<table data-slot="members-table-element">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Month limit</th>
|
||||||
|
<th></th>
|
||||||
|
<Show when={data()?.actorRole === "admin"}>
|
||||||
|
<th></th>
|
||||||
|
</Show>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<Show when={data() && data()!.members.length > 0}>
|
||||||
|
<For each={data()!.members}>
|
||||||
|
{(member) => (
|
||||||
|
<MemberRow
|
||||||
|
member={member}
|
||||||
|
workspaceID={params.id}
|
||||||
|
actorID={data()!.actorID}
|
||||||
|
actorRole={data()!.actorRole}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
[data-slot="models-list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="models-table"] {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="models-table-element"] {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
thead {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: normal;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
&[data-slot="model-name"] {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-slot="training-data"] {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-slot="model-toggle"] {
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-toggle-label"] {
|
||||||
|
/* Toggle container */
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
/* Hidden checkbox input */
|
||||||
|
input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle track (background) */
|
||||||
|
span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
/* Toggle handle (slider) */
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0.125rem;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checked state - track */
|
||||||
|
input:checked+span {
|
||||||
|
background-color: #21AD0E;
|
||||||
|
border-color: #148605;
|
||||||
|
|
||||||
|
/* Checked state - handle */
|
||||||
|
&::before {
|
||||||
|
transform: translateX(1rem) translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover states */
|
||||||
|
&:hover span {
|
||||||
|
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked:hover+span {
|
||||||
|
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state */
|
||||||
|
&:has(input:disabled) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled+span {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled:checked+span {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled~span:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled="true"] {
|
||||||
|
td[data-slot="model-name"] {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
[data-slot="models-table-element"] {
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
&:nth-child(2)
|
||||||
|
|
||||||
|
/* Training Data */
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
&:nth-child(2)
|
||||||
|
|
||||||
|
/* Training Data */
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { createMemo, For, Show } from "solid-js"
|
|||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { ZenModel } from "@opencode-ai/console-core/model.js"
|
import { ZenModel } from "@opencode-ai/console-core/model.js"
|
||||||
import styles from "./model-section.module.css"
|
import styles from "./model-section.module.css"
|
||||||
|
import { querySessionInfo } from "../common"
|
||||||
|
|
||||||
const getModelsInfo = query(async (workspaceID: string) => {
|
const getModelsInfo = query(async (workspaceID: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
@@ -39,28 +40,24 @@ const updateModel = action(async (form: FormData) => {
|
|||||||
export function ModelSection() {
|
export function ModelSection() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const modelsInfo = createAsync(() => getModelsInfo(params.id))
|
const modelsInfo = createAsync(() => getModelsInfo(params.id))
|
||||||
|
const userInfo = createAsync(() => querySessionInfo(params.id))
|
||||||
return (
|
return (
|
||||||
<section class={styles.root}>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>Models</h2>
|
<h2>Models</h2>
|
||||||
<p>Manage models for your workspace.</p>
|
<p>
|
||||||
|
Manage which models workspace members can access. Requests will fail if a member tries to use a disabled
|
||||||
|
model.{userInfo()?.isAdmin ? "" : " To use a disabled model, contact your workspace’s admin."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="models-list">
|
<div data-slot="models-list">
|
||||||
<Show
|
<Show when={modelsInfo()}>
|
||||||
when={modelsInfo()}
|
|
||||||
fallback={
|
|
||||||
<div data-component="empty-state">
|
|
||||||
<p>Loading models...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div data-slot="models-table">
|
<div data-slot="models-table">
|
||||||
<table data-slot="models-table-element">
|
<table data-slot="models-table-element">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>Status</th>
|
<th>Enabled</th>
|
||||||
<th>Action</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -68,15 +65,25 @@ export function ModelSection() {
|
|||||||
{(modelId) => {
|
{(modelId) => {
|
||||||
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId))
|
const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId))
|
||||||
return (
|
return (
|
||||||
<tr data-slot="model-row" data-enabled={isEnabled()}>
|
<tr data-slot="model-row" data-disabled={!isEnabled()}>
|
||||||
<td data-slot="model-name">{modelId}</td>
|
<td data-slot="model-name">{modelId}</td>
|
||||||
<td data-slot="model-status">{isEnabled() ? "Enabled" : "Disabled"}</td>
|
|
||||||
<td data-slot="model-toggle">
|
<td data-slot="model-toggle">
|
||||||
<form action={updateModel} method="post">
|
<form action={updateModel} method="post">
|
||||||
<input type="hidden" name="model" value={modelId} />
|
<input type="hidden" name="model" value={modelId} />
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
<input type="hidden" name="enabled" value={isEnabled().toString()} />
|
<input type="hidden" name="enabled" value={isEnabled().toString()} />
|
||||||
<button data-color="ghost">{isEnabled() ? "Disable" : "Enable"}</button>
|
<label data-slot="model-toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEnabled()}
|
||||||
|
disabled={!userInfo()?.isAdmin}
|
||||||
|
onChange={(e) => {
|
||||||
|
const form = e.currentTarget.closest("form")
|
||||||
|
if (form) form.requestSubmit()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -53,26 +53,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
|
|
||||||
[data-slot="section-title"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: -0.03125rem;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
|
|
||||||
@media (max-width: 30rem) {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="key-display"] {
|
[data-slot="key-display"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -160,4 +140,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,14 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
@@ -32,24 +40,21 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-slot="provider-status"] {
|
&[data-slot="provider-key"] {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--color-text);
|
color: var(--color-text-secondary);
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="provider-toggle"] {
|
|
||||||
text-align: left;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
|
|
||||||
[data-slot="edit-form"] {
|
[data-slot="edit-form"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
[data-slot="input-wrapper"] {
|
[data-slot="input-wrapper"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
@@ -59,6 +64,8 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -76,18 +83,43 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="form-actions"] {
|
&[data-slot="provider-action"] {
|
||||||
display: flex;
|
text-align: left;
|
||||||
gap: var(--space-2);
|
font-family: var(--font-sans);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
[data-slot="configured-actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
[data-slot="delete-form"] {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover [data-slot="delete-form"] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-actions"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr {
|
tbody tr {
|
||||||
&[data-enabled="false"] {
|
&:hover {
|
||||||
opacity: 0.6;
|
[data-slot="provider-action"] [data-slot="delete-form"] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child td {
|
&:last-child td {
|
||||||
@@ -12,6 +12,10 @@ const PROVIDERS = [
|
|||||||
|
|
||||||
type Provider = (typeof PROVIDERS)[number]
|
type Provider = (typeof PROVIDERS)[number]
|
||||||
|
|
||||||
|
function maskCredentials(credentials: string) {
|
||||||
|
return `${credentials.slice(0, 8)}...${credentials.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
const removeProvider = action(async (form: FormData) => {
|
const removeProvider = action(async (form: FormData) => {
|
||||||
"use server"
|
"use server"
|
||||||
const provider = form.get("provider")?.toString()
|
const provider = form.get("provider")?.toString()
|
||||||
@@ -58,7 +62,7 @@ function ProviderRow(props: { provider: Provider }) {
|
|||||||
|
|
||||||
let input: HTMLInputElement
|
let input: HTMLInputElement
|
||||||
|
|
||||||
const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key)
|
const providerData = () => providers()?.find((p) => p.provider === props.provider.key)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
|
if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) {
|
||||||
@@ -80,32 +84,14 @@ function ProviderRow(props: { provider: Provider }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr data-slot="provider-row" data-enabled={isEnabled()}>
|
<tr data-slot="provider-row">
|
||||||
<td data-slot="provider-name">{props.provider.name}</td>
|
<td data-slot="provider-name">{props.provider.name}</td>
|
||||||
<td data-slot="provider-status">{isEnabled() ? "Configured" : "Not Configured"}</td>
|
<td data-slot="provider-key">
|
||||||
<td data-slot="provider-toggle">
|
|
||||||
<Show
|
<Show
|
||||||
when={store.editing}
|
when={store.editing}
|
||||||
fallback={
|
fallback={<span>{providerData() ? maskCredentials(providerData()!.credentials) : "-"}</span>}
|
||||||
<Show
|
|
||||||
when={isEnabled()}
|
|
||||||
fallback={
|
|
||||||
<button data-color="ghost" onClick={() => show()}>
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form action={removeProvider} method="post">
|
|
||||||
<input type="hidden" name="provider" value={props.provider.key} />
|
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
|
||||||
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
|
|
||||||
Disable
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<form action={saveProvider} method="post" data-slot="edit-form">
|
<form id={`provider-form-${props.provider.key}`} action={saveProvider} method="post" data-slot="edit-form">
|
||||||
<div data-slot="input-wrapper">
|
<div data-slot="input-wrapper">
|
||||||
<input
|
<input
|
||||||
ref={(r) => (input = r)}
|
ref={(r) => (input = r)}
|
||||||
@@ -122,15 +108,51 @@ function ProviderRow(props: { provider: Provider }) {
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="provider" value={props.provider.key} />
|
<input type="hidden" name="provider" value={props.provider.key} />
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
<div data-slot="form-actions">
|
</form>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td data-slot="provider-action">
|
||||||
|
<Show
|
||||||
|
when={store.editing}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={!!providerData()}
|
||||||
|
fallback={
|
||||||
|
<button data-color="ghost" onClick={() => show()}>
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div data-slot="configured-actions">
|
||||||
|
<button data-color="ghost" onClick={() => show()}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<form action={removeProvider} method="post" data-slot="delete-form">
|
||||||
|
<input type="hidden" name="provider" value={props.provider.key} />
|
||||||
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
|
<button data-color="ghost" type="submit" disabled={removeSubmission.pending}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div data-slot="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-color="ghost"
|
||||||
|
disabled={saveSubmission.pending}
|
||||||
|
form={`provider-form-${props.provider.key}`}
|
||||||
|
>
|
||||||
|
{saveSubmission.pending ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<Show when={!saveSubmission.pending}>
|
||||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" data-color="ghost" disabled={saveSubmission.pending}>
|
</Show>
|
||||||
{saveSubmission.pending ? "Saving..." : "Save"}
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -149,8 +171,8 @@ export function ProviderSection() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Provider</th>
|
<th>Provider</th>
|
||||||
<th>Status</th>
|
<th>API Key</th>
|
||||||
<th>Action</th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { SettingsSection } from "./settings-section"
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return (
|
||||||
|
<div data-page="workspace-[id]">
|
||||||
|
<div data-slot="sections">
|
||||||
|
<SettingsSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,63 +1,61 @@
|
|||||||
.root {
|
.root {
|
||||||
[data-slot="section-content"] {
|
max-width: 40rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="setting"] {
|
[data-slot="setting"] {
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
|
|
||||||
@media (max-width: 30rem) {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="setting-info"] {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-3);
|
||||||
|
|
||||||
h3 {
|
p {
|
||||||
font-size: var(--font-size-md);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text);
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="value-with-action"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="current-value"] {
|
[data-slot="current-value"] {
|
||||||
font-size: var(--font-size-sm);
|
color: var(--color-text);
|
||||||
color: var(--color-text-muted);
|
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
>button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="create-form"] {
|
[data-slot="create-form"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
min-width: 15rem;
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
@media (max-width: 30rem) {
|
|
||||||
width: 100%;
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="input-container"] {
|
[data-slot="input-container"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: var(--space-1);
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@@ -68,11 +66,13 @@
|
|||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-mono);
|
line-height: 1.5;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
@@ -80,16 +80,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="form-actions"] {
|
>button[type="reset"] {
|
||||||
display: flex;
|
align-self: flex-start;
|
||||||
gap: var(--space-2);
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="form-error"] {
|
[data-slot="form-error"] {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
margin-top: calc(var(--space-1) * -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,10 +79,7 @@ export function SettingsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div data-slot="section-content">
|
<div data-slot="section-content">
|
||||||
<div data-slot="setting">
|
<div data-slot="setting">
|
||||||
<div data-slot="setting-info">
|
<p>Workspace name</p>
|
||||||
<h3>Workspace Name</h3>
|
|
||||||
<p data-slot="current-value">{workspaceInfo()?.name}</p>
|
|
||||||
</div>
|
|
||||||
<Show
|
<Show
|
||||||
when={!store.show}
|
when={!store.show}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -97,25 +94,26 @@ export function SettingsSection() {
|
|||||||
placeholder="Workspace name"
|
placeholder="Workspace name"
|
||||||
value={workspaceInfo()?.name ?? "Default"}
|
value={workspaceInfo()?.name ?? "Default"}
|
||||||
/>
|
/>
|
||||||
<Show when={submission.result && submission.result.error}>
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||||
</Show>
|
{submission.pending ? "Updating..." : "Save"}
|
||||||
</div>
|
</button>
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
|
||||||
<div data-slot="form-actions">
|
|
||||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
|
||||||
{submission.pending ? "Updating..." : "Update"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={submission.result && submission.result.error}>
|
||||||
|
{(err) => <div data-slot="form-error">{err()}</div>}
|
||||||
|
</Show>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button data-color="primary" onClick={() => show()}>
|
<div data-slot="value-with-action">
|
||||||
Edit Name
|
<p data-slot="current-value">{workspaceInfo()?.name}</p>
|
||||||
</button>
|
<button data-color="primary" onClick={() => show()}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { query, useParams, createAsync } from "@solidjs/router"
|
import { query, useParams, createAsync } from "@solidjs/router"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { formatDateUTC, formatDateForTable } from "./common"
|
import { formatDateUTC, formatDateForTable } from "../common"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import styles from "./usage-section.module.css"
|
import styles from "./usage-section.module.css"
|
||||||
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
|
import { action, query } from "@solidjs/router"
|
||||||
|
import { withActor } from "~/context/auth.withActor"
|
||||||
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
|
|
||||||
export function formatDateForTable(date: Date) {
|
export function formatDateForTable(date: Date) {
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -23,3 +29,23 @@ export function formatDateUTC(date: Date) {
|
|||||||
}
|
}
|
||||||
return date.toLocaleDateString("en-US", options)
|
return date.toLocaleDateString("en-US", options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const querySessionInfo = query(async (workspaceID: string) => {
|
||||||
|
"use server"
|
||||||
|
return withActor(() => {
|
||||||
|
return {
|
||||||
|
isAdmin: Actor.userRole() === "admin",
|
||||||
|
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
|
||||||
|
}
|
||||||
|
}, workspaceID)
|
||||||
|
}, "session.get")
|
||||||
|
|
||||||
|
export const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
|
||||||
|
"use server"
|
||||||
|
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
|
||||||
|
}, "checkoutUrl")
|
||||||
|
|
||||||
|
export const queryBillingInfo = query(async (workspaceID: string) => {
|
||||||
|
"use server"
|
||||||
|
return withActor(() => Billing.get(), workspaceID)
|
||||||
|
}, "billing.get")
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
.root {
|
|
||||||
[data-component="empty-state"] {
|
|
||||||
padding: var(--space-20) var(--space-6);
|
|
||||||
text-align: center;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
|
|
||||||
p {
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="create-form"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
|
|
||||||
[data-slot="input-container"] {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--color-text-disabled);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="form-actions"] {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="form-error"] {
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="members-table"] {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="members-table-element"] {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
|
|
||||||
thead {
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
text-align: left;
|
|
||||||
font-weight: normal;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-bottom: 1px solid var(--color-border-muted);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
|
|
||||||
&[data-slot="member-email"] {
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="member-role"] {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 400;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
text-transform: none;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-bg-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="member-date"] {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="member-actions"] {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr {
|
|
||||||
&:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 40rem) {
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
&:nth-child(3)
|
|
||||||
|
|
||||||
/* Date */
|
|
||||||
{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
&:nth-child(3)
|
|
||||||
|
|
||||||
/* Date */
|
|
||||||
{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
|
||||||
import { createEffect, createSignal, For, Show } 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"
|
|
||||||
|
|
||||||
const listMembers = query(async (workspaceID: string) => {
|
|
||||||
"use server"
|
|
||||||
return withActor(async () => {
|
|
||||||
return {
|
|
||||||
members: await User.list(),
|
|
||||||
actorID: Actor.userID(),
|
|
||||||
actorRole: Actor.userRole(),
|
|
||||||
}
|
|
||||||
}, workspaceID)
|
|
||||||
}, "member.list")
|
|
||||||
|
|
||||||
const inviteMember = action(async (form: FormData) => {
|
|
||||||
"use server"
|
|
||||||
const email = form.get("email")?.toString().trim()
|
|
||||||
if (!email) return { error: "Email is required" }
|
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
|
||||||
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" }
|
|
||||||
return json(
|
|
||||||
await withActor(
|
|
||||||
() =>
|
|
||||||
User.invite({ email, role })
|
|
||||||
.then((data) => ({ error: undefined, data }))
|
|
||||||
.catch((e) => ({ error: e.message as string })),
|
|
||||||
workspaceID,
|
|
||||||
),
|
|
||||||
{ revalidate: listMembers.key },
|
|
||||||
)
|
|
||||||
}, "member.create")
|
|
||||||
|
|
||||||
const removeMember = action(async (form: FormData) => {
|
|
||||||
"use server"
|
|
||||||
const id = form.get("id")?.toString()
|
|
||||||
if (!id) return { error: "ID is required" }
|
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
|
||||||
if (!workspaceID) return { error: "Workspace ID is required" }
|
|
||||||
return json(
|
|
||||||
await withActor(
|
|
||||||
() =>
|
|
||||||
User.remove(id)
|
|
||||||
.then((data) => ({ error: undefined, data }))
|
|
||||||
.catch((e) => ({ error: e.message as string })),
|
|
||||||
workspaceID,
|
|
||||||
),
|
|
||||||
{ revalidate: listMembers.key },
|
|
||||||
)
|
|
||||||
}, "member.remove")
|
|
||||||
|
|
||||||
const updateMember = action(async (form: FormData) => {
|
|
||||||
"use server"
|
|
||||||
|
|
||||||
const id = form.get("id")?.toString()
|
|
||||||
if (!id) return { error: "ID is required" }
|
|
||||||
const workspaceID = form.get("workspaceID")?.toString()
|
|
||||||
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.update({ id, role, monthlyLimit })
|
|
||||||
.then((data) => ({ error: undefined, data }))
|
|
||||||
.catch((e) => ({ error: e.message as string })),
|
|
||||||
workspaceID,
|
|
||||||
),
|
|
||||||
{ revalidate: listMembers.key },
|
|
||||||
)
|
|
||||||
}, "member.update")
|
|
||||||
|
|
||||||
export function MemberCreateForm() {
|
|
||||||
const params = useParams()
|
|
||||||
const submission = useSubmission(inviteMember)
|
|
||||||
const [store, setStore] = createStore({ show: false })
|
|
||||||
|
|
||||||
let input: HTMLInputElement
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!submission.pending && submission.result && !submission.result.error) {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function show() {
|
|
||||||
// submission.clear() does not clear the result in some cases, ie.
|
|
||||||
// 1. Create key with empty name => error shows
|
|
||||||
// 2. Put in a key name and creates the key => form hides
|
|
||||||
// 3. Click add key button again => form shows with the same error if
|
|
||||||
// submission.clear() is called only once
|
|
||||||
while (true) {
|
|
||||||
submission.clear()
|
|
||||||
if (!submission.result) break
|
|
||||||
}
|
|
||||||
setStore("show", true)
|
|
||||||
input.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function hide() {
|
|
||||||
setStore("show", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={store.show}
|
|
||||||
fallback={
|
|
||||||
<button data-color="primary" onClick={() => show()}>
|
|
||||||
Invite Member
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<Show when={submission.result && submission.result.error}>
|
|
||||||
{(err) => <div data-slot="form-error">{err()}</div>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="workspaceID" value={params.id} />
|
|
||||||
<div data-slot="form-actions">
|
|
||||||
<button type="reset" data-color="ghost" onClick={() => hide()}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" data-color="primary" disabled={submission.pending}>
|
|
||||||
{submission.pending ? "Inviting..." : "Invite"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!submission.pending && submission.result && !submission.result.error) {
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getUsageDisplay() {
|
|
||||||
const currentUsage = (() => {
|
|
||||||
const dateLastUsed = props.member.timeMonthlyUsageUpdated
|
|
||||||
if (!dateLastUsed) return 0
|
|
||||||
|
|
||||||
const current = new Date().toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
timeZone: "UTC",
|
|
||||||
})
|
|
||||||
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
timeZone: "UTC",
|
|
||||||
})
|
|
||||||
if (current !== lastUsed) return 0
|
|
||||||
return ((props.member.monthlyUsage ?? 0) / 100000000).toFixed(2)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit"
|
|
||||||
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>
|
|
||||||
<td data-slot="member-actions">
|
|
||||||
<Show when={isAdmin()}>
|
|
||||||
<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>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<td colspan="5">
|
|
||||||
<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} />
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={!isCurrentUser()}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<div data-slot="current-user-role">Role: {props.member.role}</div>
|
|
||||||
<input type="hidden" name="role" value={props.member.role} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div data-slot="role-selector">
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
|
|
||||||
<div>
|
|
||||||
<strong>Admin</strong>
|
|
||||||
<p>Can manage models, members, and billing</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="role" value="member" checked={props.member.role === "member"} />
|
|
||||||
<div>
|
|
||||||
<strong>Member</strong>
|
|
||||||
<p>Can only generate API keys for themselves</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</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}>
|
|
||||||
{submission.pending ? "Saving..." : "Save"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MemberSection() {
|
|
||||||
const params = useParams()
|
|
||||||
const data = createAsync(() => listMembers(params.id))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section class={styles.root}>
|
|
||||||
<div data-slot="section-title">
|
|
||||||
<h2>Members</h2>
|
|
||||||
</div>
|
|
||||||
<Show when={data()?.actorRole === "admin"}>
|
|
||||||
<MemberCreateForm />
|
|
||||||
</Show>
|
|
||||||
<div data-slot="members-table">
|
|
||||||
<table data-slot="members-table-element">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Role</th>
|
|
||||||
<th>Usage</th>
|
|
||||||
<th></th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<For each={data()?.members || []}>
|
|
||||||
{(member) => (
|
|
||||||
<MemberRow
|
|
||||||
member={member}
|
|
||||||
workspaceID={params.id}
|
|
||||||
actorID={data()!.actorID}
|
|
||||||
actorRole={data()!.actorRole}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
.root {}
|
|
||||||
|
|
||||||
[data-slot="section-title"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="section-title"] h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="section-title"] p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="models-list"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="models-table"] {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="models-table-element"] {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
|
|
||||||
thead {
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
text-align: left;
|
|
||||||
font-weight: normal;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-bottom: 1px solid var(--color-border-muted);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
|
|
||||||
&[data-slot="model-name"] {
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="training-data"] {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="model-status"] {
|
|
||||||
text-align: left;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="model-toggle"] {
|
|
||||||
text-align: left;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr {
|
|
||||||
&[data-enabled="false"] {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 40rem) {
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
&:nth-child(2)
|
|
||||||
|
|
||||||
/* Training Data */
|
|
||||||
{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
&:nth-child(2)
|
|
||||||
|
|
||||||
/* Training Data */
|
|
||||||
{
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[data-component="empty-state"] {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
@@ -67,6 +67,11 @@ export namespace Actor {
|
|||||||
return actor as Extract<Info, { type: T }>
|
return actor as Extract<Info, { type: T }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const assertAdmin = () => {
|
||||||
|
if (userRole() === "admin") return
|
||||||
|
throw new Error(`Action not allowed. Ask your workspace admin to perform this action.`)
|
||||||
|
}
|
||||||
|
|
||||||
export function workspace() {
|
export function workspace() {
|
||||||
const actor = use()
|
const actor = use()
|
||||||
if ("workspaceID" in actor.properties) {
|
if ("workspaceID" in actor.properties) {
|
||||||
|
|||||||
@@ -40,13 +40,14 @@ export namespace ZenModel {
|
|||||||
|
|
||||||
export namespace Model {
|
export namespace Model {
|
||||||
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||||
const workspaceID = Actor.workspace()
|
Actor.assertAdmin()
|
||||||
return Database.use((db) =>
|
return Database.use((db) =>
|
||||||
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, workspaceID), eq(ModelTable.model, model))),
|
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const disable = fn(z.object({ model: z.string() }), ({ model }) => {
|
export const disable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||||
|
Actor.assertAdmin()
|
||||||
return Database.use((db) =>
|
return Database.use((db) =>
|
||||||
db
|
db
|
||||||
.insert(ModelTable)
|
.insert(ModelTable)
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export namespace Provider {
|
|||||||
provider: z.string().min(1).max(64),
|
provider: z.string().min(1).max(64),
|
||||||
credentials: z.string(),
|
credentials: z.string(),
|
||||||
}),
|
}),
|
||||||
({ provider, credentials }) =>
|
async ({ provider, credentials }) => {
|
||||||
Database.use((tx) =>
|
Actor.assertAdmin()
|
||||||
|
return Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.insert(ProviderTable)
|
.insert(ProviderTable)
|
||||||
.values({
|
.values({
|
||||||
@@ -36,14 +37,21 @@ export namespace Provider {
|
|||||||
timeDeleted: null,
|
timeDeleted: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export const remove = fn(z.object({ provider: z.string() }), ({ provider }) =>
|
export const remove = fn(
|
||||||
Database.transaction((tx) =>
|
z.object({
|
||||||
tx
|
provider: z.string(),
|
||||||
.delete(ProviderTable)
|
}),
|
||||||
.where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),
|
async ({ provider }) => {
|
||||||
),
|
Actor.assertAdmin()
|
||||||
|
return Database.transaction((tx) =>
|
||||||
|
tx
|
||||||
|
.delete(ProviderTable)
|
||||||
|
.where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,9 @@ import { Account } from "./account"
|
|||||||
import { AccountTable } from "./schema/account.sql"
|
import { AccountTable } from "./schema/account.sql"
|
||||||
import { Key } from "./key"
|
import { Key } from "./key"
|
||||||
import { KeyTable } from "./schema/key.sql"
|
import { KeyTable } from "./schema/key.sql"
|
||||||
|
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||||
|
|
||||||
export namespace User {
|
export namespace User {
|
||||||
const assertAdmin = () => {
|
|
||||||
if (Actor.userRole() === "admin") return
|
|
||||||
throw new Error(`Expected admin user, got ${Actor.userRole()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const assertNotSelf = (id: string) => {
|
const assertNotSelf = (id: string) => {
|
||||||
if (Actor.userID() !== id) return
|
if (Actor.userID() !== id) return
|
||||||
throw new Error(`Expected not self actor, got self actor`)
|
throw new Error(`Expected not self actor, got self actor`)
|
||||||
@@ -63,9 +59,10 @@ export namespace User {
|
|||||||
z.object({
|
z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
role: z.enum(UserRole),
|
role: z.enum(UserRole),
|
||||||
|
monthlyLimit: z.number().nullable().optional(),
|
||||||
}),
|
}),
|
||||||
async ({ email, role }) => {
|
async ({ email, role, monthlyLimit }) => {
|
||||||
assertAdmin()
|
Actor.assertAdmin()
|
||||||
const workspaceID = Actor.workspace()
|
const workspaceID = Actor.workspace()
|
||||||
|
|
||||||
// create user
|
// create user
|
||||||
@@ -85,10 +82,12 @@ export namespace User {
|
|||||||
}),
|
}),
|
||||||
workspaceID,
|
workspaceID,
|
||||||
role,
|
role,
|
||||||
|
monthlyLimit,
|
||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({
|
.onDuplicateKeyUpdate({
|
||||||
set: {
|
set: {
|
||||||
role,
|
role,
|
||||||
|
monthlyLimit,
|
||||||
timeDeleted: null,
|
timeDeleted: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -117,6 +116,21 @@ export namespace User {
|
|||||||
|
|
||||||
// send email, ignore errors
|
// send email, ignore errors
|
||||||
try {
|
try {
|
||||||
|
const emailInfo = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
email: AccountTable.email,
|
||||||
|
workspaceName: WorkspaceTable.name,
|
||||||
|
})
|
||||||
|
.from(UserTable)
|
||||||
|
.innerJoin(AccountTable, eq(UserTable.accountID, AccountTable.id))
|
||||||
|
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, workspaceID))
|
||||||
|
.where(
|
||||||
|
and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, Actor.assert("user").properties.userID)),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
|
||||||
const { InviteEmail } = await import("@opencode-ai/console-mail/InviteEmail.jsx")
|
const { InviteEmail } = await import("@opencode-ai/console-mail/InviteEmail.jsx")
|
||||||
await AWS.sendEmail({
|
await AWS.sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
@@ -124,8 +138,10 @@ export namespace User {
|
|||||||
body: render(
|
body: render(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
InviteEmail({
|
InviteEmail({
|
||||||
|
inviter: emailInfo.email,
|
||||||
assetsUrl: `https://opencode.ai/email`,
|
assetsUrl: `https://opencode.ai/email`,
|
||||||
workspace: workspaceID,
|
workspaceID: workspaceID,
|
||||||
|
workspaceName: emailInfo.workspaceName,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -176,7 +192,7 @@ export namespace User {
|
|||||||
monthlyLimit: z.number().nullable(),
|
monthlyLimit: z.number().nullable(),
|
||||||
}),
|
}),
|
||||||
async ({ id, role, monthlyLimit }) => {
|
async ({ id, role, monthlyLimit }) => {
|
||||||
assertAdmin()
|
Actor.assertAdmin()
|
||||||
if (role === "member") assertNotSelf(id)
|
if (role === "member") assertNotSelf(id)
|
||||||
return await Database.use((tx) =>
|
return await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -188,7 +204,7 @@ export namespace User {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const remove = fn(z.string(), async (id) => {
|
export const remove = fn(z.string(), async (id) => {
|
||||||
assertAdmin()
|
Actor.assertAdmin()
|
||||||
assertNotSelf(id)
|
assertNotSelf(id)
|
||||||
|
|
||||||
return await Database.use((tx) =>
|
return await Database.use((tx) =>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export namespace Workspace {
|
|||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
}),
|
}),
|
||||||
async ({ name }) => {
|
async ({ name }) => {
|
||||||
|
Actor.assertAdmin()
|
||||||
const workspaceID = Actor.workspace()
|
const workspaceID = Actor.workspace()
|
||||||
return await Database.use((tx) =>
|
return await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function A({ children, ...props }: AProps) {
|
|||||||
return React.createElement("a", props, children)
|
return React.createElement("a", props, children)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function B({ children, ...props }: AProps) {
|
||||||
|
return React.createElement("b", props, children)
|
||||||
|
}
|
||||||
|
|
||||||
export function Span({ children, ...props }: SpanProps) {
|
export function Span({ children, ...props }: SpanProps) {
|
||||||
return React.createElement("span", props, children)
|
return React.createElement("span", props, children)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Img, Row, Html, Link, Body, Head, Button, Column, Preview, Section, Container } from "@jsx-email/all"
|
import { Img, Row, Html, Link, Body, Head, Button, Column, Preview, Section, Container } from "@jsx-email/all"
|
||||||
import { Hr, Text, Fonts, SplitString, Title, A, Span } from "../components"
|
import { Hr, Text, Fonts, SplitString, Title, A, Span, B } from "../components"
|
||||||
import {
|
import {
|
||||||
unit,
|
unit,
|
||||||
body,
|
body,
|
||||||
@@ -23,17 +23,24 @@ const CONSOLE_URL = "https://opencode.ai/"
|
|||||||
const DOC_URL = "https://opencode.ai/docs/zen"
|
const DOC_URL = "https://opencode.ai/docs/zen"
|
||||||
|
|
||||||
interface InviteEmailProps {
|
interface InviteEmailProps {
|
||||||
workspace: string
|
inviter: string
|
||||||
|
workspaceID: string
|
||||||
|
workspaceName: string
|
||||||
assetsUrl: string
|
assetsUrl: string
|
||||||
}
|
}
|
||||||
export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteEmailProps) => {
|
export const InviteEmail = ({
|
||||||
const subject = `Join the ${workspace} workspace`
|
inviter = "test@anoma.ly",
|
||||||
const messagePlain = `You've been invited to join the ${workspace} workspace in the OpenCode Zen Console.`
|
workspaceID = "wrk_01K6XFY7V53T8XN0A7X8G9BTN3",
|
||||||
const url = `${CONSOLE_URL}workspace/${workspace}`
|
workspaceName = "anomaly",
|
||||||
|
assetsUrl = LOCAL_ASSETS_URL,
|
||||||
|
}: InviteEmailProps) => {
|
||||||
|
const subject = `You were invited to the OpenCode Console`
|
||||||
|
const messagePlain = `${inviter} invited you to join the ${workspaceName} workspace (${workspaceID}).`
|
||||||
|
const url = `${CONSOLE_URL}workspace/${workspaceID}`
|
||||||
return (
|
return (
|
||||||
<Html lang="en">
|
<Html lang="en">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>{`OpenCode Zen — ${messagePlain}`}</Title>
|
<Title>{`OpenCode — ${messagePlain}`}</Title>
|
||||||
</Head>
|
</Head>
|
||||||
<Fonts assetsUrl={assetsUrl} />
|
<Fonts assetsUrl={assetsUrl} />
|
||||||
<Preview>{messagePlain}</Preview>
|
<Preview>{messagePlain}</Preview>
|
||||||
@@ -42,15 +49,10 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
|
|||||||
<Section style={frame}>
|
<Section style={frame}>
|
||||||
<Row>
|
<Row>
|
||||||
<Column>
|
<Column>
|
||||||
<A href={CONSOLE_URL}>
|
<A href={`${CONSOLE_URL}zen`}>
|
||||||
<Img height="32" alt="OpenCode Zen Logo" src={`${assetsUrl}/zen-logo.png`} />
|
<Img height="32" alt="OpenCode Logo" src={`${assetsUrl}/logo.png`} />
|
||||||
</A>
|
</A>
|
||||||
</Column>
|
</Column>
|
||||||
<Column align="right">
|
|
||||||
<Button style={buttonPrimary} href={url}>
|
|
||||||
<Span style={code}>Join Workspace</Span>
|
|
||||||
</Button>
|
|
||||||
</Column>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row style={headingHr}>
|
<Row style={headingHr}>
|
||||||
@@ -59,32 +61,26 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
|
|||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Section>
|
|
||||||
<Text style={{ ...compactText, ...breadcrumb }}>
|
|
||||||
<Span>OpenCode Zen</Span>
|
|
||||||
<Span style={{ ...code, ...breadcrumbColonSeparator }}>:</Span>
|
|
||||||
<Span>{workspace}</Span>
|
|
||||||
</Text>
|
|
||||||
<Text style={{ ...heading, ...compactText }}>
|
|
||||||
<Link href={url}>
|
|
||||||
<SplitString text={subject} split={40} />
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
<Section style={{ padding: `${unit}px 0 0 0` }}>
|
<Section style={{ padding: `${unit}px 0 0 0` }}>
|
||||||
<Text style={{ ...compactText }}>
|
<Text style={{ ...compactText }}>
|
||||||
You've been invited to join the{" "}
|
<B>{inviter}</B> invited you to join the{" "}
|
||||||
<Link style={medium} href={url}>
|
<Link style={medium} href={url}>
|
||||||
{workspace}
|
<B>{workspaceName}</B>
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
workspace in the{" "}
|
workspace ({workspaceID}) in the{" "}
|
||||||
<Link style={medium} href={CONSOLE_URL}>
|
<Link style={medium} href={`${CONSOLE_URL}zen`}>
|
||||||
OpenCode Zen Console
|
OpenCode Console
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section style={{ padding: `${unit}px 0 0 0` }}>
|
||||||
|
<Button style={buttonPrimary} href={url}>
|
||||||
|
<Span style={code}>Join Workspace</Span>
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Row style={headingHr}>
|
<Row style={headingHr}>
|
||||||
<Column>
|
<Column>
|
||||||
<Hr />
|
<Hr />
|
||||||
@@ -93,7 +89,7 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
|
|||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
<Column>
|
<Column>
|
||||||
<Link href={CONSOLE_URL} style={footerLink}>
|
<Link href={`${CONSOLE_URL}zen`} style={footerLink}>
|
||||||
Console
|
Console
|
||||||
</Link>
|
</Link>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
BIN
packages/console/mail/emails/templates/static/logo.png
Normal file
BIN
packages/console/mail/emails/templates/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Reference in New Issue
Block a user