feat(db): add support for multiple users and organizations

This commit is contained in:
Nicolas Meienberger 2026-01-17 13:52:44 +01:00
parent 36b17d73eb
commit 4425f22f3a
13 changed files with 6547 additions and 84 deletions

View file

@ -1,8 +1,14 @@
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { twoFactorClient, usernameClient } from "better-auth/client/plugins"; import { twoFactorClient, usernameClient, adminClient, organizationClient } from "better-auth/client/plugins";
import { inferAdditionalFields } from "better-auth/client/plugins"; import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "~/lib/auth"; import type { auth } from "~/lib/auth";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>(), usernameClient(), twoFactorClient()], plugins: [
inferAdditionalFields<typeof auth>(),
usernameClient(),
adminClient(),
organizationClient(),
twoFactorClient(),
],
}); });

View file

@ -0,0 +1,44 @@
CREATE TABLE `invitation` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`email` text NOT NULL,
`role` text,
`status` text DEFAULT 'pending' NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`inviter_id` text NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`inviter_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `invitation_organizationId_idx` ON `invitation` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invitation_email_idx` ON `invitation` (`email`);--> statement-breakpoint
CREATE TABLE `member` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`user_id` text NOT NULL,
`role` text DEFAULT 'member' NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `member_organizationId_idx` ON `member` (`organization_id`);--> statement-breakpoint
CREATE INDEX `member_userId_idx` ON `member` (`user_id`);--> statement-breakpoint
CREATE TABLE `organization` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`logo` text,
`created_at` integer NOT NULL,
`metadata` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `organization_slug_unique` ON `organization` (`slug`);--> statement-breakpoint
CREATE UNIQUE INDEX `organization_slug_uidx` ON `organization` (`slug`);--> statement-breakpoint
ALTER TABLE `sessions_table` ADD `impersonated_by` text;--> statement-breakpoint
ALTER TABLE `sessions_table` ADD `active_organization_id` text;--> statement-breakpoint
ALTER TABLE `users_table` ADD `role` text;--> statement-breakpoint
ALTER TABLE `users_table` ADD `banned` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `users_table` ADD `ban_reason` text;--> statement-breakpoint
ALTER TABLE `users_table` ADD `ban_expires` integer;

View file

@ -0,0 +1 @@
UPDATE users_table SET role = 'admin' WHERE role IS NULL OR role = '';

View file

@ -0,0 +1,10 @@
-- Custom SQL migration file, put your code below! --
INSERT INTO organization (id, name, slug, created_at)
SELECT
'default-org-' || u.id as id,
u.name || '''s Workspace' as name,
lower(replace(u.name, ' ', '-')) || '-' || lower(hex(randomblob(2))) as slug,
strftime('%s', 'now') * 1000 as created_at
FROM users_table u
LEFT JOIN member m ON u.id = m.user_id
WHERE m.user_id IS NULL;

View file

@ -0,0 +1,11 @@
-- Custom SQL migration file, put your code below! --
INSERT INTO member (id, organization_id, user_id, role, created_at)
SELECT
'default-mem-' || u.id as id,
'default-org-' || u.id as organization_id,
u.id as user_id,
'owner' as role,
strftime('%s', 'now') * 1000 as created_at
FROM users_table u
LEFT JOIN member m ON u.id = m.user_id
WHERE m.user_id IS NULL;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -239,6 +239,34 @@
"when": 1768497767122, "when": 1768497767122,
"tag": "0033_chunky_tyrannus", "tag": "0033_chunky_tyrannus",
"breakpoints": true "breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1768653352902,
"tag": "0034_slippery_mongu",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1768653442143,
"tag": "0035_default-admin-role",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1768653580581,
"tag": "0036_create-default-org",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1768654184173,
"tag": "0037_create-default-member",
"breakpoints": true
} }
] ]
} }

View file

@ -32,6 +32,7 @@ export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext)
name: legacyUser.name, name: legacyUser.name,
hasDownloadedResticPassword: legacyUser.hasDownloadedResticPassword, hasDownloadedResticPassword: legacyUser.hasDownloadedResticPassword,
emailVerified: false, emailVerified: false,
role: "admin", // In legacy system, the only user is an admin
}); });
await tx.insert(account).values({ await tx.insert(account).values({

View file

@ -6,12 +6,13 @@ import {
type MiddlewareOptions, type MiddlewareOptions,
} from "better-auth"; } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware, twoFactor, username } from "better-auth/plugins"; import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins";
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user"; import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
import { cryptoUtils } from "~/server/utils/crypto"; import { cryptoUtils } from "~/server/utils/crypto";
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user"; import { member, organization as organizationTable } from "~/server/db/schema";
import { config } from "~/server/core/config"; import { config } from "~/server/core/config";
import { eq } from "drizzle-orm";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>; export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -21,13 +22,71 @@ const createBetterAuth = (secret: string) =>
trustedOrigins: config.trustedOrigins ?? ["*"], trustedOrigins: config.trustedOrigins ?? ["*"],
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
await ensureOnlyOneUser(ctx); // await ensureOnlyOneUser(ctx);
await convertLegacyUserOnFirstLogin(ctx); await convertLegacyUserOnFirstLogin(ctx);
}), }),
}, },
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "sqlite", provider: "sqlite",
}), }),
databaseHooks: {
user: {
create: {
before: async (user) => {
const anyUser = await db.query.usersTable.findFirst();
const isFirstUser = !anyUser;
if (isFirstUser) {
user.role = "admin";
}
return { data: user };
},
after: async (user) => {
const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4);
await db.transaction(async (tx) => {
const orgId = Bun.randomUUIDv7();
await tx.insert(organizationTable).values({
name: `${user.name}'s Workspace`,
slug: slug,
id: orgId,
createdAt: new Date(),
});
await tx.insert(member).values({
id: Bun.randomUUIDv7(),
userId: user.id,
role: "owner",
organizationId: orgId,
createdAt: new Date(),
});
});
},
},
},
session: {
create: {
before: async (session) => {
const orgMembership = await db.query.member.findFirst({
where: eq(member.userId, session.userId),
});
if (!orgMembership) {
throw new Error("User does not belong to any organization");
}
return {
data: {
...session,
activeOrganizationId: orgMembership?.organizationId,
},
};
},
},
},
},
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
@ -50,6 +109,12 @@ const createBetterAuth = (secret: string) =>
}, },
plugins: [ plugins: [
username(), username(),
admin({
defaultRole: "user",
}),
organization({
allowUserToCreateOrganization: false,
}),
twoFactor({ twoFactor({
backupCodeOptions: { backupCodeOptions: {
storeBackupCodes: "encrypted", storeBackupCodes: "encrypted",

View file

@ -1,5 +1,5 @@
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { index, int, integer, sqliteTable, text, real, primaryKey, unique } from "drizzle-orm/sqlite-core"; import { index, int, integer, sqliteTable, text, real, primaryKey, unique, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { import type {
CompressionMode, CompressionMode,
RepositoryBackend, RepositoryBackend,
@ -10,32 +10,6 @@ import type {
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes"; import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications"; import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
/**
* Volumes Table
*/
export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: integer("last_health_check", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
createdAt: integer("created_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "number" })
.notNull()
.$onUpdate(() => Date.now())
.default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
export type Volume = typeof volumesTable.$inferSelect;
export type VolumeInsert = typeof volumesTable.$inferInsert;
/** /**
* Users Table * Users Table
*/ */
@ -57,6 +31,10 @@ export const usersTable = sqliteTable("users_table", {
image: text("image"), image: text("image"),
displayUsername: text("display_username"), displayUsername: text("display_username"),
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false), twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
role: text("role"),
banned: integer("banned", { mode: "boolean" }).default(false),
banReason: text("ban_reason"),
banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
}); });
export type User = typeof usersTable.$inferSelect; export type User = typeof usersTable.$inferSelect;
@ -78,6 +56,8 @@ export const sessionsTable = sqliteTable(
.default(sql`(unixepoch() * 1000)`), .default(sql`(unixepoch() * 1000)`),
ipAddress: text("ip_address"), ipAddress: text("ip_address"),
userAgent: text("user_agent"), userAgent: text("user_agent"),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
}, },
(table) => [index("sessionsTable_userId_idx").on(table.userId)], (table) => [index("sessionsTable_userId_idx").on(table.userId)],
); );
@ -132,25 +112,82 @@ export const verification = sqliteTable(
(table) => [index("verification_identifier_idx").on(table.identifier)], (table) => [index("verification_identifier_idx").on(table.identifier)],
); );
export const userRelations = relations(usersTable, ({ many }) => ({ export const organization = sqliteTable(
sessions: many(sessionsTable), "organization",
accounts: many(account), {
twoFactors: many(twoFactor), id: text("id").primaryKey(),
})); name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
export const sessionRelations = relations(sessionsTable, ({ one }) => ({ export const member = sqliteTable(
user: one(usersTable, { "member",
fields: [sessionsTable.userId], {
references: [usersTable.id], id: text("id").primaryKey(),
}), organizationId: text("organization_id")
})); .notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [index("member_organizationId_idx").on(table.organizationId), index("member_userId_idx").on(table.userId)],
);
export const accountRelations = relations(account, ({ one }) => ({ export const invitation = sqliteTable(
user: one(usersTable, { "invitation",
fields: [account.userId], {
references: [usersTable.id], id: text("id").primaryKey(),
}), organizationId: text("organization_id")
})); .notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
/**
* Volumes Table
*/
export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: integer("last_health_check", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
createdAt: integer("created_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "number" })
.notNull()
.$onUpdate(() => Date.now())
.default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
export type Volume = typeof volumesTable.$inferSelect;
export type VolumeInsert = typeof volumesTable.$inferInsert;
/** /**
* Repositories Table * Repositories Table
@ -223,18 +260,6 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
}); });
export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert; export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert;
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
references: [volumesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupSchedulesTable.repositoryId],
references: [repositoriesTable.id],
}),
notifications: many(backupScheduleNotificationsTable),
mirrors: many(backupScheduleMirrorsTable),
}));
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect; export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
/** /**
@ -253,9 +278,6 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
.notNull() .notNull()
.default(sql`(unixepoch() * 1000)`), .default(sql`(unixepoch() * 1000)`),
}); });
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
}));
export type NotificationDestination = typeof notificationDestinationsTable.$inferSelect; export type NotificationDestination = typeof notificationDestinationsTable.$inferSelect;
/** /**
@ -280,16 +302,6 @@ export const backupScheduleNotificationsTable = sqliteTable(
}, },
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })], (table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
); );
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleNotificationsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
destination: one(notificationDestinationsTable, {
fields: [backupScheduleNotificationsTable.destinationId],
references: [notificationDestinationsTable.id],
}),
}));
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect; export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
/** /**
@ -317,16 +329,6 @@ export const backupScheduleMirrorsTable = sqliteTable(
(table) => [unique().on(table.scheduleId, table.repositoryId)], (table) => [unique().on(table.scheduleId, table.repositoryId)],
); );
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleMirrorsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupScheduleMirrorsTable.repositoryId],
references: [repositoriesTable.id],
}),
}));
export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect; export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect;
/** /**
@ -357,3 +359,98 @@ export const twoFactor = sqliteTable(
}, },
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)], (table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
); );
export const userRelations = relations(usersTable, ({ many }) => ({
sessions: many(sessionsTable),
accounts: many(account),
members: many(member),
invitations: many(invitation),
twoFactors: many(twoFactor),
}));
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
user: one(usersTable, {
fields: [sessionsTable.userId],
references: [usersTable.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(usersTable, {
fields: [account.userId],
references: [usersTable.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
members: many(member),
invitations: many(invitation),
}));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
usersTable: one(usersTable, {
fields: [member.userId],
references: [usersTable.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
usersTable: one(usersTable, {
fields: [invitation.inviterId],
references: [usersTable.id],
}),
}));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
usersTable: one(usersTable, {
fields: [twoFactor.userId],
references: [usersTable.id],
}),
}));
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleMirrorsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupScheduleMirrorsTable.repositoryId],
references: [repositoriesTable.id],
}),
}));
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
references: [volumesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupSchedulesTable.repositoryId],
references: [repositoriesTable.id],
}),
notifications: many(backupScheduleNotificationsTable),
mirrors: many(backupScheduleMirrorsTable),
}));
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
}));
export const backupScheduleNotificationRelations = relations(backupScheduleNotificationsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleNotificationsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
destination: one(notificationDestinationsTable, {
fields: [backupScheduleNotificationsTable.destinationId],
references: [notificationDestinationsTable.id],
}),
}));