diff --git a/migrations/0012_polite_impossible_man.sql b/migrations/0012_polite_impossible_man.sql new file mode 100644 index 0000000..cdc5224 --- /dev/null +++ b/migrations/0012_polite_impossible_man.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint +ALTER TABLE "user" DROP COLUMN "token_valid_until"; \ No newline at end of file diff --git a/migrations/meta/0012_snapshot.json b/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..71f68e3 --- /dev/null +++ b/migrations/meta/0012_snapshot.json @@ -0,0 +1,853 @@ +{ + "id": "f0ff401a-6299-471e-9812-80c678b9d47e", + "prevId": "4ab7df30-6490-4189-93b7-e1b25b0c8e90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.challenge_throttle": { + "name": "challenge_throttle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reset_at": { + "name": "reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "challenge_throttle_tenant_id_tenant_id_fk": { + "name": "challenge_throttle_tenant_id_tenant_id_fk", + "tableFrom": "challenge_throttle", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "defaultLanguage": { + "name": "defaultLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "logo": { + "name": "logo", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SETTINGS'" + }, + "links": { + "name": "links", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + }, + "tenant_domain_unique": { + "name": "tenant_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "confirmation_state": { + "name": "confirmation_state", + "type": "confirmation_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'INVITED'" + }, + "passphrase_hash": { + "name": "passphrase_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recovery_passphrase": { + "name": "recovery_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.confirmation_state": { + "name": "confirmation_state", + "schema": "public", + "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 1276900..1e87d50 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1779177151355, "tag": "0011_nebulous_stephen_strange", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1780157869249, + "tag": "0012_polite_impossible_man", + "breakpoints": true } ] } diff --git a/src/lib/server/auth/__tests__/jwt-utils.test.ts b/src/lib/server/auth/__tests__/jwt-utils.test.ts index d774da9..99d63a7 100644 --- a/src/lib/server/auth/__tests__/jwt-utils.test.ts +++ b/src/lib/server/auth/__tests__/jwt-utils.test.ts @@ -20,8 +20,6 @@ const mockUser: SelectUser = { lastLoginAt: new Date(), isActive: true, confirmationState: "ACCESS_GRANTED" as const, - token: null, - tokenValidUntil: null, passphraseHash: null, recoveryPassphrase: null, language: "de", diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 6361698..1f9b59f 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -122,8 +122,6 @@ export const user = pgTable( lastLoginAt: timestamp("last_login_at"), isActive: boolean("is_active").default(true), confirmationState: confirmationStateEnum("confirmation_state").default("INVITED"), - token: text("token"), - tokenValidUntil: timestamp("token_valid_until"), /** Hashed passphrase for password authentication (optional, alternative to WebAuthn) */ passphraseHash: text("passphrase_hash"), /** Recovery passphrase for WebAuthn-only users (stored in plain text, shown only once) */ diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index a2e8aa7..97027bb 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -10,6 +10,7 @@ vi.mock("../../db", () => ({ update: vi.fn(), delete: vi.fn(), transaction: vi.fn(), + limit: vi.fn(), }, })); @@ -89,16 +90,24 @@ describe("UserService", () => { name: "Test Admin", email: "test@example.com", language: "de" as const, + role: "GLOBAL_ADMIN" as const, + }; + + const mockCreatedAdminInvite = { + id: "invite-123", + tenantId: "018f-a1b2-c3d4-a5f6-789abcdef019", + inviteCode: "018f-a1b2-c3d4-e5f6-789abcdef012", + used: false, + expiresAt: new Date("2024-01-01T12:10:00Z"), }; const mockCreatedAdmin = { id: "018f-a1b2-c3d4-e5f6-789abcdef012", name: "Test Admin", email: "test@example.com", - token: "018f-a1b2-c3d4-e5f6-789abcdef012", - tokenValidUntil: new Date("2024-01-01T12:10:00Z"), - confirmationState: "INVITED" as const, - isActive: false, + confirmationState: "ACCESS_GRANTED" as const, + role: "GLOBAL_ADMIN" as const, + isActive: true, }; const mockInsertBuilder = { @@ -106,17 +115,25 @@ describe("UserService", () => { returning: vi.fn().mockResolvedValue([mockCreatedAdmin]), }; - mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + const mockInviteInsertBuilder = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockCreatedAdminInvite]), + }; + + mockCentralDb.insert + .mockReturnValueOnce(mockInviteInsertBuilder) + .mockReturnValueOnce(mockInsertBuilder); const result = await UserService.createUser(adminData); expect(mockCentralDb.insert).toHaveBeenCalled(); expect(mockInsertBuilder.values).toHaveBeenCalledWith({ ...adminData, - token: "018f-a1b2-c3d4-e5f6-789abcdef012", - tokenValidUntil: expect.any(Date), - confirmationState: "INVITED" as const, - isActive: false, + confirmationState: "ACCESS_GRANTED" as const, + isActive: true, + role: "GLOBAL_ADMIN" as const, + tenantId: undefined, + recoveryPassphrase: expect.any(String), }); expect(result).toEqual(mockCreatedAdmin); }); @@ -178,6 +195,20 @@ describe("UserService", () => { limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]), }; + const mockUserInviteBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([ + { + id: "invite-123", + tenantId: "tenant-123", + inviteCode: token, + used: false, + expiresAt: new Date("2024-01-01T12:10:00Z"), + }, + ]), + }; + const mockCountSelectBuilder = { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([{ count: 1 }]), @@ -195,6 +226,7 @@ describe("UserService", () => { // First call for user lookup, second call for tenant admin count, third for total count mockCentralDb.select + .mockReturnValueOnce(mockUserInviteBuilder) .mockReturnValueOnce(mockSelectBuilder) .mockReturnValueOnce(mockCountSelectBuilder) .mockReturnValueOnce(mockTotalCountSelectBuilder); @@ -202,7 +234,7 @@ describe("UserService", () => { const result = await UserService.confirm(token); - expect(mockCentralDb.select).toHaveBeenCalledTimes(3); + expect(mockCentralDb.select).toHaveBeenCalledTimes(4); expect(mockCentralDb.update).toHaveBeenCalled(); expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ confirmationState: "ACCESS_GRANTED" as const, diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts index 909d062..efd12ce 100644 --- a/src/lib/server/services/invite-service.ts +++ b/src/lib/server/services/invite-service.ts @@ -33,7 +33,7 @@ export class InviteService { tenantId, invitedBy, language, - expiresAt: sql`timezone('utc', now()) + interval '30 minutes'`, + expiresAt: sql`timezone('utc', now()) + interval '10 minutes'`, used: false, }; diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 778d3f1..6ae3399 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; import { AppointmentService } from "./appointment-service"; export type InsertUser = InferInsertModel; +export type InsertUserInvite = InferInsertModel; export type InsertUserPasskey = InferInsertModel; export type UserTransaction = PgTransaction< PostgresJsQueryResultHKT, @@ -45,10 +46,8 @@ const userCreationSchema = z.object({ name: z.string().min(5), email: z.email(), role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(), - tenantId: z.string().uuid().optional(), + tenantId: z.uuid().optional(), passphrase: z.string().min(12).optional(), - token: z.uuidv7().optional(), - tokenValidUntil: z.date().optional(), language: z.enum(["de", "en"]).optional().default("de"), confirmationState: z.enum(["INVITED", "CONFIRMED", "ACCESS_GRANTED"]).optional(), // Note: passphraseHash and recoveryPassphrase are handled internally, not via user input @@ -122,8 +121,15 @@ export class UserService { throw new ValidationError("Passphrase must be at least 12 characters long"); } - userData.token = uuidv7(); - userData.tokenValidUntil = addMinutes(new Date(), 10); + const userInviteForDb: InsertUserInvite = { + email: userData.email, + name: userData.name, + role: userData.role!, + tenantId: userData.tenantId!, + invitedBy: "system", + expiresAt: addMinutes(new Date(), 10), + inviteCode: uuidv7(), + }; // Prepare user data for database const userDataForDb: InsertUser = { @@ -131,8 +137,6 @@ export class UserService { email: userData.email, role: userData.role, tenantId: userData.tenantId, - token: userData.token, - tokenValidUntil: userData.tokenValidUntil, language: userData.language || "de", confirmationState: userData.confirmationState || "INVITED", isActive: false, @@ -162,43 +166,49 @@ export class UserService { } try { - const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning(); + const [inviteResult] = await centralDb + .insert(centralSchema.userInvite) + .values(userInviteForDb) + .returning(); + const [insertedUser] = await centralDb + .insert(centralSchema.user) + .values(userDataForDb) + .returning(); log.debug("User account created successfully", { - userId: result[0].id, - email: result[0].email, - tokenValidUntil: result[0].tokenValidUntil, - hasPassphrase: !!result[0].passphraseHash, - hasRecoveryPassphrase: !!result[0].recoveryPassphrase, + userId: insertedUser.id, + email: insertedUser.email, + hasPassphrase: !!insertedUser.passphraseHash, + hasRecoveryPassphrase: !!insertedUser.recoveryPassphrase, }); // Send confirmation email to user (token is used as confirmation code) try { - if (result[0].email && result[0].token) { - const tenant = await getTenantForUser(result[0]); + if (insertedUser?.email && inviteResult?.inviteCode) { + const tenant = await getTenantForUser(insertedUser); await sendConfirmationEmail( - result[0], + insertedUser, tenant, - result[0].token, + inviteResult.inviteCode, 10, // 10 minutes expiration to match tokenValidUntil requestUrl, ); log.debug("Confirmation email sent successfully", { - userId: result[0].id, - email: result[0].email, - tenantId: result[0].tenantId, + userId: insertedUser.id, + email: insertedUser.email, + tenantId: insertedUser.tenantId, }); } } catch (emailError) { log.warn("Failed to send confirmation email", { - userId: result[0].id, - email: result[0].email, + userId: insertedUser?.id, + email: insertedUser?.email, error: String(emailError), }); // Don't throw - user creation succeeded, email is just a bonus } - return result[0]; + return insertedUser; } catch (error) { log.error("Failed to create user account", { email: userData.email, error: String(error) }); throw error; @@ -294,7 +304,32 @@ export class UserService { } | undefined = undefined; - const userData = await centralDb + const [matchingInvite] = await centralDb + .select({ + id: centralSchema.userInvite.id, + email: centralSchema.userInvite.email, + tenantId: centralSchema.userInvite.tenantId, + role: centralSchema.userInvite.role, + name: centralSchema.userInvite.name, + language: centralSchema.userInvite.language, + }) + .from(centralSchema.userInvite) + .where( + and( + eq(centralSchema.userInvite.inviteCode, linkToken), + gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`), + ), + ) + .limit(1); + + if (!matchingInvite) { + log.warn("User confirmation failed: Invalid or expired invite code", { + token: linkToken.substring(0, 8) + "...", + }); + throw new NotFoundError("Invalid or expired invite code"); + } + + const [userData] = await centralDb .select({ id: centralSchema.user.id, recoveryPassphrase: centralSchema.user.recoveryPassphrase, @@ -305,64 +340,37 @@ export class UserService { .from(centralSchema.user) .where( and( - eq(centralSchema.user.token, linkToken), - gt(centralSchema.user.tokenValidUntil, sql`timezone('utc', now())`), + eq(centralSchema.user.email, matchingInvite.email), + eq(centralSchema.user.tenantId, matchingInvite.tenantId), ), ) .limit(1); - if (userData.length === 0) { - const inviteData = await centralDb - .select({ - id: centralSchema.userInvite.id, - tenantId: centralSchema.userInvite.tenantId, - role: centralSchema.userInvite.role, - email: centralSchema.userInvite.email, - name: centralSchema.userInvite.name, - language: centralSchema.userInvite.language, - }) - .from(centralSchema.userInvite) - .where( - and( - eq(centralSchema.userInvite.inviteCode, linkToken), - gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`), - ), - ) - .limit(1); + if (!userData) { + resultData = { ...matchingInvite, recoveryPassphrase: null }; + const userDataForDb: InsertUser = { + name: resultData.name!, + email: resultData.email, + role: resultData.role, + tenantId: resultData.tenantId, + language: resultData.language || "de", + recoveryPassphrase: "warum landet der hier", + confirmationState: "CONFIRMED", + isActive: true, + }; + const retVal = await centralDb.insert(centralSchema.user).values(userDataForDb).returning(); + resultData.id = retVal[0].id; - if (inviteData.length === 0) { - log.warn("User confirmation failed: Invalid or expired token", { - token: linkToken.substring(0, 8) + "...", - }); - throw new NotFoundError("Invalid or timed-out token"); - } else { - resultData = { ...inviteData[0], recoveryPassphrase: null }; - const userDataForDb: InsertUser = { - name: resultData.name!, - email: resultData.email, - role: resultData.role, - tenantId: resultData.tenantId, - language: resultData.language || "de", - confirmationState: "CONFIRMED", - isActive: true, - }; - const retVal = await centralDb - .insert(centralSchema.user) - .values(userDataForDb) - .returning(); - resultData.id = retVal[0].id; + await InviteService.markInviteAsUsed(linkToken, resultData.id); + log.debug("Invitation marked as used", { + inviteCode: linkToken, + userId: resultData.id, + }); - await InviteService.markInviteAsUsed(linkToken, resultData.id); - log.debug("Invitation marked as used", { - inviteCode: linkToken, - userId: resultData.id, - }); - - const adminService = await TenantAdminService.getTenantById(resultData.tenantId!); - adminService.validateSetupState(); - } + const adminService = await TenantAdminService.getTenantById(resultData.tenantId!); + adminService.validateSetupState(); } else { - resultData = userData[0]; + resultData = userData; } // Check if this is the first tenant admin for the tenant diff --git a/src/routes/api/auth/invite/+server.ts b/src/routes/api/auth/invite/+server.ts index b99a9d0..3f865ae 100644 --- a/src/routes/api/auth/invite/+server.ts +++ b/src/routes/api/auth/invite/+server.ts @@ -212,6 +212,7 @@ export const POST: RequestHandler = async ({ request, locals, url }) => { invitedEmail: email, tenantId, role, + language, }); return json({