core: support managing multiple authenticated accounts with individual workspace access

Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
This commit is contained in:
Dax Raad
2026-02-28 14:23:55 -05:00
parent b5515dd2f7
commit 7b5b665b4a
5 changed files with 1089 additions and 37 deletions

View File

@@ -0,0 +1,18 @@
ALTER TABLE `account` ADD `id` text;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`active` integer NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_account`(`email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated`) SELECT `email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated` FROM `account`;--> statement-breakpoint
DROP TABLE `account`;--> statement-breakpoint
ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,15 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "@/storage/schema.sql"
export const AccountTable = sqliteTable(
"account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.email, table.url] }),
// uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
],
)
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
})

View File

@@ -1,10 +1,11 @@
import { eq, and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { Database } from "@/storage/db"
import { AccountTable } from "./account.sql"
import z from "zod"
export namespace Account {
export const Account = z.object({
id: z.string(),
email: z.string(),
url: z.string(),
})
@@ -12,18 +13,40 @@ export namespace Account {
function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account {
return {
id: row.id,
email: row.email,
url: row.url,
}
}
export function account(): Account | undefined {
export function active(): Account | undefined {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get())
return row ? fromRow(row) : undefined
}
export async function token(): Promise<string | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get())
export function list(): Account[] {
return Database.use((db) => db.select().from(AccountTable).all().map(fromRow))
}
export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return []
const access = await token(accountID)
if (!access) return []
const res = await fetch(`${row.url}/api/orgs`, {
headers: { authorization: `Bearer ${access}` },
})
if (!res.ok) return []
const json = (await res.json()) as Array<{ id?: string; name?: string }>
return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" }))
}
export async function token(accountID: string): Promise<string | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
@@ -52,7 +75,7 @@ export namespace Account {
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(and(eq(AccountTable.email, row.email), eq(AccountTable.url, row.url)))
.where(eq(AccountTable.id, row.id))
.run(),
)
@@ -122,23 +145,20 @@ export namespace Account {
access_token?: string
refresh_token?: string
expires_in?: number
email?: string
error?: string
error_description?: string
}
if (json.access_token) {
let email = json.email
if (!email) {
const me = await fetch(`${input.server}/api/user`, {
headers: { authorization: `Bearer ${json.access_token}` },
})
const user = (await me.json()) as { email?: string }
if (!user.email) {
return { type: "error", msg: "No email in response" }
}
email = user.email
const me = await fetch(`${input.server}/api/user`, {
headers: { authorization: `Bearer ${json.access_token}` },
})
const user = (await me.json()) as { id?: string; email?: string }
if (!user.id || !user.email) {
return { type: "error", msg: "No id or email in response" }
}
const id = user.id
const email = user.email
const access = json.access_token
const expiry = Date.now() + json.expires_in! * 1000
@@ -148,6 +168,7 @@ export namespace Account {
db.update(AccountTable).set({ active: false }).run()
db.insert(AccountTable)
.values({
id,
email,
url: input.url,
access_token: access,
@@ -156,7 +177,7 @@ export namespace Account {
active: true,
})
.onConflictDoUpdate({
target: [AccountTable.email, AccountTable.url],
target: AccountTable.id,
set: {
access_token: access,
refresh_token: refresh,

View File

@@ -107,7 +107,8 @@ export namespace Config {
}
}
const token = await Account.token()
const active = Account.active()
const token = active ? await Account.token(active.id) : undefined
if (token) {
}