diff --git a/apps/api/drizzle/0002_solid_prodigy.sql b/apps/api/drizzle/0002_solid_prodigy.sql new file mode 100644 index 0000000..168768f --- /dev/null +++ b/apps/api/drizzle/0002_solid_prodigy.sql @@ -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; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..b20aea4 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index f90dec7..10400aa 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1781702878767, "tag": "0001_common_leopardon", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1781710299706, + "tag": "0002_solid_prodigy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index edfa0a4..2f735dd 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -1,6 +1,6 @@ import { betterAuth } from 'better-auth'; 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 * as schema from './db/schema'; import { env } from './env'; @@ -13,6 +13,7 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: false, + disableSignUp: true, // admin creates users; see docs/plans/phase-2-accounts-roles.md }, - plugins: [bearer()], + plugins: [bearer(), admin({ defaultRole: 'worker', adminRoles: ['admin'] })], }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 61e0f55..d1d2d48 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -11,6 +11,10 @@ export const user = sqliteTable('user', { email: text('email').notNull().unique(), emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(), 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' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), @@ -34,6 +38,7 @@ export const session = sqliteTable( .notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'), + impersonatedBy: text('impersonated_by'), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), diff --git a/apps/api/test/auth.test.ts b/apps/api/test/auth.test.ts index f915ffa..f97d657 100644 --- a/apps/api/test/auth.test.ts +++ b/apps/api/test/auth.test.ts @@ -1,28 +1,25 @@ import { describe, it, expect } from 'vitest'; import { createApp } from '../src/app'; - -const json = { 'content-type': 'application/json' }; +import { authToken } from './helpers'; 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 creds = { email: 'worker@example.com', password: 'sterk-wachtwoord-123', name: 'Worker' }; - - 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'); + const token = await authToken(app, 'worker@example.com'); 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); + }); }); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts index 65d3aba..87db62c 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -2,9 +2,7 @@ import { describe, it, expect } from 'vitest'; import { eq } from 'drizzle-orm'; import { db } from '../src/db/client'; import { activities, workSessions, user } from '../src/db/schema'; -import { createApp } from '../src/app'; - -const json = { 'content-type': 'application/json' }; +import { createTestUser } from './helpers'; describe('domain schema', () => { 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 () => { - const app = createApp(); - const creds = { - 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 email = 'schema-user@example.com'; + await createTestUser(email); - 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(); const [activity] = await db