Frank 1 yıl önce
ebeveyn
işleme
918739057d

+ 14 - 7
packages/console/app/src/routes/workspace/[id].tsx

@@ -1,12 +1,16 @@
 import "./[id].css"
-import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
-import { NewUserSection } from "~/component/workspace/new-user-section"
-import { BillingSection } from "~/component/workspace/billing-section"
-import { PaymentSection } from "~/component/workspace/payment-section"
-import { UsageSection } from "~/component/workspace/usage-section"
-import { KeySection } from "~/component/workspace/key-section"
+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 { Show } from "solid-js"
+import { useParams } from "@solidjs/router"
 
 export default function () {
+  const params = useParams()
   return (
     <div data-page="workspace-[id]">
       <section data-component="title-section">
@@ -23,6 +27,9 @@ export default function () {
       <div data-slot="sections">
         <NewUserSection />
         <KeySection />
+        <Show when={isBeta(params.id)}>
+          <MemberSection />
+        </Show>
         <BillingSection />
         <MonthlyLimitSection />
         <UsageSection />
@@ -36,6 +43,6 @@ export function isBeta(workspaceID: string) {
   return [
     "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production
     "wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev
-    "wrk_01K4PJRKJ2WPQZN3FFYRV4673F", // frank
+    "wrk_01K68M8J1KK0PJ39H59B1EGHP6", // frank
   ].includes(workspaceID)
 }

+ 0 - 0
packages/console/app/src/component/workspace/billing-section.module.css → packages/console/app/src/routes/workspace/billing-section.module.css


+ 0 - 0
packages/console/app/src/component/workspace/billing-section.tsx → packages/console/app/src/routes/workspace/billing-section.tsx


+ 0 - 0
packages/console/app/src/component/workspace/common.tsx → packages/console/app/src/routes/workspace/common.tsx


+ 0 - 0
packages/console/app/src/component/workspace/key-section.module.css → packages/console/app/src/routes/workspace/key-section.module.css


+ 0 - 0
packages/console/app/src/component/workspace/key-section.tsx → packages/console/app/src/routes/workspace/key-section.tsx


+ 179 - 0
packages/console/app/src/routes/workspace/member-section.module.css

@@ -0,0 +1,179 @@
+.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;
+        }
+      }
+    }
+  }
+}

+ 189 - 0
packages/console/app/src/routes/workspace/member-section.tsx

@@ -0,0 +1,189 @@
+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 { formatDateUTC, formatDateForTable } from "./common"
+import styles from "./member-section.module.css"
+import { and, Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
+import { UserTable } from "@opencode/console-core/schema/user.sql.js"
+import { Identifier } from "@opencode/console-core/identifier.js"
+
+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(
+      () =>
+        Database.use((tx) =>
+          tx
+            .update(UserTable)
+            .set({ timeDeleted: sql`now()` })
+            .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID))),
+        ),
+      workspaceID,
+    ),
+    { revalidate: listMembers.key },
+  )
+}, "member.remove")
+
+const inviteMember = action(async (form: FormData) => {
+  "use server"
+  const name = form.get("name")?.toString().trim()
+  if (!name) return { error: "Name is required" }
+  const workspaceID = form.get("workspaceID")?.toString()
+  if (!workspaceID) return { error: "Workspace ID is required" }
+  return json(
+    await withActor(
+      () =>
+        Database.use((tx) =>
+          tx
+            .insert(UserTable)
+            .values({
+              id: Identifier.create("user"),
+              name: "",
+              email: name,
+              workspaceID,
+              role: "member",
+              timeJoined: sql`now()`,
+            })
+            .onDuplicateKeyUpdate({ set: { timeJoined: sql`now()` } })
+            .then((data) => ({ error: undefined, data }))
+            .catch((e) => ({ error: e.message as string })),
+        ),
+      workspaceID,
+    ),
+    { revalidate: listMembers.key },
+  )
+}, "member.create")
+
+const listMembers = query(async (workspaceID: string) => {
+  "use server"
+  return withActor(
+    () => Database.use((tx) => tx.select().from(UserTable).where(eq(UserTable.workspaceID, workspaceID))),
+    workspaceID,
+  )
+}, "member.list")
+
+export function MemberCreateForm() {
+  const params = useParams()
+  const submission = useSubmission(inviteMember)
+  const [store, setStore] = createStore({ show: false })
+
+  let input: HTMLInputElement
+
+  createEffect(() => {
+    if (!submission.pending && submission.result && !submission.result.error) {
+      hide()
+    }
+  })
+
+  function show() {
+    // submission.clear() does not clear the result in some cases, ie.
+    //  1. Create key with empty name => error shows
+    //  2. Put in a key name and creates the key => form hides
+    //  3. Click add key button again => form shows with the same error if
+    //     submission.clear() is called only once
+    while (true) {
+      submission.clear()
+      if (!submission.result) break
+    }
+    setStore("show", true)
+    input.focus()
+  }
+
+  function hide() {
+    setStore("show", false)
+  }
+
+  return (
+    <Show
+      when={store.show}
+      fallback={
+        <button data-color="primary" onClick={() => show()}>
+          Invite Member
+        </button>
+      }
+    >
+      <form action={inviteMember} method="post" data-slot="create-form">
+        <div data-slot="input-container">
+          <input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter email" />
+          <Show when={submission.result && submission.result.error}>
+            {(err) => <div data-slot="form-error">{err()}</div>}
+          </Show>
+        </div>
+        <input type="hidden" name="workspaceID" value={params.id} />
+        <div data-slot="form-actions">
+          <button type="reset" data-color="ghost" onClick={() => hide()}>
+            Cancel
+          </button>
+          <button type="submit" data-color="primary" disabled={submission.pending}>
+            {submission.pending ? "Inviting..." : "Invite"}
+          </button>
+        </div>
+      </form>
+    </Show>
+  )
+}
+
+export function MemberSection() {
+  const params = useParams()
+  const members = createAsync(() => listMembers(params.id))
+
+  return (
+    <section class={styles.root}>
+      <div data-slot="section-title">
+        <h2>Members</h2>
+        <p>Manage your members for accessing opencode services.</p>
+      </div>
+      <MemberCreateForm />
+      <div data-slot="members-table">
+        <Show
+          when={members()?.length}
+          fallback={
+            <div data-component="empty-state">
+              <p>Invite a member to your workspace</p>
+            </div>
+          }
+        >
+          <table data-slot="members-table-element">
+            <thead>
+              <tr>
+                <th>Email</th>
+                <th>Role</th>
+                <th>Joined</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              <For each={members()!}>
+                {(member) => {
+                  const [copied, setCopied] = createSignal(false)
+                  // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
+                  return (
+                    <tr>
+                      <td data-slot="member-email">{member.email}</td>
+                      <td data-slot="member-role">{member.role}</td>
+                      <td data-slot="member-joined" title={formatDateUTC(member.timeJoined!)}>
+                        {formatDateForTable(member.timeJoined!)}
+                      </td>
+                      <td data-slot="member-actions">
+                        <form action={removeMember} method="post">
+                          <input type="hidden" name="id" value={member.id} />
+                          <input type="hidden" name="workspaceID" value={params.id} />
+                          <button data-color="ghost">Delete</button>
+                        </form>
+                      </td>
+                    </tr>
+                  )
+                }}
+              </For>
+            </tbody>
+          </table>
+        </Show>
+      </div>
+    </section>
+  )
+}

+ 0 - 0
packages/console/app/src/component/workspace/monthly-limit-section.module.css → packages/console/app/src/routes/workspace/monthly-limit-section.module.css


+ 0 - 0
packages/console/app/src/component/workspace/monthly-limit-section.tsx → packages/console/app/src/routes/workspace/monthly-limit-section.tsx


+ 0 - 0
packages/console/app/src/component/workspace/new-user-section.module.css → packages/console/app/src/routes/workspace/new-user-section.module.css


+ 0 - 0
packages/console/app/src/component/workspace/new-user-section.tsx → packages/console/app/src/routes/workspace/new-user-section.tsx


+ 0 - 0
packages/console/app/src/component/workspace/payment-section.module.css → packages/console/app/src/routes/workspace/payment-section.module.css


+ 0 - 0
packages/console/app/src/component/workspace/payment-section.tsx → packages/console/app/src/routes/workspace/payment-section.tsx


+ 0 - 0
packages/console/app/src/component/workspace/usage-section.module.css → packages/console/app/src/routes/workspace/usage-section.module.css


+ 0 - 0
packages/console/app/src/component/workspace/usage-section.tsx → packages/console/app/src/routes/workspace/usage-section.tsx


+ 1 - 0
packages/console/core/migrations/0019_dazzling_cable.sql

@@ -0,0 +1 @@
+ALTER TABLE `user` MODIFY COLUMN `role` enum('admin','member') NOT NULL;

+ 702 - 0
packages/console/core/migrations/meta/0019_snapshot.json

@@ -0,0 +1,702 @@
+{
+  "version": "5",
+  "dialect": "mysql",
+  "id": "a2bb7222-561c-45f0-8939-8ef9b8e57bb3",
+  "prevId": "e9c91c2d-787d-4234-b98d-1620e4ce80e1",
+  "tables": {
+    "account": {
+      "name": "account",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "email": {
+          "name": "email",
+          "columns": [
+            "email"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "billing": {
+      "name": "billing",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_id": {
+          "name": "payment_method_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_last4": {
+          "name": "payment_method_last4",
+          "type": "varchar(4)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "balance": {
+          "name": "balance",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "monthly_limit": {
+          "name": "monthly_limit",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "monthly_usage": {
+          "name": "monthly_usage",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_monthly_usage_updated": {
+          "name": "time_monthly_usage_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload": {
+          "name": "reload",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload_error": {
+          "name": "reload_error",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_error": {
+          "name": "time_reload_error",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_locked_till": {
+          "name": "time_reload_locked_till",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_customer_id": {
+          "name": "global_customer_id",
+          "columns": [
+            "customer_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "billing_workspace_id_id_pk": {
+          "name": "billing_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "payment": {
+      "name": "payment",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "invoice_id": {
+          "name": "invoice_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_id": {
+          "name": "payment_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "amount": {
+          "name": "amount",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_refunded": {
+          "name": "time_refunded",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "payment_workspace_id_id_pk": {
+          "name": "payment_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "usage": {
+      "name": "usage",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reasoning_tokens": {
+          "name": "reasoning_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_5m_tokens": {
+          "name": "cache_write_5m_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_1h_tokens": {
+          "name": "cache_write_1h_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost": {
+          "name": "cost",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_workspace_id_id_pk": {
+          "name": "usage_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "key": {
+      "name": "key",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "actor": {
+          "name": "actor",
+          "type": "json",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "old_name": {
+          "name": "old_name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_used": {
+          "name": "time_used",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_key": {
+          "name": "global_key",
+          "columns": [
+            "key"
+          ],
+          "isUnique": true
+        },
+        "name": {
+          "name": "name",
+          "columns": [
+            "workspace_id",
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "key_workspace_id_id_pk": {
+          "name": "key_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "user": {
+      "name": "user",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_seen": {
+          "name": "time_seen",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_joined": {
+          "name": "time_joined",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "color": {
+          "name": "color",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "enum('admin','member')",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "user_email": {
+          "name": "user_email",
+          "columns": [
+            "workspace_id",
+            "email"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "user_workspace_id_id_pk": {
+          "name": "user_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "workspace": {
+      "name": "workspace",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "slug": {
+          "name": "slug",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "slug": {
+          "name": "slug",
+          "columns": [
+            "slug"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "workspace_id": {
+          "name": "workspace_id",
+          "columns": [
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    }
+  },
+  "views": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "tables": {},
+    "indexes": {}
+  }
+}

+ 7 - 0
packages/console/core/migrations/meta/_journal.json

@@ -134,6 +134,13 @@
       "when": 1759077265040,
       "tag": "0018_nervous_iron_lad",
       "breakpoints": true
+    },
+    {
+      "idx": 19,
+      "version": "5",
+      "when": 1759103696975,
+      "tag": "0019_dazzling_cable",
+      "breakpoints": true
     }
   ]
 }

+ 1 - 1
packages/console/core/src/schema/user.sql.ts

@@ -15,7 +15,7 @@ export const UserTable = mysqlTable(
     timeSeen: utc("time_seen"),
     timeJoined: utc("time_joined"),
     color: int("color"),
-    role: mysqlEnum("role", ["admin", "member"]),
+    role: mysqlEnum("role", ["admin", "member"]).notNull(),
   },
   (table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)],
 )