From 03d50894360ddaf5d8dae990f3bf484b553de223 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Oct 2025 00:02:04 -0400 Subject: wip: zen style model --- packages/console/core/src/actor.ts | 5 +++++ packages/console/core/src/model.ts | 5 +++-- packages/console/core/src/provider.ts | 26 +++++++++++++++++--------- packages/console/core/src/user.ts | 11 +++-------- 4 files changed, 28 insertions(+), 19 deletions(-) (limited to 'packages/console/core/src') diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts index 88c5e4b51..e8d1b7a6b 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(`Expected admin user, got ${userRole()}`) + } + 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..1580783fd 100644 --- a/packages/console/core/src/user.ts +++ b/packages/console/core/src/user.ts @@ -13,11 +13,6 @@ import { Key } from "./key" import { KeyTable } from "./schema/key.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`) @@ -65,7 +60,7 @@ export namespace User { role: z.enum(UserRole), }), async ({ email, role }) => { - assertAdmin() + Actor.assertAdmin() const workspaceID = Actor.workspace() // create user @@ -176,7 +171,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 +183,7 @@ export namespace User { ) export const remove = fn(z.string(), async (id) => { - assertAdmin() + Actor.assertAdmin() assertNotSelf(id) return await Database.use((tx) => -- cgit v1.2.3 From 4227b89ebcab636a7e7f8e2bbbd560ac5081a2c4 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Oct 2025 14:54:49 -0400 Subject: wip: zen --- .../[id]/members/member-section.module.css | 147 +++++++++++++++------ .../workspace/[id]/members/member-section.tsx | 128 ++++++++++++++---- packages/console/core/src/user.ts | 5 +- 3 files changed, 216 insertions(+), 64 deletions(-) (limited to 'packages/console/core/src') 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 index 8fd866536..4d142c486 100644 --- 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 @@ -30,83 +30,150 @@ border: 1px solid var(--color-border); border-radius: var(--border-radius-sm); - [data-slot="input-container"] { + [data-slot="input-row"] { display: flex; - flex-direction: column; - gap: var(--space-1); - } + flex-direction: row; + gap: var(--space-3); - @media (max-width: 30rem) { - gap: var(--space-2); + @media (max-width: 40rem) { + flex-direction: column; + gap: var(--space-2); + } } - input { + [data-slot="input-field"] { + display: flex; + flex-direction: column; + gap: var(--space-1); 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); + p { + line-height: 1.2; + margin: 0; + color: var(--color-text-muted); + font-size: var(--font-size-sm); } - &::placeholder { - color: var(--color-text-disabled); + 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); - margin-top: var(--space-1); line-height: 1.4; + margin-top: calc(var(--space-1) * -1); } [data-slot="role-selector"] { - display: flex; - flex-direction: column; - gap: var(--space-2); + position: relative; - label { + [data-slot="trigger"] { display: flex; - gap: var(--space-3); - padding: var(--space-3); + align-items: center; + justify-content: space-between; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--border-radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: 1.5; cursor: pointer; + transition: all 0.15s ease; &:hover { - background-color: var(--color-bg-surface); + border-color: var(--color-accent); } - input[type="radio"] { - margin-top: var(--space-1); + &:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-alpha); } - div { - flex: 1; + [data-slot="chevron"] { + opacity: 0.6; + transition: transform 0.15s ease; + } + } - strong { - display: block; - color: var(--color-text); - font-family: var(--font-sans); - margin-bottom: var(--space-1); + [data-slot="dropdown"] { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + margin-top: var(--space-1); + padding: var(--space-1); + background-color: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + + [data-slot="item"] { + display: block; + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + background-color: transparent; + color: var(--color-text); + font-size: var(--font-size-sm); + text-align: left; + cursor: pointer; + border-radius: var(--border-radius-sm); + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--color-bg-surface); + } + + &[data-selected="true"] { + background-color: var(--color-accent-alpha); } - p { - font-size: var(--font-size-xs); - color: var(--color-text-muted); - font-family: var(--font-sans); + 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; + } } } } 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 index f18311569..89c0ac957 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx @@ -1,11 +1,12 @@ import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" -import { createEffect, createSignal, For, Show } from "solid-js" +import { createEffect, createSignal, For, Show, onCleanup } from "solid-js" import { withActor } from "~/context/auth.withActor" import { createStore } from "solid-js/store" import styles from "./member-section.module.css" import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js" import { Actor } from "@opencode-ai/console-core/actor.js" import { User } from "@opencode-ai/console-core/user.js" +import { IconChevron } from "~/component/icon" const listMembers = query(async (workspaceID: string) => { "use server" @@ -26,10 +27,13 @@ const inviteMember = action(async (form: FormData) => { if (!workspaceID) return { error: "Workspace ID is required" } const role = form.get("role")?.toString() as (typeof UserRole)[number] if (!role) return { error: "Role is required" } + const limit = form.get("limit")?.toString() + const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null + if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" } return json( await withActor( () => - User.invite({ email, role }) + User.invite({ email, role, monthlyLimit }) .then((data) => ({ error: undefined, data })) .catch((e) => ({ error: e.message as string })), workspaceID, @@ -213,9 +217,15 @@ export function MemberSection() { const params = useParams() const data = createAsync(() => listMembers(params.id)) const submission = useSubmission(inviteMember) - const [store, setStore] = createStore({ show: false }) + const [store, setStore] = createStore({ + show: false, + selectedRole: "member" as (typeof UserRole)[number], + showRoleDropdown: false, + limit: "", + }) let input: HTMLInputElement + let roleDropdownRef: HTMLDivElement | undefined createEffect(() => { if (!submission.pending && submission.result && !submission.result.error) { @@ -223,17 +233,36 @@ export function MemberSection() { } }) + createEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (roleDropdownRef && !roleDropdownRef.contains(event.target as Node)) { + setStore("showRoleDropdown", false) + } + } + + document.addEventListener("click", handleClickOutside) + onCleanup(() => document.removeEventListener("click", handleClickOutside)) + }) + function show() { while (true) { submission.clear() if (!submission.result) break } setStore("show", true) + setStore("selectedRole", "member") + setStore("limit", "") setTimeout(() => input?.focus(), 0) } function hide() { setStore("show", false) + setStore("showRoleDropdown", false) + } + + const roleLabels = { + admin: { title: "Admin", description: "Can manage models, members, and billing" }, + member: { title: "Member", description: "Can only generate API keys for themselves" }, } return ( @@ -251,28 +280,81 @@ export function MemberSection() {
-
- (input = r)} data-component="input" name="email" type="text" placeholder="Enter email" /> -
- - +
+
+

Email

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

Role

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

Usage limit

+ setStore("limit", e.currentTarget.value)} + min="0" + /> +
+
+ + {(err) =>
{err()}
} +
+
-
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx index a28bf93b3..8347cd49c 100644 --- a/packages/console/app/src/routes/workspace/[id].tsx +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -24,10 +24,10 @@ export default function WorkspaceLayout(props: RouteSectionProps) { Billing + + Settings + - - Settings -
{props.children}
diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts index e8d1b7a6b..48f4a6366 100644 --- a/packages/console/core/src/actor.ts +++ b/packages/console/core/src/actor.ts @@ -69,7 +69,7 @@ export namespace Actor { export const assertAdmin = () => { if (userRole() === "admin") return - throw new Error(`Expected admin user, got ${userRole()}`) + throw new Error(`Action not allowed. Ask your workspace admin to perform this action.`) } export function workspace() { 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 -- cgit v1.2.3 From c7dfbbeed0e7b5a7421b4b0d8c115a24f5ba7534 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Oct 2025 21:21:55 -0400 Subject: wip: zen --- packages/console/core/src/user.ts | 20 ++++++- packages/console/mail/emails/components.tsx | 4 ++ .../console/mail/emails/templates/InviteEmail.tsx | 60 ++++++++++----------- .../console/mail/emails/templates/static/logo.png | Bin 0 -> 1726 bytes 4 files changed, 51 insertions(+), 33 deletions(-) create mode 100644 packages/console/mail/emails/templates/static/logo.png (limited to 'packages/console/core/src') diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts index 63877150e..40d74f93d 100644 --- a/packages/console/core/src/user.ts +++ b/packages/console/core/src/user.ts @@ -11,6 +11,7 @@ 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 assertNotSelf = (id: string) => { @@ -115,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, @@ -122,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, }), ), }) 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 = "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 000000000..1d4a39639 Binary files /dev/null and b/packages/console/mail/emails/templates/static/logo.png differ -- cgit v1.2.3