Untangle invite from user. Removed redundant data fields.

This commit is contained in:
Hendrik Belitz
2026-05-30 20:53:45 +02:00
parent bad2fe2ddb
commit 4a5b77fb6f
9 changed files with 989 additions and 90 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "token_valid_until";
+853
View File
@@ -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": {}
}
}
+7
View File
@@ -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
}
]
}
@@ -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",
-2
View File
@@ -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) */
@@ -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,
+1 -1
View File
@@ -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,
};
+83 -75
View File
@@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
import { AppointmentService } from "./appointment-service";
export type InsertUser = InferInsertModel<typeof centralSchema.user>;
export type InsertUserInvite = InferInsertModel<typeof centralSchema.userInvite>;
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
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
+1
View File
@@ -212,6 +212,7 @@ export const POST: RequestHandler = async ({ request, locals, url }) => {
invitedEmail: email,
tenantId,
role,
language,
});
return json({