diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index 2b2dbe41..4d3865c8 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) { return ( - - - - - - - - - - - - -) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } export function IconCopy(props: JSX.SvgSVGAttributes) { @@ -55,3 +99,22 @@ export function IconCreditCard(props: JSX.SvgSVGAttributes) { ) } + +export function IconChevron(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconWorkspaceLogo(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css new file mode 100644 index 00000000..23b6831c --- /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 00000000..d6dc8a3d --- /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 ( + +
+
e.stopPropagation()}> + +

{props.title}

+
+ {props.children} +
+
+
+ ) +} diff --git a/packages/console/app/src/lib/beta.ts b/packages/console/app/src/lib/beta.ts deleted file mode 100644 index d60a735e..00000000 --- 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 00000000..28c7937f --- /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 00000000..8c011fc0 --- /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 ( +
+
+ + + +
+
+ +
+
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css index c22ced86..dec48228 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 18182633..34a54497 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 (
-
setStore("showDropdown", !store.showDropdown)}> +
+ +
{(workspace) => ( )} -
- + setStore("showForm", false)} title="Create New Workspace">
- - +
+ + +
-
+
) } diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css index ed94365f..e8f12796 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 ac394f58..2ac629f5 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 (
-
- + - {userInfo()?.email} -
- -
+
+
+
{props.children}
diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css index 8b318a19..e2aa0774 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 15aeb57a..8347cd49 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" +import { createAsync, RouteSectionProps, useParams, A } from "@solidjs/router" +import { querySessionInfo } from "./common" +import "./[id].css" -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") - -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 ( -
-
-

Zen

-

- Curated list of models provided by opencode.{" "} - - Learn more - - . -

-
- -
- - - - - - - - - - - - - - - - - - +
+ -
+
) } 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 similarity index 93% rename from packages/console/app/src/routes/workspace/billing-section.module.css rename to packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css index 0bb5709c..123bb1c8 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 similarity index 97% rename from packages/console/app/src/routes/workspace/billing-section.tsx rename to packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index 295ad339..f9084bbf 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 00000000..a6d4825b --- /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 ( +
+
+ + + + + +
+
+ ) +} 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 similarity index 95% rename from packages/console/app/src/routes/workspace/monthly-limit-section.module.css rename to packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css index 02de058e..4f0f8b2e 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 similarity index 100% rename from packages/console/app/src/routes/workspace/monthly-limit-section.tsx rename to 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 similarity index 100% rename from packages/console/app/src/routes/workspace/payment-section.module.css rename to 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 similarity index 95% rename from packages/console/app/src/routes/workspace/payment-section.tsx rename to packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx index c35a5066..c830cee8 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 && ( + 0}>

Payments History

@@ -109,6 +107,6 @@ export function PaymentSection() {
- ) +
) } 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 00000000..7f196e45 --- /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 ( +
+
+ +

+ + Reliable optimized models for coding agents.{" "} + + Learn more + + . + + + { + const baseUrl = window.location.href + const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) + if (checkoutUrl) { + window.location.href = checkoutUrl + } + }} + > + {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"} + + } + > + + Current balance: ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} + + + +

+
+ +
+ + + + + + +
+
+ ) +} 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 00000000..367c4e47 --- /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 ( +
+
+ +
+
+ ) +} 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 similarity index 87% rename from packages/console/app/src/routes/workspace/key-section.module.css rename to packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css index 6a1d0c85..1066b7f0 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 similarity index 77% rename from packages/console/app/src/routes/workspace/key-section.tsx rename to packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index 3b7e399a..565981c7 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,69 +53,59 @@ 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() { setStore("show", false) } - return ( - show()}> - Create API Key - - } - > -
-
- (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" /> - - {(err) =>
{err()}
} -
-
- -
- - -
-
-
- ) -} - -export function KeySection() { - const params = useParams() - const keys = createAsync(() => listKeys(params.id)) - return (

API Keys

-

Manage your API keys for accessing opencode services.

+
+

Manage your API keys for accessing opencode services.

+ +
- + +
+
+ (input = r)} + data-component="input" + name="name" + type="text" + placeholder="Enter key name" + /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+
+
+
+ +
+
+ ) +} 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 00000000..d67a29eb --- /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 00000000..e60049a7 --- /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 ( + + {props.member.accountEmail ?? props.member.email} + + {props.member.role}}> +
+ + +
+ + +
+
+
+
+ + + {getUsageDisplay()}}> + setStore("limit", e.currentTarget.value)} + placeholder="No limit" + min="0" + /> + + + {props.member.timeSeen ? "" : "invited"} + + + + + +
+ + + +
+
+ + } + > +
+ + + + + + + + +
+
+ +
+ + ) +} + +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 ( +
+
+

Members

+
+

Manage workspace members and their permissions.

+ + + +
+
+ +
+
+
+

Invitee

+ (input = r)} + data-component="input" + name="email" + type="text" + placeholder="Enter email" + /> +
+
+

Role

+
+ + +
+ + +
+
+
+
+
+

Monthly spending limit

+ setStore("limit", e.currentTarget.value)} + min="0" + /> +
+
+ + {(err) =>
{err()}
} +
+ + +
+ + +
+
+
+
+ + + + + + + + + + + + + + 0}> + + {(member) => ( + + )} + + + +
EmailRoleMonth limit
+
+
+ ) +} 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 00000000..42054567 --- /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 similarity index 71% rename from packages/console/app/src/routes/workspace/model-section.tsx rename to packages/console/app/src/routes/workspace/[id]/model-section.tsx index 4128b4a2..96d6950c 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 (

Models

-

Manage models for your workspace.

+

+ 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."} +

- -

Loading models...

-
- } - > +
- - + @@ -68,15 +65,25 @@ export function ModelSection() { {(modelId) => { const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId)) return ( - + - 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 similarity index 88% rename from packages/console/app/src/routes/workspace/new-user-section.module.css rename to packages/console/app/src/routes/workspace/[id]/new-user-section.module.css index 2edc7cc1..aaad823a 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 similarity index 100% rename from packages/console/app/src/routes/workspace/new-user-section.tsx rename to 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 similarity index 69% rename from packages/console/app/src/routes/workspace/provider-section.module.css rename to packages/console/app/src/routes/workspace/[id]/provider-section.module.css index 5f18862f..1a450d3d 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"] { + &[data-slot="provider-key"] { text-align: left; - color: var(--color-text); - } - - &[data-slot="provider-toggle"] { - 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 similarity index 73% rename from packages/console/app/src/routes/workspace/provider-section.tsx rename to packages/console/app/src/routes/workspace/[id]/provider-section.tsx index 856b3a6a..6ec8477b 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 ( - + - - + @@ -149,8 +171,8 @@ export function ProviderSection() { - - + + 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 00000000..7c8f1fd1 --- /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 ( +
+
+ +
+
+ ) +} 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 similarity index 56% rename from packages/console/app/src/routes/workspace/settings-section.module.css rename to packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css index e3a5ad50..058fbe30 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 similarity index 83% rename from packages/console/app/src/routes/workspace/settings-section.tsx rename to packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx index 0fc0158d..828f1be7 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() {
-
-

Workspace Name

-

{workspaceInfo()?.name}

-
+

Workspace name

- - {(err) =>
{err()}
} -
-
- -
+ + -
+ + {(err) =>
{err()}
} +
} > - +
+

{workspaceInfo()?.name}

+ +
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 similarity index 100% rename from packages/console/app/src/routes/workspace/usage-section.module.css rename to 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 similarity index 98% rename from packages/console/app/src/routes/workspace/usage-section.tsx rename to packages/console/app/src/routes/workspace/[id]/usage-section.tsx index 9f65fe5f..47a2e43f 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 f85fd842..fef1b3cd 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 e69de29b..00000000 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 16b6ff8d..00000000 --- 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 b13e8e5e..00000000 --- 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()}> - Invite Member - - } - > -
-
- (input = r)} data-component="input" name="email" type="text" placeholder="Enter email" /> -
- - -
- - {(err) =>
{err()}
} -
-
- -
- - -
- -
- ) -} - -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 ( - - - - - - - - } - > - - - - - ) -} - -export function MemberSection() { - const params = useParams() - const data = createAsync(() => listMembers(params.id)) - - return ( -
-
-

Members

-
- - - -
-
ModelStatusActionEnabled
{modelId}{isEnabled() ? "Enabled" : "Disabled"}
- +
{props.provider.name}{isEnabled() ? "Configured" : "Not Configured"} + show()}> - Configure - - } - > -
- - - -
-
- } + fallback={{providerData() ? maskCredentials(providerData()!.credentials) : "-"}} > -
+
(input = r)} @@ -122,15 +108,51 @@ function ProviderRow(props: { provider: Provider }) {
-
+ + +
+ show()}> + Configure + + } + > +
+ +
+ + + +
+
+
+ } + > +
+ + - -
- + +
ProviderStatusActionAPI Key
{props.member.accountEmail ?? props.member.email}{props.member.role}{getUsageDisplay()}{props.member.timeSeen ? "" : "invited"} - - - -
- - - -
-
-
-
-
-
{props.member.accountEmail ?? props.member.email}
- - - - -
Role: {props.member.role}
- - - } - > -
- - -
-
- -
- -
- - - {(err) =>
{err()}
} -
- -
- - -
-
-
- - - - - - - - - - - - {(member) => ( - - )} - - -
EmailRoleUsage
-
-
- ) -} 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 5a98c9b1..00000000 --- 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 88c5e4b5..48f4a636 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 } + 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 ae636c4f..48d7e16c 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 1f8c07b9..cf2040b5 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 38c8e5e3..40d74f93 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 7a742e89..655112ae 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 d030b6cb..ff845c8f 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 978080a9..5c963022 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 = "test@anoma.ly", + 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 ( - {`OpenCode Zen — ${messagePlain}`} + {`OpenCode — ${messagePlain}`} {messagePlain} @@ -42,15 +49,10 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE
- - OpenCode Zen Logo + + OpenCode Logo - - - @@ -59,32 +61,26 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE -
- - OpenCode Zen - : - {workspace} - - - - - - -
- You've been invited to join the{" "} + {inviter} invited you to join the{" "} - {workspace} + {workspaceName} {" "} - workspace in the{" "} - - OpenCode Zen Console + workspace ({workspaceID}) in the{" "} + + OpenCode Console .
+
+ +
+
@@ -93,7 +89,7 @@ export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteE - + Console diff --git a/packages/console/mail/emails/templates/static/logo.png b/packages/console/mail/emails/templates/static/logo.png new file mode 100644 index 00000000..1d4a3963 Binary files /dev/null and b/packages/console/mail/emails/templates/static/logo.png differ