diff options
| author | Frank <[email protected]> | 2025-10-10 21:24:05 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-10 21:24:05 -0400 |
| commit | 2d35b783335cae9898ec80362934bab892fcf973 (patch) | |
| tree | 146ba002b1bd58044bcac15c5ced977c086a9bd1 /packages | |
| parent | 07645e070525e627cda5cd4ad7f001f70cbc57dc (diff) | |
| parent | c7dfbbeed0e7b5a7421b4b0d8c115a24f5ba7534 (diff) | |
| download | opencode-2d35b783335cae9898ec80362934bab892fcf973.tar.gz opencode-2d35b783335cae9898ec80362934bab892fcf973.zip | |
Merge branch 'console-workspaces' into dev
Diffstat (limited to 'packages')
50 files changed, 2070 insertions, 1176 deletions
diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index 2b2dbe411..4d3865c87 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -2,26 +2,70 @@ import { JSX } from "solid-js" export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) { return ( - <svg {...props} width="234" height="42" viewBox="0 0 234 42" fill="none" - xmlns="http://www.w3.org/2000/svg"> - <path fill-rule="evenodd" clip-rule="evenodd" - d="M54 36H36V42H30V6H54V36ZM36 30H48V12H36V30Z" fill="currentColor"/> - <path fill-rule="evenodd" clip-rule="evenodd" - d="M24 36H0V6H24V36ZM6 30H18V12H6V30Z" fill="currentColor"/> - <path fill-rule="evenodd" clip-rule="evenodd" - d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="currentColor"/> - <path d="M108 12H96V36H90V6H108V12Z" fill="currentColor"/> - <path d="M114 36H108V12H114V36Z" fill="currentColor"/> - <path d="M144 12H126V30H144V36H120V6H144V12Z" fill="currentColor"/> - <path fill-rule="evenodd" clip-rule="evenodd" - d="M174 36H150V6H174V36ZM156 30H168V12H156V30Z" fill="currentColor"/> - <path fill-rule="evenodd" clip-rule="evenodd" - d="M204 36H180V6H198V0H204V36ZM186 30H198V12H186V30Z" fill="currentColor"/> - <path fill-rule="evenodd" clip-rule="evenodd" - d="M234 24H216V30H234V36H210V6H234V24ZM216 18H228V12H216V18Z" fill="currentColor"/> - </svg> - -) + <svg width="64" height="32" viewBox="0 0 64 32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M0 9.14333V4.5719H4.57143V9.14333H0Z" fill="currentColor" /> + <path d="M4.57178 9.14333V4.5719H9.14321V9.14333H4.57178Z" fill="currentColor" /> + <path d="M9.1438 9.14333V4.5719H13.7152V9.14333H9.1438Z" fill="currentColor" /> + <path d="M13.7124 9.14333V4.5719H18.2838V9.14333H13.7124Z" fill="currentColor" /> + <path d="M13.7124 13.7136V9.14221H18.2838V13.7136H13.7124Z" fill="currentColor" /> + <path d="M0 18.2857V13.7142H4.57143V18.2857H0Z" fill="currentColor" fill-opacity="0.2" /> + <rect width="4.57143" height="4.57143" transform="translate(4.57178 13.7141)" fill="currentColor" /> + <path d="M4.57178 18.2855V13.7141H9.14321V18.2855H4.57178Z" fill="currentColor" fill-opacity="0.2" /> + <path d="M9.1438 18.2855V13.7141H13.7152V18.2855H9.1438Z" fill="currentColor" /> + <path d="M13.7156 18.2855V13.7141H18.287V18.2855H13.7156Z" fill="currentColor" fill-opacity="0.2" /> + <rect width="4.57143" height="4.57143" transform="translate(0 18.2859)" fill="currentColor" /> + <path d="M0 22.8572V18.2858H4.57143V22.8572H0Z" fill="currentColor" fill-opacity="0.2" /> + <rect + width="4.57143" + height="4.57143" + transform="translate(4.57178 18.2859)" + 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>) { @@ -55,3 +99,22 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) { </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> + ) +} diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css new file mode 100644 index 000000000..23b6831c9 --- /dev/null +++ b/packages/console/app/src/component/modal.css @@ -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); + } +}
\ No newline at end of file diff --git a/packages/console/app/src/component/modal.tsx b/packages/console/app/src/component/modal.tsx new file mode 100644 index 000000000..d6dc8a3de --- /dev/null +++ b/packages/console/app/src/component/modal.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/lib/beta.ts b/packages/console/app/src/lib/beta.ts deleted file mode 100644 index d60a735ee..000000000 --- a/packages/console/app/src/lib/beta.ts +++ /dev/null @@ -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") diff --git a/packages/console/app/src/routes/user-menu.css b/packages/console/app/src/routes/user-menu.css new file mode 100644 index 000000000..28c7937f5 --- /dev/null +++ b/packages/console/app/src/routes/user-menu.css @@ -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; + } +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx new file mode 100644 index 000000000..8c011fc0b --- /dev/null +++ b/packages/console/app/src/routes/user-menu.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css index c22ced867..dec482286 100644 --- a/packages/console/app/src/routes/workspace-picker.css +++ b/packages/console/app/src/routes/workspace-picker.css @@ -1,33 +1,38 @@ [data-component="workspace-picker"] { 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"] { + /* 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; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-2) var(--space-3); - border: 1px solid var(--color-border); + border: none; border-radius: var(--border-radius-sm); - background-color: var(--color-bg); + background-color: transparent; color: var(--color-text); font-size: var(--font-size-sm); font-family: var(--font-sans); cursor: pointer; - min-width: 200px; + 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); } } @@ -36,20 +41,10 @@ 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"] { position: absolute; top: 100%; left: 0; - right: 0; z-index: 1000; margin-top: var(--space-1); border: 1px solid var(--color-border); @@ -58,14 +53,15 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); max-height: 240px; overflow-y: auto; + min-width: 200px; @media (prefers-color-scheme: dark) { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } } - [data-slot="option"], - [data-slot="create-option"] { + [data-slot="item"], + [data-slot="create-item"] { width: 100%; padding: var(--space-2-5) var(--space-3); border: none; @@ -74,60 +70,22 @@ font-size: var(--font-size-sm); font-family: var(--font-sans); 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"] { - margin-top: var(--space-4); - padding: var(--space-4); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-sm); - background-color: var(--color-surface); + width: 100%; } [data-slot="create-input-group"] { display: flex; - gap: var(--space-2); - align-items: center; + flex-direction: column; + gap: var(--space-3); + } - @media (max-width: 30rem) { - flex-direction: column; - align-items: stretch; - } + [data-slot="button-group"] { + display: flex; + gap: var(--space-2); + justify-content: flex-end; } [data-slot="create-input"] { @@ -150,35 +108,4 @@ 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; - } - } }
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx index 181826335..34a544973 100644 --- a/packages/console/app/src/routes/workspace-picker.tsx +++ b/packages/console/app/src/routes/workspace-picker.tsx @@ -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 { createStore } from "solid-js/store" 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 { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { Workspace } from "@opencode-ai/console-core/workspace.js" +import { IconChevron } from "~/component/icon" +import { Modal } from "~/component/modal" import "./workspace-picker.css" const getWorkspaces = query(async () => { @@ -40,11 +42,13 @@ const createWorkspace = action(async (form: FormData) => { export function WorkspacePicker() { const params = useParams() const workspaces = createAsync(() => getWorkspaces()) + const submission = useSubmission(createWorkspace) const [store, setStore] = createStore({ showForm: false, showDropdown: false, }) let dropdownRef: HTMLDivElement | undefined + let inputRef: HTMLInputElement | undefined const currentWorkspace = () => { const ws = workspaces()?.find((w) => w.id === params.id) @@ -55,6 +59,12 @@ export function WorkspacePicker() { setStore({ showForm: true, showDropdown: false }) } + createEffect(() => { + if (store.showForm && inputRef) { + setTimeout(() => inputRef?.focus(), 0) + } + }) + const handleSelectWorkspace = (workspaceID: string) => { if (workspaceID === params.id) { setStore("showDropdown", false) @@ -85,25 +95,17 @@ export function WorkspacePicker() { return ( <div data-component="workspace-picker"> <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> - <svg data-slot="chevron" width="12" height="8" viewBox="0 0 12 8" fill="none"> - <path - d="M1 1L6 6L11 1" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - /> - </svg> - </div> + <IconChevron data-slot="chevron" /> + </button> <Show when={store.showDropdown}> <div data-slot="dropdown"> <For each={workspaces()}> {(workspace) => ( <button - data-slot="option" + data-slot="item" data-selected={workspace.id === params.id} type="button" onClick={() => handleSelectWorkspace(workspace.id)} @@ -112,33 +114,35 @@ export function WorkspacePicker() { </button> )} </For> - <button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}> + <button data-slot="create-item" type="button" onClick={() => handleWorkspaceNew()}> + Create New Workspace </button> </div> </Show> </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"> <div data-slot="create-input-group"> <input + ref={inputRef} data-slot="create-input" type="text" name="workspaceName" placeholder="Enter workspace name" required - autofocus /> - <button type="submit" data-color="primary"> - Create - </button> - <button type="button" onClick={() => setStore("showForm", false)}> - Cancel - </button> + <div data-slot="button-group"> + <button type="button" data-color="ghost" onClick={() => setStore("showForm", false)}> + Cancel + </button> + <button type="submit" data-color="primary" disabled={submission.pending}> + {submission.pending ? "Creating..." : "Create"} + </button> + </div> </div> </form> - </Show> + </Modal> </div> ) } diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css index ed94365f0..e8f12796e 100644 --- a/packages/console/app/src/routes/workspace.css +++ b/packages/console/app/src/routes/workspace.css @@ -11,7 +11,6 @@ font-size: var(--font-size-sm); font-family: var(--font-sans); font-weight: 500; - text-transform: uppercase; cursor: pointer; transition: all 0.15s ease; @@ -55,9 +54,6 @@ a { color: var(--color-text); - text-decoration: underline; - text-underline-offset: var(--space-0-75); - text-decoration-thickness: 1px; } /* Workspace Header */ @@ -80,16 +76,14 @@ [data-slot="header-brand"] { flex: 0 0 auto; padding-top: 4px; - - svg { - width: 138px; - } + display: flex; + align-items: center; + gap: var(--space-4); [data-component="site-title"] { font-size: var(--font-size-lg); font-weight: 600; color: var(--color-text); - text-decoration: none; letter-spacing: -0.02em; } } @@ -109,19 +103,5 @@ 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; - } } -} +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx index ac394f585..2ac629f52 100644 --- a/packages/console/app/src/routes/workspace.tsx +++ b/packages/console/app/src/routes/workspace.tsx @@ -1,62 +1,40 @@ import { Show } from "solid-js" -import { getRequestEvent } from "solid-js/web" -import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router" +import { query, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router" import "./workspace.css" -import { useAuthSession } from "~/context/auth.session" -import { IconLogo } from "../component/icon" +import { IconWorkspaceLogo } from "../component/icon" import { WorkspacePicker } from "./workspace-picker" +import { UserMenu } from "./user-menu" import { withActor } from "~/context/auth.withActor" import { User } from "@opencode-ai/console-core/user.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" return withActor(async () => { const actor = Actor.assert("user") const email = await User.getAccountEmail(actor.properties.userID) - return { email } + return email }, workspaceID) -}, "userInfo") - -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") -}) +}, "userEmail") export default function WorkspaceLayout(props: RouteSectionProps) { const params = useParams() - const userInfo = createAsync(() => getUserInfo(params.id)) - const isBeta = createAsync(() => beta(params.id)) + const userEmail = createAsync(() => getUserEmail(params.id)) + const sessionInfo = createAsync(() => querySessionInfo(params.id)) return ( <main data-page="workspace"> <header data-component="workspace-header"> <div data-slot="header-brand"> <A href="/" data-component="site-title"> - <IconLogo /> + <IconWorkspaceLogo /> </A> - </div> - <div data-slot="header-actions"> - <Show when={isBeta()}> + <Show when={sessionInfo()?.isBeta}> <WorkspacePicker /> </Show> - <span data-slot="user">{userInfo()?.email}</span> - <form action={logout} method="post"> - <button type="submit" formaction={logout}> - Logout - </button> - </form> + </div> + <div data-slot="header-actions"> + <UserMenu email={userEmail()} /> </div> </header> <div>{props.children}</div> diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css index 8b318a19f..e2aa0774c 100644 --- a/packages/console/app/src/routes/workspace/[id].css +++ b/packages/console/app/src/routes/workspace/[id].css @@ -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]"] { max-width: 64rem; - padding: var(--space-10) var(--space-4); - margin: 0 auto; + padding: var(--space-2) var(--space-4); + margin: 0; width: 100%; display: flex; flex-direction: column; @@ -32,7 +97,6 @@ gap: var(--space-6); } - /* Section titles */ [data-slot="section-title"] { display: flex; flex-direction: column; @@ -44,8 +108,7 @@ line-height: 1.2; letter-spacing: -0.03125rem; margin: 0; - color: var(--color-text-secondary); - text-transform: uppercase; + color: var(--color-text); @media (max-width: 30rem) { 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) { border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-16); @@ -78,7 +149,7 @@ } /* Title section */ - [data-component="title-section"] { + [data-component="header-section"] { display: flex; flex-direction: column; gap: var(--space-2); @@ -105,11 +176,50 @@ p { line-height: 1.5; 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 { 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); + } +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx index 15aeb57a0..8347cd49c 100644 --- a/packages/console/app/src/routes/workspace/[id].tsx +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -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 { createAsync, query, useParams } from "@solidjs/router" -import { Actor } from "@opencode-ai/console-core/actor.js" -import { withActor } from "~/context/auth.withActor" -import { User } from "@opencode-ai/console-core/user.js" -import { beta } from "~/lib/beta" - -const getUserInfo = query(async (workspaceID: string) => { - "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") +import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router" +import { querySessionInfo } from "./common" +import "./[id].css" -export default function () { +export default function WorkspaceLayout(props: RouteSectionProps) { const params = useParams() - const userInfo = createAsync(() => getUserInfo(params.id)) - const isBeta = createAsync(() => beta(params.id)) - + const userInfo = createAsync(() => querySessionInfo(params.id)) return ( - <div data-page="workspace-[id]"> - <section data-component="title-section"> - <h1>Zen</h1> - <p> - Curated list of models provided by opencode.{" "} - <a target="_blank" href="/docs/zen"> - Learn more - </a> - . - </p> - </section> - - <div data-slot="sections"> - <NewUserSection /> - <KeySection /> - <Show when={isBeta()}> - <MemberSection /> - </Show> - <Show when={userInfo()?.isAdmin}> - <Show when={isBeta()}> - <SettingsSection /> - <ModelSection /> - <ProviderSection /> - </Show> - <BillingSection /> - <MonthlyLimitSection /> - </Show> - <UsageSection /> - <Show when={userInfo()?.isAdmin}> - <PaymentSection /> - </Show> + <main data-page="workspace"> + <div data-component="workspace-container"> + <nav data-component="workspace-nav"> + <div data-component="workspace-nav-items"> + <A href={`/workspace/${params.id}`} end activeClass="active" data-nav-button> + Zen + </A> + <A href={`/workspace/${params.id}/keys`} activeClass="active" data-nav-button> + API Keys + </A> + <A href={`/workspace/${params.id}/members`} activeClass="active" data-nav-button> + Members + </A> + <Show when={userInfo()?.isAdmin}> + <A href={`/workspace/${params.id}/billing`} activeClass="active" data-nav-button> + Billing + </A> + <A href={`/workspace/${params.id}/settings`} activeClass="active" data-nav-button> + Settings + </A> + </Show> + </div> + </nav> + <div data-component="workspace-content">{props.children}</div> </div> - </div> + </main> ) } diff --git a/packages/console/app/src/routes/workspace/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css index 0bb5709cb..123bb1c86 100644 --- a/packages/console/app/src/routes/workspace/billing-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css @@ -1,10 +1,4 @@ .root { - [data-slot="section-content"] { - display: flex; - flex-direction: column; - gap: var(--space-3); - } - [data-slot="reload-error"] { display: flex; align-items: center; @@ -29,6 +23,7 @@ flex-shrink: 0; } } + [data-slot="payment"] { display: flex; flex-direction: column; @@ -86,7 +81,7 @@ @media (max-width: 30rem) { flex-direction: column; - > button { + >button { width: 100%; } } @@ -96,19 +91,21 @@ } /* Make Enable Billing button full width when it's the only button */ - > button { + >button { flex: 1; } } } + [data-slot="usage"] { p { font-size: var(--font-size-sm); line-height: 1.5; color: var(--color-text-secondary); + b { font-weight: 600; } } } -} +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index 295ad3396..f9084bbf1 100644 --- a/packages/console/app/src/routes/workspace/billing-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -6,11 +6,7 @@ import { IconCreditCard } from "~/component/icon" import styles from "./billing-section.module.css" import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" - -const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => { - "use server" - return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID) -}, "checkoutUrl") +import { createCheckoutUrl } from "../../common" const reload = action(async (form: FormData) => { "use server" diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx new file mode 100644 index 000000000..a6d4825bf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace/monthly-limit-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css index 02de058e4..4f0f8b2e6 100644 --- a/packages/console/app/src/routes/workspace/monthly-limit-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css @@ -1,10 +1,4 @@ .root { - [data-slot="section-content"] { - display: flex; - flex-direction: column; - gap: var(--space-3); - } - [data-slot="balance"] { display: flex; flex-direction: column; @@ -99,4 +93,4 @@ margin: 0; line-height: 1.4; } -} +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx index dbeda115c..dbeda115c 100644 --- a/packages/console/app/src/routes/workspace/monthly-limit-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx diff --git a/packages/console/app/src/routes/workspace/payment-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css index 2e1afe78b..2e1afe78b 100644 --- a/packages/console/app/src/routes/workspace/payment-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css diff --git a/packages/console/app/src/routes/workspace/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx index c35a50660..c830cee8a 100644 --- a/packages/console/app/src/routes/workspace/payment-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx @@ -1,8 +1,8 @@ import { Billing } from "@opencode-ai/console-core/billing.js" 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 { formatDateUTC, formatDateForTable } from "./common" +import { formatDateUTC, formatDateForTable } from "../../common" import styles from "./payment-section.module.css" const getPaymentsInfo = query(async (workspaceID: string) => { @@ -19,7 +19,6 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) => export function PaymentSection() { const params = useParams() - // ORIGINAL CODE - COMMENTED OUT FOR TESTING const payments = createAsync(() => getPaymentsInfo(params.id)) const downloadReceiptAction = useAction(downloadReceipt) @@ -58,8 +57,7 @@ export function PaymentSection() { // ] return ( - payments() && - payments()!.length > 0 && ( + <Show when={payments() && payments()!.length > 0}> <section class={styles.root}> <div data-slot="section-title"> <h2>Payments History</h2> @@ -109,6 +107,6 @@ export function PaymentSection() { </table> </div> </section> - ) + </Show> ) } diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx new file mode 100644 index 000000000..7f196e45d --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/index.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/keys/index.tsx b/packages/console/app/src/routes/workspace/[id]/keys/index.tsx new file mode 100644 index 000000000..367c4e476 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/keys/index.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace/key-section.module.css b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css index 6a1d0c85f..1066b7f09 100644 --- a/packages/console/app/src/routes/workspace/key-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css @@ -1,4 +1,11 @@ .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; @@ -107,6 +114,7 @@ align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); + margin-left: calc(-1 * var(--space-3)); font-size: var(--font-size-sm); font-weight: 400; border: none; @@ -140,16 +148,30 @@ &[data-slot="key-actions"] { font-family: var(--font-sans); + + button { + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } } } tbody tr { + &:hover { + [data-slot="key-actions"] 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); @@ -157,16 +179,22 @@ } th { - &:nth-child(3) /* Date */ { + &:nth-child(3) + + /* Date */ + { display: none; } } td { - &:nth-child(3) /* Date */ { + &:nth-child(3) + + /* Date */ + { display: none; } } } } -} +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index 3b7e399aa..565981c7f 100644 --- a/packages/console/app/src/routes/workspace/key-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -4,7 +4,7 @@ import { IconCopy, IconCheck } from "~/component/icon" import { Key } from "@opencode-ai/console-core/key.js" import { withActor } from "~/context/auth.withActor" import { createStore } from "solid-js/store" -import { formatDateUTC, formatDateForTable } from "./common" +import { formatDateUTC, formatDateForTable } from "../../common" import styles from "./key-section.module.css" import { Actor } from "@opencode-ai/console-core/actor.js" @@ -43,8 +43,9 @@ const listKeys = query(async (workspaceID: string) => { return withActor(() => Key.list(), workspaceID) }, "key.list") -export function KeyCreateForm() { +export function KeySection() { const params = useParams() + const keys = createAsync(() => listKeys(params.id)) const submission = useSubmission(createKey) const [store, setStore] = createStore({ show: false }) @@ -52,22 +53,17 @@ export function KeyCreateForm() { createEffect(() => { if (!submission.pending && submission.result && !submission.result.error) { - hide() + setStore("show", false) } }) 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() + setTimeout(() => input?.focus(), 0) } function hide() { @@ -75,46 +71,41 @@ export function KeyCreateForm() { } 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 ( <section class={styles.root}> <div data-slot="section-title"> <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> - <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"> <Show when={keys()?.length} diff --git a/packages/console/app/src/routes/workspace/[id]/members/index.tsx b/packages/console/app/src/routes/workspace/[id]/members/index.tsx new file mode 100644 index 000000000..51e07b715 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/index.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css new file mode 100644 index 000000000..d67a29eba --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css @@ -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; + } + } + } + } +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx new file mode 100644 index 000000000..e60049a7e --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.module.css b/packages/console/app/src/routes/workspace/[id]/model-section.module.css new file mode 100644 index 000000000..420545670 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/model-section.module.css @@ -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; + } + } + } +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index 4128b4a2c..96d6950c9 100644 --- a/packages/console/app/src/routes/workspace/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -4,6 +4,7 @@ import { createMemo, For, Show } from "solid-js" import { withActor } from "~/context/auth.withActor" import { ZenModel } from "@opencode-ai/console-core/model.js" import styles from "./model-section.module.css" +import { querySessionInfo } from "../common" const getModelsInfo = query(async (workspaceID: string) => { "use server" @@ -39,28 +40,24 @@ const updateModel = action(async (form: FormData) => { export function ModelSection() { const params = useParams() const modelsInfo = createAsync(() => getModelsInfo(params.id)) + const userInfo = createAsync(() => querySessionInfo(params.id)) return ( <section class={styles.root}> <div data-slot="section-title"> <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 data-slot="models-list"> - <Show - when={modelsInfo()} - fallback={ - <div data-component="empty-state"> - <p>Loading models...</p> - </div> - } - > + <Show when={modelsInfo()}> <div data-slot="models-table"> <table data-slot="models-table-element"> <thead> <tr> <th>Model</th> - <th>Status</th> - <th>Action</th> + <th>Enabled</th> </tr> </thead> <tbody> @@ -68,15 +65,25 @@ export function ModelSection() { {(modelId) => { const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId)) 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-status">{isEnabled() ? "Enabled" : "Disabled"}</td> <td data-slot="model-toggle"> <form action={updateModel} method="post"> <input type="hidden" name="model" value={modelId} /> <input type="hidden" name="workspaceID" value={params.id} /> <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> </td> </tr> diff --git a/packages/console/app/src/routes/workspace/new-user-section.module.css b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css index 2edc7cc14..aaad823ab 100644 --- a/packages/console/app/src/routes/workspace/new-user-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css @@ -53,26 +53,6 @@ flex-direction: column; 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"] { display: flex; flex-direction: column; @@ -160,4 +140,4 @@ } } } -} +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/new-user-section.tsx b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx index b694801cc..b694801cc 100644 --- a/packages/console/app/src/routes/workspace/new-user-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx diff --git a/packages/console/app/src/routes/workspace/provider-section.module.css b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css index 5f18862f5..1a450d3dc 100644 --- a/packages/console/app/src/routes/workspace/provider-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css @@ -18,6 +18,14 @@ font-weight: normal; color: var(--color-text-muted); text-transform: uppercase; + + &:nth-child(1) { + width: 180px; + } + + &:nth-child(3) { + width: 200px; + } } td { @@ -32,24 +40,21 @@ font-weight: 500; } - &[data-slot="provider-status"] { - text-align: left; - color: var(--color-text); - } - - &[data-slot="provider-toggle"] { + &[data-slot="provider-key"] { text-align: left; - font-family: var(--font-sans); + color: var(--color-text-secondary); [data-slot="edit-form"] { display: flex; flex-direction: column; gap: var(--space-3); + max-width: 100%; [data-slot="input-wrapper"] { display: flex; flex-direction: column; gap: var(--space-1); + max-width: 100%; input { padding: var(--space-2) var(--space-3); @@ -59,6 +64,8 @@ color: var(--color-text); font-size: var(--font-size-sm); font-family: var(--font-mono); + width: 100%; + box-sizing: border-box; &:focus { outline: none; @@ -76,18 +83,43 @@ line-height: 1.4; } } + } + } - [data-slot="form-actions"] { - display: flex; - gap: var(--space-2); + &[data-slot="provider-action"] { + text-align: left; + 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 { - &[data-enabled="false"] { - opacity: 0.6; + &:hover { + [data-slot="provider-action"] [data-slot="delete-form"] { + opacity: 1; + pointer-events: auto; + } } &:last-child td { diff --git a/packages/console/app/src/routes/workspace/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx index 856b3a6a2..6ec8477b4 100644 --- a/packages/console/app/src/routes/workspace/provider-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx @@ -12,6 +12,10 @@ const PROVIDERS = [ type Provider = (typeof PROVIDERS)[number] +function maskCredentials(credentials: string) { + return `${credentials.slice(0, 8)}...${credentials.slice(-8)}` +} + const removeProvider = action(async (form: FormData) => { "use server" const provider = form.get("provider")?.toString() @@ -58,7 +62,7 @@ function ProviderRow(props: { provider: Provider }) { let input: HTMLInputElement - const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key) + const providerData = () => providers()?.find((p) => p.provider === props.provider.key) createEffect(() => { if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) { @@ -80,32 +84,14 @@ function ProviderRow(props: { provider: Provider }) { } 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-status">{isEnabled() ? "Configured" : "Not Configured"}</td> - <td data-slot="provider-toggle"> + <td data-slot="provider-key"> <Show when={store.editing} - fallback={ - <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> - } + fallback={<span>{providerData() ? maskCredentials(providerData()!.credentials) : "-"}</span>} > - <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"> <input ref={(r) => (input = r)} @@ -122,15 +108,51 @@ function ProviderRow(props: { provider: Provider }) { </div> <input type="hidden" name="provider" value={props.provider.key} /> <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()}> Cancel </button> - <button type="submit" data-color="ghost" disabled={saveSubmission.pending}> - {saveSubmission.pending ? "Saving..." : "Save"} - </button> - </div> - </form> + </Show> + </div> </Show> </td> </tr> @@ -149,8 +171,8 @@ export function ProviderSection() { <thead> <tr> <th>Provider</th> - <th>Status</th> - <th>Action</th> + <th>API Key</th> + <th></th> </tr> </thead> <tbody> diff --git a/packages/console/app/src/routes/workspace/[id]/settings/index.tsx b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx new file mode 100644 index 000000000..7c8f1fd17 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx @@ -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> + ) +} diff --git a/packages/console/app/src/routes/workspace/settings-section.module.css b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css index e3a5ad508..058fbe301 100644 --- a/packages/console/app/src/routes/workspace/settings-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css @@ -1,63 +1,61 @@ .root { - [data-slot="section-content"] { - display: flex; - flex-direction: column; - gap: var(--space-4); - } + max-width: 40rem; [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; flex-direction: column; - gap: var(--space-1); + gap: var(--space-3); - h3 { - font-size: var(--font-size-md); - font-weight: 500; + p { line-height: 1.2; 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"] { - font-size: var(--font-size-sm); - color: var(--color-text-muted); + color: var(--color-text); line-height: 1.4; margin: 0; } + + >button { + align-self: flex-start; + } } [data-slot="create-form"] { display: flex; flex-direction: column; - gap: var(--space-3); - min-width: 15rem; - width: fit-content; - - @media (max-width: 30rem) { - width: 100%; - min-width: auto; - } + gap: var(--space-2); [data-slot="input-container"] { display: flex; - flex-direction: column; - gap: var(--space-1); + flex-direction: row; + 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 { @@ -68,11 +66,13 @@ background-color: var(--color-bg); color: var(--color-text); font-size: var(--font-size-sm); - font-family: var(--font-mono); + line-height: 1.5; + min-width: 0; &:focus { outline: none; border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); } &::placeholder { @@ -80,16 +80,15 @@ } } - [data-slot="form-actions"] { - display: flex; - gap: var(--space-2); - justify-content: flex-end; + >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); } } -} +}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx index 0fc0158da..828f1be7e 100644 --- a/packages/console/app/src/routes/workspace/settings-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx @@ -79,10 +79,7 @@ export function SettingsSection() { </div> <div data-slot="section-content"> <div data-slot="setting"> - <div data-slot="setting-info"> - <h3>Workspace Name</h3> - <p data-slot="current-value">{workspaceInfo()?.name}</p> - </div> + <p>Workspace name</p> <Show when={!store.show} fallback={ @@ -97,25 +94,26 @@ export function SettingsSection() { placeholder="Workspace name" value={workspaceInfo()?.name ?? "Default"} /> - <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"> + <input type="hidden" name="workspaceID" value={params.id} /> + <button type="submit" data-color="primary" disabled={submission.pending}> + {submission.pending ? "Updating..." : "Save"} + </button> <button type="reset" data-color="ghost" onClick={() => hide()}> Cancel </button> - <button type="submit" data-color="primary" disabled={submission.pending}> - {submission.pending ? "Updating..." : "Update"} - </button> </div> + <Show when={submission.result && submission.result.error}> + {(err) => <div data-slot="form-error">{err()}</div>} + </Show> </form> } > - <button data-color="primary" onClick={() => show()}> - Edit Name - </button> + <div data-slot="value-with-action"> + <p data-slot="current-value">{workspaceInfo()?.name}</p> + <button data-color="primary" onClick={() => show()}> + Edit + </button> + </div> </Show> </div> </div> diff --git a/packages/console/app/src/routes/workspace/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css index 1a772ba87..1a772ba87 100644 --- a/packages/console/app/src/routes/workspace/usage-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css diff --git a/packages/console/app/src/routes/workspace/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx index 9f65fe5f7..47a2e43f7 100644 --- a/packages/console/app/src/routes/workspace/usage-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx @@ -1,7 +1,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js" import { query, useParams, createAsync } from "@solidjs/router" import { createMemo, For, Show } from "solid-js" -import { formatDateUTC, formatDateForTable } from "./common" +import { formatDateUTC, formatDateForTable } from "../common" import { withActor } from "~/context/auth.withActor" import styles from "./usage-section.module.css" diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index f85fd8423..fef1b3cd9 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -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) { const options: Intl.DateTimeFormatOptions = { day: "numeric", @@ -23,3 +29,23 @@ export function formatDateUTC(date: Date) { } 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") diff --git a/packages/console/app/src/routes/workspace/index.tsx b/packages/console/app/src/routes/workspace/index.tsx deleted file mode 100644 index e69de29bb..000000000 --- a/packages/console/app/src/routes/workspace/index.tsx +++ /dev/null diff --git a/packages/console/app/src/routes/workspace/member-section.module.css b/packages/console/app/src/routes/workspace/member-section.module.css deleted file mode 100644 index 16b6ff8d2..000000000 --- a/packages/console/app/src/routes/workspace/member-section.module.css +++ /dev/null @@ -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; - } - } - } - } -}
\ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/member-section.tsx b/packages/console/app/src/routes/workspace/member-section.tsx deleted file mode 100644 index b13e8e5ed..000000000 --- a/packages/console/app/src/routes/workspace/member-section.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/console/app/src/routes/workspace/model-section.module.css b/packages/console/app/src/routes/workspace/model-section.module.css deleted file mode 100644 index 5a98c9b15..000000000 --- a/packages/console/app/src/routes/workspace/model-section.module.css +++ /dev/null @@ -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; -}
\ No newline at end of file diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts index 88c5e4b51..48f4a6366 100644 --- a/packages/console/core/src/actor.ts +++ b/packages/console/core/src/actor.ts @@ -67,6 +67,11 @@ export namespace Actor { 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() { const actor = use() if ("workspaceID" in actor.properties) { diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index ae636c4f3..48d7e16c5 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -40,13 +40,14 @@ export namespace ZenModel { export namespace Model { export const enable = fn(z.object({ model: z.string() }), ({ model }) => { - const workspaceID = Actor.workspace() + Actor.assertAdmin() 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 }) => { + Actor.assertAdmin() return Database.use((db) => db .insert(ModelTable) diff --git a/packages/console/core/src/provider.ts b/packages/console/core/src/provider.ts index 1f8c07b9f..cf2040b59 100644 --- a/packages/console/core/src/provider.ts +++ b/packages/console/core/src/provider.ts @@ -20,8 +20,9 @@ export namespace Provider { provider: z.string().min(1).max(64), credentials: z.string(), }), - ({ provider, credentials }) => - Database.use((tx) => + async ({ provider, credentials }) => { + Actor.assertAdmin() + return Database.use((tx) => tx .insert(ProviderTable) .values({ @@ -36,14 +37,21 @@ export namespace Provider { timeDeleted: null, }, }), - ), + ) + }, ) - export const remove = fn(z.object({ provider: z.string() }), ({ provider }) => - Database.transaction((tx) => - tx - .delete(ProviderTable) - .where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))), - ), + export const remove = fn( + z.object({ + provider: z.string(), + }), + async ({ provider }) => { + Actor.assertAdmin() + return Database.transaction((tx) => + tx + .delete(ProviderTable) + .where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))), + ) + }, ) } diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts index 38c8e5e3a..40d74f93d 100644 --- a/packages/console/core/src/user.ts +++ b/packages/console/core/src/user.ts @@ -11,13 +11,9 @@ import { Account } from "./account" import { AccountTable } from "./schema/account.sql" import { Key } from "./key" import { KeyTable } from "./schema/key.sql" +import { WorkspaceTable } from "./schema/workspace.sql" export namespace User { - const assertAdmin = () => { - if (Actor.userRole() === "admin") return - throw new Error(`Expected admin user, got ${Actor.userRole()}`) - } - const assertNotSelf = (id: string) => { if (Actor.userID() !== id) return throw new Error(`Expected not self actor, got self actor`) @@ -63,9 +59,10 @@ export namespace User { z.object({ email: z.string(), role: z.enum(UserRole), + monthlyLimit: z.number().nullable().optional(), }), - async ({ email, role }) => { - assertAdmin() + async ({ email, role, monthlyLimit }) => { + Actor.assertAdmin() const workspaceID = Actor.workspace() // create user @@ -85,10 +82,12 @@ export namespace User { }), workspaceID, role, + monthlyLimit, }) .onDuplicateKeyUpdate({ set: { role, + monthlyLimit, timeDeleted: null, }, }), @@ -117,6 +116,21 @@ export namespace User { // send email, ignore errors 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") await AWS.sendEmail({ to: email, @@ -124,8 +138,10 @@ export namespace User { body: render( // @ts-ignore InviteEmail({ + inviter: emailInfo.email, assetsUrl: `https://opencode.ai/email`, - workspace: workspaceID, + workspaceID: workspaceID, + workspaceName: emailInfo.workspaceName, }), ), }) @@ -176,7 +192,7 @@ export namespace User { monthlyLimit: z.number().nullable(), }), async ({ id, role, monthlyLimit }) => { - assertAdmin() + Actor.assertAdmin() if (role === "member") assertNotSelf(id) return await Database.use((tx) => tx @@ -188,7 +204,7 @@ export namespace User { ) export const remove = fn(z.string(), async (id) => { - assertAdmin() + Actor.assertAdmin() assertNotSelf(id) return await Database.use((tx) => diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts index 7a742e896..655112ae2 100644 --- a/packages/console/core/src/workspace.ts +++ b/packages/console/core/src/workspace.ts @@ -52,6 +52,7 @@ export namespace Workspace { name: z.string().min(1).max(255), }), async ({ name }) => { + Actor.assertAdmin() const workspaceID = Actor.workspace() return await Database.use((tx) => tx diff --git a/packages/console/mail/emails/components.tsx b/packages/console/mail/emails/components.tsx index d030b6cbf..ff845c8f4 100644 --- a/packages/console/mail/emails/components.tsx +++ b/packages/console/mail/emails/components.tsx @@ -31,6 +31,10 @@ export function A({ children, ...props }: AProps) { return React.createElement("a", props, children) } +export function B({ children, ...props }: AProps) { + return React.createElement("b", props, children) +} + export function Span({ children, ...props }: SpanProps) { return React.createElement("span", props, children) } diff --git a/packages/console/mail/emails/templates/InviteEmail.tsx b/packages/console/mail/emails/templates/InviteEmail.tsx index 978080a9c..5c9630224 100644 --- a/packages/console/mail/emails/templates/InviteEmail.tsx +++ b/packages/console/mail/emails/templates/InviteEmail.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from "react" 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 { unit, body, @@ -23,17 +23,24 @@ const CONSOLE_URL = "https://opencode.ai/" const DOC_URL = "https://opencode.ai/docs/zen" interface InviteEmailProps { - workspace: string + inviter: string + workspaceID: string + workspaceName: string assetsUrl: string } -export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteEmailProps) => { - const subject = `Join the ${workspace} workspace` - const messagePlain = `You've been invited to join the ${workspace} workspace in the OpenCode Zen Console.` - const url = `${CONSOLE_URL}workspace/${workspace}` +export const InviteEmail = ({ + inviter = "[email protected]", + workspaceID = "wrk_01K6XFY7V53T8XN0A7X8G9BTN3", + 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 ( <Html lang="en"> <Head> - <Title>{`OpenCode Zen — ${messagePlain}`}</Title> + <Title>{`OpenCode — ${messagePlain}`}</Title> </Head> <Fonts assetsUrl={assetsUrl} /> <Preview>{messagePlain}</Preview> @@ -42,15 +49,10 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE <Section style={frame}> <Row> <Column> - <A href={CONSOLE_URL}> - <Img height="32" alt="OpenCode Zen Logo" src={`${assetsUrl}/zen-logo.png`} /> + <A href={`${CONSOLE_URL}zen`}> + <Img height="32" alt="OpenCode Logo" src={`${assetsUrl}/logo.png`} /> </A> </Column> - <Column align="right"> - <Button style={buttonPrimary} href={url}> - <Span style={code}>Join Workspace</Span> - </Button> - </Column> </Row> <Row style={headingHr}> @@ -59,32 +61,26 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE </Column> </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` }}> <Text style={{ ...compactText }}> - You've been invited to join the{" "} + <B>{inviter}</B> invited you to join the{" "} <Link style={medium} href={url}> - {workspace} + <B>{workspaceName}</B> </Link>{" "} - workspace in the{" "} - <Link style={medium} href={CONSOLE_URL}> - OpenCode Zen Console + workspace ({workspaceID}) in the{" "} + <Link style={medium} href={`${CONSOLE_URL}zen`}> + OpenCode Console </Link> . </Text> </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}> <Column> <Hr /> @@ -93,7 +89,7 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE <Row> <Column> - <Link href={CONSOLE_URL} style={footerLink}> + <Link href={`${CONSOLE_URL}zen`} style={footerLink}> Console </Link> </Column> diff --git a/packages/console/mail/emails/templates/static/logo.png b/packages/console/mail/emails/templates/static/logo.png Binary files differnew file mode 100644 index 000000000..1d4a39639 --- /dev/null +++ b/packages/console/mail/emails/templates/static/logo.png |
