From 36bdf7e1bb543e334a70efdfa345ffbefdc9e295 Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 16 Mar 2026 02:00:53 +0000 Subject: [PATCH] feat(backend): add admin plugin and create-admin script Add Better Auth admin plugin for role-based user management. Includes a CLI script to create admin accounts. Co-authored-by: Ona --- apps/aelis-backend/auth.ts | 2 + apps/aelis-backend/package.json | 4 +- apps/aelis-backend/src/auth/index.ts | 3 + .../src/auth/session-middleware.ts | 8 +- apps/aelis-backend/src/db/auth-schema.ts | 159 +++++++++--------- .../aelis-backend/src/scripts/create-admin.ts | 63 +++++++ 6 files changed, 158 insertions(+), 81 deletions(-) create mode 100644 apps/aelis-backend/src/scripts/create-admin.ts diff --git a/apps/aelis-backend/auth.ts b/apps/aelis-backend/auth.ts index e9a5259..2cfd311 100644 --- a/apps/aelis-backend/auth.ts +++ b/apps/aelis-backend/auth.ts @@ -2,6 +2,7 @@ // Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" +import { admin } from "better-auth/plugins" import { SQL } from "bun" import { drizzle } from "drizzle-orm/bun-sql" @@ -13,6 +14,7 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, + plugins: [admin()], }) export default auth diff --git a/apps/aelis-backend/package.json b/apps/aelis-backend/package.json index 5486125..e222a16 100644 --- a/apps/aelis-backend/package.json +++ b/apps/aelis-backend/package.json @@ -9,8 +9,10 @@ "test": "bun test src/", "db:generate": "bunx drizzle-kit generate", "db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y", + "db:push": "bunx drizzle-kit push", "db:migrate": "bunx drizzle-kit migrate", - "db:studio": "bunx drizzle-kit studio" + "db:studio": "bunx drizzle-kit studio", + "create-admin": "bun run src/scripts/create-admin.ts" }, "dependencies": { "@aelis/core": "workspace:*", diff --git a/apps/aelis-backend/src/auth/index.ts b/apps/aelis-backend/src/auth/index.ts index 6fe8048..19cc640 100644 --- a/apps/aelis-backend/src/auth/index.ts +++ b/apps/aelis-backend/src/auth/index.ts @@ -1,7 +1,9 @@ import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" +import { admin } from "better-auth/plugins" import type { Database } from "../db/index.ts" + import * as schema from "../db/schema.ts" export function createAuth(db: Database) { @@ -13,6 +15,7 @@ export function createAuth(db: Database) { emailAndPassword: { enabled: true, }, + plugins: [admin()], }) } diff --git a/apps/aelis-backend/src/auth/session-middleware.ts b/apps/aelis-backend/src/auth/session-middleware.ts index 1ac1455..c681d45 100644 --- a/apps/aelis-backend/src/auth/session-middleware.ts +++ b/apps/aelis-backend/src/auth/session-middleware.ts @@ -57,9 +57,7 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware { * Creates a function to get session from headers. Useful for WebSocket upgrade validation. */ export function createGetSessionFromHeaders(auth: Auth) { - return async ( - headers: Headers, - ): Promise<{ user: AuthUser; session: AuthSession } | null> => { + return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => { const session = await auth.api.getSession({ headers }) return session } @@ -86,6 +84,10 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar image: null, createdAt: now, updatedAt: now, + role: "admin", + banned: false, + banReason: null, + banExpires: null, } const session: AuthSession = { diff --git a/apps/aelis-backend/src/db/auth-schema.ts b/apps/aelis-backend/src/db/auth-schema.ts index 7e7a4d9..5121c3e 100644 --- a/apps/aelis-backend/src/db/auth-schema.ts +++ b/apps/aelis-backend/src/db/auth-schema.ts @@ -1,91 +1,96 @@ -import { relations } from "drizzle-orm"; -import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm" +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core" export const user = pgTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").default(false).notNull(), - image: text("image"), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) - .notNull(), -}); + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + role: text("role"), + banned: boolean("banned").default(false), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires"), +}) export const session = pgTable( - "session", - { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) - .notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - }, - (table) => [index("session_userId_idx").on(table.userId)], -); + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + impersonatedBy: text("impersonated_by"), + }, + (table) => [index("session_userId_idx").on(table.userId)], +) export const account = pgTable( - "account", - { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) - .notNull(), - }, - (table) => [index("account_userId_idx").on(table.userId)], -); + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +) export const verification = pgTable( - "verification", - { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at") - .$onUpdate(() => new Date()) - .notNull(), - }, - (table) => [index("verification_identifier_idx").on(table.identifier)], -); + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +) export const userRelations = relations(user, ({ many }) => ({ - sessions: many(session), - accounts: many(account), -})); + sessions: many(session), + accounts: many(account), +})) export const sessionRelations = relations(session, ({ one }) => ({ - user: one(user, { - fields: [session.userId], - references: [user.id], - }), -})); + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})) export const accountRelations = relations(account, ({ one }) => ({ - user: one(user, { - fields: [account.userId], - references: [user.id], - }), -})); + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})) diff --git a/apps/aelis-backend/src/scripts/create-admin.ts b/apps/aelis-backend/src/scripts/create-admin.ts new file mode 100644 index 0000000..87ca480 --- /dev/null +++ b/apps/aelis-backend/src/scripts/create-admin.ts @@ -0,0 +1,63 @@ +/** + * Creates an admin user account via Better Auth's server-side API. + * + * Usage: + * bun run src/scripts/create-admin.ts --name "Admin" --email admin@example.com --password secret123 + * + * Requires DATABASE_URL and BETTER_AUTH_SECRET to be set (reads .env automatically). + */ + +import { parseArgs } from "util" + +import { createAuth } from "../auth/index.ts" +import { createDatabase } from "../db/index.ts" + +function parseCliArgs(): { name: string; email: string; password: string } { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + name: { type: "string" }, + email: { type: "string" }, + password: { type: "string" }, + }, + strict: true, + }) + + if (!values.name || !values.email || !values.password) { + console.error( + "Usage: bun run src/scripts/create-admin.ts --name --email --password ", + ) + process.exit(1) + } + + return { name: values.name, email: values.email, password: values.password } +} + +async function main() { + const { name, email, password } = parseCliArgs() + + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) { + console.error("DATABASE_URL is not set") + process.exit(1) + } + + const { db, close } = createDatabase(databaseUrl) + + try { + const auth = createAuth(db) + + const result = await auth.api.createUser({ + body: { name, email, password, role: "admin" }, + }) + + console.log(`Admin account created: ${result.user.id} (${result.user.email})`) + } finally { + await close() + } +} + +main().catch((err) => { + console.error("Failed to create admin account:", err) + process.exit(1) +})