feat(api): add better-auth admin plugin + close public sign-up (migration 0002)

This commit is contained in:
Bas van Rossem
2026-06-17 17:36:26 +02:00
parent f6bd8eb036
commit c73fa0f898
7 changed files with 626 additions and 37 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE `session` ADD `impersonated_by` text;--> statement-breakpoint
ALTER TABLE `user` ADD `role` text;--> statement-breakpoint
ALTER TABLE `user` ADD `banned` integer;--> statement-breakpoint
ALTER TABLE `user` ADD `ban_reason` text;--> statement-breakpoint
ALTER TABLE `user` ADD `ban_expires` integer;

View File

@@ -0,0 +1,585 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3d48e08d-2ae7-4987-bb60-e5199726c129",
"prevId": "a878be91-939d-44a7-8aa7-546bb6ff8b6f",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"activities": {
"name": "activities",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"insole_types": {
"name": "insole_types",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[\"Kurk\",\"Berk\",\"3D\"]'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"banned": {
"name": "banned",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ban_expires": {
"name": "ban_expires",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"work_sessions": {
"name": "work_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"activity_id": {
"name": "activity_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"insole_type": {
"name": "insole_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pair_count": {
"name": "pair_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 2
},
"start_time": {
"name": "start_time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_seconds": {
"name": "duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'app'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"work_sessions_userId_idx": {
"name": "work_sessions_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"work_sessions_startTime_idx": {
"name": "work_sessions_startTime_idx",
"columns": [
"start_time"
],
"isUnique": false
}
},
"foreignKeys": {
"work_sessions_user_id_user_id_fk": {
"name": "work_sessions_user_id_user_id_fk",
"tableFrom": "work_sessions",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"work_sessions_activity_id_activities_id_fk": {
"name": "work_sessions_activity_id_activities_id_fk",
"tableFrom": "work_sessions",
"tableTo": "activities",
"columnsFrom": [
"activity_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1781702878767, "when": 1781702878767,
"tag": "0001_common_leopardon", "tag": "0001_common_leopardon",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1781710299706,
"tag": "0002_solid_prodigy",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { bearer } from 'better-auth/plugins'; import { admin, bearer } from 'better-auth/plugins';
import { db } from './db/client'; import { db } from './db/client';
import * as schema from './db/schema'; import * as schema from './db/schema';
import { env } from './env'; import { env } from './env';
@@ -13,6 +13,7 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
requireEmailVerification: false, requireEmailVerification: false,
disableSignUp: true, // admin creates users; see docs/plans/phase-2-accounts-roles.md
}, },
plugins: [bearer()], plugins: [bearer(), admin({ defaultRole: 'worker', adminRoles: ['admin'] })],
}); });

View File

@@ -11,6 +11,10 @@ export const user = sqliteTable('user', {
email: text('email').notNull().unique(), email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(), emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
image: text('image'), image: text('image'),
role: text('role'),
banned: integer('banned', { mode: 'boolean' }),
banReason: text('ban_reason'),
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' }) createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(), .notNull(),
@@ -34,6 +38,7 @@ export const session = sqliteTable(
.notNull(), .notNull(),
ipAddress: text('ip_address'), ipAddress: text('ip_address'),
userAgent: text('user_agent'), userAgent: text('user_agent'),
impersonatedBy: text('impersonated_by'),
userId: text('user_id') userId: text('user_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),

View File

@@ -1,28 +1,25 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createApp } from '../src/app'; import { createApp } from '../src/app';
import { authToken } from './helpers';
const json = { 'content-type': 'application/json' };
describe('auth', () => { describe('auth', () => {
it('signs a user up and signs them in, returning a bearer token', async () => { it('signs in an admin-created user, returning a bearer token', async () => {
const app = createApp(); const app = createApp();
const creds = { email: 'worker@example.com', password: 'sterk-wachtwoord-123', name: 'Worker' }; const token = await authToken(app, 'worker@example.com');
const signup = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: json,
body: JSON.stringify(creds),
});
expect(signup.status).toBe(200);
const signin = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: json,
body: JSON.stringify({ email: creds.email, password: creds.password }),
});
expect(signin.status).toBe(200);
const token = signin.headers.get('set-auth-token');
expect(token).toBeTruthy(); expect(token).toBeTruthy();
}); });
it('rejects public sign-up (admin creates users)', async () => {
const app = createApp();
const res = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email: 'should-not-exist@example.com',
password: 'sterk-wachtwoord-123',
name: 'Nope',
}),
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
}); });

View File

@@ -2,9 +2,7 @@ import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '../src/db/client'; import { db } from '../src/db/client';
import { activities, workSessions, user } from '../src/db/schema'; import { activities, workSessions, user } from '../src/db/schema';
import { createApp } from '../src/app'; import { createTestUser } from './helpers';
const json = { 'content-type': 'application/json' };
describe('domain schema', () => { describe('domain schema', () => {
it('creates and reads back an activity with a json insole_types array', async () => { it('creates and reads back an activity with a json insole_types array', async () => {
@@ -20,19 +18,10 @@ describe('domain schema', () => {
}); });
it('defaults a work_sessions row to status=active, source=app, pair_count=2, null end_time', async () => { it('defaults a work_sessions row to status=active, source=app, pair_count=2, null end_time', async () => {
const app = createApp(); const email = 'schema-user@example.com';
const creds = { await createTestUser(email);
email: 'schema-user@example.com',
password: 'sterk-wachtwoord-123',
name: 'Schema User',
};
await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: json,
body: JSON.stringify(creds),
});
const [createdUser] = await db.select().from(user).where(eq(user.email, creds.email)); const [createdUser] = await db.select().from(user).where(eq(user.email, email));
expect(createdUser).toBeTruthy(); expect(createdUser).toBeTruthy();
const [activity] = await db const [activity] = await db