From 5d60bb58854c9fa9065a0ca1ae710ddf7fa770bc Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 23 Oct 2025 16:57:31 +0200 Subject: [PATCH 01/61] Fix lint --- tenant-migrations/meta/0005_snapshot.json | 98 ++++++----------------- tenant-migrations/meta/_journal.json | 2 +- 2 files changed, 25 insertions(+), 75 deletions(-) diff --git a/tenant-migrations/meta/0005_snapshot.json b/tenant-migrations/meta/0005_snapshot.json index cdbc618..b0d71d5 100644 --- a/tenant-migrations/meta/0005_snapshot.json +++ b/tenant-migrations/meta/0005_snapshot.json @@ -98,12 +98,8 @@ "name": "agent_absence_agent_id_agent_id_fk", "tableFrom": "agent_absence", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -213,12 +209,8 @@ "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", "tableFrom": "appointment", "tableTo": "client_appointment_tunnel", - "columnsFrom": [ - "tunnel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -226,12 +218,8 @@ "name": "appointment_channel_id_channel_id_fk", "tableFrom": "appointment", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -239,12 +227,8 @@ "name": "appointment_agent_id_agent_id_fk", "tableFrom": "appointment", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -291,12 +275,8 @@ "name": "appointment_key_share_appointment_id_appointment_id_fk", "tableFrom": "appointment_key_share", "tableTo": "appointment", - "columnsFrom": [ - "appointment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -445,12 +425,8 @@ "name": "channel_agent_channel_id_channel_id_fk", "tableFrom": "channel_agent", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -458,12 +434,8 @@ "name": "channel_agent_agent_id_agent_id_fk", "tableFrom": "channel_agent", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -497,12 +469,8 @@ "name": "channel_slot_template_channel_id_channel_id_fk", "tableFrom": "channel_slot_template", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -510,12 +478,8 @@ "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", "tableFrom": "channel_slot_template", "tableTo": "slotTemplate", - "columnsFrom": [ - "slot_template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -575,9 +539,7 @@ "client_hash_key_unique": { "name": "client_hash_key_unique", "nullsNotDistinct": false, - "columns": [ - "hash_key" - ] + "columns": ["hash_key"] } }, "policies": {}, @@ -641,9 +603,7 @@ "client_appointment_tunnel_email_hash_unique": { "name": "client_appointment_tunnel_email_hash_unique", "nullsNotDistinct": false, - "columns": [ - "email_hash" - ] + "columns": ["email_hash"] } }, "policies": {}, @@ -693,12 +653,8 @@ "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", - "columnsFrom": [ - "tunnel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -823,13 +779,7 @@ "public.appointment_status": { "name": "appointment_status", "schema": "public", - "values": [ - "NEW", - "CONFIRMED", - "HELD", - "REJECTED", - "NO_SHOW" - ] + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] } }, "schemas": {}, @@ -842,4 +792,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 319549b..334f4bd 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -45,4 +45,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From 12be3016f4256a3cb01d8dd8cf13dd6f0f9b1cf0 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Thu, 23 Oct 2025 17:11:23 +0200 Subject: [PATCH 02/61] =?UTF-8?q?Validate=20the=20setup=20state=20in=20all?= =?UTF-8?q?=20relevant=20actions.=20Update=20it=20according=E2=80=A6=20(#1?= =?UTF-8?q?19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Validate the setup state in all relevant actions. Update it according to the tenant's current state. * Update src/lib/server/db/central-schema.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fixed schema, fixed tests, incorporated review advice * Unused variable error fixed. * Refactored deletion methods. Fixed partial deletion test. * Try to delete user first before deleting key shares (to make sure the transaction fails if we cannot delete the user) --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/server/db/central-schema.ts | 13 +- .../email/__tests__/email-system.test.ts | 22 +-- .../email/__tests__/generate-base-url.test.ts | 28 ++-- .../__tests__/tenant-admin-invite.test.ts | 2 +- .../services/__tests__/agent-service.test.ts | 102 +++++++++++- .../__tests__/channel-service.test.ts | 32 +++- .../services/__tests__/staff-service.test.ts | 114 ++++++++++--- .../services/__tests__/user-service.test.ts | 142 +++++++++++++++- src/lib/server/services/agent-service.ts | 7 + src/lib/server/services/channel-service.ts | 7 + src/lib/server/services/staff-service.ts | 71 ++++---- .../server/services/tenant-admin-service.ts | 98 ++++++++++- src/lib/server/services/user-service.ts | 155 ++++++++++++++++-- .../api/tenants/[id]/setup-state/+server.ts | 2 +- 14 files changed, 665 insertions(+), 130 deletions(-) diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 2718aa0..966ce21 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -29,10 +29,11 @@ export const confirmationStateEnum = pgEnum("confirmation_state", [ ]); export const tenantSetupState = pgEnum("setup_state", [ - "NEW", // newly created - "SETTINGS_CREATED", // settings were reviewed - "AGENTS_SET_UP", // agents set up was triggered or skipped - "FIRST_CHANNEL_CREATED", // the first channel was set up + "SETTINGS", // Settings need to be saved initially + "AGENTS", // At least one agent needs to be created + "CHANNELS", // At least one channel needs to be created + "STAFF", // At least one staff member needs to be created + "READY", // Tenant is fully set up and ready to use ]); /** @@ -60,8 +61,8 @@ export const tenant = pgTable( logo: varchar("logo", { length: 100_000 }), /** Database connection string for this tenant's isolated database */ databaseUrl: text("database_url").notNull(), - /** STate of tenant setup */ - setupState: tenantSetupState("setup_state").notNull().default("NEW"), + /** State of tenant setup */ + setupState: tenantSetupState("setup_state").notNull().default("SETTINGS"), /** Links (object) */ links: json("links") .$type<{ imprint?: string; privacyStatement?: string; website?: string }>() diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index 432c196..70b6c6d 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -113,7 +113,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -146,7 +146,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -179,7 +179,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -212,7 +212,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -270,7 +270,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -336,7 +336,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -378,7 +378,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -414,7 +414,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -450,7 +450,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -505,7 +505,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -551,7 +551,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index 3ee2ef8..7352d3c 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -33,7 +33,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", links: { website: "", imprint: "", privacyStatement: "" }, }; @@ -58,7 +58,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -79,7 +79,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -100,7 +100,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -141,7 +141,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -162,7 +162,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -183,7 +183,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -204,7 +204,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -225,7 +225,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -246,7 +246,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -267,7 +267,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -291,7 +291,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -313,7 +313,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -335,7 +335,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), diff --git a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts b/src/lib/server/email/__tests__/tenant-admin-invite.test.ts index fa34f58..bc16a0c 100644 --- a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts +++ b/src/lib/server/email/__tests__/tenant-admin-invite.test.ts @@ -10,7 +10,7 @@ describe("sendTenantAdminInviteEmail", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "postgresql://test", - setupState: "NEW" as const, + setupState: "SETTINGS" as const, createdAt: new Date(), updatedAt: new Date(), logo: null, diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 347c418..3a18f00 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -4,14 +4,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock dependencies before imports vi.mock("../../db", () => ({ getTenantDb: vi.fn(), + centralDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, })); vi.mock("$lib/logger", () => ({ + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + })), default: { setContext: vi.fn(() => ({ debug: vi.fn(), error: vi.fn(), warn: vi.fn(), + info: vi.fn(), })), }, })); @@ -82,9 +97,15 @@ const mockAgent = { }; describe("AgentService", () => { - beforeEach(() => { + let mockCentralDb: any; + + beforeEach(async () => { vi.clearAllMocks(); vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + // Import and get the mocked centralDb + const dbModule = await import("../../db"); + mockCentralDb = dbModule.centralDb; }); describe("forTenant", () => { @@ -110,6 +131,46 @@ describe("AgentService", () => { }); it("should create agent successfully", async () => { + // Mock centralDb to handle TenantConfig.create() and tenant queries + let callCount = 0; + const mockSelectBuilder = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + from: vi.fn((_) => ({ + where: vi.fn(() => { + callCount++; + if (callCount === 1) { + // First call: TenantConfig.#getTenantConfig() - return config entries + return Promise.resolve([ + { tenantId: "tenant-123", name: "maxAgents", type: "NUMBER", value: "10" }, + { + tenantId: "tenant-123", + name: "allowNotifications", + type: "BOOLEAN", + value: "true", + }, + ]); + } else { + // Second call: getTenantById tenant query - return with limit + return { + limit: vi.fn(() => + Promise.resolve([ + { + id: "tenant-123", + subdomain: "test-tenant", + plan: "basic", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]), + ), + }; + } + }), + })), + }; + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + const insertChain = { values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([mockAgent]), @@ -338,6 +399,45 @@ describe("AgentService", () => { }); it("should delete agent successfully", async () => { + // Mock centralDb to handle TenantConfig.create() and tenant queries + let callCount = 0; + const mockSelectBuilder = { + from: vi.fn(() => ({ + where: vi.fn(() => { + callCount++; + if (callCount === 1) { + // First call: TenantConfig.#getTenantConfig() - return config entries + return Promise.resolve([ + { tenantId: "tenant-123", name: "maxAgents", type: "NUMBER", value: "10" }, + { + tenantId: "tenant-123", + name: "allowNotifications", + type: "BOOLEAN", + value: "true", + }, + ]); + } else { + // Second call: getTenantById tenant query - return with limit + return { + limit: vi.fn(() => + Promise.resolve([ + { + id: "tenant-123", + subdomain: "test-tenant", + plan: "basic", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]), + ), + }; + } + }), + })), + }; + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + const result = await service.deleteAgent("agent-123"); expect(result).toBe(true); diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index a2efd88..db6f909 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -7,8 +7,27 @@ vi.mock("../../db", () => ({ getTenantDb: vi.fn(), centralDb: { select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve([])), + from: vi.fn((table) => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => { + // Return different data based on table + if (table?.name === "tenant_config" || table === "tenant_config") { + return Promise.resolve([]); + } + // Default to tenant data + return Promise.resolve([ + { + id: "tenant-123", + name: "Test Tenant", + subdomain: "test", + plan: "basic", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + }), + })), })), })), insert: vi.fn(), @@ -18,11 +37,20 @@ vi.mock("../../db", () => ({ })); vi.mock("$lib/logger", () => ({ + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + })), default: { setContext: vi.fn(() => ({ debug: vi.fn(), error: vi.fn(), warn: vi.fn(), + info: vi.fn(), })), }, })); diff --git a/src/lib/server/services/__tests__/staff-service.test.ts b/src/lib/server/services/__tests__/staff-service.test.ts index 423f7cd..f2d9f47 100644 --- a/src/lib/server/services/__tests__/staff-service.test.ts +++ b/src/lib/server/services/__tests__/staff-service.test.ts @@ -20,6 +20,18 @@ vi.mock("../staff-crypto.service", () => ({ })), })); +vi.mock("../tenant-admin-service", () => ({ + TenantAdminService: { + getTenantById: vi.fn(), + }, +})); + +vi.mock("../user-service", () => ({ + UserService: { + deleteUser: vi.fn(), + }, +})); + const mockStaffMember = { id: "staff-123", email: "staff@example.com", @@ -35,6 +47,7 @@ const mockStaffMember = { describe("StaffService", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetAllMocks(); }); describe("getStaffMembers", () => { @@ -123,9 +136,68 @@ describe("StaffService", () => { }); describe("deleteStaffMember", () => { - it("should delete a staff member successfully", async () => { - const { centralDb, getTenantDb } = await import("../../db"); + it("should prevent deletion of last staff member", async () => { + const { centralDb } = await import("../../db"); + // Mock the non-global admin staff check - only one staff member exists + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { + id: "staff-123", // Only one staff member + }, + ]), + }; + + vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + + await expect(StaffService.deleteStaffMember("tenant-123", "staff-123")).rejects.toThrow( + ValidationError, + ); + }); + + it("should delete a staff member successfully when multiple exist", async () => { + const { centralDb, getTenantDb } = await import("../../db"); + const { UserService } = await import("../user-service"); + + // Mock the non-global admin staff check - multiple staff members exist + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { + id: "staff-123", + }, + { + id: "staff-456", // Multiple staff members + }, + ]), + }; + + vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + + // Mock UserService.deleteUser + const mockUserDeletionResult = { + success: true, + deletedUser: { + id: "staff-123", + email: "staff@example.com", + name: "Staff Member", + role: "STAFF" as const, + }, + deletedPasskeysCount: 2, + tenantId: "tenant-123", + }; + vi.mocked(UserService.deleteUser).mockResolvedValue(mockUserDeletionResult); + + // Mock tenant database for key shares + const mockTenantDb = { + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue({ count: 1 }), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockTenantDb as any); + + // Mock transaction const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ @@ -143,35 +215,11 @@ describe("StaffService", () => { }), }), }), - delete: vi - .fn() - .mockReturnValueOnce({ - where: vi.fn().mockResolvedValue({ count: 2 }), - }) - .mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([ - { - id: "staff-123", - email: "staff@example.com", - name: "Staff Member", - role: "STAFF", - }, - ]), - }), - }), }; return await callback(tx); }); - const mockTenantDb = { - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue({ count: 1 }), - }), - }; - vi.mocked(centralDb.transaction).mockImplementation(mockTransaction); - vi.mocked(getTenantDb).mockResolvedValue(mockTenantDb as any); const result = await StaffService.deleteStaffMember("tenant-123", "staff-123"); @@ -190,12 +238,24 @@ describe("StaffService", () => { it("should throw NotFoundError when staff member not found", async () => { const { centralDb } = await import("../../db"); + // Mock the pre-transaction select queries + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { + id: "staff-456", // Different ID to simulate multiple staff members + }, + ]), + }; + + vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), + limit: vi.fn().mockResolvedValue([]), // No user found }), }), }), diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index 4cb2833..27c361a 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NotFoundError } from "../../utils/errors"; +import { NotFoundError, ValidationError } from "../../utils/errors"; // Mock the database module vi.mock("../../db", () => ({ @@ -9,6 +9,7 @@ vi.mock("../../db", () => ({ select: vi.fn(), update: vi.fn(), delete: vi.fn(), + transaction: vi.fn(), }, })); @@ -74,6 +75,7 @@ describe("UserService", () => { shortName: "test", longName: "Test Tenant", }, + validateSetupState: vi.fn(), }); }); @@ -295,25 +297,147 @@ describe("UserService", () => { }); describe("deleteUser", () => { - it("should delete admin and associated passkeys", async () => { + it("should delete global admin successfully", async () => { const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; const mockDeletedAdmin = { id: adminId, name: "Deleted Admin", email: "deleted@example.com", + role: "GLOBAL_ADMIN", }; - const mockDeleteBuilder = { - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), - }; + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: adminId, + email: "deleted@example.com", + name: "Deleted Admin", + role: "GLOBAL_ADMIN", + tenantId: null, + }, + ]), + }), + }), + }), + delete: vi + .fn() + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue({ count: 2 }), + }) + .mockReturnValueOnce({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), + }), + }), + }; + return await callback(tx); + }); - mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + mockCentralDb.transaction.mockImplementation(mockTransaction); const result = await UserService.deleteUser(adminId); - expect(mockCentralDb.delete).toHaveBeenCalledTimes(2); - expect(result).toEqual(mockDeletedAdmin); + expect(result.success).toBe(true); + expect(result.deletedUser).toEqual(mockDeletedAdmin); + expect(result.deletedPasskeysCount).toBe(2); + expect(result.tenantId).toBeNull(); + }); + + it("should prevent deletion of last tenant admin", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + const tx = { + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: adminId, + email: "admin@example.com", + name: "Last Admin", + role: "TENANT_ADMIN", + tenantId: "tenant-123", + }, + ]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), // Only one admin + }), + }), + }; + return await callback(tx); + }); + + mockCentralDb.transaction.mockImplementation(mockTransaction); + + await expect(UserService.deleteUser(adminId)).rejects.toThrow(ValidationError); + }); + + it("should delete tenant admin when multiple exist", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + const mockDeletedAdmin = { + id: adminId, + name: "Deleted Admin", + email: "deleted@example.com", + role: "TENANT_ADMIN", + }; + + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + const tx = { + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: adminId, + email: "deleted@example.com", + name: "Deleted Admin", + role: "TENANT_ADMIN", + tenantId: "tenant-123", + }, + ]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 2 }]), // Multiple admins + }), + }), + delete: vi + .fn() + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue({ count: 1 }), + }) + .mockReturnValueOnce({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), + }), + }), + }; + return await callback(tx); + }); + + mockCentralDb.transaction.mockImplementation(mockTransaction); + + const result = await UserService.deleteUser(adminId); + + expect(result.success).toBe(true); + expect(result.deletedUser).toEqual(mockDeletedAdmin); + expect(result.deletedPasskeysCount).toBe(1); + expect(result.tenantId).toBe("tenant-123"); }); }); diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index badabaa..4cb061b 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -7,6 +7,7 @@ import logger from "$lib/logger"; import z from "zod/v4"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; import { supportedLocales } from "$lib/const/locales"; +import { TenantAdminService } from "./tenant-admin-service"; const agentCreationSchema = z.object({ name: z.string().min(1).max(100), @@ -110,6 +111,9 @@ export class AgentService { name: result[0].name, }); + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return result[0]; } catch (error) { log.error("Failed to create agent", { @@ -279,6 +283,9 @@ export class AgentService { agentId, }); + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return true; } catch (error) { log.error("Failed to delete agent", { diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index c4b26a0..9874489 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -7,6 +7,7 @@ import { TenantConfig } from "../db/tenant-config"; import * as tenantSchema from "../db/tenant-schema"; import { type SelectAgent, type SelectChannel, type SelectSlotTemplate } from "../db/tenant-schema"; import { NotFoundError, ValidationError } from "../utils/errors"; +import { TenantAdminService } from "./tenant-admin-service"; const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const; const NEXT_COLOR_KEY = "nextChannelColor"; @@ -201,6 +202,9 @@ export class ChannelService { slotTemplateCount: result.slotTemplates.length, }); + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return result; } catch (error) { log.error("Failed to create channel", { @@ -678,6 +682,9 @@ export class ChannelService { }); } + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return result; } catch (error) { log.error("Failed to delete channel", { diff --git a/src/lib/server/services/staff-service.ts b/src/lib/server/services/staff-service.ts index da9da8d..7883d6f 100644 --- a/src/lib/server/services/staff-service.ts +++ b/src/lib/server/services/staff-service.ts @@ -1,11 +1,12 @@ import { centralDb, getTenantDb } from "../db"; -import { user, userPasskey } from "../db/central-schema"; +import { user } from "../db/central-schema"; import { clientTunnelStaffKeyShare } from "../db/tenant-schema"; -import { eq, and } from "drizzle-orm"; +import { eq, and, or } from "drizzle-orm"; import { NotFoundError, ValidationError, InternalError } from "../utils/errors"; import { StaffCryptoService } from "./staff-crypto.service"; import { UniversalLogger } from "$lib/logger"; import type { InferSelectModel } from "drizzle-orm"; +import { UserService } from "./user-service"; const logger = new UniversalLogger().setContext("StaffService"); @@ -167,6 +168,23 @@ export class StaffService { throw new ValidationError("You cannot delete your own account"); } + // Check if this is the only non-global admin staff member + const nonGlobalAdminStaff = await centralDb + .select({ id: user.id }) + .from(user) + .where( + and( + eq(user.tenantId, tenantId), + eq(user.isActive, true), + or(eq(user.role, "TENANT_ADMIN"), eq(user.role, "STAFF")), + eq(user.confirmationState, "ACCESS_GRANTED"), + ), + ); + + if (nonGlobalAdminStaff.length === 1 && nonGlobalAdminStaff[0].id === staffId) { + throw new ValidationError("Cannot delete the last active non-global admin staff member"); + } + try { // Use transaction to ensure all related data is deleted consistently const result = await centralDb.transaction(async (tx) => { @@ -187,20 +205,11 @@ export class StaffService { throw new NotFoundError("Staff member not found in this tenant"); } - // Delete associated passkeys from central database - const passkeyDeletionResult = await tx - .delete(userPasskey) - .where(eq(userPasskey.userId, staffId)); + // Use UserService to delete central database data (user + passkeys) first + // This ensures all validation logic is applied before deleting tenant data + const userDeletionResult = await UserService.deleteUser(staffId, tx); - const deletedPasskeysCount = passkeyDeletionResult.count || 0; - - logger.debug("Deleted user passkeys", { - staffId, - tenantId, - deletedCount: deletedPasskeysCount, - }); - - // Delete client tunnel key shares from tenant database + // Delete tenant-specific data (client tunnel key shares) after user deletion succeeds let deletedKeySharesCount = 0; try { const tenantDb = await getTenantDb(tenantId); @@ -208,7 +217,7 @@ export class StaffService { .delete(clientTunnelStaffKeyShare) .where(eq(clientTunnelStaffKeyShare.userId, staffId)); - deletedKeySharesCount = keyShareDeletionResult.count || 0; + deletedKeySharesCount = keyShareDeletionResult?.count || 0; logger.debug("Deleted client tunnel key shares", { staffId, @@ -221,39 +230,29 @@ export class StaffService { tenantId, error: String(error), }); - // Continue with user deletion even if key share deletion fails + // Note: User deletion already succeeded, key share deletion is auxiliary } - // Finally, delete the user account from central database - const deletedUsers = await tx.delete(user).where(eq(user.id, staffId)).returning({ - id: user.id, - email: user.email, - name: user.name, - role: user.role, - }); - - if (deletedUsers.length === 0) { - throw new InternalError("Failed to delete user account"); - } - - const deletionResult = { - success: true, - deletedUser: deletedUsers[0], - deletedPasskeysCount, + // Combine results for staff-specific response format + const staffDeletionResult: StaffDeletionResult = { + success: userDeletionResult.success, + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, deletedKeySharesCount, }; logger.info("Staff member deleted successfully", { staffId, tenantId, - deletedUser: deletedUsers[0], - deletedPasskeysCount, + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, deletedKeySharesCount, }); - return deletionResult; + return staffDeletionResult; }); + // Setup state validation is handled by UserService.deleteUser return result; } catch (error) { if (error instanceof ValidationError || error instanceof NotFoundError) { diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index b5847fd..c8df42c 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -5,7 +5,7 @@ import { TenantConfig } from "../db/tenant-config"; import { TenantMigrationService } from "./tenant-migration-service"; import { env } from "$env/dynamic/private"; -import { eq, and, not } from "drizzle-orm"; +import { eq, and, not, count, or } from "drizzle-orm"; import logger from "$lib/logger"; import z from "zod/v4"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; @@ -318,6 +318,10 @@ export class TenantAdminService { updatedCount: results.length, }); + if (this.#tenant?.setupState === "SETTINGS") { + await this.setSetupState("AGENTS"); + } + return results; } catch (error) { log.error("Failed to update tenant configuration", { @@ -332,9 +336,7 @@ export class TenantAdminService { /** * Set the setup state of the tenant */ - async setSetupState( - setupState: "NEW" | "SETTINGS_CREATED" | "AGENTS_SET_UP" | "FIRST_CHANNEL_CREATED", - ) { + async setSetupState(setupState: "SETTINGS" | "AGENTS" | "CHANNELS" | "STAFF" | "READY") { const log = logger.setContext("TenantAdminService"); log.debug("Setting tenant setup state", { tenantId: this.tenantId, @@ -400,6 +402,94 @@ export class TenantAdminService { return this.#db; } + /** + * Validates and updates the setup state of a tenant. Called by other services after certain operations that might influence the setup state. + */ + async validateSetupState() { + const log = logger.setContext("TenantAdminService"); + log.debug("Validating tenant setup state", { tenantId: this.tenantId }); + + if (!this.#tenant) { + throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); + } + + let newState = this.#tenant.setupState || "SETTINGS"; + + // If it is SETTINGS, return. The tenant was not configured yet. + if (newState === "SETTINGS") { + log.debug("Tenant setup state validation complete - still in SETTINGS", { + tenantId: this.tenantId, + }); + return; + } + + try { + const db = await this.getDb(); + + // Check for agents + const { agent, channel } = await import("../db/tenant-schema"); + + const agentCount = await db + .select({ count: count() }) + .from(agent) + .where(eq(agent.archived, false)); + + if (agentCount.length === 0) { + newState = "AGENTS"; + } else { + // Check for channels + const channelCount = await db + .select({ count: count() }) + .from(channel) + .where(eq(channel.archived, false)); + if (channelCount.length === 0) { + newState = "CHANNELS"; + } else { + // Check for staff members + const staffCount = await centralDb + .select({ count: count() }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, this.tenantId), + or( + eq(centralSchema.user.role, "STAFF"), + eq(centralSchema.user.role, "TENANT_ADMIN"), + ), + ), + ); + + if (staffCount.length === 0) { + newState = "STAFF"; + } else { + newState = "READY"; + } + } + } + + // Only update if state has changed + if (newState !== this.#tenant.setupState) { + log.debug("Tenant setup state needs update", { + tenantId: this.tenantId, + currentState: this.#tenant.setupState, + newState, + }); + await this.setSetupState(newState); + } else { + log.debug("Tenant setup state validation complete - no change needed", { + tenantId: this.tenantId, + currentState: newState, + }); + } + } catch (error) { + log.error("Failed to validate tenant setup state", { + tenantId: this.tenantId, + error: error, + }); + throw error; + } + } + /** * Delete a tenant and all associated data * This operation: diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index abb6358..dc569fc 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -1,9 +1,10 @@ import { centralDb } from "../db"; import * as centralSchema from "../db/central-schema"; -import { eq, desc, gt, and, count } from "drizzle-orm"; -import type { InferInsertModel } from "drizzle-orm"; +import { eq, desc, gt, and, count, or } from "drizzle-orm"; +import type { InferInsertModel, TablesRelationalConfig } from "drizzle-orm"; + import z from "zod/v4"; -import { NotFoundError, ValidationError } from "../utils/errors"; +import { NotFoundError, ValidationError, InternalError } from "../utils/errors"; import { uuidv7 } from "uuidv7"; import { addMinutes } from "date-fns"; import logger from "$lib/logger"; @@ -15,9 +16,28 @@ import { import { sendConfirmationEmail } from "../email/email-service"; import type { SelectTenant } from "../db/central-schema"; import { TenantAdminService } from "./tenant-admin-service"; +import type { PgTransaction } from "drizzle-orm/pg-core"; +import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; export type InsertUser = InferInsertModel; export type InsertUserPasskey = InferInsertModel; +export type UserTransaction = PgTransaction< + PostgresJsQueryResultHKT, + Record, + TablesRelationalConfig +>; + +export interface UserDeletionResult { + success: boolean; + deletedUser: { + id: string; + email: string; + name: string; + role: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; + }; + deletedPasskeysCount: number; + tenantId?: string | null; +} const userCreationSchema = z.object({ name: z.string().min(5), @@ -49,7 +69,7 @@ function createSystemTenant(): SelectTenant { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "", - setupState: "FIRST_CHANNEL_CREATED", + setupState: "SETTINGS", createdAt: new Date(), updatedAt: new Date(), }; @@ -293,6 +313,9 @@ export class UserService { hadRecoveryPassphrase: !!user.recoveryPassphrase, }); + const adminService = await TenantAdminService.getTenantById(user.tenantId!); + adminService.validateSetupState(); + return { recoveryPassphrase: user.recoveryPassphrase || undefined, isSetup: countResult[0].count === 1, @@ -461,36 +484,132 @@ export class UserService { } /** - * Permanently delete admin and all associated passkeys + * Permanently delete user and all associated passkeys */ - static async deleteUser(userId: string) { + static async deleteUser( + userId: string, + externalTransaction?: UserTransaction, + ): Promise { const log = logger.setContext("UserService"); log.debug("Deleting user and associated passkeys", { userId }); - try { + const executeDeleteUser = async (tx: UserTransaction) => { + // First, verify the user exists and get user data + const userToDelete = await tx + .select({ + id: centralSchema.user.id, + email: centralSchema.user.email, + name: centralSchema.user.name, + role: centralSchema.user.role, + tenantId: centralSchema.user.tenantId, + }) + .from(centralSchema.user) + .where(eq(centralSchema.user.id, userId)) + .limit(1); + + if (userToDelete.length === 0) { + log.warn("User deletion failed: User not found", { userId }); + throw new NotFoundError("User not found"); + } + + const user = userToDelete[0]; + + // We cannot delete the last user with access to the tenant's appointments + if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { + const usersCount = await tx + .select({ count: count() }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, user.tenantId!), + or(eq(centralSchema.user.role, "STAFF"), eq(centralSchema.user.role, "TENANT_ADMIN")), + eq(centralSchema.user.isActive, true), + eq(centralSchema.user.confirmationState, "ACCESS_GRANTED"), + ), + ); + + if (usersCount[0].count <= 1) { + throw new ValidationError(`Cannot delete the last ${user.role} user for this tenant`); + } + } + // Delete associated passkeys first - const passkeyResult = await centralDb + const passkeyDeletionResult = await tx .delete(centralSchema.userPasskey) .where(eq(centralSchema.userPasskey.userId, userId)); - log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 }); + const deletedPasskeysCount = passkeyDeletionResult?.count || 0; + log.debug("Deleted user passkeys", { + userId, + deletedCount: deletedPasskeysCount, + }); - // Delete admin - const result = await centralDb + // Delete the user account + const deletedUsers = await tx .delete(centralSchema.user) .where(eq(centralSchema.user.id, userId)) - .returning(); + .returning({ + id: centralSchema.user.id, + email: centralSchema.user.email, + name: centralSchema.user.name, + role: centralSchema.user.role, + }); - if (result[0]) { - log.debug("User deleted successfully", { userId, email: result[0].email }); - } else { - log.warn("User deletion failed: User not found", { userId }); + if (deletedUsers.length === 0) { + throw new NotFoundError("Failed to delete user account"); } - return result[0] || null; + const deletionResult = { + success: true, + deletedUser: deletedUsers[0], + deletedPasskeysCount, + tenantId: user.tenantId, // Include tenantId for setup state validation + }; + + log.info("User deleted successfully", { + userId, + deletedUser: deletedUsers[0], + deletedPasskeysCount, + tenantId: user.tenantId, + }); + + return deletionResult; + }; + + try { + let result: UserDeletionResult; + + if (externalTransaction) { + // Use provided transaction + result = await executeDeleteUser(externalTransaction); + } else { + // Create new transaction + result = await centralDb.transaction(executeDeleteUser); + } + + // Validate setup state only if we have a tenant + if (result.deletedUser.role !== "GLOBAL_ADMIN" && result.tenantId) { + try { + const adminService = await TenantAdminService.getTenantById(result.tenantId); + adminService.validateSetupState(); + } catch (error) { + log.warn("Failed to validate setup state after user deletion", { + userId, + tenantId: result.tenantId, + error: String(error), + }); + // Don't throw - user deletion was successful + } + } + + return result; } catch (error) { + if (error instanceof ValidationError || error instanceof NotFoundError) { + throw error; + } + log.error("Failed to delete user", { userId, error: String(error) }); - throw error; + throw new InternalError("Failed to delete user"); } } diff --git a/src/routes/api/tenants/[id]/setup-state/+server.ts b/src/routes/api/tenants/[id]/setup-state/+server.ts index 852f613..53ea7ee 100644 --- a/src/routes/api/tenants/[id]/setup-state/+server.ts +++ b/src/routes/api/tenants/[id]/setup-state/+server.ts @@ -9,7 +9,7 @@ import { checkPermission } from "$lib/server/utils/permissions"; import { ERRORS } from "$lib/errors"; const setupStateSchema = z.object({ - setupState: z.enum(["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]), + setupState: z.enum(["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]), }); // Register OpenAPI documentation for PUT From 23941e7a5e3f1ce6acd9c312583875969c3cd50c Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 24 Oct 2025 13:09:52 +0200 Subject: [PATCH 03/61] Feat/staff (#110) * Added basic staff functions -wip * Fix: Use proper route for staff member creation * Fixed errors in confirm, invite and register routes. * Also return invited staff members when getting staff members * Updated error handling * Allow global admin to add other global admins * Sort staff members by name * Added dummy implementation of granting access to staff members * Render tenant logo, if set * Fix delete * Make invited but not confirmed user entries deletable. * Added staff onboarding with passkey -wip * Fix delete route * Fix passkey setup flow for staff * Fix lint * Fix lint * Fix some unit tests * Updated docs * Refactored staff-service, fixed tests * Setup state db migrations --------- Co-authored-by: Karl Ludwig Weise Co-authored-by: Hendrik Belitz --- migrations/0007_milky_wasp.sql | 6 + migrations/meta/0007_snapshot.json | 793 ++++++++++++++++++ migrations/meta/_journal.json | 7 + project.inlang/messages/de.json | 91 +- project.inlang/messages/en.json | 91 +- .../components/tenant-switcher.svelte | 24 +- .../empty-state/center-loading-state.svelte | 13 +- .../templates/list/list-item.svelte | 21 +- .../ui/form/form-description.svelte | 1 + .../ui/form/form-field-errors.svelte | 6 +- src/lib/components/ui/passkey/state.svelte | 2 +- src/lib/const/routes.ts | 1 + src/lib/errors.ts | 5 + .../auth/__tests__/session-service.test.ts | 37 - src/lib/server/auth/session-service.ts | 9 - .../services/__tests__/staff-service.test.ts | 27 +- src/lib/server/services/invite-service.ts | 5 +- src/lib/server/services/staff-service.ts | 144 ++-- .../server/services/tenant-admin-service.ts | 7 +- src/lib/server/services/user-service.ts | 106 ++- src/lib/types/tenant.ts | 4 +- src/lib/types/users.ts | 14 + .../(pages)/confirm/[token]/+page.server.ts | 9 +- .../(pages)/confirm/[token]/+page.svelte | 10 +- .../confirm/setup-passkey/+page.server.ts | 67 ++ .../confirm/setup-passkey/+page.svelte | 53 ++ .../(pages)/confirm/setup-passkey/schema.ts | 12 + .../setup-passkey/setup-passkey-form.svelte | 163 ++++ .../(components)/delete-agent-form/index.ts | 4 +- .../(components)/edit-agent-form/index.ts | 4 +- .../(pages)/dashboard/agents/+page.svelte | 6 +- .../add-staff-member-form.svelte | 136 +++ .../add-staff-member-form/index.ts | 5 + .../add-staff-member-form/schema.ts | 11 + .../delete-staff-member-form.svelte | 89 ++ .../delete-staff-member-form/index.ts | 5 + .../delete-staff-member-form/schema.ts | 10 + .../edit-staff-member-form.svelte | 110 +++ .../edit-staff-member-form/index.ts | 5 + .../edit-staff-member-form/schema.ts | 11 + .../grant-access-form.svelte | 65 ++ .../(components)/grant-access-form/index.ts | 3 + .../(components)/role-permissions.svelte | 27 + .../dashboard/staff/(components)/utils.ts | 17 + .../(pages)/dashboard/staff/+page.server.ts | 188 +++++ .../(pages)/dashboard/staff/+page.svelte | 168 +++- .../(pages)/dashboard/tenants/+page.svelte | 25 +- src/routes/(pages)/login/+page.server.ts | 1 + src/routes/(pages)/login/login-form.svelte | 2 +- src/routes/api/auth/challenge/+server.ts | 13 +- src/routes/api/auth/confirm/+server.ts | 19 +- src/routes/api/auth/invite/+server.ts | 2 +- .../api/auth/invite/__tests__/invite.test.ts | 2 +- src/routes/api/auth/register/+server.ts | 267 ------ src/routes/api/auth/register/[id]/+server.ts | 151 ++++ src/routes/api/openapi.json/+server.ts | 2 +- src/routes/api/tenants/[id]/staff/+server.ts | 261 +----- .../tenants/[id]/staff/[staffId]/+server.ts | 207 ++++- src/server-hooks/apiAuthHandle.ts | 2 +- 59 files changed, 2862 insertions(+), 684 deletions(-) create mode 100644 migrations/0007_milky_wasp.sql create mode 100644 migrations/meta/0007_snapshot.json create mode 100644 src/lib/types/users.ts create mode 100644 src/routes/(pages)/confirm/setup-passkey/+page.server.ts create mode 100644 src/routes/(pages)/confirm/setup-passkey/+page.svelte create mode 100644 src/routes/(pages)/confirm/setup-passkey/schema.ts create mode 100644 src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte create mode 100644 src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/add-staff-member-form.svelte create mode 100644 src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte create mode 100644 src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte create mode 100644 src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte create mode 100644 src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts create mode 100644 src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte create mode 100644 src/routes/(pages)/dashboard/staff/(components)/utils.ts create mode 100644 src/routes/(pages)/dashboard/staff/+page.server.ts create mode 100644 src/routes/api/auth/register/[id]/+server.ts diff --git a/migrations/0007_milky_wasp.sql b/migrations/0007_milky_wasp.sql new file mode 100644 index 0000000..cbadf5a --- /dev/null +++ b/migrations/0007_milky_wasp.sql @@ -0,0 +1,6 @@ +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DEFAULT 'SETTINGS'::text;--> statement-breakpoint +DROP TYPE "public"."setup_state";--> statement-breakpoint +CREATE TYPE "public"."setup_state" AS ENUM('SETTINGS', 'AGENTS', 'CHANNELS', 'STAFF', 'READY');--> statement-breakpoint +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DEFAULT 'SETTINGS'::"public"."setup_state";--> statement-breakpoint +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DATA TYPE "public"."setup_state" USING "setup_state"::"public"."setup_state"; \ No newline at end of file diff --git a/migrations/meta/0007_snapshot.json b/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..3552f4a --- /dev/null +++ b/migrations/meta/0007_snapshot.json @@ -0,0 +1,793 @@ +{ + "id": "c06d9c90-44ff-4e2e-a834-8533afcfe755", + "prevId": "c7e97210-1d91-4d46-942d-157da03e44e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "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" + }, + "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"] + } + }, + "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'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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 6880a1e..6146f3e 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1760360156572, "tag": "0006_legal_dracula", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1761303767674, + "tag": "0007_milky_wasp", + "breakpoints": true } ] } diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index f2ccb6e..ee09f45 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -77,9 +77,9 @@ }, "confirm": { "success": { - "title": "Du bist bereit!", - "description": "Du hast Dein Konto erfolgreich gesichert", - "action": "Weiter" + "title": "E-Mail Adresse bestätigt!", + "description": "Jetzt musst Du nur noch einen Passkey hinzufügen.", + "action": "Passkey hinzufügen" }, "error": { "title": "Bestätigung fehlgeschlagen", @@ -94,6 +94,13 @@ "success": "E-Mail gesendet" } }, + "setupPasskey": { + "title": "Passkey einrichten", + "description": "Erstelle Deinen Passkey, um Dein Konto zu sichern.", + "action": "Passkey setzen", + "success": "Passkey eingerichtet. Logge Dich damit jetzt ein.", + "error": "Passkey konnte nicht gesetzt werden" + }, "logout": { "title": "Erfolgreich abgemeldet", "description": "Du wurdest abgemeldet.", @@ -140,7 +147,7 @@ "title": "Mandant hinzufügen", "description": "Erstelle eine Seite für jemanden, der Termine sammeln möchte.", "name": { - "description": "Muss einmalig auf diesem Server sein. Wird als URL verwendet {domain}", + "description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden. Wird als URL verwendet {domain}", "errors": { "urlFormat": "Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt", "startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden" @@ -639,5 +646,79 @@ } } } - } + }, + "staff": { + "title": "Personal", + "roles": { + "GLOBAL_ADMIN": "Globaler Admin", + "TENANT_ADMIN": "Mandanten-Admin", + "STAFF": "Mitarbeiter:in" + }, + "permissions": { + "agents": "verwaltet Akteure", + "channels": "verwaltet Kanäle", + "absences": "verwaltet Abwesenheiten", + "settings": "verwaltet Einstellungen", + "staff": "verwaltet Personal", + "appointments": "verwaltet Termine" + }, + "form": { + "fields": { + "language": { + "title": "Sprache", + "description": "Wird in der Einlaungs-E-Mail verwendet.", + "placeholder": "Sprache auswählen" + }, + "role": { + "title": "Rolle", + "placeholder": "Rolle auswählen" + } + } + }, + "add": { + "title": "Mitarbeiter:in hinzufügen", + "description": "Mitarbeiter:innen verwalten Termine und Einstellungen. Diese Konten sind nicht öffentlich.", + "action": "Mitarbeiter:in hinzufügen", + "success": "Mitarbeiter:in hinzugefügt", + "errors": { + "unknown": "Konnte Mitarbeiter:in nicht hinzufügen" + } + }, + "list": { + "needsAccess": "Kann Termine nicht sehen", + "empty": { + "title": "Bisher kein Personal", + "description": "Personal verwaltet Termine und Einstellungen. Diese Konten sind nicht öffentlich." + } + }, + "edit": { + "title": "Konto bearbeiten", + "description": "Aktualisiere das Konto dieser Mitarbeiter:in.", + "success": "Konto aktualisiert", + "error": "Konnte Konto nicht aktualisieren.", + "action": "Übernehmen" + }, + "delete": { + "title": "Konto entfernen", + "description": "Tippe {email} um dieses Konto zu entfernen. Dieser Schritt kann nicht rückgängig gemacht werden.", + "action": "Konto entfernen", + "success": "Konto entfernt", + "error": "Konnte Konto nicht entfernen." + }, + "access": { + "title": "Zugriff auf Klientendaten gewähren", + "description": "Willst Du {name} Zugriff auf alle Kundendaten geben?", + "action": "Zugriff gewähren", + "loading": "Zugriff wird gewährt ({success}/{total} Klienten)", + "success": "Zugriff gewährt", + "error": "Konnte Zugriff nicht für alle Klienten gewähren. Bitte erneut versuchen.", + "unavailable": { + "title": "Du kannst keinen Zugriff gewähren", + "description": "Nur Mitarbeiter:innen und Mandanten-Admins können Zugriff gewähren." + } + }, + "loading": "Lade Personal" + }, + "yes": "Ja", + "no": "Nein" } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 4adbba4..cf14e03 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -35,6 +35,13 @@ } } }, + "setupPasskey": { + "title": "Setup Passkey", + "description": "Create your passkey to secure your account.", + "action": "Set Passkey", + "success": "Passkey set. Login with it now.", + "error": "Passkey could not be set" + }, "slogan": "End-to-End encrypted appointment booking platform.", "logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock", "form": { @@ -85,9 +92,9 @@ }, "confirm": { "success": { - "title": "You’re all set!", - "description": "You’ve successfully secured your account", - "action": "Proceed" + "title": "E-Mail address confirmed!", + "description": "Now you just need to setup a passkey.", + "action": "Setup Passkey" }, "error": { "title": "Verification failed", @@ -148,7 +155,7 @@ "title": "Add Tenant", "description": "Create a site for someone to start collecting appointments.", "name": { - "description": "Must be unique on this server. Used as a subdomain {domain}", + "description": "Must be unique on this server. Cannot be changed. Used as a subdomain {domain}", "errors": { "urlFormat": "Only letters, numbers, and hyphens are allowed", "startEndDash": "Must not start or end with a dash" @@ -648,5 +655,79 @@ } } } - } + }, + "staff": { + "title": "Staff", + "roles": { + "GLOBAL_ADMIN": "Global Admin", + "TENANT_ADMIN": "Tenant Admin", + "STAFF": "Appointment Manager" + }, + "permissions": { + "agents": "Manages agents", + "channels": "Manages channels", + "absences": "Manages absences", + "settings": "Manages settings", + "staff": "Manages staff", + "appointments": "Manages appointments" + }, + "form": { + "fields": { + "language": { + "title": "Language", + "description": "Will be used in the invitation e-mail.", + "placeholder": "Select langauge" + }, + "role": { + "title": "Role", + "placeholder": "Select role" + } + } + }, + "add": { + "title": "Add Member", + "description": "Staff members manage your appointments and settings. They are not public.", + "action": "Add Member", + "success": "Member added", + "errors": { + "unknown": "Could not add member" + } + }, + "list": { + "needsAccess": "Can't see appointments", + "empty": { + "title": "No staff members yet", + "description": "Staff members manage your appointments and settings" + } + }, + "edit": { + "title": "Edit Member", + "description": "Update the members details.", + "success": "Member updated", + "error": "Could not update member.", + "action": "Edit Member" + }, + "delete": { + "title": "Remove Member", + "description": "Type {email} to remove this member. This step cannot be undone.", + "action": "Remove Member", + "success": "Member removed", + "error": "Could not remove member." + }, + "access": { + "title": "Grant access to appointments", + "description": "Do you want to grant {name} access to all client data?", + "action": "Grant access", + "loading": "Granting access ({success}/{total} clients)", + "success": "Access granted", + "error": "Granting access to all clients failed. You can retry anytime.", + "unavailable": { + "title": "You can't grant access", + "description": "Only Appointment Managers and Tenant Admins can be granted access to client data." + } + }, + "loading": "Loading staff members" + }, + "yes": "Yes", + "no": "No" } diff --git a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte index 0eb1705..d3dba98 100644 --- a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte @@ -11,7 +11,7 @@ import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down"; import EllipsisIcon from "@lucide/svelte/icons/ellipsis"; import UnplugIcon from "@lucide/svelte/icons/unplug"; - import UnknownTenantIcon from "@lucide/svelte/icons/shield-question-mark"; + import UnknownTenantIcon from "@lucide/svelte/icons/landmark"; import Loader from "@lucide/svelte/icons/loader-2"; import { cn } from "$lib/utils"; @@ -49,7 +49,16 @@ class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg" > {#if activeTenant} - + {#if activeTenant.logo} + {activeTenant.shortName} + {:else} + + {/if} {:else} {/if} @@ -91,7 +100,16 @@ class="gap-2 p-2" >
- + {#if tenant.logo} + {tenant.shortName} + {:else} + + {/if}
{tenant.shortName} diff --git a/src/lib/components/templates/empty-state/center-loading-state.svelte b/src/lib/components/templates/empty-state/center-loading-state.svelte index fc3b146..0f381eb 100644 --- a/src/lib/components/templates/empty-state/center-loading-state.svelte +++ b/src/lib/components/templates/empty-state/center-loading-state.svelte @@ -1,14 +1,19 @@
- - - + {#if label} + {label} + {:else} + + + + {/if}
diff --git a/src/lib/components/templates/list/list-item.svelte b/src/lib/components/templates/list/list-item.svelte index 6f1fa8d..5be5ccb 100644 --- a/src/lib/components/templates/list/list-item.svelte +++ b/src/lib/components/templates/list/list-item.svelte @@ -15,6 +15,7 @@ icon: Component; label: string; isDestructive?: boolean; + isHidden?: boolean; onClick: () => void; } | { type: "divider" }; @@ -63,7 +64,16 @@ {/if}
- {title} +
+ {title} + {#if badges && badges.length > 0} +
+ {#each badges as badge, index (`${badge.label}-${index}`)} + {badge.label} + {/each} +
+ {/if} +
{#if description} {#if descriptionOnClick}
{#if actions && actions.length > 0} @@ -103,7 +106,7 @@ {m["actions"]()} {#each actions as action, index (`action-${index}`)} - {#if action.type === "action"} + {#if action.type === "action" && action.isHidden !== true} + /* + Custom Changes + * added empty:hidden + */ import * as FormPrimitive from "formsnap"; import { cn, type WithoutChild } from "$lib/utils.js"; import { Text } from "../typography"; @@ -16,7 +20,7 @@ {#snippet children({ errors, errorProps })} diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte index 4c0bd99..fbc162d 100644 --- a/src/lib/components/ui/passkey/state.svelte +++ b/src/lib/components/ui/passkey/state.svelte @@ -31,7 +31,7 @@ variant="outline" onclick={onClick} class={cn( - "flex w-full justify-start", + "flex h-[40px] w-full justify-start", state === "error" ? "dark:border-destructive border-destructive text-destructive" : "", className, )} diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index 962b72d..ca1359f 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -5,6 +5,7 @@ export const ROUTES = { CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", CHECK_EMAIL: "/setup/check-email", }, + SETUP_PASSKEY: "/confirm/setup-passkey", RESEND_CONFIRMATION: "/confirm/resend", LOGIN: "/login", LOGOUT: "/logout", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index d8aad8b..617670d 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -14,6 +14,11 @@ export const ERRORS = { BOTH_PASSKEY_AND_PHRASE: "Cannot provide both passkey and passphrase", SESSION_MISSING: "No session found", }, + STAFF: { + NO_STAFF_ID: "No staff id given", + NOT_FOUND: "Staff member not found", + CANNOT_DELETE_OWN_ACCOUNT: "Users cannot delete their own account", + }, TENANTS: { NAME_EXISTS: "A tenant with this short name already exists", NO_TENANT_ID: "No tenant id given", diff --git a/src/lib/server/auth/__tests__/session-service.test.ts b/src/lib/server/auth/__tests__/session-service.test.ts index 51b5b54..559dece 100644 --- a/src/lib/server/auth/__tests__/session-service.test.ts +++ b/src/lib/server/auth/__tests__/session-service.test.ts @@ -166,43 +166,6 @@ describe("SessionService.validateTokenWithDB", () => { expect(result).toBeNull(); }); - it("should return null for unconfirmed user", async () => { - const { db } = await import("$lib/server/db"); - - const mockTokenData = { - sessionId: "session-id", - userId: "test-user-id", - exp: Math.floor(Date.now() / 1000) + 3600, - }; - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); - - const unconfirmedUser = { - ...mockUser, - confirmationState: "PENDING_CONFIRMATION" as const, - }; - - const mockQuery = vi.fn().mockResolvedValue([ - { - user: unconfirmedUser, - user_session: mockSession, - }, - ]); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockQuery, - }), - }), - }), - } as any); - - const result = await SessionService.validateTokenWithDB("valid-token"); - - expect(result).toBeNull(); - }); - it("should return null and handle errors gracefully", async () => { vi.mocked(jwtUtils.verifyAccessToken).mockRejectedValue(new Error("JWT error")); diff --git a/src/lib/server/auth/session-service.ts b/src/lib/server/auth/session-service.ts index 1c11ff1..efec715 100644 --- a/src/lib/server/auth/session-service.ts +++ b/src/lib/server/auth/session-service.ts @@ -56,10 +56,6 @@ export class SessionService { throw new ValidationError("User account is inactive"); } - if (userData.confirmationState !== "ACCESS_GRANTED") { - throw new ValidationError("User account is not fully confirmed"); - } - // Create session entry first to get the ID const sessionData: InsertUserSession = { userId: userData.id, @@ -143,11 +139,6 @@ export class SessionService { return null; } - if (session.user.confirmationState !== "ACCESS_GRANTED") { - logger.debug("User account is not fully confirmed"); - return null; - } - // Update last used time await db .update(userSession) diff --git a/src/lib/server/services/__tests__/staff-service.test.ts b/src/lib/server/services/__tests__/staff-service.test.ts index f2d9f47..36028ba 100644 --- a/src/lib/server/services/__tests__/staff-service.test.ts +++ b/src/lib/server/services/__tests__/staff-service.test.ts @@ -54,18 +54,30 @@ describe("StaffService", () => { it("should return staff members for a tenant", async () => { const { centralDb } = await import("../../db"); - const mockSelectBuilder = { + // Mock the first select query (for users) + const mockSelectBuilder1 = { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([mockStaffMember]), }; - vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + // Mock the second select query (for invites) + const mockSelectBuilder2 = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }; + + // Mock centralDb.select to return different builders for each call + vi.mocked(centralDb.select) + .mockReturnValueOnce(mockSelectBuilder1 as any) + .mockReturnValueOnce(mockSelectBuilder2 as any); const result = await StaffService.getStaffMembers("tenant-123"); - expect(centralDb.select).toHaveBeenCalled(); - expect(mockSelectBuilder.from).toHaveBeenCalled(); - expect(mockSelectBuilder.where).toHaveBeenCalled(); + expect(centralDb.select).toHaveBeenCalledTimes(2); + expect(mockSelectBuilder1.from).toHaveBeenCalled(); + expect(mockSelectBuilder1.where).toHaveBeenCalled(); + expect(mockSelectBuilder2.from).toHaveBeenCalled(); + expect(mockSelectBuilder2.where).toHaveBeenCalled(); expect(result).toEqual([mockStaffMember]); }); @@ -175,7 +187,7 @@ describe("StaffService", () => { vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); - // Mock UserService.deleteUser + // Mock UserService.deleteUser - wichtig: das muss vor der transaction() mock sein const mockUserDeletionResult = { success: true, deletedUser: { @@ -215,6 +227,9 @@ describe("StaffService", () => { }), }), }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue({ count: 1 }), + }), }; return await callback(tx); }); diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts index 8732357..7b3d0d9 100644 --- a/src/lib/server/services/invite-service.ts +++ b/src/lib/server/services/invite-service.ts @@ -7,6 +7,7 @@ import { } from "$lib/server/db/central-schema"; import { eq, and, lt } from "drizzle-orm"; import { UniversalLogger } from "$lib/logger"; +import { InternalError } from "../utils/errors"; const logger = new UniversalLogger().setContext("InviteService"); @@ -59,7 +60,7 @@ export class InviteService { return createdInvite; } catch (error) { logger.error("Failed to create user invitation", { error, email, tenantId, role }); - throw new Error(`Failed to create invitation: ${error}`); + throw new InternalError(`Failed to create invitation: ${error}`); } } @@ -142,7 +143,7 @@ export class InviteService { return updatedInvite; } catch (error) { logger.error("Failed to mark invitation as used", { error, inviteCode, createdUserId }); - throw new Error(`Failed to update invitation: ${error}`); + throw new InternalError(`Failed to update invitation: ${error}`); } } diff --git a/src/lib/server/services/staff-service.ts b/src/lib/server/services/staff-service.ts index 7883d6f..64f52d5 100644 --- a/src/lib/server/services/staff-service.ts +++ b/src/lib/server/services/staff-service.ts @@ -1,5 +1,5 @@ import { centralDb, getTenantDb } from "../db"; -import { user } from "../db/central-schema"; +import { user, userInvite } from "../db/central-schema"; import { clientTunnelStaffKeyShare } from "../db/tenant-schema"; import { eq, and, or } from "drizzle-orm"; import { NotFoundError, ValidationError, InternalError } from "../utils/errors"; @@ -56,7 +56,7 @@ export class StaffService { logger.debug("Fetching staff members", { tenantId }); try { - const staff: StaffMember[] = await centralDb + let staff: StaffMember[] = await centralDb .select({ id: user.id, email: user.email, @@ -71,12 +71,37 @@ export class StaffService { .from(user) .where(eq(user.tenantId, tenantId)); + const invitedStaff = await centralDb + .select({ + id: userInvite.id, + email: userInvite.email, + name: userInvite.name, + role: userInvite.role, + createdAt: userInvite.createdAt, + }) + .from(userInvite) + .where(and(eq(userInvite.tenantId, tenantId), eq(userInvite.used, false))); + + staff = staff.concat( + invitedStaff.map((invite) => ({ + id: invite.id, + email: invite.email, + name: invite.name, + role: invite.role, + isActive: null, + confirmationState: "INVITED" as const, + createdAt: invite.createdAt, + updatedAt: null, + lastLoginAt: null, + })), + ); + logger.debug("Staff members fetched successfully", { tenantId, count: staff.length, }); - return staff; + return staff.sort((a, b) => a.name.localeCompare(b.name)); } catch (error) { logger.error("Failed to fetch staff members", { tenantId, @@ -160,6 +185,7 @@ export class StaffService { tenantId: string, staffId: string, currentUserId?: string, + confirmationState?: "INVITED" | "CONFIRMED" | "ACCESS_GRANTED", ): Promise { logger.debug("Deleting staff member", { tenantId, staffId, currentUserId }); @@ -189,76 +215,96 @@ export class StaffService { // Use transaction to ensure all related data is deleted consistently const result = await centralDb.transaction(async (tx) => { // First, verify the user exists and belongs to this tenant - const userToDelete = await tx - .select({ - id: user.id, - email: user.email, - name: user.name, - role: user.role, - tenantId: user.tenantId, - }) - .from(user) - .where(and(eq(user.id, staffId), eq(user.tenantId, tenantId))) - .limit(1); + if (confirmationState !== "INVITED") { + const userToDelete = await tx + .select({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + tenantId: user.tenantId, + }) + .from(user) + .where(and(eq(user.id, staffId), eq(user.tenantId, tenantId))) + .limit(1); - if (userToDelete.length === 0) { - throw new NotFoundError("Staff member not found in this tenant"); - } + if (userToDelete.length === 0) { + throw new NotFoundError("Staff member not found in this tenant"); + } - // Use UserService to delete central database data (user + passkeys) first - // This ensures all validation logic is applied before deleting tenant data - const userDeletionResult = await UserService.deleteUser(staffId, tx); + // Use UserService to delete central database data (user + passkeys) first + // This ensures all validation logic is applied before deleting tenant data + const userDeletionResult = await UserService.deleteUser(staffId, tx); - // Delete tenant-specific data (client tunnel key shares) after user deletion succeeds - let deletedKeySharesCount = 0; - try { + // Remove old invites of user if any exist + const deletedInvites = await tx + .delete(userInvite) + .where(eq(userInvite.email, userToDelete[0].email)); + logger.debug("Deleted user invites", { + staffId, + tenantId, + deletedCount: deletedInvites.count || 0, + }); + + // Delete tenant-specific data (client tunnel key shares) after user deletion succeeds const tenantDb = await getTenantDb(tenantId); const keyShareDeletionResult = await tenantDb .delete(clientTunnelStaffKeyShare) .where(eq(clientTunnelStaffKeyShare.userId, staffId)); - deletedKeySharesCount = keyShareDeletionResult?.count || 0; + const deletedKeySharesCount = keyShareDeletionResult?.count || 0; logger.debug("Deleted client tunnel key shares", { staffId, tenantId, - deletedCount: deletedKeySharesCount, + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, + deletedKeySharesCount, }); - } catch (error) { - logger.warn("Failed to delete client tunnel key shares", { + + // Combine results for staff-specific response format + const staffDeletionResult: StaffDeletionResult = { + success: userDeletionResult.success, + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, + deletedKeySharesCount, + }; + + logger.info("Staff member deleted successfully", { staffId, tenantId, - error: String(error), + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, + deletedKeySharesCount, + }); + + return staffDeletionResult; + } else { + const deletedInvites = await tx.delete(userInvite).where(eq(userInvite.id, staffId)); + logger.debug("Deleted user invites", { + staffId, + tenantId, + deletedCount: deletedInvites.count || 0, }); // Note: User deletion already succeeded, key share deletion is auxiliary + return { + success: true, + deletedUser: { + id: staffId, + email: "", + name: "", + role: "STAFF" as const, + }, + deletedPasskeysCount: 0, + deletedKeySharesCount: 0, + }; } - - // Combine results for staff-specific response format - const staffDeletionResult: StaffDeletionResult = { - success: userDeletionResult.success, - deletedUser: userDeletionResult.deletedUser, - deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, - deletedKeySharesCount, - }; - - logger.info("Staff member deleted successfully", { - staffId, - tenantId, - deletedUser: userDeletionResult.deletedUser, - deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, - deletedKeySharesCount, - }); - - return staffDeletionResult; }); - - // Setup state validation is handled by UserService.deleteUser return result; } catch (error) { if (error instanceof ValidationError || error instanceof NotFoundError) { throw error; } - logger.error("Failed to delete staff member", { tenantId, staffId, diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index c8df42c..c589981 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -255,12 +255,7 @@ export class TenantAdminService { }); if (updateData.shortName) { - const shortNameValidation = tenantCreationSchema.shape.shortName.safeParse( - updateData.shortName, - ); - if (!shortNameValidation.success) { - throw new ValidationError("Invalid shortName format"); - } + throw new ValidationError("Shortname cannot be changed"); } try { diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index dc569fc..4b36b73 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -16,6 +16,7 @@ import { import { sendConfirmationEmail } from "../email/email-service"; import type { SelectTenant } from "../db/central-schema"; import { TenantAdminService } from "./tenant-admin-service"; +import { InviteService } from "./invite-service"; import type { PgTransaction } from "drizzle-orm/pg-core"; import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; @@ -254,20 +255,40 @@ export class UserService { * Confirm and activate user after confirmation link was clicked * @param linkToken - The token from the link */ - static async confirm( - linkToken: string, - ): Promise<{ recoveryPassphrase?: string; isSetup: boolean }> { + static async confirm(linkToken: string): Promise<{ + recoveryPassphrase?: string; + isSetup: boolean; + id: string; + email: string; + name?: string; + language?: string; + }> { const log = logger.setContext("UserService"); log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." }); try { - // First, get the user data to check for recovery passphrase + // First, get the user data to check for recovery pass + // phrase + + let resultData: + | { + id: string; + name?: string; + language?: string; + recoveryPassphrase: string | null; + tenantId: string | null; + role: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; + email: string; + } + | undefined = undefined; + const userData = await centralDb .select({ id: centralSchema.user.id, recoveryPassphrase: centralSchema.user.recoveryPassphrase, tenantId: centralSchema.user.tenantId, role: centralSchema.user.role, + email: centralSchema.user.email, }) .from(centralSchema.user) .where( @@ -279,17 +300,63 @@ export class UserService { .limit(1); if (userData.length === 0) { - log.warn("User confirmation failed: Invalid or expired token", { - token: linkToken.substring(0, 8) + "...", - }); - throw new NotFoundError("Invalid or timed-out token"); + 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, new Date()), + ), + ) + .limit(1); + + 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, + }); + + const adminService = await TenantAdminService.getTenantById(resultData.tenantId!); + adminService.validateSetupState(); + } + } else { + resultData = userData[0]; } - const user = userData[0]; - // Check if this is the first tenant admin for the tenant - const shouldGrantAccess = userData[0].role === "GLOBAL_ADMIN"; // Global admins always get ACCESS_GRANTED + const shouldGrantAccess = resultData.role === "GLOBAL_ADMIN"; // Global admins always get ACCESS_GRANTED const confirmationState = shouldGrantAccess ? "ACCESS_GRANTED" : "CONFIRMED"; + // Update the user to confirmed and active, and clear the recovery passphrase const result = await centralDb .update(centralSchema.user) @@ -298,7 +365,7 @@ export class UserService { isActive: true, recoveryPassphrase: null, // Clear it after showing it once }) - .where(eq(centralSchema.user.id, user.id)) + .where(eq(centralSchema.user.id, resultData.id)) .execute(); if (result.count != 1) { @@ -308,17 +375,18 @@ export class UserService { const countResult = await centralDb.select({ count: count() }).from(centralSchema.user); log.debug("User account confirmed successfully", { - userId: user.id, + userId: resultData.id, token: linkToken.substring(0, 8) + "...", - hadRecoveryPassphrase: !!user.recoveryPassphrase, + hadRecoveryPassphrase: !!resultData.recoveryPassphrase, }); - const adminService = await TenantAdminService.getTenantById(user.tenantId!); - adminService.validateSetupState(); - return { - recoveryPassphrase: user.recoveryPassphrase || undefined, + recoveryPassphrase: resultData.recoveryPassphrase || undefined, isSetup: countResult[0].count === 1, + id: resultData.id, + email: resultData.email, + name: resultData.name, + language: resultData.language, }; } catch (error) { if (error instanceof NotFoundError) throw error; @@ -380,7 +448,7 @@ export class UserService { */ static async getUserByEmail(email: string) { const log = logger.setContext("UserService"); - log.debug("Getting admin by email", { email }); + log.debug("Getting user by email", { email }); try { const result = await centralDb diff --git a/src/lib/types/tenant.ts b/src/lib/types/tenant.ts index 8475ce3..c2a965d 100644 --- a/src/lib/types/tenant.ts +++ b/src/lib/types/tenant.ts @@ -1,6 +1,8 @@ import type { SelectTenant } from "$lib/server/db/central-schema"; -export type TTenant = Pick; +export type TTenant = Pick & { + logo: string | null; +}; export type TTenantSettings = Omit & { languages: string[]; diff --git a/src/lib/types/users.ts b/src/lib/types/users.ts new file mode 100644 index 0000000..991cf4f --- /dev/null +++ b/src/lib/types/users.ts @@ -0,0 +1,14 @@ +import type { SelectUser } from "$lib/server/db/central-schema"; + +export type TStaff = Pick< + SelectUser, + | "id" + | "email" + | "name" + | "role" + | "isActive" + | "createdAt" + | "updatedAt" + | "lastLoginAt" + | "confirmationState" +>; diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index b1dcc48..0805aac 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -3,8 +3,10 @@ import type { PageServerLoad } from "./$types"; const log = logger.setContext(import.meta.filename); +type Error = { success: false; isSetup: boolean }; +type Success = { success: boolean; isSetup: boolean; id: string; email: string }; export const load: PageServerLoad = async (event) => { - const confirmation: Promise<{ success: boolean; isSetup: boolean }> = event + const confirmation: Promise = event .fetch("/api/auth/confirm", { method: "POST", headers: { @@ -16,11 +18,10 @@ export const load: PageServerLoad = async (event) => { const success = resp.status < 400; try { const body = await resp.json(); - const isSetup = body.isSetup ?? false; - return { success, isSetup }; + return { success, isSetup: body.isSetup ?? false, id: body.id, email: body.email }; } catch (error) { log.error("Failed to parse confirm token response", { error }); - return { success, isSetup: false }; + return { success: false, isSetup: false }; } }); diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index 9b2326c..768f6eb 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -8,6 +8,7 @@ import Ban from "@lucide/svelte/icons/ban"; import Check from "@lucide/svelte/icons/check"; import { ROUTES } from "$lib/const/routes"; + import { goto } from "$app/navigation"; const { data } = $props(); @@ -60,7 +61,14 @@ {m["setup.confirm.success.action"]()} {:else} - {/if} diff --git a/src/routes/(pages)/confirm/setup-passkey/+page.server.ts b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts new file mode 100644 index 0000000..44dd318 --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts @@ -0,0 +1,67 @@ +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; +import logger from "$lib/logger"; +import { base64ToArrayBuffer, getCounterFromAuthenticatorData } from "$lib/utils/passkey"; + +const log = logger.setContext(import.meta.filename); + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)), + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + log.error("Setup passkey form is not valid", { errors: form.errors }); + return fail(400, { + form, + }); + } + + const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); + const counter = getCounterFromAuthenticatorData(authenticatorData); + const resp = await event.fetch(`/api/auth/register/${form.data.userId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: form.data.id, + email: form.data.email, + passkey: { + id: form.data.id, + publicKey: form.data.publicKeyBase64, + deviceName: "Unkown Device", + counter, + response: { + authenticatorData: form.data.authenticatorDataBase64, + }, + }, + counter, + }), + }); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse setup passkey error response", { error: e }); + } + return fail(400, { + form: { ...form, data: { ...form.data } }, + error, + }); + } + }, +}; diff --git a/src/routes/(pages)/confirm/setup-passkey/+page.svelte b/src/routes/(pages)/confirm/setup-passkey/+page.svelte new file mode 100644 index 0000000..892323d --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["setupPasskey.title"]()} - OpenReception + + + + + + + {m["setupPasskey.title"]()} + + + {m["setupPasskey.description"]()} + + + + + + + + {m["setupPasskey.action"]()} + + + + diff --git a/src/routes/(pages)/confirm/setup-passkey/schema.ts b/src/routes/(pages)/confirm/setup-passkey/schema.ts new file mode 100644 index 0000000..23ea4d0 --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/schema.ts @@ -0,0 +1,12 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + userId: z.string().min(3), + id: z.string().min(3), + email: z.string().email(m["form.errors.email"]()), + publicKeyBase64: z.string().base64(), + authenticatorDataBase64: z.string().base64(), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte new file mode 100644 index 0000000..483a16d --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -0,0 +1,163 @@ + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + +
+ + + + + + +
+
diff --git a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts index 0f61afd..d3de63a 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts +++ b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts @@ -1,5 +1,5 @@ -import DeleteTenantForm from "./delete-agent-form.svelte"; +import DeleteAgentForm from "./delete-agent-form.svelte"; -export { DeleteTenantForm }; +export { DeleteAgentForm }; export { formSchema } from "./schema"; export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts index f9b02ef..06e3aec 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts +++ b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts @@ -1,5 +1,5 @@ -import EditTenantForm from "./edit-agent-form.svelte"; +import EditAgentForm from "./edit-agent-form.svelte"; -export { EditTenantForm }; +export { EditAgentForm }; export { formSchema } from "./schema"; export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index 94e5e7b..e6acb56 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -9,6 +9,7 @@ import { Button } from "$lib/components/ui/button"; import { ResponsiveDialog, closeDialog, openDialog } from "$lib/components/ui/responsive-dialog"; import { ROUTES } from "$lib/const/routes"; + import { agents } from "$lib/stores/agents"; import { type TAgent } from "$lib/types/agent"; import EditIcon from "@lucide/svelte/icons/pencil"; import PlusIcon from "@lucide/svelte/icons/plus"; @@ -16,9 +17,8 @@ import UnknownItemIcon from "@lucide/svelte/icons/user-star"; import { onMount } from "svelte"; import { AddAgentForm } from "./(components)/add-agent-form"; - import DeleteAgentForm from "./(components)/delete-agent-form/delete-agent-form.svelte"; - import EditAgentForm from "./(components)/edit-agent-form/edit-agent-form.svelte"; - import { agents } from "$lib/stores/agents"; + import { DeleteAgentForm } from "./(components)/delete-agent-form"; + import { EditAgentForm } from "./(components)/edit-agent-form"; const { data } = $props(); let curItem: TAgent | null = $state(null); diff --git a/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/add-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/add-staff-member-form.svelte new file mode 100644 index 0000000..ea8b4c9 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/add-staff-member-form.svelte @@ -0,0 +1,136 @@ + + + + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["staff.form.fields.language.title"]()} + ($formData.language = v)} + > + + {$formData.language + ? translatedLocales[$formData.language as keyof typeof translatedLocales] + : m["staff.form.fields.language.placeholder"]()} + + + {#each supportedLocales as locale (locale)} + + {translatedLocales[locale as keyof typeof translatedLocales]} + + {/each} + + + + {m["staff.form.fields.language.description"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["staff.form.fields.role.title"]()} + ($formData.role = v as TStaff["role"])} + > + + {@const value = roles.find((r) => r.value === $formData.role)?.label} + {value ? value : m["staff.form.fields.role.placeholder"]()} + + + {#each availableRolesForUser as role (role.value)} + + {role.label} + + {/each} + + + + + + {/snippet} + + + +
+ + {m["staff.add.action"]()} + +
+
diff --git a/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts new file mode 100644 index 0000000..6196ea5 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts @@ -0,0 +1,5 @@ +import AddStaffMemberForm from "./add-staff-member-form.svelte"; + +export { AddStaffMemberForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts new file mode 100644 index 0000000..57f766c --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts @@ -0,0 +1,11 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()), + email: z.string().email(m["form.errors.email"]()), + language: z.string().default("en"), + role: z.enum(["STAFF", "TENANT_ADMIN", "GLOBAL_ADMIN"]).default("STAFF"), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte new file mode 100644 index 0000000..8ee7542 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte @@ -0,0 +1,89 @@ + + + + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + +
+ + {m["staff.delete.action"]()} + +
+
+ +{#snippet inlineCode(value: string | number)} + {value} +{/snippet} diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts new file mode 100644 index 0000000..c336c54 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts @@ -0,0 +1,5 @@ +import DeleteStaffMemberForm from "./delete-staff-member-form.svelte"; + +export { DeleteStaffMemberForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts new file mode 100644 index 0000000..3c286a8 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts @@ -0,0 +1,10 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + id: z.string(), + email: z.string().email(m["form.errors.email"]()), + confirmationState: z.enum(["INVITED", "CONFIRMED", "ACCESS_GRANTED"]), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte new file mode 100644 index 0000000..d11175d --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte @@ -0,0 +1,110 @@ + + + + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["staff.form.fields.role.title"]()} + ($formData.role = v as TStaff["role"])} + > + + {@const value = roles.find((r) => r.value === $formData.role)?.label} + {value ? value : m["staff.form.fields.role.placeholder"]()} + + + {#each availableRolesForUser as role (role.value)} + + {role.label} + + {/each} + + + + + + {/snippet} + + + + + +
+ + {m["staff.edit.action"]()} + +
+
diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts new file mode 100644 index 0000000..af29d8f --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts @@ -0,0 +1,5 @@ +import EditStaffMemberForm from "./edit-staff-member-form.svelte"; + +export { EditStaffMemberForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts new file mode 100644 index 0000000..379b148 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts @@ -0,0 +1,11 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + id: z.string(), + name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()), + email: z.string().email(m["form.errors.email"]()), + role: z.enum(["STAFF", "TENANT_ADMIN", "GLOBAL_ADMIN"]).default("STAFF"), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte new file mode 100644 index 0000000..8a1ec95 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte @@ -0,0 +1,65 @@ + + +
+ + + + {#if myUserRole === "GLOBAL_ADMIN"} + + {:else} +
+ {#if isSubmitting} + + {/if} + +
+ {/if} +
+ +{#snippet inlineCode(value: string | number)} + {value} +{/snippet} diff --git a/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts new file mode 100644 index 0000000..9715767 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts @@ -0,0 +1,3 @@ +import GrantAccessForm from "./grant-access-form.svelte"; + +export { GrantAccessForm }; diff --git a/src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte b/src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte new file mode 100644 index 0000000..abf988b --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte @@ -0,0 +1,27 @@ + + +
    + {#each permissions as permission (permission.label)} +
  • + {#if permission.roles.includes(role)} + + {:else} + + {/if} +
    {permission.label}
    + {#if permission.roles.includes(role)} + {m.yes()} + {:else} + {m.no()} + {/if} +
  • + {/each} +
diff --git a/src/routes/(pages)/dashboard/staff/(components)/utils.ts b/src/routes/(pages)/dashboard/staff/(components)/utils.ts new file mode 100644 index 0000000..c1beb3f --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/utils.ts @@ -0,0 +1,17 @@ +import { m } from "$i18n/messages"; +import type { TStaff } from "$lib/types/users"; + +export const roles = [ + { label: m["staff.roles.STAFF"](), value: "STAFF" }, + { label: m["staff.roles.TENANT_ADMIN"](), value: "TENANT_ADMIN" }, + { label: m["staff.roles.GLOBAL_ADMIN"](), value: "GLOBAL_ADMIN" }, +] as const; + +export const permissions: { label: string; roles: TStaff["role"][] }[] = [ + { label: m["staff.permissions.agents"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.channels"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.absences"](), roles: ["STAFF", "TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.settings"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.staff"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.appointments"](), roles: ["STAFF"] }, +]; diff --git a/src/routes/(pages)/dashboard/staff/+page.server.ts b/src/routes/(pages)/dashboard/staff/+page.server.ts new file mode 100644 index 0000000..e3ac908 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/+page.server.ts @@ -0,0 +1,188 @@ +import { ROUTES } from "$lib/const/routes.js"; +import logger from "$lib/logger"; +import type { TStaff } from "$lib/types/users.js"; +import { fail, redirect, type Actions } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import { formSchema as addFormSchema } from "./(components)/add-staff-member-form"; +import { formSchema as deleteFormSchema } from "./(components)/delete-staff-member-form"; +import { formSchema as editFormSchema } from "./(components)/edit-staff-member-form"; + +const log = logger.setContext(import.meta.filename); + +export const load = async (event) => { + if (!event.locals.user?.tenantId) { + log.error("User trying to access staff, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const list = event + .fetch(`/api/tenants/${event.locals.user?.tenantId}/staff`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + // Logout if session expired + if (res.status === 401) { + redirect(302, ROUTES.LOGOUT); + } + + try { + const body = await res.json(); + return body.staff as TStaff[]; + } catch (error) { + log.error("Failed to parse staff members response", { error }); + return []; + } + }); + + return { + streamed: { + list, + }, + }; +}; + +export const actions: Actions = { + add: async (event) => { + const form = await superValidate(event, zod(addFormSchema)); + + if (!form.valid) { + log.error("Add staff member form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to add a staff member, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch(`/api/auth/invite`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + name: form.data.name, + email: form.data.email, + language: form.data.language, + role: form.data.role, + tenantId: event.locals.user?.tenantId, + }), + }); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse add staff member error response", { error: e }); + } + return fail(400, { + form, + error, + }); + } + }, + edit: async (event) => { + const form = await superValidate(event, zod(editFormSchema)); + + if (!form.valid) { + log.error("Edit staff member form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to edit a staff member, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/staff/${form.data.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + name: form.data.name, + email: form.data.email, + role: form.data.role, + }), + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse edit staff member error response", { error: e }); + } + return fail(400, { + form: { ...form, data: { ...form.data } }, + error, + }); + } + }, + delete: async (event) => { + const form = await superValidate(event, zod(deleteFormSchema)); + + if (!form.valid) { + log.error("Delete staff member form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to delete a staff member, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/staff/${form.data.id}?confirmationState=${form.data.confirmationState}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse delete staff member error response", { error: e }); + } + return fail(400, { + form, + error, + }); + } + }, +}; diff --git a/src/routes/(pages)/dashboard/staff/+page.svelte b/src/routes/(pages)/dashboard/staff/+page.svelte index c745be6..a11c7a7 100644 --- a/src/routes/(pages)/dashboard/staff/+page.svelte +++ b/src/routes/(pages)/dashboard/staff/+page.svelte @@ -1,9 +1,44 @@ + + {m["staff.title"]()} - OpenReception + + +> + + {#await data.streamed.list as TStaff[]} + + {:then items} +
+ + {#snippet triggerLabel()} + {m["staff.add.title"]()} + {/snippet} + { + invalidate(ROUTES.DASHBOARD.STAFF); + closeDialog("add"); + }} + /> + + + {#if items.length > 0} + + {#each items as item (item.id)} + {@const role = roles.find((r) => r.value === item.role)?.label || item.role} + { + curItem = item; + openDialog("edit"); + }, + }, + { + type: "action", + icon: AccessIcon, + label: m["staff.access.action"](), + isHidden: item.confirmationState === "ACCESS_GRANTED", + onClick: () => { + curItem = item; + openDialog("access"); + }, + }, + { + type: "divider", + }, + { + type: "action", + icon: DeleteIcon, + label: m["delete"](), + isDestructive: true, + onClick: () => { + curItem = item; + openDialog("delete"); + }, + }, + ]} + badges={item.role !== "GLOBAL_ADMIN" && item.confirmationState !== "ACCESS_GRANTED" + ? [{ label: m["staff.list.needsAccess"](), variant: "outline" }] + : undefined} + > + {item.name} + + {/each} + + + {#if curItem} + { + closeDialog("edit"); + curItem = null; + invalidate(ROUTES.DASHBOARD.STAFF); + }} + /> + {/if} + + + {#if curItem} + { + closeDialog("delete"); + curItem = null; + invalidate(ROUTES.DASHBOARD.STAFF); + }} + /> + {/if} + + + {#if curItem} + { + closeDialog("access"); + curItem = null; + invalidate(ROUTES.DASHBOARD.STAFF); + }} + /> + {/if} + + {:else} +
+ + +
+ {/if} +
+ {/await} +
+ diff --git a/src/routes/(pages)/dashboard/tenants/+page.svelte b/src/routes/(pages)/dashboard/tenants/+page.svelte index 04daf10..ce5521b 100644 --- a/src/routes/(pages)/dashboard/tenants/+page.svelte +++ b/src/routes/(pages)/dashboard/tenants/+page.svelte @@ -11,10 +11,10 @@ import { ROUTES } from "$lib/const/routes"; import { tenants as tenantsStore } from "$lib/stores/tenants"; import { type TTenant } from "$lib/types/tenant"; - import EditIcon from "@lucide/svelte/icons/pencil"; + // import EditIcon from "@lucide/svelte/icons/pencil"; import SelectIcon from "@lucide/svelte/icons/plug-zap"; import PlusIcon from "@lucide/svelte/icons/plus"; - import UnknownItemIcon from "@lucide/svelte/icons/ticket-x"; + import UnknownItemIcon from "@lucide/svelte/icons/landmark"; import DeleteIcon from "@lucide/svelte/icons/trash-2"; import { onMount } from "svelte"; import { AddTenantForm } from "./(components)/add-tenant-form"; @@ -72,6 +72,7 @@ {#each items as item (item.id)} @@ -81,15 +82,16 @@ "noopener,noreferrer", )} actions={[ - { - type: "action", - icon: EditIcon, - label: m["edit"](), - onClick: () => { - curItem = item; - openDialog("edit"); - }, - }, + // Editiing will we re-introduced, when we support custom domains + // { + // type: "action", + // icon: EditIcon, + // label: m["edit"](), + // onClick: () => { + // curItem = item; + // openDialog("edit"); + // }, + // }, { type: "action", icon: SelectIcon, @@ -126,6 +128,7 @@ entity={curItem} done={() => { closeDialog("edit"); + tenantsStore.reload(); curItem = null; invalidate(ROUTES.DASHBOARD.TENANTS); }} diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts index c512b11..85f9428 100644 --- a/src/routes/(pages)/login/+page.server.ts +++ b/src/routes/(pages)/login/+page.server.ts @@ -53,6 +53,7 @@ export const actions: Actions = { "Content-Type": "application/json", }, body: JSON.stringify(body), + credentials: "same-origin", }); let user: TUser | null = null; diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index b1f6c05..89740f5 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -228,7 +228,7 @@ - + {m["login.or"]()} {/each} diff --git a/src/lib/const/tenants.ts b/src/lib/const/tenants.ts new file mode 100644 index 0000000..4ce870d --- /dev/null +++ b/src/lib/const/tenants.ts @@ -0,0 +1 @@ +export const SETUP_STATES_LIST = ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]; diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 966ce21..7e79912 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -28,6 +28,7 @@ export const confirmationStateEnum = pgEnum("confirmation_state", [ "ACCESS_GRANTED", ]); +// Warning: Duplication in src/lib/const/tenants.ts export const tenantSetupState = pgEnum("setup_state", [ "SETTINGS", // Settings need to be saved initially "AGENTS", // At least one agent needs to be created diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index fd52317..f0d5466 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -284,7 +284,7 @@ export class TenantAdminService { /** * Set the setup state of the tenant */ - async setSetupState(setupState: "SETTINGS" | "AGENTS" | "CHANNELS" | "STAFF" | "READY") { + async setSetupState(setupState: centralSchema.SelectTenant["setupState"]) { const log = logger.setContext("TenantAdminService"); log.debug("Setting tenant setup state", { tenantId: this.tenantId, @@ -382,7 +382,7 @@ export class TenantAdminService { .from(agent) .where(eq(agent.archived, false)); - if (agentCount.length === 0) { + if (agentCount[0].count === 0) { newState = "AGENTS"; } else { // Check for channels @@ -390,7 +390,7 @@ export class TenantAdminService { .select({ count: count() }) .from(channel) .where(eq(channel.archived, false)); - if (channelCount.length === 0) { + if (channelCount[0].count === 0) { newState = "CHANNELS"; } else { // Check for staff members @@ -406,8 +406,7 @@ export class TenantAdminService { ), ), ); - - if (staffCount.length === 0) { + if (staffCount[0].count === 0) { newState = "STAFF"; } else { newState = "READY"; diff --git a/src/lib/stores/tenants.ts b/src/lib/stores/tenants.ts index 130157e..e58acfa 100644 --- a/src/lib/stores/tenants.ts +++ b/src/lib/stores/tenants.ts @@ -2,7 +2,7 @@ import logger from "$lib/logger"; import type { TTenant } from "$lib/types/tenant"; import { changeTenantUsingApi } from "$lib/utils/tenants"; import { toast } from "svelte-sonner"; -import { writable } from "svelte/store"; +import { get, writable } from "svelte/store"; import { auth } from "./auth"; import { m } from "$i18n/messages"; import { goto } from "$app/navigation"; @@ -71,13 +71,37 @@ const createTenantsStore = () => { const body = await res.json(); const tenants = body.tenants ?? ([] as TTenant[]); const newCurrentTenantId = auth.getTenant(); + const newCurrentTenant = tenants.find((t: TTenant) => t.id === newCurrentTenantId) || null; + + if (newCurrentTenant?.setupState !== "READY") { + toast.info(m["dashboard.onboarding.notification.ongoing.title"](), { + duration: 4000, + description: m["dashboard.onboarding.notification.ongoing.description"](), + action: { + label: m["dashboard.onboarding.notification.ongoing.action"](), + onClick: () => goto(ROUTES.DASHBOARD.MAIN), + }, + }); + } else { + const curState = get(store); + if (curState && curState.currentTenant?.setupState !== "READY") { + toast.info(m["dashboard.onboarding.notification.done.title"](), { + duration: 4000, + description: m["dashboard.onboarding.notification.done.description"](), + action: { + label: m["dashboard.onboarding.notification.done.action"](), + onClick: () => goto(ROUTES.MAIN), + }, + }); + } + } store.update((state) => { return { ...state, tenants, isLoading: false, - currentTenant: tenants.find((t: TTenant) => t.id === newCurrentTenantId) || null, + currentTenant: newCurrentTenant, }; }); } catch (error) { diff --git a/src/lib/utils/tenants.ts b/src/lib/utils/tenants.ts index 5d0a70f..dbb58ce 100644 --- a/src/lib/utils/tenants.ts +++ b/src/lib/utils/tenants.ts @@ -1,3 +1,6 @@ +import { SETUP_STATES_LIST } from "$lib/const/tenants"; +import type { TTenant } from "$lib/types/tenant"; + export const changeTenantUsingApi = async (tenantId: string | null): Promise => { const response = await fetch("/api/admin/tenant", { method: "POST", @@ -14,3 +17,9 @@ export const changeTenantUsingApi = async (tenantId: string | null): Promise { + const curStateIndex = SETUP_STATES_LIST.indexOf(tenant.setupState); + const stepIndex = SETUP_STATES_LIST.indexOf(step); + return curStateIndex > stepIndex; +}; diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte index 03fd076..0c86543 100644 --- a/src/routes/(pages)/dashboard/+page.svelte +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -12,10 +12,17 @@ import { auth } from "$lib/stores/auth"; import { sidebar } from "$lib/stores/sidebar"; import { tenants } from "$lib/stores/tenants"; + import { isSetupStateDone } from "$lib/utils/tenants"; import MenuPositionIcon from "@lucide/svelte/icons/corner-left-up"; import CloseIcon from "@lucide/svelte/icons/x"; + + const tenant = $derived($tenants.currentTenant); + + {m["dashboard.title"]()} - OpenReception + + { - e.preventDefault(); + onClick: () => { goto(ROUTES.DASHBOARD.TENANTS, { state: { action: "add" } }); }, - href: ROUTES.DASHBOARD.TENANTS, }, ], isDone: false, @@ -86,6 +91,69 @@ {/if} {/if} + {#if $auth.user && ["GLOBAL_ADMIN", "TENANT_ADMIN"].includes($auth.user?.role) && tenant && tenant.setupState !== "READY"} + { + goto(ROUTES.DASHBOARD.SETTINGS); + }, + }, + ], + isDone: isSetupStateDone(tenant, "SETTINGS"), + }, + { + title: m["dashboard.onboarding.sections.agents.title"](), + description: m["dashboard.onboarding.sections.agents.description"](), + actions: [ + { + label: m["dashboard.onboarding.sections.agents.action"](), + onClick: () => { + goto(ROUTES.DASHBOARD.AGENTS, { state: { action: "add" } }); + }, + }, + ], + isDone: isSetupStateDone(tenant, "AGENTS"), + isLaterStep: !isSetupStateDone(tenant, "SETTINGS"), + }, + { + title: m["dashboard.onboarding.sections.channels.title"](), + description: m["dashboard.onboarding.sections.channels.description"](), + actions: [ + { + label: m["dashboard.onboarding.sections.channels.action"](), + onClick: () => { + goto(ROUTES.DASHBOARD.CHANNELS, { state: { action: "add" } }); + }, + }, + ], + isDone: isSetupStateDone(tenant, "CHANNELS"), + isLaterStep: !isSetupStateDone(tenant, "AGENTS"), + }, + { + title: m["dashboard.onboarding.sections.staff.title"](), + description: m["dashboard.onboarding.sections.staff.description"](), + actions: [ + { + label: m["dashboard.onboarding.sections.staff.action"](), + onClick: () => { + goto(ROUTES.DASHBOARD.STAFF, { state: { action: "add" } }); + }, + }, + ], + isDone: isSetupStateDone(tenant, "STAFF"), + isLaterStep: !isSetupStateDone(tenant, "CHANNELS"), + }, + ]} + /> + {/if} diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index e6acb56..f4c8994 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -19,6 +19,7 @@ import { AddAgentForm } from "./(components)/add-agent-form"; import { DeleteAgentForm } from "./(components)/delete-agent-form"; import { EditAgentForm } from "./(components)/edit-agent-form"; + import { tenants } from "$lib/stores/tenants"; const { data } = $props(); let curItem: TAgent | null = $state(null); @@ -60,6 +61,7 @@ { agents.load(); + tenants.reload(); invalidate(ROUTES.DASHBOARD.AGENTS); closeDialog("add"); }} @@ -127,6 +129,7 @@ closeDialog("delete"); curItem = null; agents.load(); + tenants.reload(); invalidate(ROUTES.DASHBOARD.AGENTS); }} /> diff --git a/src/routes/(pages)/dashboard/channels/+page.svelte b/src/routes/(pages)/dashboard/channels/+page.svelte index 9bdcb7b..9feb04a 100644 --- a/src/routes/(pages)/dashboard/channels/+page.svelte +++ b/src/routes/(pages)/dashboard/channels/+page.svelte @@ -9,12 +9,13 @@ import { Button } from "$lib/components/ui/button"; import { ResponsiveDialog, closeDialog, openDialog } from "$lib/components/ui/responsive-dialog"; import { ROUTES } from "$lib/const/routes"; + import { tenants } from "$lib/stores/tenants"; import { type TChannelWithFullAgents } from "$lib/types/channel"; import { getCurrentTranlslation } from "$lib/utils/localizations"; - import EditIcon from "@lucide/svelte/icons/pencil"; - import PlusIcon from "@lucide/svelte/icons/plus"; import PauseIcon from "@lucide/svelte/icons/pause"; + import EditIcon from "@lucide/svelte/icons/pencil"; import PlayIcon from "@lucide/svelte/icons/play"; + import PlusIcon from "@lucide/svelte/icons/plus"; import UnknownItemIcon from "@lucide/svelte/icons/split"; import DeleteIcon from "@lucide/svelte/icons/trash-2"; import { onMount } from "svelte"; @@ -61,6 +62,7 @@ {/snippet} { + tenants.reload(); invalidate(ROUTES.DASHBOARD.CHANNELS); closeDialog("add"); }} @@ -153,6 +155,7 @@ done={() => { closeDialog("delete"); curItem = null; + tenants.reload(); invalidate(ROUTES.DASHBOARD.CHANNELS); }} /> diff --git a/src/routes/(pages)/dashboard/staff/+page.svelte b/src/routes/(pages)/dashboard/staff/+page.svelte index a11c7a7..d1c78b8 100644 --- a/src/routes/(pages)/dashboard/staff/+page.svelte +++ b/src/routes/(pages)/dashboard/staff/+page.svelte @@ -10,18 +10,19 @@ import { ResponsiveDialog, closeDialog, openDialog } from "$lib/components/ui/responsive-dialog"; import { ROUTES } from "$lib/const/routes"; import { auth } from "$lib/stores/auth"; + import { tenants } from "$lib/stores/tenants"; import type { TStaff } from "$lib/types/users.js"; + import AccessIcon from "@lucide/svelte/icons/key-round"; import EditIcon from "@lucide/svelte/icons/pencil"; import PlusIcon from "@lucide/svelte/icons/plus"; import DeleteIcon from "@lucide/svelte/icons/trash-2"; - import AccessIcon from "@lucide/svelte/icons/key-round"; import UnknownItemIcon from "@lucide/svelte/icons/user-star"; import { onMount } from "svelte"; import { AddStaffMemberForm } from "./(components)/add-staff-member-form"; import { DeleteStaffMemberForm } from "./(components)/delete-staff-member-form"; import { EditStaffMemberForm } from "./(components)/edit-staff-member-form"; - import { roles } from "./(components)/utils"; import { GrantAccessForm } from "./(components)/grant-access-form"; + import { roles } from "./(components)/utils"; const { data } = $props(); let curItem: TStaff | null = $state(null); @@ -63,6 +64,7 @@ {/snippet} { + tenants.reload(); invalidate(ROUTES.DASHBOARD.STAFF); closeDialog("add"); }} @@ -144,6 +146,7 @@ done={() => { closeDialog("delete"); curItem = null; + tenants.reload(); invalidate(ROUTES.DASHBOARD.STAFF); }} /> From 8f927ba5d7f76abac76bde001e7552d2f35250b1 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 12 Nov 2025 14:30:07 +0100 Subject: [PATCH 08/61] Pages for managing my account set up (#131) Co-authored-by: Karl Ludwig Weise --- project.inlang/messages/de.json | 25 ++++++ project.inlang/messages/en.json | 25 ++++++ .../sidebar-layout/components/nav-user.svelte | 2 +- src/lib/components/ui/item/index.ts | 34 ++++++++ .../components/ui/item/item-actions.svelte | 20 +++++ .../components/ui/item/item-content.svelte | 20 +++++ .../ui/item/item-description.svelte | 24 ++++++ src/lib/components/ui/item/item-footer.svelte | 20 +++++ src/lib/components/ui/item/item-group.svelte | 21 +++++ src/lib/components/ui/item/item-header.svelte | 20 +++++ src/lib/components/ui/item/item-media.svelte | 42 +++++++++ .../components/ui/item/item-separator.svelte | 19 +++++ src/lib/components/ui/item/item-title.svelte | 20 +++++ src/lib/components/ui/item/item.svelte | 60 +++++++++++++ src/lib/components/ui/sidebar/sidebar.svelte | 1 + src/lib/const/routes.ts | 8 +- .../(pages)/dashboard/account/+page.svelte | 85 ++++++++++++++++++- .../account/change-email/+page.svelte | 24 ++++++ .../account/change-passphrase/+page.svelte | 24 ++++++ .../dashboard/account/general/+page.svelte | 24 ++++++ .../dashboard/account/passkeys/+page.svelte | 24 ++++++ 21 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/ui/item/index.ts create mode 100644 src/lib/components/ui/item/item-actions.svelte create mode 100644 src/lib/components/ui/item/item-content.svelte create mode 100644 src/lib/components/ui/item/item-description.svelte create mode 100644 src/lib/components/ui/item/item-footer.svelte create mode 100644 src/lib/components/ui/item/item-group.svelte create mode 100644 src/lib/components/ui/item/item-header.svelte create mode 100644 src/lib/components/ui/item/item-media.svelte create mode 100644 src/lib/components/ui/item/item-separator.svelte create mode 100644 src/lib/components/ui/item/item-title.svelte create mode 100644 src/lib/components/ui/item/item.svelte create mode 100644 src/routes/(pages)/dashboard/account/change-email/+page.svelte create mode 100644 src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte create mode 100644 src/routes/(pages)/dashboard/account/general/+page.svelte create mode 100644 src/routes/(pages)/dashboard/account/passkeys/+page.svelte diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 607d22a..1f31114 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -686,6 +686,31 @@ } } }, + "account": { + "overview": { + "title": "Dein Konto" + }, + "general": { + "navItem": "Einstellungen", + "title": "Kontoeinstellungen", + "description": "Ändere Deinen Namen." + }, + "change-email": { + "navItem": "E-Mail Adresse", + "title": "E-Mail Adresse ändern", + "description": "Ändere Deine E-Mail Adresse." + }, + "passkeys": { + "navItem": "Passkeys", + "title": "Passkeys verwalten", + "description": "Du kannst bis zu drei Passkeys für Dein Konto hinzufügen." + }, + "change-passphrase": { + "navItem": "Passphrase", + "title": "Change Passphrase", + "description": "Globale Admins können einen Passphrase als Backup-Methode verwenden." + } + }, "staff": { "title": "Personal", "roles": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 1ee42a7..5466a38 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -695,6 +695,31 @@ } } }, + "account": { + "overview": { + "title": "Your Account" + }, + "general": { + "navItem": "Settings", + "title": "Account Settings", + "description": "Change your name." + }, + "change-email": { + "navItem": "E-Mail Address", + "title": "Change E-Mail Address", + "description": "Change your e-mail address." + }, + "passkeys": { + "navItem": "Passkeys", + "title": "Manage Passkeys", + "description": "You can add up to three passkeys." + }, + "change-passphrase": { + "navItem": "Passphrase", + "title": "Change Passphrase", + "description": "Global Admins can set strong passphrases as a backup login method." + } + }, "staff": { "title": "Staff", "roles": { diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte index 854c49b..555a5e0 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte @@ -61,7 +61,7 @@ - goto(ROUTES.DASHBOARD.ACCOUNT)}> + goto(ROUTES.DASHBOARD.ACCOUNT.MAIN)}> {m["nav.account"]()} diff --git a/src/lib/components/ui/item/index.ts b/src/lib/components/ui/item/index.ts new file mode 100644 index 0000000..1427ff4 --- /dev/null +++ b/src/lib/components/ui/item/index.ts @@ -0,0 +1,34 @@ +import Root from "./item.svelte"; +import Group from "./item-group.svelte"; +import Separator from "./item-separator.svelte"; +import Header from "./item-header.svelte"; +import Footer from "./item-footer.svelte"; +import Content from "./item-content.svelte"; +import Title from "./item-title.svelte"; +import Description from "./item-description.svelte"; +import Actions from "./item-actions.svelte"; +import Media from "./item-media.svelte"; + +export { + Root, + Group, + Separator, + Header, + Footer, + Content, + Title, + Description, + Actions, + Media, + // + Root as Item, + Group as ItemGroup, + Separator as ItemSeparator, + Header as ItemHeader, + Footer as ItemFooter, + Content as ItemContent, + Title as ItemTitle, + Description as ItemDescription, + Actions as ItemActions, + Media as ItemMedia, +}; diff --git a/src/lib/components/ui/item/item-actions.svelte b/src/lib/components/ui/item/item-actions.svelte new file mode 100644 index 0000000..8e12000 --- /dev/null +++ b/src/lib/components/ui/item/item-actions.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item-content.svelte b/src/lib/components/ui/item/item-content.svelte new file mode 100644 index 0000000..5a7c39b --- /dev/null +++ b/src/lib/components/ui/item/item-content.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item-description.svelte b/src/lib/components/ui/item/item-description.svelte new file mode 100644 index 0000000..4eb6fb0 --- /dev/null +++ b/src/lib/components/ui/item/item-description.svelte @@ -0,0 +1,24 @@ + + +

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...restProps} +> + {@render children?.()} +

diff --git a/src/lib/components/ui/item/item-footer.svelte b/src/lib/components/ui/item/item-footer.svelte new file mode 100644 index 0000000..527eaf9 --- /dev/null +++ b/src/lib/components/ui/item/item-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item-group.svelte b/src/lib/components/ui/item/item-group.svelte new file mode 100644 index 0000000..6d67d52 --- /dev/null +++ b/src/lib/components/ui/item/item-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item-header.svelte b/src/lib/components/ui/item/item-header.svelte new file mode 100644 index 0000000..0c2a68c --- /dev/null +++ b/src/lib/components/ui/item/item-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item-media.svelte b/src/lib/components/ui/item/item-media.svelte new file mode 100644 index 0000000..e8e28e1 --- /dev/null +++ b/src/lib/components/ui/item/item-media.svelte @@ -0,0 +1,42 @@ + + + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item-separator.svelte b/src/lib/components/ui/item/item-separator.svelte new file mode 100644 index 0000000..bc62b02 --- /dev/null +++ b/src/lib/components/ui/item/item-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/item/item-title.svelte b/src/lib/components/ui/item/item-title.svelte new file mode 100644 index 0000000..568de0f --- /dev/null +++ b/src/lib/components/ui/item/item-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/item/item.svelte b/src/lib/components/ui/item/item.svelte new file mode 100644 index 0000000..0201336 --- /dev/null +++ b/src/lib/components/ui/item/item.svelte @@ -0,0 +1,60 @@ + + + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render mergedProps.children?.()} +
+{/if} diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte index ccdf338..07f2b0e 100644 --- a/src/lib/components/ui/sidebar/sidebar.svelte +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -44,6 +44,7 @@ {side} > + Sidebar Displays the mobile sidebar. diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index ca1359f..2e4a551 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -18,6 +18,12 @@ export const ROUTES = { CHANNELS: "/dashboard/channels", ABSENCES: "/dashboard/absences", SETTINGS: "/dashboard/settings", - ACCOUNT: "/dashboard/account", + ACCOUNT: { + MAIN: "/dashboard/account", + GENERAL: "/dashboard/account/general", + PASSKEYS: "/dashboard/account/passkeys", + CHANGE_EMAIL: "/dashboard/account/change-email", + CHANGE_PASSPHRASE: "/dashboard/account/change-passphrase", + }, }, }; diff --git a/src/routes/(pages)/dashboard/account/+page.svelte b/src/routes/(pages)/dashboard/account/+page.svelte index bd65b26..402e5aa 100644 --- a/src/routes/(pages)/dashboard/account/+page.svelte +++ b/src/routes/(pages)/dashboard/account/+page.svelte @@ -1,14 +1,95 @@ +> + + {m["account.overview.title"]()} + + + {#snippet child({ props })} + + + + {m["account.general.title"]()} + + + {m["account.general.description"]()} + + + + + + + {/snippet} + + + {#snippet child({ props })} + + + + {m["account.change-email.title"]()} + + + {m["account.change-email.description"]()} + + + + + + + {/snippet} + + + {#snippet child({ props })} + + + + {m["account.passkeys.title"]()} + + + {m["account.passkeys.description"]()} + + + + + + + {/snippet} + + {#if $auth.user?.role === "GLOBAL_ADMIN"} + + {#snippet child({ props })} + + + + {m["account.change-passphrase.title"]()} + + + {m["account.change-passphrase.description"]()} + + + + + + + {/snippet} + + {/if} + + + diff --git a/src/routes/(pages)/dashboard/account/change-email/+page.svelte b/src/routes/(pages)/dashboard/account/change-email/+page.svelte new file mode 100644 index 0000000..5bb7d5b --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-email/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.change-email.title"]()} + + diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte b/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte new file mode 100644 index 0000000..b3ebcd0 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.change-passphrase.title"]()} + + diff --git a/src/routes/(pages)/dashboard/account/general/+page.svelte b/src/routes/(pages)/dashboard/account/general/+page.svelte new file mode 100644 index 0000000..304fb00 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/general/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.general.title"]()} + + diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte new file mode 100644 index 0000000..0552bb2 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.passkeys.title"]()} + + From c4ba8d871619786fa4cad3c8cb3b3ba3a35c1c65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:54:31 +0100 Subject: [PATCH 09/61] Bump validator from 13.15.15 to 13.15.20 (#126) Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.15 to 13.15.20. - [Release notes](https://github.com/validatorjs/validator.js/releases) - [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/validatorjs/validator.js/compare/13.15.15...13.15.20) --- updated-dependencies: - dependency-name: validator dependency-version: 13.15.20 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ddb968..5edcfe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7835,9 +7835,9 @@ } }, "node_modules/validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", "dev": true, "license": "MIT", "optional": true, From 316d7ef25b9939473216518432e952a39adc4369 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 13 Nov 2025 14:55:11 +0100 Subject: [PATCH 10/61] Feat/book appointment (#121) * Adding book appointment workflow -wip * Load channels * Added channel and agent selection * Provide optional filter criteria for agents and channels in schedule endpoint. Return agent info for free schedule slots. * Select slot * Added personal data form * Added login and register step * Empty PIN input when crypto fails * Added summary and complete steps * Format errors in otp component * Format error in otp component * Store staff keypairs for appointment encryption * storeStaffKeyPair during passkey setup * Fix lint * Allow access to tenants for tenant admins * Removed centralised route-based authorization. * Use setupState from back-end * First tenant admin is ACCESS_GRANTED. * load local argon2 directly in central html file * Make sure cookie is deleted on logout * Allow WASM execution * Staff key is base64 encoded, not hex encoded * Fix proceeding to summary * Fix TunnelId. Fix Appointment Date. Fix missing AgentId for Tunnel Creation call. * Fix book appoint flow for new users * Fix design issues * Show only slots that have not passed * Reimplemented ShamirsSecretSharing with Laplacian Interpolation * Check for existing users before creating new client tunnels. Corrected base64 decoding of challenge. * Properly deal with existing client errors (422) * Use new shamir implementation for shard construction and key reconstruction. * Fix lint and check * Fix csp header test * Fixed tests --------- Co-authored-by: Karl Ludwig Weise Co-authored-by: Hendrik Belitz --- project.inlang/messages/de.json | 117 ++++++- project.inlang/messages/en.json | 117 ++++++- src/app.html | 1 + src/hooks.server.test.ts | 2 +- src/lib/client/appointment-crypto.ts | 181 ++++++++-- .../language-switch/language-switch.svelte | 52 +-- .../ui/alert/alert-description.svelte | 23 ++ .../components/ui/alert/alert-title.svelte | 20 ++ src/lib/components/ui/alert/alert.svelte | 44 +++ src/lib/components/ui/alert/index.ts | 14 + src/lib/components/ui/form/form-root.svelte | 10 +- src/lib/components/ui/input-otp/index.ts | 15 + .../ui/input-otp/input-otp-group.svelte | 20 ++ .../ui/input-otp/input-otp-separator.svelte | 19 + .../ui/input-otp/input-otp-slot.svelte | 31 ++ .../components/ui/input-otp/input-otp.svelte | 22 ++ .../ui/input-top-customized/index.ts | 7 + .../input-otp-customized.svelte | 24 ++ .../components/ui/page/page-with-claim.svelte | 30 +- .../ui/public/appointment-card.svelte | 108 ++++++ src/lib/components/ui/public/index.ts | 5 + .../ui/public/localized-text.svelte | 20 ++ .../components/ui/public/side-by-side.svelte | 17 + .../components/ui/typography/headline.svelte | 1 + src/lib/const/locales.ts | 5 + src/lib/const/pin.ts | 22 ++ src/lib/const/routes.ts | 2 + src/lib/crypto/__tests__/utils.test.ts | 99 ++++-- src/lib/crypto/hashing.ts | 4 +- src/lib/crypto/utils.ts | 324 ++++++++++++++++-- .../__tests__/appointment-service.test.ts | 80 ++++- .../services/__tests__/user-service.test.ts | 14 +- .../server/services/appointment-service.ts | 17 + src/lib/server/services/schedule-service.ts | 12 +- src/lib/server/services/user-service.ts | 14 +- src/lib/stores/public.ts | 32 ++ src/lib/types/public.ts | 80 +++++ src/lib/utils/localizations.ts | 11 + .../(pages)/(clients)/+layout.server.ts | 45 +++ src/routes/(pages)/(clients)/+layout.svelte | 65 ++++ .../(pages)/{ => (clients)}/+page.server.ts | 18 +- src/routes/(pages)/(clients)/+page.svelte | 96 ++++++ .../add-personal-data-form.svelte | 111 ++++++ .../add-personal-data-form/index.ts | 5 + .../add-personal-data-form/schema.ts | 19 + .../[[id]]/(components)/auth/auth-tabs.svelte | 31 ++ .../auth/book-appointment-login-form.svelte | 70 ++++ .../book-appointment-register-form.svelte | 81 +++++ .../[[id]]/(components)/auth/schema.ts | 22 ++ .../[[id]]/(components)/select-agent.svelte | 164 +++++++++ .../[[id]]/(components)/select-channel.svelte | 50 +++ .../[[id]]/(components)/select-slot.svelte | 223 ++++++++++++ .../[[id]]/(components)/summary.svelte | 70 ++++ .../[[id]]/(components)/utils.ts | 127 +++++++ .../book-appointment/[[id]]/+page.server.ts | 13 + .../book-appointment/[[id]]/+page.svelte | 112 ++++++ .../book-appointment/complete/+page.svelte | 52 +++ src/routes/(pages)/+page.svelte | 28 -- .../(pages)/confirm/[token]/+page.server.ts | 16 +- .../(pages)/confirm/[token]/+page.svelte | 6 +- .../setup-passkey/setup-passkey-form.svelte | 42 ++- src/routes/(pages)/logout/+page.server.ts | 7 + src/routes/(pages)/page.svelte.test.ts | 32 -- src/routes/(pages)/setup/+layout.server.ts | 2 +- src/routes/api/admin/init/+server.ts | 4 +- src/routes/api/auth/confirm/+server.ts | 3 +- src/routes/api/auth/register/+server.ts | 0 src/routes/api/auth/register/[id]/+server.ts | 4 +- src/routes/api/tenants/+server.ts | 6 +- .../[id]/appointments/challenge/+server.ts | 4 +- .../api/tenants/[id]/schedule/+server.ts | 20 +- .../schedule/__tests__/schedule-api.test.ts | 2 +- .../[id]/staff/[staffId]/crypto/+server.ts | 194 +++++++++++ .../crypto/__tests__/crypto-api.test.ts | 215 ++++++++++++ src/server-hooks/apiAuthHandle.ts | 128 ++----- src/server-hooks/secHeaderHandle.ts | 4 +- 76 files changed, 3372 insertions(+), 335 deletions(-) create mode 100644 src/lib/components/ui/alert/alert-description.svelte create mode 100644 src/lib/components/ui/alert/alert-title.svelte create mode 100644 src/lib/components/ui/alert/alert.svelte create mode 100644 src/lib/components/ui/alert/index.ts create mode 100644 src/lib/components/ui/input-otp/index.ts create mode 100644 src/lib/components/ui/input-otp/input-otp-group.svelte create mode 100644 src/lib/components/ui/input-otp/input-otp-separator.svelte create mode 100644 src/lib/components/ui/input-otp/input-otp-slot.svelte create mode 100644 src/lib/components/ui/input-otp/input-otp.svelte create mode 100644 src/lib/components/ui/input-top-customized/index.ts create mode 100644 src/lib/components/ui/input-top-customized/input-otp-customized.svelte create mode 100644 src/lib/components/ui/public/appointment-card.svelte create mode 100644 src/lib/components/ui/public/index.ts create mode 100644 src/lib/components/ui/public/localized-text.svelte create mode 100644 src/lib/components/ui/public/side-by-side.svelte create mode 100644 src/lib/const/pin.ts create mode 100644 src/lib/stores/public.ts create mode 100644 src/lib/types/public.ts create mode 100644 src/routes/(pages)/(clients)/+layout.server.ts create mode 100644 src/routes/(pages)/(clients)/+layout.svelte rename src/routes/(pages)/{ => (clients)}/+page.server.ts (50%) create mode 100644 src/routes/(pages)/(clients)/+page.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts create mode 100644 src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte create mode 100644 src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte delete mode 100644 src/routes/(pages)/+page.svelte delete mode 100644 src/routes/(pages)/page.svelte.test.ts delete mode 100644 src/routes/api/auth/register/+server.ts create mode 100644 src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts create mode 100644 src/routes/api/tenants/[id]/staff/[staffId]/crypto/__tests__/crypto-api.test.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 1f31114..bca8966 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -61,10 +61,18 @@ "url": "Ungültige URL", "languages": "Wähle mindestens eine Sprache", "language": "Wähle eine Sprache", - "deleteAfterDays": "Wähle einen Zeitpunkt" + "deleteAfterDays": "Wähle einen Zeitpunkt", + "phoneNoRequired": "Telefonnummer ist erforderlich", + "phoneNoInvalid": "Format der Telefonnummer ist ungültig", + "pinLength": "PIN muss 6 Ziffern lang sein", + "pinDigitsOnly": "PIN darf nur Ziffern enthalten", + "pinInsecure": "Diese PIN ist zu unsicher" }, "passkey": "Passkey", - "name": "Name" + "name": "Name", + "phone": "Telefonnummer", + "pin": "PIN", + "pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager." }, "login": { "or": "Oder", @@ -99,7 +107,10 @@ "description": "Erstelle Deinen Passkey, um Dein Konto zu sichern.", "action": "Passkey setzen", "success": "Passkey eingerichtet. Logge Dich damit jetzt ein.", - "error": "Passkey konnte nicht gesetzt werden" + "successKeyPairSaved": "Schlüsselpaar erfolgreich gespeichert.", + "error": "Passkey konnte nicht gesetzt werden", + "errorKeyPairDataMissing": "Konnte Schlüsselpaar nicht generieren. Fehlende Daten.", + "errorKeyPairNotSaved": "Schlüsselpaar konnte nicht gespeichert werden." }, "logout": { "title": "Erfolgreich abgemeldet", @@ -784,5 +795,103 @@ "loading": "Lade Personal" }, "yes": "Ja", - "no": "Nein" + "no": "Nein", + "public": { + "links": { + "website": "Webseite aufrufen", + "imprint": "Impressum", + "privacyStatement": "Datenschutz" + }, + "bookAppointment": "Termin buchen", + "appointment": { + "requiresConfirmation": "Erfordert Bestätigung durch {name}" + }, + "tenantNotReady": { + "title": "Diese Seite ist noch nicht bereit", + "description": "Falls dies Ihre Seite ist, loggen Sie sich ein und schließen Sie das Onboarding ab." + }, + "steps": { + "channel": { + "title": "Bitte wählen Sie eine Terminart aus:", + "empty": "Aktuell keine Termine verfügbar. Bitte versuchen Sie es später erneut." + }, + "agent": { + "title": "Mit wem möchten Sie Ihren Termin vereinbaren?", + "anyone": { + "title": "Keinen Wunsch äußern", + "description": "Wählen Sie diese Option, wenn Sie den frühsten Termin suchen." + } + }, + "slot": { + "title": "Bitte wählen Sie einen verfügbaren Termin aus:", + "today": "Heute", + "selectDate": "Wähle Sie ein Datum aus", + "selectTime": "Wähle Sie eine Uhrzeit aus:", + "loading": "Lade verfügbare Termine...", + "empty": "Keine Termine verfügbar. Bitte wählen Sie ein anderes Datum.", + "action": "Termin auswählen" + }, + "data": { + "title": "Bitte fügen Sie Ihre Informationen hinzu:", + "alert": { + "title": "Ihre Daten sind sicher", + "description": "Nur Sie und {name} können diese Informationen sehen. Die Daten sind Ende-zu-Ende verschlüsselt." + }, + "email": { + "description": "Wir senden Ihnen eine Bestätigungs-E-Mail mit den Termindetails." + }, + "phone": { + "description": "Format: +491234567890. Wir kontaktieren Sie, falls es Fragen/Anderungen zu Ihrem Termin gibt.", + "optional": "(optional)" + }, + "action": "Daten hinzufügen" + }, + "auth": { + "register": { + "title": "Ich bin das erste Mal hier", + "description": "Bitte legen Sie eine PIN fest:", + "hint": "Fortfahren erstellt ein Konto für Sie.", + "action": "Zussammenfassung anzeigen", + "success": "Konto erfolgreich erstellt!", + "error": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", + "alreadyExists": "Diese E-Mail ist bereits registriert. Bitte nutzen Sie die Login-Option, um weitere Termine zu buchen." + }, + "login": { + "title": "Anmelden", + "description": "Bitte geben Sie Ihre PIN ein:", + "action": "Zusammenfassung anzeigen" + } + }, + "summary": { + "title": "Bitte prüfen Sie Ihren Termin, bevor Sie buchen", + "book": { + "action": "Termin buchen" + }, + "request": { + "action": "Termin anfragen", + "hint": "Dieser Termin muss manuell bestätigt werden. Sie erhalten per E-Mail informiert." + }, + "error": "Buchung fehlgeschlagen. Bitte erneut versuchen." + }, + "complete": { + "book": { + "title": "Termin gebucht", + "description": "Wir haben Ihnen eine E-Mail mit den Details gesendet." + }, + "request": { + "title": "Termin angefragt", + "description": "{name} wird Ihren Termin in Kürze bestätigen oder absagen. Sie werden per E-Mail informiert." + }, + "action": "Weiter zur Startseite" + } + }, + "anyAgent": "Jede verfügbare Person", + "register": { + "error": "Verschlüsselung konnte nicht vorbereitet werden. Bitte erneut versuchen" + }, + "login": { + "success": "Anmeldung erfolgreich", + "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen" + } + } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 5466a38..6c0a5c4 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -40,7 +40,10 @@ "description": "Create your passkey to secure your account.", "action": "Set Passkey", "success": "Passkey set. Login with it now.", - "error": "Passkey could not be set" + "successKeyPairSaved": "Key pair saved successfully.", + "error": "Passkey could not be set", + "errorKeyPairDataMissing": "Could not generate key pair. Missing data.", + "errorKeyPairNotSaved": "Key pair could not be saved." }, "slogan": "End-to-End encrypted appointment booking platform.", "logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock", @@ -69,11 +72,19 @@ "url": "Invalid URL Format", "languages": "Select at least one language", "language": "Select a default language", - "deleteAfterDays": "Choose a point in time" + "deleteAfterDays": "Choose a point in time", + "phoneNoRequired": "Phone number is required", + "phoneNoInvalid": "Phone number format is invalid", + "pinLength": "PIN must be 6 digits", + "pinDigitsOnly": "PIN must contain only digits", + "pinInsecure": "This PIN is too insecure" }, "noPassAtAll": "Either passphrase or passkey is required", "passkey": "Passkey", - "name": "Name" + "name": "Name", + "phone": "Phone Number", + "pin": "PIN", + "pinHint": "Save your PIN in a secure place, like a password-manager." }, "login": { "or": "Or", @@ -793,5 +804,103 @@ "loading": "Loading staff members" }, "yes": "Yes", - "no": "No" + "no": "No", + "public": { + "links": { + "website": "Go to Website", + "imprint": "Imprint", + "privacyStatement": "Privacy Statement" + }, + "bookAppointment": "Book Appointment", + "appointment": { + "requiresConfirmation": "Requires cofirmation by {name}" + }, + "tenantNotReady": { + "title": "Not ready to accept appointments.", + "description": "If this is yours, please login and complete onboarding." + }, + "steps": { + "channel": { + "title": "Please select an appointment type:", + "empty": "Currently we don't have available appointments. Please check again later." + }, + "agent": { + "title": "Please select who you want your appointment with:", + "anyone": { + "title": "No preference", + "description": "Choose this option to get the fastest appointment." + } + }, + "slot": { + "title": "Please select an available appointment:", + "today": "Today", + "selectDate": "Select a date", + "selectTime": "Select a time:", + "loading": "Loading available appointments...", + "empty": "No appointments available. Please select another date.", + "action": "Select appointment" + }, + "data": { + "title": "Please add your information:", + "alert": { + "title": "Your data is safe", + "description": "Only you and {name} will be able to access this information. It is end-to-end encrypted." + }, + "email": { + "description": "We will use this to send you the appointment details." + }, + "phone": { + "description": "Format: +1234567890. We may contact you, if there are questions or changes regarding your appointment.", + "optional": "(optional)" + }, + "action": "Add personal data" + }, + "auth": { + "register": { + "title": "It's my first time here", + "description": "Please add an PIN number so you can change your appointment later.", + "hint": "Proceeding will create an account", + "action": "Show summary", + "success": "Account created successfully!", + "error": "Failed to create account. Please try again.", + "alreadyExists": "This email is already registered. Please use the login option to book additional appointments." + }, + "login": { + "title": "Login", + "description": "Please enter the PIN number, you've set:", + "action": "Show summary" + } + }, + "summary": { + "title": "Please review your appointment, before confirming.", + "book": { + "action": "Book appointment" + }, + "request": { + "action": "Request appointment", + "hint": "This appointment will have to be manually confirmed. You will receive an E-Mail once it's confirmed." + }, + "error": "Operation failed. Please retry." + }, + "complete": { + "book": { + "title": "Appointment booked", + "description": "We’ve sent you an e-mail with the details." + }, + "request": { + "title": "Appointment requested", + "description": "{name} will confirm or deny your request soon. You will be informed via e-mail." + }, + "action": "Proceed to Homepage" + } + }, + "anyAgent": "Anyone available", + "register": { + "error": "Encryption could not be set up. Please retry" + }, + "login": { + "success": "Login successful", + "error": "Login failed. Please retry" + } + } } diff --git a/src/app.html b/src/app.html index 54410cc..58d1582 100644 --- a/src/app.html +++ b/src/app.html @@ -9,6 +9,7 @@ + %sveltekit.head% diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index e7a051c..d6fb9a4 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -205,7 +205,7 @@ describe("hooks.server", () => { expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block"); expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); expect(response.headers.get("Content-Security-Policy")).toContain( - "script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", ); }); diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 5db62e9..b34df82 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -103,6 +103,7 @@ export class UnifiedAppointmentCrypto { private emailHash: string | null = null; private tunnelId: string | null = null; private clientAuthenticated: boolean = false; + private serverPrivateKeyShare: string | null = null; // Server share of the private key // Staff-specific properties private staffKeyPair: StaffKeyPair | null = null; @@ -118,6 +119,26 @@ export class UnifiedAppointmentCrypto { // ===== CLIENT (PATIENT) METHODS ===== + /** + * Run a precheck + */ + async preCheck(email: string, tenantId: string): Promise { + try { + const emailHash = await this.hashEmail(email); + + const response = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + return response.ok; + } catch (error) { + console.error("❌ Error running pre-check:", error); + return false; + } + } + /** * Initializes a new client with E2E encryption */ @@ -135,9 +156,12 @@ export class UnifiedAppointmentCrypto { // 4. Generate tunnel key for AES encryption this.tunnelKey = await this.generateTunnelKey(); - // 5. Split private key into Shamir shares (2-of-2) - // Note: privateKeyShare will be used during actual appointment creation - await this.createPrivateKeyShare(this.clientKeyPair.privateKey, pin); + // 5. Create server share of private key (PIN-based split) + // Server share will be sent to server during appointment creation + this.serverPrivateKeyShare = await this.createPrivateKeyShare( + this.clientKeyPair.privateKey, + pin, + ); // 6. Fetch staff public keys from server const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId); @@ -200,22 +224,24 @@ export class UnifiedAppointmentCrypto { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - emailHash: this.emailHash, + challengeId: challengeData.challengeId, challengeResponse: decryptedChallenge, }), }, ); if (!verificationResponse.ok) { + const errorData = await verificationResponse.json(); + console.error("❌ Challenge verification failed:", errorData); throw new Error("Challenge verification failed"); } const verificationData = await verificationResponse.json(); - // 6. Decrypt tunnel key + // 6. Decrypt tunnel key and store tunnel ID this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey); + this.tunnelId = verificationData.tunnelId; - console.log("✅ Existing client authenticated"); this.clientAuthenticated = true; } catch (error) { console.error("❌ Error during client login:", error); @@ -229,6 +255,7 @@ export class UnifiedAppointmentCrypto { async createAppointment( appointmentData: AppointmentData, appointmentDate: string, + agentId: string, channelId: string, tenantId: string, isFirstAppointment: boolean = false, @@ -250,6 +277,7 @@ export class UnifiedAppointmentCrypto { ? { // New client tunnelId: this.tunnelId, + agentId, channelId, appointmentDate, emailHash: this.emailHash, @@ -258,11 +286,13 @@ export class UnifiedAppointmentCrypto { encryptedAppointment, staffKeyShares: await this.getStaffKeyShares(tenantId), clientKeyShare: await this.getClientKeyShare(), + clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(), } : { // Existing client emailHash: this.emailHash, tunnelId: this.tunnelId!, + agentId, channelId, appointmentDate, encryptedAppointment, @@ -521,6 +551,7 @@ export class UnifiedAppointmentCrypto { this.clientKeyPair = null; this.emailHash = null; this.tunnelId = null; + this.serverPrivateKeyShare = null; this.clientAuthenticated = false; console.log("🔒 Client logged out"); } @@ -574,6 +605,39 @@ export class UnifiedAppointmentCrypto { }; } + /** + * Store the staff key pair after the registration of a new staff member + */ + public async storeStaffKeyPair( + tenantId: string, + staffId: string, + passkeyId: string, + authenticatorData: ArrayBuffer, + ): Promise { + const keyPair = KyberCrypto.generateKeyPair(); + + const passkeyBasedShard = await this.derivePasskeyBasedShard(passkeyId, authenticatorData); + + const dbShard = new Uint8Array(keyPair.privateKey.length); + for (let i = 0; i < keyPair.privateKey.length; i++) { + dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; + } + + await fetch(`/api/tenants/${tenantId}/staff/${staffId}/crypto`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + passkeyId, + publicKey: this.uint8ArrayToBase64(keyPair.publicKey), + privateKeyShare: this.uint8ArrayToBase64(dbShard), + }), + }); + } + + private uint8ArrayToBase64(array: Uint8Array): string { + return btoa(String.fromCharCode.apply(null, Array.from(array))); + } + /** * Derive a deterministic shard from passkey authentication data * @@ -723,20 +787,54 @@ export class UnifiedAppointmentCrypto { // ===== UTILITY METHODS ===== private generateTunnelId(): string { - return "tunnel_" + crypto.randomUUID(); + return crypto.randomUUID(); } + /** + * Creates a server share of the private key using Shamir Secret Sharing (2-of-2) + * + * This implements a 2-party key splitting scheme where: + * - PIN-derived share (x=1): Deterministically derived from PIN + email hash (client can always recreate) + * - Server share (x=2): Stored in database + * + * Reconstruction requires both shares (2-of-2 threshold). + * + * Security properties: + * - Each share reveals NO information about the private key (information-theoretic security) + * - Both shares are required for reconstruction + * - Server share is useless without the PIN + * + * This allows the client to authenticate from any device: + * 1. Client provides email + PIN + * 2. Server returns the server share via challenge API + * 3. Client derives PIN share deterministically and reconstructs the private key + * + * @param privateKey - The ML-KEM-768 private key (hex string) + * @param pin - User's PIN for deterministic share derivation + * @returns Server share (hex string) to be stored in database + */ private async createPrivateKeyShare(privateKey: string, pin: string): Promise { - // Use Shamir Secret Sharing for private key const privateKeyBytes = this.hexToUint8Array(privateKey); - const shares = ShamirSecretSharing.splitSecret(privateKeyBytes, 2, 2); - // One share is PIN-encrypted and stored on client - const clientShare = shares[0].y; - const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || ""); - const encryptedShare = await AESCrypto.encrypt(this.uint8ArrayToHex(clientShare), pinHash); + // Derive a deterministic y-value for the PIN-based share (x=1) + // ML-KEM-768 private key is 2400 bytes, so we need a hash of that length + const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "", { + hashLength: privateKeyBytes.length, + }); - return this.uint8ArrayToHex(encryptedShare.encrypted); + // Use ShamirSecretSharing to create the shares with deterministic first share + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(privateKeyBytes, pinHash); + + // TEST: Immediately reconstruct to verify + const testReconstruct = ShamirSecretSharing.reconstructSecret(shares); + const match = privateKeyBytes.every((v, i) => v === testReconstruct[i]); + + if (!match) { + console.error("❌ CRITICAL: Shamir reconstruction failed immediately after split!"); + } + + // Return the server share (x=2) + return this.uint8ArrayToHex(shares[1].y); } private async fetchStaffPublicKeys(tenantId: string): Promise { @@ -763,7 +861,8 @@ export class UnifiedAppointmentCrypto { const results = []; for (const staff of staffKeys) { - const staffPublicKeyBytes = this.hexToUint8Array(staff.publicKey); + // Public key is stored as Base64, not Hex + const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey); const encryptedKey = KyberCrypto.encapsulate(staffPublicKeyBytes); results.push({ @@ -786,30 +885,51 @@ export class UnifiedAppointmentCrypto { } private async reconstructPrivateKey(pin: string, serverShare: string): Promise { - // For now, simplified reconstruction - in real implementation this would be more complex - // TODO: Implement proper Shamir reconstruction with PIN-derived decryption + if (!this.emailHash) { + throw new Error("Email hash not available for key reconstruction"); + } + // 1. Derive PIN-based share deterministically (x=1, y=pinShareY) const serverShareBytes = this.hexToUint8Array(serverShare); - const clientShareBytes = serverShareBytes; // Simplified for now + // ML-KEM-768 private key is 2400 bytes, derive hash of same length + const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash, { + hashLength: serverShareBytes.length, + }); - // In future: derive pinHash and use it to decrypt the client share - // const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || ""); - - // Reconstruct private key from both shares + // 2. Reconstruct using proper Shamir Secret Sharing const shares = [ - { x: 1, y: clientShareBytes }, + { x: 1, y: pinHash }, { x: 2, y: serverShareBytes }, ]; - const reconstructedKey = ShamirSecretSharing.reconstructSecret(shares); + const privateKey = ShamirSecretSharing.reconstructSecret(shares); - return this.uint8ArrayToHex(reconstructedKey); + return this.uint8ArrayToHex(privateKey); } private async decryptChallenge(encryptedChallenge: string, privateKey: string): Promise { const privateKeyBytes = this.hexToUint8Array(privateKey); - const challengeBytes = this.hexToUint8Array(encryptedChallenge); + const fullChallengeBytes = this.hexToUint8Array(encryptedChallenge); - const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, challengeBytes); - return this.uint8ArrayToHex(sharedSecret); + // Split the encryptedChallenge into: + // 1. Encapsulated secret (first 1088 bytes for ML-KEM-768) + // 2. Encrypted challenge (remaining bytes) + const encapsulatedSecret = fullChallengeBytes.slice(0, 1088); + const encryptedChallengeBuffer = fullChallengeBytes.slice(1088); + + // Decapsulate to get the shared secret + const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret); + + // XOR the encrypted challenge with the shared secret to decrypt + const challenge = new Uint8Array(encryptedChallengeBuffer.length); + for (let i = 0; i < encryptedChallengeBuffer.length; i++) { + challenge[i] = encryptedChallengeBuffer[i] ^ sharedSecret[i]; + } + + // The decrypted challenge is raw bytes (32 bytes from randomBytes) + // Convert to base64 string for server verification + const challengeBase64 = this.uint8ArrayToBase64(challenge); + + // Return the base64-encoded challenge (server expects it in base64 format) + return challengeBase64; } private async decryptTunnelKey( @@ -832,9 +952,8 @@ export class UnifiedAppointmentCrypto { // Helper methods for completing the API private async getPrivateKeyShare(): Promise { - if (!this.clientKeyPair) throw new Error("Client keypair not available"); - // TODO: Return the actual server share from Shamir splitting - return this.clientKeyPair.privateKey; // Simplified for now + if (!this.serverPrivateKeyShare) throw new Error("Server private key share not available"); + return this.serverPrivateKeyShare; } private async getStaffKeyShares( diff --git a/src/lib/components/templates/language-switch/language-switch.svelte b/src/lib/components/templates/language-switch/language-switch.svelte index 6267f9d..5006c9d 100644 --- a/src/lib/components/templates/language-switch/language-switch.svelte +++ b/src/lib/components/templates/language-switch/language-switch.svelte @@ -3,39 +3,49 @@ import { getLocale, setLocale } from "$i18n/runtime.js"; import type { ButtonSize, ButtonVariant } from "$lib/components/ui/button"; import { ComboBox } from "$lib/components/ui/combobox"; + import { languageSwitchLocales, supportedLocales } from "$lib/const/locales"; import { cn } from "$lib/utils"; let { + locales, class: className = "", triggerClass = "", triggerSize = "default", triggerVariant = "ghost", }: { + locales?: (typeof supportedLocales)[]; triggerVariant?: ButtonVariant; triggerSize?: ButtonSize; class?: string; triggerClass?: string; } = $props(); + + const options = $derived( + locales + ? Object.values(languageSwitchLocales).filter((it) => + locales.includes(it.value as unknown as typeof supportedLocales), + ) + : Object.values(languageSwitchLocales), + ); -
- { - setLocale(value as "de" | "en"); - }} - labels={{ - placeholder: m["i18n.label"](), - search: m["i18n.search"](), - notFound: m["i18n.notFound"](), - }} - {triggerVariant} - {triggerSize} - {triggerClass} - class={cn("w-auto")} - /> -
+{#if locales && options.length > 1} +
+ { + setLocale(value as "de" | "en"); + }} + labels={{ + placeholder: m["i18n.label"](), + search: m["i18n.search"](), + notFound: m["i18n.notFound"](), + }} + {triggerVariant} + {triggerSize} + {triggerClass} + class={cn("w-auto")} + /> +
+{/if} diff --git a/src/lib/components/ui/alert/alert-description.svelte b/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..939ba5b --- /dev/null +++ b/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert/alert-title.svelte b/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..25feecc --- /dev/null +++ b/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert/alert.svelte b/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..1dbadbb --- /dev/null +++ b/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/src/lib/components/ui/alert/index.ts b/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..e47ba7d --- /dev/null +++ b/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/src/lib/components/ui/form/form-root.svelte b/src/lib/components/ui/form/form-root.svelte index 39ec8c1..be1a6a7 100644 --- a/src/lib/components/ui/form/form-root.svelte +++ b/src/lib/components/ui/form/form-root.svelte @@ -13,6 +13,7 @@ class: className, action, children, + ...restProps }: { formId?: string; enhance: EnhanceFunction; @@ -21,6 +22,13 @@ } & HTMLAttributes = $props(); -
+ {@render children?.()}
diff --git a/src/lib/components/ui/input-otp/index.ts b/src/lib/components/ui/input-otp/index.ts new file mode 100644 index 0000000..97ce027 --- /dev/null +++ b/src/lib/components/ui/input-otp/index.ts @@ -0,0 +1,15 @@ +import Root from "./input-otp.svelte"; +import Group from "./input-otp-group.svelte"; +import Slot from "./input-otp-slot.svelte"; +import Separator from "./input-otp-separator.svelte"; + +export { + Root, + Group, + Slot, + Separator, + Root as InputOTP, + Group as InputOTPGroup, + Slot as InputOTPSlot, + Separator as InputOTPSeparator, +}; diff --git a/src/lib/components/ui/input-otp/input-otp-group.svelte b/src/lib/components/ui/input-otp/input-otp-group.svelte new file mode 100644 index 0000000..a1cca6c --- /dev/null +++ b/src/lib/components/ui/input-otp/input-otp-group.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/input-otp/input-otp-separator.svelte b/src/lib/components/ui/input-otp/input-otp-separator.svelte new file mode 100644 index 0000000..3afee1e --- /dev/null +++ b/src/lib/components/ui/input-otp/input-otp-separator.svelte @@ -0,0 +1,19 @@ + + +
+ {#if children} + {@render children?.()} + {:else} + + {/if} +
diff --git a/src/lib/components/ui/input-otp/input-otp-slot.svelte b/src/lib/components/ui/input-otp/input-otp-slot.svelte new file mode 100644 index 0000000..ce6f6cb --- /dev/null +++ b/src/lib/components/ui/input-otp/input-otp-slot.svelte @@ -0,0 +1,31 @@ + + + + {cell.char} + {#if cell.hasFakeCaret} +
+ +
+ {/if} +
diff --git a/src/lib/components/ui/input-otp/input-otp.svelte b/src/lib/components/ui/input-otp/input-otp.svelte new file mode 100644 index 0000000..92b447e --- /dev/null +++ b/src/lib/components/ui/input-otp/input-otp.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/lib/components/ui/input-top-customized/index.ts b/src/lib/components/ui/input-top-customized/index.ts new file mode 100644 index 0000000..ecde38f --- /dev/null +++ b/src/lib/components/ui/input-top-customized/index.ts @@ -0,0 +1,7 @@ +import Root from "./input-otp-customized.svelte"; + +export { + Root, + // + Root as InputOptCustomized, +}; diff --git a/src/lib/components/ui/input-top-customized/input-otp-customized.svelte b/src/lib/components/ui/input-top-customized/input-otp-customized.svelte new file mode 100644 index 0000000..a82f89b --- /dev/null +++ b/src/lib/components/ui/input-top-customized/input-otp-customized.svelte @@ -0,0 +1,24 @@ + + + + {#snippet children({ cells })} + + {#each cells.slice(0, 3) as cell (cell)} + + {/each} + + + + {#each cells.slice(3, 6) as cell (cell)} + + {/each} + + {/snippet} + diff --git a/src/lib/components/ui/page/page-with-claim.svelte b/src/lib/components/ui/page/page-with-claim.svelte index b72067d..4341bbf 100644 --- a/src/lib/components/ui/page/page-with-claim.svelte +++ b/src/lib/components/ui/page/page-with-claim.svelte @@ -7,20 +7,33 @@ import { Text } from "../typography"; import HorizontalPagePadding from "./horizontal-page-padding.svelte"; import { LanguageSwitch } from "$lib/components/templates/language-switch"; + import type { Snippet } from "svelte"; + import type { supportedLocales } from "$lib/const/locales"; let { ref = $bindable(null), class: className, children, + left, + footer, + languages, isWithLanguageSwitch = false, ...restProps - }: WithElementRef> & { isWithLanguageSwitch?: boolean } = $props(); + }: WithElementRef> & { + left?: Snippet; + footer?: Snippet; + languages?: (typeof supportedLocales)[]; + isWithLanguageSwitch?: boolean; + } = $props();
{#if isWithLanguageSwitch} - + {#if left} + {@render left()} + {/if} + {/if} {@render children?.()} @@ -40,10 +53,15 @@ {/if} - - {m.poweredBy()} - OpenReception - +
+ {#if footer} + {@render footer()} + {/if} + + {m.poweredBy()} + OpenReception + +
{#if dev} +
+ {#if channel.requiresConfirmation} +
+ + + {m["public.appointment.requiresConfirmation"]({ name: tenant.longName })} + +
+ {/if} + {#if appointment.agent || appointment.agent === null} +
+ + + {appointment.agent?.name || m["public.anyAgent"]()} + +
+ {/if} + {#if appointment.slot} +
+ + + {Intl.DateTimeFormat($publicStore.locale, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(appointment.slot.datetime.toDate(getLocalTimeZone()))} + +
+ {/if} + {#if appointment.data} +
+ + + {appointment.data.name}
+ {appointment.data.email} + {#if appointment.data.phone} +
+ {appointment.data.phone} + {/if} +
+
+ {/if} + + {/if} + + + +{/if} diff --git a/src/lib/components/ui/public/index.ts b/src/lib/components/ui/public/index.ts new file mode 100644 index 0000000..ee22f0f --- /dev/null +++ b/src/lib/components/ui/public/index.ts @@ -0,0 +1,5 @@ +import LocalizedText from "./localized-text.svelte"; +import AppointmentCard from "./appointment-card.svelte"; +import SideBySide from "./side-by-side.svelte"; + +export { LocalizedText, AppointmentCard, SideBySide }; diff --git a/src/lib/components/ui/public/localized-text.svelte b/src/lib/components/ui/public/localized-text.svelte new file mode 100644 index 0000000..6e02a6d --- /dev/null +++ b/src/lib/components/ui/public/localized-text.svelte @@ -0,0 +1,20 @@ + + +{getTranslation()} diff --git a/src/lib/components/ui/public/side-by-side.svelte b/src/lib/components/ui/public/side-by-side.svelte new file mode 100644 index 0000000..8eb5cbd --- /dev/null +++ b/src/lib/components/ui/public/side-by-side.svelte @@ -0,0 +1,17 @@ + + + + + + {@render children?.()} + + + diff --git a/src/lib/components/ui/typography/headline.svelte b/src/lib/components/ui/typography/headline.svelte index c9f30fe..cf2734d 100644 --- a/src/lib/components/ui/typography/headline.svelte +++ b/src/lib/components/ui/typography/headline.svelte @@ -12,6 +12,7 @@ h3: "text-2xl font-semibold", h4: "text-xl font-semibold", h5: "text-lg font-semibold", + h6: "text-md font-semibold", }, }, }); diff --git a/src/lib/const/locales.ts b/src/lib/const/locales.ts index 47fb5de..dabcb15 100644 --- a/src/lib/const/locales.ts +++ b/src/lib/const/locales.ts @@ -6,3 +6,8 @@ export const translatedLocales = { en: m["locales.en"](), de: m["locales.de"](), }; + +export const languageSwitchLocales = { + de: { label: "Deutsch", value: "de", keywords: ["german", "deutsch"] }, + en: { label: "English", value: "en", keywords: ["english"] }, +}; diff --git a/src/lib/const/pin.ts b/src/lib/const/pin.ts new file mode 100644 index 0000000..aa95b30 --- /dev/null +++ b/src/lib/const/pin.ts @@ -0,0 +1,22 @@ +export const INSECURE_PINS = new Set([ + "000000", + "111111", + "222222", + "333333", + "444444", + "555555", + "666666", + "777777", + "888888", + "999999", + "123456", + "654321", + "123123", + "121212", + "000001", + "112233", + "123321", + "012345", + "234567", + "345678", +]); diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index 2e4a551..f4b200b 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -1,5 +1,7 @@ export const ROUTES = { MAIN: "/", + BOOK_APPOINTMENT: "/book-appointment", + APPOINTMENT_BOOKED: "/book-appointment/complete", SETUP: { MAIN: "/setup", CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", diff --git a/src/lib/crypto/__tests__/utils.test.ts b/src/lib/crypto/__tests__/utils.test.ts index 47e331b..49ac5aa 100644 --- a/src/lib/crypto/__tests__/utils.test.ts +++ b/src/lib/crypto/__tests__/utils.test.ts @@ -345,59 +345,96 @@ describe("AESCrypto", () => { }); describe("ShamirSecretSharing", () => { - describe("splitSecret()", () => { - it("should split secret into shares", () => { + describe("splitSecretWithDeterministicShare()", () => { + it("should split secret into 2 shares with deterministic first share", () => { const secret = BufferUtils.from("test secret"); - const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); + const deterministicShare = BufferUtils.randomBytes(secret.length); - expect(shares).toHaveLength(3); - shares.forEach((share, index) => { - expect(share.x).toBe(index + 1); - expect(share.y).toBeInstanceOf(Uint8Array); - expect(share.y.length).toBe(4 + secret.length); // 4 bytes for length + secret - }); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + secret, + deterministicShare, + ); + + expect(shares).toHaveLength(2); + expect(shares[0].x).toBe(1); + expect(shares[1].x).toBe(2); + expect(shares[0].y).toBeInstanceOf(Uint8Array); + expect(shares[1].y).toBeInstanceOf(Uint8Array); + expect(shares[0].y.length).toBe(secret.length); + expect(shares[1].y.length).toBe(secret.length); + // First share should be the deterministic one + expect(shares[0].y).toEqual(deterministicShare); }); it("should throw error for empty secret", () => { const secret = new Uint8Array([]); - expect(() => ShamirSecretSharing.splitSecret(secret, 2, 3)).toThrow( - "Secret cannot be empty for Shamir secret sharing", - ); + const deterministicShare = new Uint8Array([]); + + expect(() => + ShamirSecretSharing.splitSecretWithDeterministicShare(secret, deterministicShare), + ).toThrow("Secret cannot be empty"); }); it("should handle large secrets", () => { const secret = BufferUtils.randomBytes(1000); - const shares = ShamirSecretSharing.splitSecret(secret, 3, 5); + const deterministicShare = BufferUtils.randomBytes(secret.length); - expect(shares).toHaveLength(5); - shares.forEach((share) => { - expect(share.y.length).toBe(4 + secret.length); - }); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + secret, + deterministicShare, + ); + + expect(shares).toHaveLength(2); + expect(shares[0].y.length).toBe(secret.length); + expect(shares[1].y.length).toBe(secret.length); + }); + + it("should throw error if deterministic share length differs", () => { + const secret = BufferUtils.from("test secret"); + const deterministicShare = BufferUtils.randomBytes(5); // Different length + + expect(() => + ShamirSecretSharing.splitSecretWithDeterministicShare(secret, deterministicShare), + ).toThrow("Deterministic share must have same length as secret"); }); }); describe("reconstructSecret()", () => { it("should reconstruct secret from shares", () => { const originalSecret = BufferUtils.from("test secret for reconstruction"); - const shares = ShamirSecretSharing.splitSecret(originalSecret, 2, 5); + const deterministicShare = BufferUtils.randomBytes(originalSecret.length); - // Use first 2 shares (minimum threshold) - const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + originalSecret, + deterministicShare, + ); + + // Use both shares + const reconstructed = ShamirSecretSharing.reconstructSecret(shares); expect(reconstructed).toEqual(originalSecret); }); - it("should reconstruct secret from any subset of shares", () => { + it("should reconstruct secret consistently with same deterministic share", () => { const originalSecret = BufferUtils.from("another test secret"); - const shares = ShamirSecretSharing.splitSecret(originalSecret, 3, 5); + const deterministicShare = BufferUtils.randomBytes(originalSecret.length); - // Test different combinations - const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares.slice(0, 3)); - const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares.slice(1, 4)); - const reconstructed3 = ShamirSecretSharing.reconstructSecret(shares.slice(2, 5)); + // Split and reconstruct multiple times with same deterministic share + const shares1 = ShamirSecretSharing.splitSecretWithDeterministicShare( + originalSecret, + deterministicShare, + ); + const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares1); + + const shares2 = ShamirSecretSharing.splitSecretWithDeterministicShare( + originalSecret, + deterministicShare, + ); + const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares2); expect(reconstructed1).toEqual(originalSecret); expect(reconstructed2).toEqual(originalSecret); - expect(reconstructed3).toEqual(originalSecret); + // Deterministic shares should be identical + expect(shares1[0].y).toEqual(shares2[0].y); }); it("should throw error with insufficient shares", () => { @@ -415,8 +452,12 @@ describe("ShamirSecretSharing", () => { ]; secrets.forEach((secret) => { - const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); - const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); + const deterministicShare = BufferUtils.randomBytes(secret.length); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + secret, + deterministicShare, + ); + const reconstructed = ShamirSecretSharing.reconstructSecret(shares); expect(reconstructed).toEqual(secret); }); }); diff --git a/src/lib/crypto/hashing.ts b/src/lib/crypto/hashing.ts index 3fa6777..66bffe3 100644 --- a/src/lib/crypto/hashing.ts +++ b/src/lib/crypto/hashing.ts @@ -171,12 +171,14 @@ export class OptimizedArgon2 { ): Promise { const { argon2id } = await import("@noble/hashes/argon2"); - return argon2id(BufferUtils.from(pin), new Uint8Array(salt), { + const result = argon2id(BufferUtils.from(pin), new Uint8Array(salt), { m: options.memoryCost, t: options.timeCost, p: options.parallelism, dkLen: options.hashLength, }); + + return result; } /** diff --git a/src/lib/crypto/utils.ts b/src/lib/crypto/utils.ts index c79c3e7..5d1c2ed 100644 --- a/src/lib/crypto/utils.ts +++ b/src/lib/crypto/utils.ts @@ -183,15 +183,22 @@ export class AESCrypto { // Use Web Crypto API if available (browser), otherwise fall back to Node.js if (typeof crypto !== "undefined" && crypto.subtle) { // Browser implementation using Web Crypto API - const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ - "encrypt", - ]); + // Ensure native Uint8Array with ArrayBuffer for Web Crypto API compatibility + const nativeKey = new Uint8Array(key); + const cryptoKey = await crypto.subtle.importKey( + "raw", + nativeKey, + { name: "AES-GCM" }, + false, + ["encrypt"], + ); - const additionalData = BufferUtils.from("appointment-data"); + const additionalData = new Uint8Array(BufferUtils.from("appointment-data")); + const nativeIv = new Uint8Array(iv); const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv, additionalData }, + { name: "AES-GCM", iv: nativeIv, additionalData }, cryptoKey, - BufferUtils.from(data), + new Uint8Array(BufferUtils.from(data)), ); // Extract tag from encrypted data (last 16 bytes) @@ -238,17 +245,24 @@ export class AESCrypto { ): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { // Browser implementation using Web Crypto API - const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ - "decrypt", - ]); + // Ensure native Uint8Array with ArrayBuffer for Web Crypto API compatibility + const nativeKey = new Uint8Array(key); + const cryptoKey = await crypto.subtle.importKey( + "raw", + nativeKey, + { name: "AES-GCM" }, + false, + ["decrypt"], + ); - const additionalData = BufferUtils.from("appointment-data"); + const additionalData = new Uint8Array(BufferUtils.from("appointment-data")); + const nativeIv = new Uint8Array(iv); // Combine encrypted data and tag for Web Crypto API - const encryptedWithTag = BufferUtils.concat([encrypted, tag]); + const encryptedWithTag = new Uint8Array(BufferUtils.concat([encrypted, tag])); const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv, additionalData }, + { name: "AES-GCM", iv: nativeIv, additionalData }, cryptoKey, encryptedWithTag, ); @@ -280,45 +294,151 @@ export interface ShamirShare { } /** - * Simple Shamir Secret Sharing implementation - * Note: This is a basic implementation for demonstration purposes + * Shamir Secret Sharing implementation using Lagrange interpolation over GF(256) + * + * This implementation uses polynomial interpolation in Galois Field 256 to split + * a secret into n shares where any k shares can reconstruct the secret, but + * k-1 shares reveal no information about the secret (information-theoretic security). + * + * Mathematical basis: + * - Secret is encoded as f(0) where f is a polynomial of degree k-1 + * - Each share is a point (x, f(x)) on the polynomial + * - Lagrange interpolation reconstructs f(0) from k points */ export class ShamirSecretSharing { + // Cache for GF(256) multiplicative inverses + private static invCache: Map = new Map(); + /** - * Splits a secret into multiple shares - * @param secret - The secret to split - * @param threshold - Minimum number of shares needed to reconstruct - * @param totalShares - Total number of shares to create + * Splits a secret into multiple shares using Shamir's Secret Sharing scheme + * + * For a k-of-n threshold scheme: + * - Generates a random polynomial f(x) of degree k-1 where f(0) = secret + * - Creates n shares as points (i, f(i)) for i = 1, 2, ..., n + * - Any k shares can reconstruct the secret via Lagrange interpolation + * + * @param secret - The secret to split (any length) + * @param threshold - Minimum number of shares needed to reconstruct (k) + * @param totalShares - Total number of shares to create (n) * @returns Array of ShamirShare objects */ static splitSecret(secret: CryptoBuffer, threshold: number, totalShares: number): ShamirShare[] { + return this.splitSecretInternal(secret, threshold, totalShares); + } + + /** + * Splits a secret into shares with a deterministic first share (2-of-2 scheme) + * + * Special case for client key splitting where: + * - Share 1 (x=1): Deterministically derived from PIN + email (y₁ provided) + * - Share 2 (x=2): Calculated to maintain the secret relationship + * + * This enables cross-device authentication: + * - Client can always recreate share 1 from their PIN + * - Server stores share 2 in database + * - Both shares required for reconstruction (2-of-2 threshold) + * + * @param secret - The private key to split + * @param deterministicShareY - The deterministic y-value for x=1 (from PIN derivation) + * @returns Array with 2 shares: [pinShare, serverShare] + */ + static splitSecretWithDeterministicShare( + secret: CryptoBuffer, + deterministicShareY: Uint8Array, + ): ShamirShare[] { + if (!secret || secret.length === 0) { + throw new Error("Secret cannot be empty"); + } + if (deterministicShareY.length !== secret.length) { + throw new Error("Deterministic share must have same length as secret"); + } + + // For 2-of-2 Shamir: f(x) = a₀ + a₁·x where f(0) = secret + // We have: f(1) = deterministicShareY (given) + // From f(1) = a₀ + a₁·1: a₁ = f(1) - a₀ = deterministicShareY - secret (in GF(256)) + // Calculate f(2) = a₀ + a₁·2 = secret + 2·(deterministicShareY - secret) + // = 2·deterministicShareY - secret (in GF(256)) + + const serverShareY = new Uint8Array(secret.length); + + for (let i = 0; i < secret.length; i++) { + // a₁ = deterministicShareY[i] - secret[i] in GF(256) + // Since subtraction in GF(256) is XOR (same as addition): + const a1 = this.gf256Add(deterministicShareY[i], secret[i]); + + // f(2) = secret[i] + 2·a₁ in GF(256) + const twoTimesA1 = this.gf256Mul(2, a1); + serverShareY[i] = this.gf256Add(secret[i], twoTimesA1); + } + + return [ + { x: 1, y: deterministicShareY }, + { x: 2, y: serverShareY }, + ]; + } + + /** + * Internal implementation for splitting secrets + */ + private static splitSecretInternal( + secret: CryptoBuffer, + threshold: number, + totalShares: number, + ): ShamirShare[] { if (!secret || secret.length === 0) { throw new Error("Secret cannot be empty for Shamir secret sharing"); } - - const originalLength = secret.length; - const lengthBytes = new Uint8Array(4); - new DataView(lengthBytes.buffer).setUint32(0, originalLength, true); + if (threshold < 2) { + throw new Error("Threshold must be at least 2"); + } + if (threshold > totalShares) { + throw new Error("Threshold cannot be greater than total shares"); + } + if (totalShares > 255) { + throw new Error("Cannot create more than 255 shares (GF(256) limitation)"); + } const shares: ShamirShare[] = []; - for (let i = 0; i < totalShares; i++) { - const shareData = new Uint8Array(4 + secret.length); - shareData.set(lengthBytes, 0); - shareData.set(secret, 4); + // Process each byte of the secret independently + for (let shareIndex = 0; shareIndex < totalShares; shareIndex++) { + const x = shareIndex + 1; // x-coordinates: 1, 2, 3, ..., n (never 0, as f(0) = secret) + const y = new Uint8Array(secret.length); - shares.push({ - x: i + 1, - y: shareData, - }); + for (let byteIndex = 0; byteIndex < secret.length; byteIndex++) { + // For this byte, create a polynomial f(x) = a₀ + a₁x + a₂x² + ... + a_{k-1}x^{k-1} + // where a₀ = secret[byteIndex] and a₁, ..., a_{k-1} are random + const coefficients = new Uint8Array(threshold); + coefficients[0] = secret[byteIndex]; // f(0) = secret byte + + // Generate random coefficients for higher degree terms + for (let i = 1; i < threshold; i++) { + coefficients[i] = Math.floor(Math.random() * 256); + } + + // Evaluate polynomial at x: f(x) = a₀ + a₁x + a₂x² + ... in GF(256) + let result = 0; + for (let i = threshold - 1; i >= 0; i--) { + result = this.gf256Add(this.gf256Mul(result, x), coefficients[i]); + } + + y[byteIndex] = result; + } + + shares.push({ x, y }); } return shares; } /** - * Reconstructs a secret from shares - * @param shareArray - Array of shares to reconstruct from + * Reconstructs a secret from shares using Lagrange interpolation + * + * Given k shares (x₁, y₁), (x₂, y₂), ..., (xₖ, yₖ), reconstructs f(0) where: + * f(0) = Σᵢ yᵢ · Lᵢ(0) + * where Lᵢ(0) = Πⱼ≠ᵢ (0 - xⱼ) / (xᵢ - xⱼ) (computed in GF(256)) + * + * @param shareArray - Array of shares to reconstruct from (must have at least threshold shares) * @returns The reconstructed secret */ static reconstructSecret(shareArray: ShamirShare[]): CryptoBuffer { @@ -326,13 +446,145 @@ export class ShamirSecretSharing { throw new Error("Need at least 2 shares to reconstruct the secret"); } - const firstShare = shareArray[0]; + const secretLength = shareArray[0].y.length; + const secret = new Uint8Array(secretLength); - const lengthBytes = firstShare.y.slice(0, 4); - const originalLength = new DataView(lengthBytes.buffer).getUint32(0, true); + // Reconstruct each byte independently + for (let byteIndex = 0; byteIndex < secretLength; byteIndex++) { + let secretByte = 0; - const secret = firstShare.y.slice(4, 4 + originalLength); + // Lagrange interpolation: f(0) = Σᵢ yᵢ · Lᵢ(0) + for (let i = 0; i < shareArray.length; i++) { + const xi = shareArray[i].x; + const yi = shareArray[i].y[byteIndex]; + + // Calculate Lagrange basis polynomial Lᵢ(0) + let basis = 1; + for (let j = 0; j < shareArray.length; j++) { + if (i !== j) { + const xj = shareArray[j].x; + // Lᵢ(0) *= (0 - xⱼ) / (xᵢ - xⱼ) in GF(256) + // In GF(256), subtraction is XOR (same as addition) + const numerator = this.gf256Add(0, xj); // 0 - xⱼ = 0 ⊕ xⱼ = xⱼ + const denominator = this.gf256Add(xi, xj); // xᵢ - xⱼ = xᵢ ⊕ xⱼ + basis = this.gf256Mul(basis, this.gf256Div(numerator, denominator)); + } + } + + // Add yᵢ · Lᵢ(0) to the result + secretByte = this.gf256Add(secretByte, this.gf256Mul(yi, basis)); + } + + secret[byteIndex] = secretByte; + } return secret; } + + /** + * Addition in GF(256) - simply XOR + */ + private static gf256Add(a: number, b: number): number { + return (a ^ b) & 0xff; + } + + /** + * Multiplication in GF(256) using the rijndael polynomial + * This is the same multiplication used in AES + */ + private static gf256Mul(a: number, b: number): number { + let result = 0; + a = a & 0xff; + b = b & 0xff; + + for (let i = 0; i < 8; i++) { + if (b & 1) { + result ^= a; + } + const hiBitSet = a & 0x80; + a = (a << 1) & 0xff; + if (hiBitSet) { + a ^= 0x1b; // Rijndael's Galois field polynomial + } + b >>= 1; + } + + return result & 0xff; + } + + /** + * Division in GF(256) - multiply by multiplicative inverse + */ + private static gf256Div(a: number, b: number): number { + if (b === 0) { + throw new Error("Division by zero in GF(256)"); + } + return this.gf256Mul(a, this.gf256Inv(b)); + } + + /** + * Multiplicative inverse in GF(256) using Extended Euclidean Algorithm + */ + private static gf256Inv(a: number): number { + if (a === 0) { + throw new Error("Zero has no multiplicative inverse in GF(256)"); + } + + // Check cache first + if (this.invCache.has(a)) { + return this.invCache.get(a)!; + } + + // Extended Euclidean Algorithm in GF(256) + let u = a & 0xff; + let v = 0x11b; // GF(256) irreducible polynomial: x^8 + x^4 + x^3 + x + 1 + let g1 = 1; + let g2 = 0; + + let iterations = 0; + const maxIterations = 16; // Should never need more than this + + while (u !== 1 && iterations < maxIterations) { + iterations++; + + if (u === 0) { + // This shouldn't happen, but safety check + throw new Error(`GF(256) inverse failed for ${a}`); + } + + const j = this.bitLength(u) - this.bitLength(v); + + if (j < 0) { + [u, v] = [v, u]; + [g1, g2] = [g2, g1]; + continue; + } + + u ^= v << j; + g1 ^= g2 << j; + } + + if (iterations >= maxIterations) { + throw new Error(`GF(256) inverse calculation did not converge for ${a}`); + } + + const result = g1 & 0xff; + + // Cache the result + this.invCache.set(a, result); + + return result; + } + + /** + * Helper: Calculate bit length of a number + */ + private static bitLength(n: number): number { + let length = 0; + while (n > 0) { + length++; + n >>= 1; + } + return length; + } } diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 2e2e504..c81bb63 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -132,6 +132,13 @@ describe("AppointmentService", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); + // Mock existing tunnel check (client doesn't exist yet) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing tunnel + }; + // Mock tenant database transaction const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { @@ -167,7 +174,10 @@ describe("AppointmentService", () => { return await callback(tx); }); - const mockDb = { transaction: mockTransaction }; + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + transaction: mockTransaction, + }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); const service = await AppointmentService.forTenant("tenant-123"); @@ -196,6 +206,39 @@ describe("AppointmentService", () => { ); }); + it("should throw ConflictError when client already exists", async () => { + const { getTenantDb, centralDb } = await import("../../db"); + + // Mock authorization check - users exist + const mockAuthBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ count: "1" }]), + }; + vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); + + // Mock existing tunnel check (client already exists) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ id: "existing-tunnel-123" }]), // Existing tunnel found + }; + + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow( + ConflictError, + ); + await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow( + "This email address is already registered", + ); + }); + it("should throw NotFoundError when channel not found", async () => { const { getTenantDb, centralDb } = await import("../../db"); @@ -207,6 +250,13 @@ describe("AppointmentService", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); + // Mock existing tunnel check (client doesn't exist yet) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing tunnel + }; + // Mock tenant database transaction with channel not found const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { @@ -231,7 +281,10 @@ describe("AppointmentService", () => { return await callback(tx); }); - const mockDb = { transaction: mockTransaction }; + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + transaction: mockTransaction, + }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); const service = await AppointmentService.forTenant("tenant-123"); @@ -241,7 +294,7 @@ describe("AppointmentService", () => { ); }); - it("should create appointment with CONFIRMED status when channel doesn't require confirmation", async () => { + it("should create appointment with CONFIRMED status when staff user creates it", async () => { const { getTenantDb, centralDb } = await import("../../db"); // Mock authorization check - users exist @@ -252,7 +305,14 @@ describe("AppointmentService", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); - // Mock tenant database transaction with channel that doesn't require confirmation + // Mock existing tunnel check (client doesn't exist yet) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing tunnel + }; + + // Mock tenant database transaction with successful creation const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { insert: vi @@ -269,8 +329,8 @@ describe("AppointmentService", () => { values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([ { - id: "appointment-123", - appointmentDate: new Date("2024-01-15T10:00:00Z"), + id: "apt-123", + appointmentDate: new Date("2024-01-01T10:00:00Z"), status: "CONFIRMED", }, ]), @@ -279,7 +339,7 @@ describe("AppointmentService", () => { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ requiresConfirmation: false }]), + limit: vi.fn().mockResolvedValue([{ id: "channel-456" }]), // Channel found }), }), }), @@ -287,13 +347,17 @@ describe("AppointmentService", () => { return await callback(tx); }); - const mockDb = { transaction: mockTransaction }; + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + transaction: mockTransaction, + }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); const service = await AppointmentService.forTenant("tenant-123"); const result = await service.createNewClientWithAppointment(mockClientTunnelData); expect(result.status).toBe("CONFIRMED"); + expect(mockTransaction).toHaveBeenCalled(); }); }); diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index 27c361a..a863328 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -179,6 +179,11 @@ describe("UserService", () => { }; const mockCountSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([{ count: 1 }]), + }; + + const mockTotalCountSelectBuilder = { from: vi.fn().mockResolvedValue([{ count: 1 }]), }; @@ -188,18 +193,19 @@ describe("UserService", () => { execute: vi.fn().mockResolvedValue({ count: 1 }), }; - // First call for user lookup, second call for count query + // First call for user lookup, second call for tenant admin count, third for total count mockCentralDb.select .mockReturnValueOnce(mockSelectBuilder) - .mockReturnValueOnce(mockCountSelectBuilder); + .mockReturnValueOnce(mockCountSelectBuilder) + .mockReturnValueOnce(mockTotalCountSelectBuilder); mockCentralDb.update.mockReturnValue(mockUpdateBuilder); const result = await UserService.confirm(token); - expect(mockCentralDb.select).toHaveBeenCalledTimes(2); + expect(mockCentralDb.select).toHaveBeenCalledTimes(3); expect(mockCentralDb.update).toHaveBeenCalled(); expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - confirmationState: "CONFIRMED" as const, + confirmationState: "ACCESS_GRANTED" as const, isActive: true, recoveryPassphrase: null, }); diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index d492daa..c04c0c8 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -236,6 +236,23 @@ export class AppointmentService { const db = await this.getDb(); + // Check if client already exists + const existingTunnel = await db + .select({ id: tenantSchema.clientAppointmentTunnel.id }) + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.emailHash, clientData.emailHash)) + .limit(1); + + if (existingTunnel.length > 0) { + log.warn("Client creation failed: Email already registered", { + tenantId: this.tenantId, + emailHashPrefix: clientData.emailHash.slice(0, 8), + }); + throw new ConflictError( + "This email address is already registered. Please use the login option to book additional appointments.", + ); + } + // Transactional: Create tunnel and appointment const result = await db.transaction(async (tx) => { // 1. Create client appointment tunnel diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index 0d556fc..7fe72f0 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -17,6 +17,8 @@ const scheduleRequestSchema = z.object({ startDate: z.string().datetime({ offset: true }), // ISO date string with timezone endDate: z.string().datetime({ offset: true }), // ISO date string with timezone tenantId: z.string().uuid({ message: "Invalid tenant ID format" }), + channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(), + agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(), }); export type ScheduleRequest = z.infer; @@ -96,12 +98,15 @@ export class ScheduleService { const db = await this.getDb(); // 1. Get all channels for the tenant - const channels = await db + let channels = await db .select() .from(tenantSchema.channel) .where( and(eq(tenantSchema.channel.pause, false), eq(tenantSchema.channel.archived, false)), ); // Only active channels + if (request.channelId) { + channels = channels.filter((channel) => channel.id === request.channelId); + } // 2. Get all slot templates associated with channels const slotTemplates = await db @@ -159,7 +164,7 @@ export class ScheduleService { ); // 5. Get channel-agent assignments - const channelAgents = await db + let channelAgents = await db .select({ channelId: tenantSchema.channelAgent.channelId, agent: tenantSchema.agent, @@ -169,6 +174,9 @@ export class ScheduleService { tenantSchema.agent, eq(tenantSchema.channelAgent.agentId, tenantSchema.agent.id), ); + if (request.agentId) { + channelAgents = channelAgents.filter((ca) => ca.agent.id === request.agentId); + } // 6. Generate daily schedules const schedule = await this.generateDailySchedules({ diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 8e11611..9cce357 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -260,6 +260,7 @@ export class UserService { isSetup: boolean; id: string; email: string; + tenantId: string | null; name?: string; language?: string; }> { @@ -354,7 +355,17 @@ export class UserService { } // Check if this is the first tenant admin for the tenant - const shouldGrantAccess = resultData.role === "GLOBAL_ADMIN"; // Global admins always get ACCESS_GRANTED + const numberOfUsers = await centralDb + .select({ count: count() }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, resultData.tenantId!), + eq(centralSchema.user.role, "TENANT_ADMIN"), + ), + ); + + const shouldGrantAccess = resultData.role === "GLOBAL_ADMIN" || numberOfUsers[0].count === 1; const confirmationState = shouldGrantAccess ? "ACCESS_GRANTED" : "CONFIRMED"; // Update the user to confirmed and active, and clear the recovery passphrase @@ -387,6 +398,7 @@ export class UserService { email: resultData.email, name: resultData.name, language: resultData.language, + tenantId: resultData.tenantId, }; } catch (error) { if (error instanceof NotFoundError) throw error; diff --git a/src/lib/stores/public.ts b/src/lib/stores/public.ts new file mode 100644 index 0000000..ab3574e --- /dev/null +++ b/src/lib/stores/public.ts @@ -0,0 +1,32 @@ +import type { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import type { TPublicTenant, TPublicAppointment, TPublicChannel } from "$lib/types/public"; +import { writable } from "svelte/store"; + +interface PublicState { + isLoading: boolean; // probably not needed + locale: string; + newAppointment: TPublicAppointment; + tenant?: TPublicTenant; + channels?: TPublicChannel[]; + crypto?: UnifiedAppointmentCrypto; +} + +const createPublicStore = () => { + const store = writable({ + isLoading: false, + locale: "en", + newAppointment: { step: "SELECT_CHANNEL" }, + }); + + return { + ...store, + reset: () => + store.update((state) => ({ + ...state, + newAppointment: { step: "SELECT_CHANNEL" }, + crypto: undefined, + })), + }; +}; + +export const publicStore = createPublicStore(); diff --git a/src/lib/types/public.ts b/src/lib/types/public.ts new file mode 100644 index 0000000..a46588a --- /dev/null +++ b/src/lib/types/public.ts @@ -0,0 +1,80 @@ +import type { supportedLocales } from "$lib/const/locales"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { SelectAgent, SelectChannel } from "$lib/server/db/tenant-schema"; +import type { CalendarDateTime } from "@internationalized/date"; + +export type TPublicTenant = Pick< + SelectTenant, + "descriptions" | "id" | "links" | "logo" | "longName" | "setupState" | "shortName" +> & { + defaultLanguage: typeof supportedLocales; + languages: (typeof supportedLocales)[]; + address: { + street: string; + number: string; + additionalAddressInfo?: string; + zip: string; + city: string; + }; + requirePhone: boolean; +}; + +export type TPublicAppointment = { + step: + | "SELECT_CHANNEL" + | "SELECT_AGENT" + | "SELECT_SLOT" + | "ADD_PERSONAL_DATA" + | "LOGIN" + | "SUMMARY" + | "COMPLETE"; + channel?: string; + agent?: { + id: string; + name: string; + image: string | null; + } | null; + slot?: { + datetime: CalendarDateTime; + }; + data?: { + name: string; + email: string; + phone?: string; + }; + isNewClient?: boolean; +}; + +export type TPublicChannel = Pick< + SelectChannel, + "id" | "names" | "descriptions" | "requiresConfirmation" +>; + +export type TPublicAgent = Pick; + +export type TPublicSchedule = { + period: { + startDate: string; + endDate: string; + }; + schedule: { + date: string; // Format YYYY-MM-DD + channels: { + [channelId: string]: { + availableSlots: TPublicSlot[]; + channel: TPublicChannel; + }[]; + }; + }[]; +}; + +export type TPublicSlot = { + from: string; // Format HH:mm + to: string; // Format HH:mm + duration: number; + availableAgents: { + id: string; + name: string; + image: string | null; + }[]; +}; diff --git a/src/lib/utils/localizations.ts b/src/lib/utils/localizations.ts index 07515d7..0094bbb 100644 --- a/src/lib/utils/localizations.ts +++ b/src/lib/utils/localizations.ts @@ -1,4 +1,6 @@ import { getLocale } from "$i18n/runtime"; +import type { supportedLocales } from "$lib/const/locales"; +import type { TPublicTenant } from "$lib/types/public"; export const removeEmptyTranslations = (object: { [key: string]: string } | undefined) => { if (!object) return object; @@ -14,3 +16,12 @@ export const getCurrentTranlslation = (object: { [key: string]: string } | undef return object[getLocale()] || Object.values(object)[0]; }; + +export const getPublicLocale = (tenant: TPublicTenant): string => { + const locale = getLocale(); + const availableLanguages = tenant.languages; + if (availableLanguages.includes(locale as unknown as typeof supportedLocales)) { + return locale; + } + return tenant.defaultLanguage as unknown as string; +}; diff --git a/src/routes/(pages)/(clients)/+layout.server.ts b/src/routes/(pages)/(clients)/+layout.server.ts new file mode 100644 index 0000000..1c4a85b --- /dev/null +++ b/src/routes/(pages)/(clients)/+layout.server.ts @@ -0,0 +1,45 @@ +import logger from "$lib/logger"; +import type { TPublicChannel, TPublicTenant } from "$lib/types/public"; +import type { LayoutServerLoad } from "./$types"; + +const log = logger.setContext(import.meta.filename); + +export const load: LayoutServerLoad = async (event) => { + const tenant = event + .fetch(`/api/public`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + try { + const body = await res.json(); + return body.tenant as TPublicTenant; + } catch (error) { + log.error("Failed to parse settings base response", { error }); + } + }); + + const channels = event + .fetch(`/api/public/channels`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + try { + const body = await res.json(); + return body.channels || ([] as TPublicChannel[]); + } catch (error) { + log.error("Failed to parse settings base response", { error }); + } + }); + + return { + streaming: { tenant, channels }, + }; +}; diff --git a/src/routes/(pages)/(clients)/+layout.svelte b/src/routes/(pages)/(clients)/+layout.svelte new file mode 100644 index 0000000..fb1428d --- /dev/null +++ b/src/routes/(pages)/(clients)/+layout.svelte @@ -0,0 +1,65 @@ + + + + {#snippet left()} + + {/snippet} + {@render children()} + {#snippet footer()} + {#await data.streaming.tenant then tenant} +
+ {#if tenant?.links.imprint} + + {/if} + {#if tenant?.links.privacyStatement} + + {/if} +
+ {/await} + {/snippet} +
diff --git a/src/routes/(pages)/+page.server.ts b/src/routes/(pages)/(clients)/+page.server.ts similarity index 50% rename from src/routes/(pages)/+page.server.ts rename to src/routes/(pages)/(clients)/+page.server.ts index c114543..3d50ae0 100644 --- a/src/routes/(pages)/+page.server.ts +++ b/src/routes/(pages)/(clients)/+page.server.ts @@ -2,27 +2,11 @@ import { ROUTES } from "$lib/const/routes.js"; import { UserService } from "$lib/server/services/user-service"; import { redirect } from "@sveltejs/kit"; -export const load = async (event) => { +export const load = async () => { // Check if global admin exists // If not, redirect to setup page const adminExists = await UserService.adminExists(); if (!adminExists) { redirect(302, ROUTES.SETUP.MAIN); } - - // Dummy placeholder for streaming data - const fetchEnvOk = async () => { - const response = await event.fetch("/api/env"); - try { - return (await response.json()).envOkay; - } catch { - return false; - } - }; - - return { - streamed: { - isEnvOk: fetchEnvOk(), - }, - }; }; diff --git a/src/routes/(pages)/(clients)/+page.svelte b/src/routes/(pages)/(clients)/+page.svelte new file mode 100644 index 0000000..694bc2a --- /dev/null +++ b/src/routes/(pages)/(clients)/+page.svelte @@ -0,0 +1,96 @@ + + + + {#await data.streaming.tenant} + OpenReception + {:then tenant} + {#if tenant} + {tenant.longName} - OpenReception + {/if} + {/await} + + + + + {#await data.streaming.tenant} +
+ +
+ +
+ + + + +
+ +
+
+ {:then tenant} + {#if tenant && tenant.longName} +
+ {#if typeof tenant.logo === "string" && tenant.logo} + {tenant.longName} + {/if} +
+ {tenant?.longName} + + + + {#if tenant?.links.website} + + {/if} +
+
+ {:else} + + {m["public.tenantNotReady.title"]()} + + + {m["public.tenantNotReady.description"]()} + + {/if} + {/await} +
+ + {#await data.streaming.tenant} + + {:then tenant} + {#if tenant?.setupState === "READY" && tenant.longName} + + {:else} +
+ +
+ {/if} + {/await} +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte new file mode 100644 index 0000000..50ab004 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte @@ -0,0 +1,111 @@ + + +
+ + {m["public.steps.data.title"]()} + + + + {m["public.steps.data.alert.title"]()} + {#if tenant} + {m["public.steps.data.alert.description"]({ name: tenant.longName })} + {/if} + + + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + {m["public.steps.data.email.description"]()} + + + + + {#snippet children({ props })} + {m["form.phone"]()} + + {/snippet} + + + + {m["public.steps.data.phone.description"]()} + {#if tenant?.requirePhone === false} + {m["public.steps.data.phone.optional"]()} + {/if} + + +
+ + {m["public.steps.data.action"]()} + +
+
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts new file mode 100644 index 0000000..e8d4126 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts @@ -0,0 +1,5 @@ +import AddPersonalDataForm from "./add-personal-data-form.svelte"; + +export { AddPersonalDataForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts new file mode 100644 index 0000000..c40d916 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts @@ -0,0 +1,19 @@ +import { m } from "$i18n/messages"; +import { z } from "zod/v4"; + +export const createFormSchema = (requirePhone?: boolean) => { + return z.object({ + ...formSchema.shape, + phone: requirePhone + ? z.e164(m["form.errors.phoneNoInvalid"]()) + : z.e164(m["form.errors.phoneNoInvalid"]()).optional(), + }); +}; + +export const formSchema = z.object({ + name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()), + email: z.string().email(m["form.errors.email"]()), + phone: z.e164(m["form.errors.phoneNoInvalid"]()).optional(), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte new file mode 100644 index 0000000..3df9638 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte @@ -0,0 +1,31 @@ + + +
+ + + {m["public.steps.auth.register.title"]()} + {m["public.steps.auth.login.title"]()} + + + + {m["public.steps.auth.register.description"]()} + + + + + + {m["public.steps.auth.login.description"]()} + + + + +
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte new file mode 100644 index 0000000..566e1a8 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -0,0 +1,70 @@ + + + + + + {#snippet children({ props })} + {m["form.pin"]()} + + {/snippet} + + + + {m["form.pinHint"]()} + + +
+ + {m["public.steps.auth.login.action"]()} + +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte new file mode 100644 index 0000000..8e92603 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte @@ -0,0 +1,81 @@ + + + + + + {#snippet children({ props })} + {m["form.pin"]()} + + {/snippet} + + + + {m["form.pinHint"]()} + + +
+ + {m["public.steps.auth.register.action"]()} + +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts new file mode 100644 index 0000000..e23125e --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts @@ -0,0 +1,22 @@ +import { m } from "$i18n/messages"; +import { INSECURE_PINS } from "$lib/const/pin"; +import { z } from "zod/v4"; + +export const formSchemaRegister = z.object({ + pin: z + .string() + .length(6, m["form.errors.pinLength"]()) + .regex(/^\d{6}$/, m["form.errors.pinDigitsOnly"]()) + .refine((pin) => !INSECURE_PINS.has(pin), { message: m["form.errors.pinInsecure"]() }), +}); + +export type FormSchemaRegister = typeof formSchemaRegister; + +export const formSchemaLogin = z.object({ + pin: z + .string() + .length(6, m["form.errors.pinLength"]()) + .regex(/^\d{6}$/, m["form.errors.pinDigitsOnly"]()), +}); + +export type FormSchemaLogin = typeof formSchemaLogin; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte new file mode 100644 index 0000000..919676c --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte @@ -0,0 +1,164 @@ + + +
+ + {m["public.steps.agent.title"]()} + +
    + {#if agents} +
  • + +
  • + {#each agents as agent (agent.id)} +
  • + +
  • + {/each} + {:else} +
    + + + +
    + {/if} +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte new file mode 100644 index 0000000..0b2acd2 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte @@ -0,0 +1,50 @@ + + +
+ {#if channels.length === 0} + + {m["public.steps.channel.empty"]()} + + {:else} + + {m["public.steps.channel.title"]()} + +
    + {#each channels as channel (channel.id)} +
  • + +
  • + {/each} +
+ {/if} +
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte new file mode 100644 index 0000000..e3e7616 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -0,0 +1,223 @@ + + +
+ + {m["public.steps.slot.title"]()} + + +
+ + {#if schedule === undefined}{/if} +
+
+ { + if (!placeholder) return; + if (placeholder.year === year && placeholder.month === month) return; + year = placeholder.year; + month = placeholder.month; + schedule = undefined; + }} + /> +
+ {#if slots === null} + + {m["public.steps.slot.selectDate"]()} + + {:else if slots === undefined} +
+ + + {m["public.steps.slot.loading"]()} + +
+ {:else if slots.length === 0} + + {m["public.steps.slot.empty"]()} + + {:else} + +
+ + {m["public.steps.slot.selectTime"]()} + + {#each slots as slot (slot.from)} + + {/each} +
+
+ {/if} +
+
+
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte new file mode 100644 index 0000000..7a4791a --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -0,0 +1,70 @@ + + +
+ + {m["public.steps.summary.title"]()} + + {#if channel} +
+ {#if channel.requiresConfirmation} + + {m["public.steps.summary.request.hint"]()} + + {/if} + +
+ {/if} +
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts new file mode 100644 index 0000000..f6cc920 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts @@ -0,0 +1,127 @@ +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { ROUTES } from "$lib/const/routes"; +import { publicStore } from "$lib/stores/public"; +import type { TPublicAppointment, TPublicSchedule } from "$lib/types/public"; +import { CalendarDateTime, getLocalTimeZone, toZoned, today } from "@internationalized/date"; +import { get } from "svelte/store"; + +export const proceed = ( + newAppointment: Partial, +): Partial => { + const curAppointment = get(publicStore).newAppointment; + switch (true) { + case newAppointment.step === "SUMMARY": { + const updatedAppointment: TPublicAppointment = { + ...newAppointment, + step: "SUMMARY", + }; + if (curAppointment.step !== "SUMMARY") { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.data) && + Boolean(newAppointment.slot) && + Boolean(newAppointment.channel) && + Boolean(newAppointment.agent): { + const updatedAppointment: TPublicAppointment = { + ...newAppointment, + step: "LOGIN", + }; + if (!curAppointment.data) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.slot) && + Boolean(newAppointment.channel) && + Boolean(newAppointment.agent): { + const updatedAppointment: TPublicAppointment = { + ...newAppointment, + step: "ADD_PERSONAL_DATA", + }; + if (!curAppointment.slot) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.channel) && + (Boolean(newAppointment.agent) || newAppointment.agent === null): { + const updatedAppointment: TPublicAppointment = { ...newAppointment, step: "SELECT_SLOT" }; + if (!curAppointment.agent) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.channel): { + const updatedAppointment: TPublicAppointment = { ...newAppointment, step: "SELECT_AGENT" }; + if (!curAppointment.channel) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + goto(`${ROUTES.BOOK_APPOINTMENT}/${newAppointment.channel}`); + return { ...newAppointment, step: "SELECT_AGENT" }; + } + default: + return { ...newAppointment, step: "SELECT_CHANNEL" }; + } +}; + +export const resest = () => { + goto(ROUTES.BOOK_APPOINTMENT, { invalidateAll: true }); +}; + +export const fetchSchedule = async (opts: { + tenant: string; + channel: string; + agent: string | null; + year: number; + month: number; +}) => { + if (!browser) return; + + const curDate = today(getLocalTimeZone()); + const startDay = curDate.year === opts.year && curDate.month === opts.month ? curDate.day : 1; + const startDate = new CalendarDateTime(opts.year, opts.month, startDay, 0, 0, 0, 0); + const endDate = startDate + .add({ months: 1 }) + .set({ day: 1 }) + .subtract({ days: 1 }) + .set({ hour: 23, minute: 59, second: 59, millisecond: 999 }); + + const params = new URLSearchParams({ + startDate: toZoned(startDate, "UTC").toAbsoluteString(), + endDate: toZoned(endDate, "UTC").toAbsoluteString(), + channel: opts.channel, + agent: opts.agent || "", + }); + const res = await fetch(`/api/tenants/${opts.tenant}/schedule?${params}`, { + method: "GET", + }); + + if (res.status < 400) { + try { + const data = await res.json(); + return data.schedule as TPublicSchedule["schedule"]; + } catch (error) { + console.error("Unable to parse public agents response", error); + } + } else { + console.error("Invalid public agents response"); + } +}; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts new file mode 100644 index 0000000..0e87dc9 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts @@ -0,0 +1,13 @@ +import type { PageServerLoad } from "./$types.js"; + +export const load: PageServerLoad = async ({ params }) => { + if (params.id) { + return { + channelId: params.id, + }; + } else { + return { + channelId: undefined, + }; + } +}; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte new file mode 100644 index 0000000..a0feff7 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte @@ -0,0 +1,112 @@ + + + + {#await data.streaming.tenant} + {m["public.bookAppointment"]()} - OpenReception + {:then tenant} + {#if tenant} + {m["public.bookAppointment"]()} - {tenant.longName} - OpenReception + {/if} + {/await} + + +{#await data.streaming.tenant} + + +
+ +
+ + + +
+
+
+{:then tenant} + {#if tenant} + {#if tenant.setupState === "READY" && tenant.longName} + + +
+ {#await data.streaming.channels} +
+ + + +
+ {:then channels} + {#if appointment.step === "SUMMARY"} + + {:else if appointment.step === "LOGIN"} + + {:else if appointment.step === "ADD_PERSONAL_DATA"} + + {:else if appointment.step === "SELECT_SLOT" && appointment.channel && appointment.agent !== undefined} + + {:else if appointment.step === "SELECT_AGENT" && appointment.channel} + + {:else} + + {/if} + {/await} +
+
+ {:else} + + + + + + + + + {/if} + {:else} + + + + + + + + + {/if} +{/await} diff --git a/src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte b/src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte new file mode 100644 index 0000000..cf24026 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte @@ -0,0 +1,52 @@ + + + + {#await data.streaming.tenant} + {m["public.bookAppointment"]()} - OpenReception + {:then tenant} + {#if tenant} + {m["public.bookAppointment"]()} - {tenant.longName} - OpenReception + {/if} + {/await} + + +{#if tenant} + + + + + + + + +{/if} diff --git a/src/routes/(pages)/+page.svelte b/src/routes/(pages)/+page.svelte deleted file mode 100644 index a299a51..0000000 --- a/src/routes/(pages)/+page.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - Hello - OpenReception - - - -
- - Hello World - - Environment configuration is - {#await data.streamed.isEnvOk} - unknown - {:then isEnvOk} - {isEnvOk ? "OK" : "NOT OK"} - {/await}. - - - -
-
diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index 0805aac..1965656 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -4,7 +4,13 @@ import type { PageServerLoad } from "./$types"; const log = logger.setContext(import.meta.filename); type Error = { success: false; isSetup: boolean }; -type Success = { success: boolean; isSetup: boolean; id: string; email: string }; +type Success = { + success: boolean; + isSetup: boolean; + id: string; + email: string; + tenantId: string | null; +}; export const load: PageServerLoad = async (event) => { const confirmation: Promise = event .fetch("/api/auth/confirm", { @@ -18,7 +24,13 @@ export const load: PageServerLoad = async (event) => { const success = resp.status < 400; try { const body = await resp.json(); - return { success, isSetup: body.isSetup ?? false, id: body.id, email: body.email }; + return { + success, + isSetup: body.isSetup ?? false, + id: body.id, + email: body.email, + tenantId: body.tenantId, + }; } catch (error) { log.error("Failed to parse confirm token response", { error }); return { success: false, isSetup: false }; diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index 768f6eb..28d4a96 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -66,7 +66,11 @@ class="w-full" onclick={() => goto(ROUTES.SETUP_PASSKEY, { - state: { id: confirmation.id, email: confirmation.email }, + state: { + id: confirmation.id, + email: confirmation.email, + tenantId: confirmation.tenantId, + }, })} > {m["confirm.success.action"]()} diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 24102f9..0efd20a 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -17,6 +17,7 @@ import { superForm } from "sveltekit-superforms/client"; import { formSchema, type FormSchema } from "./schema"; import { onMount } from "svelte"; + import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; let { data, @@ -25,6 +26,10 @@ }: { formId: string; onEvent: EventReporter; data: { form: SuperValidated> } } = $props(); + let tenantId: string | undefined = $state(); + let passkeyId: string | undefined = $state(); + let authenticatorData: ArrayBuffer | undefined = $state(); + const form = superForm(data.form, { validators: zodClient(formSchema), onChange: (event) => { @@ -35,6 +40,7 @@ onResult: async (event) => { if (event.result.type === "success") { toast.success(m["setupPasskey.success"]()); + await storeStaffKeyPair(); await goto(ROUTES.LOGIN); } else { toast.error(m["setupPasskey.error"]()); @@ -88,11 +94,11 @@ } // May include device name and counter - const authenticatorData = passkeyResp.response.getAuthenticatorData(); + const authenticatorDataResp = passkeyResp.response.getAuthenticatorData(); // Update form data with passkey info const publicKeyBase64 = arrayBufferToBase64(publicKey); - const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData); + const authenticatorDataBase64 = arrayBufferToBase64(authenticatorDataResp); $formData = { ...$formData, id: passkeyResp.id, @@ -100,6 +106,10 @@ authenticatorDataBase64, }; + // Set for later use + passkeyId = passkeyResp.id; + authenticatorData = authenticatorDataResp; + // Update UI to show passkey is ready $passkeyLoading = "success"; } @@ -113,9 +123,37 @@ userId: navState.id, email: navState.email, }; + tenantId = navState.tenantId; history.replaceState({}, ""); } }); + + const storeStaffKeyPair = async () => { + if (tenantId && passkeyId && authenticatorData) { + const crypto = new UnifiedAppointmentCrypto(); + return await crypto + .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData) + .then(() => { + toast.success(m["setupPasskey.successKeyPairSaved"]()); + }) + .catch((error) => { + toast.error(m["setupPasskey.errorKeyPairNotSaved"]()); + logger.error("Failed to store staff key pair", { + tenantId, + userId: $formData.userId, + passkeyId, + error, + }); + }); + } else { + logger.error("Failed to store staff key pair", { + tenantId, + userId: $formData.userId, + passkeyId, + }); + toast.error(m["setupPasskey.errorKeyPairDataMissing"]()); + } + }; diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts index ccbcf54..4686081 100644 --- a/src/routes/(pages)/logout/+page.server.ts +++ b/src/routes/(pages)/logout/+page.server.ts @@ -2,6 +2,13 @@ import { auth } from "$lib/stores/auth"; import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async (event) => { + event.cookies.delete("access_token", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict", + }); + const success: Promise = event .fetch("/api/auth/logout", { method: "POST", diff --git a/src/routes/(pages)/page.svelte.test.ts b/src/routes/(pages)/page.svelte.test.ts deleted file mode 100644 index ae95885..0000000 --- a/src/routes/(pages)/page.svelte.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; -import "@testing-library/jest-dom/vitest"; -import { render, screen } from "@testing-library/svelte"; -import Page from "./+page.svelte"; -import type { Locale } from "$i18n/runtime"; - -describe("/+page.svelte", () => { - beforeEach(() => { - // Mock fetch to avoid the "Invalid URL" error - global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ envOkay: true }), - } as Response), - ); - }); - - test("should render h1", async () => { - const mockData = { - locale: "en" as Locale, - streamed: { - isEnvOk: Promise.resolve(true), - }, - }; - render(Page, { - props: { - data: mockData, - }, - }); - await mockData.streamed.isEnvOk; - expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); - }); -}); diff --git a/src/routes/(pages)/setup/+layout.server.ts b/src/routes/(pages)/setup/+layout.server.ts index fcd47d7..48f84c2 100644 --- a/src/routes/(pages)/setup/+layout.server.ts +++ b/src/routes/(pages)/setup/+layout.server.ts @@ -5,7 +5,7 @@ import { redirect } from "@sveltejs/kit"; export const load = async (event) => { // Some routes should not be accessed, once an admin is created const cleanedId = event.route.id.replace("/(pages)", ""); - const blocklist = [ROUTES.SETUP.MAIN, ROUTES.SETUP.CREATE_ADMIN_ACCOUNT]; + const blocklist = [ROUTES.SETUP.MAIN, ROUTES.SETUP.CREATE_ADMIN_ACCOUNT, ROUTES.LOGIN]; // Check if global admin exists const adminExists = await UserService.adminExists(); diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index 8728f3f..ad0af6d 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -164,8 +164,8 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { throw new ValidationError(ERRORS.SECURITY.INVALID_PASSKEY_REGISTRATION); } - // Clear the registration cookie after validation (challenge cookie is cleared by login route) - cookies.delete("webauthn-registration-email", { path: "/" }); + // Note: Cookie is not deleted here to allow subsequent crypto key storage. + // It will expire automatically after 5 minutes (set in /api/auth/challenge) // Extract counter from WebAuthn credential const counter = WebAuthnService.extractCounterFromCredential(body.passkey); diff --git a/src/routes/api/auth/confirm/+server.ts b/src/routes/api/auth/confirm/+server.ts index 252bd3d..cdd5a8f 100644 --- a/src/routes/api/auth/confirm/+server.ts +++ b/src/routes/api/auth/confirm/+server.ts @@ -81,11 +81,12 @@ export const POST: RequestHandler = async ({ request }) => { const confirmationResult = await UserService.confirm(body.token); - const response: Record = { + const response: Record = { message: "User account confirmed successfully. You can now log in.", isSetup: confirmationResult.isSetup, id: confirmationResult.id, email: confirmationResult.email, + tenantId: confirmationResult.tenantId, }; // Include recovery passphrase if it exists (for WebAuthn-only users) diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/routes/api/auth/register/[id]/+server.ts b/src/routes/api/auth/register/[id]/+server.ts index 9d55516..7308dd8 100644 --- a/src/routes/api/auth/register/[id]/+server.ts +++ b/src/routes/api/auth/register/[id]/+server.ts @@ -115,8 +115,8 @@ export const POST: RequestHandler = async ({ params, cookies, request }) => { throw new ValidationError("Invalid or missing registration challenge cookie"); } - // Clear the registration cookie after validation (challenge cookie is cleared by login route) - cookies.delete("webauthn-registration-email", { path: "/" }); + // Note: Cookie is not deleted here to allow subsequent crypto key storage. + // It will expire automatically after 5 minutes (set in /api/auth/challenge) // Extract counter from WebAuthn credential const counter = WebAuthnService.extractCounterFromCredential(body.passkey); diff --git a/src/routes/api/tenants/+server.ts b/src/routes/api/tenants/+server.ts index d4f702d..414a11a 100644 --- a/src/routes/api/tenants/+server.ts +++ b/src/routes/api/tenants/+server.ts @@ -202,7 +202,11 @@ export const GET: RequestHandler = async ({ locals }) => { try { // Check if user is authenticated and is a global admin - checkPermission(locals, null, true, true); + checkPermission( + locals, + locals.user?.role === "TENANT_ADMIN" ? locals.user.tenantId : null, + true, + ); log.debug("Getting all tenants", { requestedBy: locals.user?.userId, diff --git a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts index c899569..a882f2c 100644 --- a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts @@ -159,8 +159,8 @@ export const POST: RequestHandler = async ({ request, params }) => { const clientPublicKeyBuffer = BufferUtils.from(tunnel.clientPublicKey, "hex"); const encapsulation = KyberCrypto.encapsulate(clientPublicKeyBuffer); - // Use the shared secret to encrypt the challenge - const challengeBuffer = BufferUtils.from(challenge); + // Use the shared secret to encrypt the challenge (decode base64 first!) + const challengeBuffer = Buffer.from(challenge, "base64"); const encryptedChallengeBuffer = BufferUtils.xor( challengeBuffer, encapsulation.sharedSecret.slice(0, challengeBuffer.length), diff --git a/src/routes/api/tenants/[id]/schedule/+server.ts b/src/routes/api/tenants/[id]/schedule/+server.ts index b49bee3..8cdf9b0 100644 --- a/src/routes/api/tenants/[id]/schedule/+server.ts +++ b/src/routes/api/tenants/[id]/schedule/+server.ts @@ -33,6 +33,20 @@ registerOpenAPIRoute("/tenants/{id}/schedule", "GET", { schema: { type: "string", format: "date-time" }, description: "End date for the schedule range (ISO 8601 format with timezone)", }, + { + name: "channel", + in: "query", + required: false, + schema: { type: "string", format: "uuid" }, + description: "Channel ID (UUID) to filter the schedule", + }, + { + name: "agent", + in: "query", + required: false, + schema: { type: "string", format: "uuid" }, + description: "Agent ID (UUID) to filter the schedule", + }, ], responses: { "200": { @@ -123,6 +137,8 @@ export const GET: RequestHandler = async ({ params, url }) => { // Parse query parameters for date range const startDateParam = url.searchParams.get("startDate"); const endDateParam = url.searchParams.get("endDate"); + const channelIdParam = url.searchParams.get("channel"); + const agentIdParam = url.searchParams.get("agent"); if (!startDateParam || !endDateParam) { throw new ValidationError("Both startDate and endDate query parameters are required"); @@ -159,6 +175,8 @@ export const GET: RequestHandler = async ({ params, url }) => { tenantId, startDate: startDateParam, endDate: endDateParam, + channelId: channelIdParam || undefined, + agentId: agentIdParam || undefined, }); // Transform schedule to client-friendly format (remove appointments, simplify available slots) @@ -179,7 +197,7 @@ export const GET: RequestHandler = async ({ params, url }) => { from: slot.from, to: slot.to, duration: slot.duration, - availableAgentCount: slot.availableAgents.length, + availableAgents: slot.availableAgents, })), }, ]), diff --git a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts index 2bced48..6864aa3 100644 --- a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts +++ b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts @@ -105,7 +105,7 @@ describe("Schedule API Route", () => { from: "09:00", to: "09:30", duration: 30, - availableAgentCount: 1, + availableAgents: [{ id: "agent-1", name: "Agent 1" }], }, ], }, diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts new file mode 100644 index 0000000..af52e2f --- /dev/null +++ b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts @@ -0,0 +1,194 @@ +/** + * API Route: Store Staff Cryptographic Keys + * + * Called by the browser after passkey registration to store: + * - Public key (for encryption by others) + * - Private key shard (database portion of split key) + * - Associated passkey ID + */ + +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "@sveltejs/kit"; +import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; +import { logger } from "$lib/logger"; +import { checkPermission } from "$lib/server/utils/permissions"; +import { BackendError, ValidationError, InternalError, logError } from "$lib/server/utils/errors"; +import { z } from "zod"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; + +// Register OpenAPI documentation +registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", { + summary: "Store staff cryptographic keys", + description: + "Stores the browser-generated cryptographic keys for a staff member after passkey registration. Can be called during passkey registration (with registration cookie) or when authenticated.", + tags: ["Staff", "Cryptography"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "staffId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Staff member ID", + }, + ], + requestBody: { + description: "Cryptographic key data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + passkeyId: { + type: "string", + description: "WebAuthn credential ID from passkey registration", + }, + publicKey: { + type: "string", + description: "Base64-encoded ML-KEM-768 public key", + }, + privateKeyShare: { + type: "string", + description: "Base64-encoded database shard of private key", + }, + email: { + type: "string", + format: "email", + description: "Email address for cookie validation during registration", + }, + }, + required: ["passkeyId", "publicKey", "privateKeyShare", "email"], + }, + }, + }, + }, + responses: { + "200": { + description: "Keys stored successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + }, + }, + }, + }, + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Unauthorized - can only store own keys", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +const requestSchema = z.object({ + passkeyId: z.string().min(1, "Passkey ID is required"), + publicKey: z.string().min(1, "Public key is required"), + privateKeyShare: z.string().min(1, "Private key share is required"), + email: z.string().email("Valid email is required").optional(), // Required for cookie validation +}); + +export const POST: RequestHandler = async ({ params, locals, request, cookies }) => { + const log = logger.setContext("API.StoreStaffCrypto"); + const { id: tenantId, staffId } = params; + + try { + if (!tenantId || !staffId) { + throw new ValidationError("Tenant ID and Staff ID are required"); + } + + // Parse and validate request body first (needed for both auth methods) + const body = await request.json(); + const validation = requestSchema.safeParse(body); + + if (!validation.success) { + console.error("Validation error:", validation.error.issues); + throw new ValidationError( + "Invalid request data: " + validation.error.issues.map((e) => e.message).join(", "), + ); + } + + const { passkeyId, publicKey, privateKeyShare, email } = validation.data; + + // Authentication: Accept either active session OR registration cookie + const isAuthenticated = locals.user && locals.user.userId === staffId; + const registrationEmail = cookies.get("webauthn-registration-email"); + const isRegistration = registrationEmail === email; + + if (!isAuthenticated && !isRegistration) { + log.warn("Unauthorized crypto key storage attempt", { + tenantId, + staffId, + requesterId: locals.user?.userId, + hasRegistrationCookie: !!registrationEmail, + emailsMatch: registrationEmail === email, + }); + } + + // For authenticated users, verify tenant access + if (isAuthenticated) { + checkPermission(locals, tenantId, false); + } + + log.debug("Storing staff crypto keys", { tenantId, staffId, passkeyId }); + + // Store the keys + const staffCryptoService = new StaffCryptoService(); + await staffCryptoService.storeStaffKeypair( + tenantId, + staffId, + passkeyId, + publicKey, + privateKeyShare, + ); + + log.info("Staff crypto keys stored successfully", { + tenantId, + staffId, + passkeyId, + }); + + return json({ + success: true, + message: "Cryptographic keys stored successfully", + }); + } catch (error) { + logError(log)("Failed to store staff crypto keys", error, locals.user?.userId, params.id); + log.error("Additional context", { tenantId, staffId }); + + if (error instanceof BackendError) { + return error.toJson(); + } + + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/__tests__/crypto-api.test.ts b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/__tests__/crypto-api.test.ts new file mode 100644 index 0000000..eb35b55 --- /dev/null +++ b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/__tests__/crypto-api.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect } from "vitest"; + +describe("Staff Crypto API", () => { + const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; + const mockStaffId = "456e7890-e89b-12d3-a456-426614174001"; + const mockPasskeyId = "passkey_abc123def456"; + const mockEmail = "staff@example.com"; + + describe("POST /api/tenants/[id]/staff/[staffId]/crypto", () => { + it("should validate required parameters", () => { + expect(mockTenantId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(mockStaffId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it("should validate request body structure", () => { + const validRequest = { + passkeyId: mockPasskeyId, + publicKey: "base64EncodedPublicKey==", + privateKeyShare: "base64EncodedPrivateKeyShare==", + email: mockEmail, + }; + + expect(validRequest.passkeyId).toBeTruthy(); + expect(validRequest.publicKey).toBeTruthy(); + expect(validRequest.privateKeyShare).toBeTruthy(); + expect(validRequest.email).toContain("@"); + }); + + it("should validate Base64 encoding format", () => { + const mockPublicKey = "SGVsbG8gV29ybGQ="; // "Hello World" in base64 + const mockPrivateKeyShare = "VGVzdCBEYXRh"; // "Test Data" in base64 + + // Base64 pattern: alphanumeric + / + = for padding + const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/; + + expect(mockPublicKey).toMatch(base64Pattern); + expect(mockPrivateKeyShare).toMatch(base64Pattern); + }); + + it("should handle authentication via session", () => { + const sessionUserId = mockStaffId; + const requestStaffId = mockStaffId; + + const isAuthenticated = sessionUserId === requestStaffId; + expect(isAuthenticated).toBe(true); + + // Test with different user + const differentUserId: string = "999e7890-e89b-12d3-a456-426614174999"; + const isNotAuthenticated = differentUserId === requestStaffId; + expect(isNotAuthenticated).toBe(false); + }); + + it("should handle authentication via registration cookie", () => { + const cookieEmail = "staff@example.com"; + const requestEmail = "staff@example.com"; + + const isRegistration = cookieEmail === requestEmail; + expect(isRegistration).toBe(true); + + // Test with different email + const differentEmail: string = "other@example.com"; + const isNotRegistration = cookieEmail === differentEmail; + expect(isNotRegistration).toBe(false); + }); + + it("should validate dual authentication logic", () => { + // Case 1: Authenticated user + const authenticatedUserId = mockStaffId; + const requestStaffId = mockStaffId; + const isAuthenticated = authenticatedUserId === requestStaffId; + + // Case 2: Registration with cookie + const registrationEmail = mockEmail; + const requestEmail = mockEmail; + const isRegistration = registrationEmail === requestEmail; + + // Should pass if either authentication method is valid + const shouldAllowAccess = isAuthenticated || isRegistration; + expect(shouldAllowAccess).toBe(true); + }); + + it("should reject unauthorized access", () => { + // No session + const authenticatedUserId = null; + const requestStaffId = mockStaffId; + const isAuthenticated = authenticatedUserId === requestStaffId; + + // Wrong email in cookie + const registrationEmail = "other@example.com"; + const requestEmail: string = mockEmail; + const isRegistration = registrationEmail === requestEmail; + + // Should deny if neither authentication method is valid + const shouldDenyAccess = !isAuthenticated && !isRegistration; + expect(shouldDenyAccess).toBe(true); + }); + + it("should validate successful storage response structure", () => { + const mockSuccessResponse = { + success: true, + message: "Cryptographic keys stored successfully", + }; + + expect(mockSuccessResponse.success).toBe(true); + expect(mockSuccessResponse.message).toBeTruthy(); + expect(typeof mockSuccessResponse.message).toBe("string"); + }); + + it("should validate crypto key relationships", () => { + // Each passkey should have exactly one crypto key entry + const passkeyToCryptoMapping = new Map< + string, + { publicKey: string; privateKeyShare: string } + >(); + + passkeyToCryptoMapping.set(mockPasskeyId, { + publicKey: "publicKeyBase64", + privateKeyShare: "privateKeyShareBase64", + }); + + expect(passkeyToCryptoMapping.has(mockPasskeyId)).toBe(true); + expect(passkeyToCryptoMapping.get(mockPasskeyId)?.publicKey).toBeTruthy(); + expect(passkeyToCryptoMapping.get(mockPasskeyId)?.privateKeyShare).toBeTruthy(); + }); + + it("should validate ML-KEM-768 key size constraints", () => { + // ML-KEM-768 specifications: + // - Public key: 1184 bytes + // - Private key: 2400 bytes + const mlKem768PublicKeySize = 1184; + const mlKem768PrivateKeySize = 2400; + + // Base64 encoding increases size by ~33% + const base64PublicKeyMinSize = Math.floor((mlKem768PublicKeySize * 4) / 3); + const base64PrivateKeyMinSize = Math.floor((mlKem768PrivateKeySize * 4) / 3); + + expect(base64PublicKeyMinSize).toBeGreaterThan(1500); + expect(base64PrivateKeyMinSize).toBeGreaterThan(3100); + }); + + it("should validate error response structure", () => { + const mockErrorResponse = { + error: "Invalid request data: Passkey ID is required", + }; + + expect(mockErrorResponse.error).toBeTruthy(); + expect(typeof mockErrorResponse.error).toBe("string"); + }); + + it("should handle missing required fields", () => { + const incompleteRequests = [ + { publicKey: "key", privateKeyShare: "share", email: mockEmail }, // Missing passkeyId + { passkeyId: mockPasskeyId, privateKeyShare: "share", email: mockEmail }, // Missing publicKey + { passkeyId: mockPasskeyId, publicKey: "key", email: mockEmail }, // Missing privateKeyShare + { passkeyId: mockPasskeyId, publicKey: "key", privateKeyShare: "share" }, // Missing email + ]; + + incompleteRequests.forEach((request) => { + const hasAllRequiredFields = + "passkeyId" in request && + "publicKey" in request && + "privateKeyShare" in request && + "email" in request; + expect(hasAllRequiredFields).toBe(false); + }); + }); + + it("should validate tenant membership for authenticated users", () => { + const userTenantId = mockTenantId; + const requestTenantId = mockTenantId; + + const belongsToTenant = userTenantId === requestTenantId; + expect(belongsToTenant).toBe(true); + + // Test with different tenant + const differentTenantId: string = "999e4567-e89b-12d3-a456-426614174999"; + const belongsToDifferentTenant = userTenantId === differentTenantId; + expect(belongsToDifferentTenant).toBe(false); + }); + + it("should validate XOR shard relationship", () => { + // Mock data representing the split-key architecture + const mockPrivateKey = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const mockPasskeyBasedShard = new Uint8Array([0x0a, 0x0b, 0x0c, 0x0d]); + + // Database shard = privateKey XOR passkeyBasedShard + const dbShard = new Uint8Array(mockPrivateKey.length); + for (let i = 0; i < mockPrivateKey.length; i++) { + dbShard[i] = mockPrivateKey[i] ^ mockPasskeyBasedShard[i]; + } + + // Verify reconstruction: privateKey = dbShard XOR passkeyBasedShard + const reconstructedKey = new Uint8Array(dbShard.length); + for (let i = 0; i < dbShard.length; i++) { + reconstructedKey[i] = dbShard[i] ^ mockPasskeyBasedShard[i]; + } + + expect(reconstructedKey).toEqual(mockPrivateKey); + }); + + it("should validate cookie TTL constraints", () => { + // Cookie is set with 5-minute expiry in /api/auth/challenge + const cookieTTLSeconds = 5 * 60; + const expectedMaxAge = 300; // 5 minutes + + expect(cookieTTLSeconds).toBe(expectedMaxAge); + expect(cookieTTLSeconds).toBeGreaterThan(0); + expect(cookieTTLSeconds).toBeLessThan(600); // Less than 10 minutes + }); + }); +}); diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index 89c57df..8f69bdc 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -3,49 +3,11 @@ import { SessionService } from "$lib/server/auth/session-service"; import { UniversalLogger } from "$lib/logger"; import { AuthorizationService } from "$lib/server/auth/authorization-service"; import { getAccessToken } from "./utils/accessToken"; +import type { SelectUser } from "$lib/server/db/central-schema"; const logger = new UniversalLogger().setContext("AuthHandle"); -const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants"]; -const PUBLIC_PATHS = [ - "/", - "/api/auth/challenge", - "/api/auth/login", - "/api/auth/register", - "/api/auth/confirm", - "/api/auth/resend-confirmation", - "/api/health", - "/api/docs", - "/api/openapi.json", - "/api/env", - "/api/log", - "/api/admin/init", - "/api/admin/exists", - "/api/auth/refresh", // Must be public since the access token might already be invalid when this gets called -]; - -// Client booking routes that need public access (no authentication required) -const CLIENT_BOOKING_PATHS = [ - "/api/public", // Public tenant information - "/schedule", // Available time slots for clients - "/appointments/challenge", // Create authentication challenge for existing clients - "/appointments/verify-challenge", // Verify challenge response - "/appointments/create-new-client", // Create new client with first appointment - "/appointments/add-to-tunnel", // Add appointment to existing client tunnel - "/appointments/staff-public-keys", // Get staff public keys for encryption - "/appointments/", // Individual appointment details (for clients to view their own appointments) -]; - -const GLOBAL_ADMIN_PATHS = ["/api/admin", "/api/tenants"]; -const ADMIN_PATHS = ["/api/tenant-admin"]; - -const PROTECTED_AUTH_PATHS = [ - "/api/auth/logout", - "/api/auth/session", - "/api/auth/sessions", - "/api/auth/passkeys", - "/api/auth/invite", -]; +const GLOBAL_ADMIN_PATHS = ["/api/admin"]; export const apiAuthHandle: Handle = async ({ event, resolve }) => { const { url } = event; @@ -55,42 +17,40 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { } // Check for tenant client booking routes (public for clients) - const isTenantClientBookingRoute = - path.match(/^\/api\/tenants\/[^/]+\/?/) && - CLIENT_BOOKING_PATHS.some((clientPath) => path.includes(clientPath)); + const isGlobalAdminPath = GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); + const isAdminPath = false; - const isProtectedPath = - PROTECTED_PATHS.some((protectedPath) => path.startsWith(protectedPath)) && - !isTenantClientBookingRoute; - const isPublicPath = PUBLIC_PATHS.some((publicPath) => path.startsWith(publicPath)); - const isProtectedAuthPath = PROTECTED_AUTH_PATHS.some((authPath) => path.startsWith(authPath)); - const isGlobalAdminPath = - GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)) && !isTenantClientBookingRoute; - const isAdminPath = ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); + const accessToken: string | null = getAccessToken(event); + let sessionData: { + user: SelectUser; + sessionId: string; + exp: Date; + } | null = null; + if (accessToken) { + // Verify access token with database session check + sessionData = await SessionService.validateTokenWithDB(accessToken); + if (!sessionData) { + logger.warn(`Invalid or revoked access token for ${path}`); + return new Response(JSON.stringify({ error: "Invalid or expired access token" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // Add sessionId to the user object for easy access + event.locals.user = { + userId: sessionData.user.id, + exp: sessionData.exp.valueOf(), + ...sessionData.user, + sessionId: sessionData.sessionId, + }; + } // Allow public paths and tenant client booking routes - if ( - (isPublicPath || isTenantClientBookingRoute) && - !isProtectedAuthPath && - !isProtectedPath && - !isAdminPath && - !isGlobalAdminPath - ) { + if (!isGlobalAdminPath) { return resolve(event); } - // Require authentication for protected paths and protected auth paths - if (!isProtectedPath && !isProtectedAuthPath) { - return new Response( - JSON.stringify({ error: `Path ${path} not handled by auth. This is an error` }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } - - const accessToken: string | null = getAccessToken(event); if (!accessToken) { logger.warn(`Authentication required for ${path}`); return new Response(JSON.stringify({ error: "Authentication required" }), { @@ -99,32 +59,18 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { }); } - // Verify access token with database session check - const sessionData = await SessionService.validateTokenWithDB(accessToken); - if (!sessionData) { - logger.warn(`Invalid or revoked access token for ${path}`); - return new Response(JSON.stringify({ error: "Invalid or expired access token" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - // Add sessionId to the user object for easy access - event.locals.user = { - userId: sessionData.user.id, - exp: sessionData.exp.valueOf(), - ...sessionData.user, - sessionId: sessionData.sessionId, - }; - - if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { + if ( + isGlobalAdminPath && + (!sessionData || !AuthorizationService.hasRole(sessionData?.user, "GLOBAL_ADMIN")) + ) { return new Response(JSON.stringify({ error: "Authentication failed" }), { status: 403, headers: { "Content-Type": "application/json" }, }); } else if ( isAdminPath && - !AuthorizationService.hasAnyRole(sessionData.user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]) + (!sessionData || + !AuthorizationService.hasAnyRole(sessionData.user, ["GLOBAL_ADMIN", "TENANT_ADMIN"])) ) { return new Response(JSON.stringify({ error: "Authentication failed" }), { status: 403, @@ -134,7 +80,7 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { // Protected auth paths require any authenticated user (no specific role required) // The authentication check above is sufficient - logger.debug(`User authenticated: ${sessionData.user.email} for ${path}`); + logger.debug(`User authenticated: ${sessionData?.user.email ?? "unauthenticated"} for ${path}`); return resolve(event); }; diff --git a/src/server-hooks/secHeaderHandle.ts b/src/server-hooks/secHeaderHandle.ts index 801bf1e..c1fb1e4 100644 --- a/src/server-hooks/secHeaderHandle.ts +++ b/src/server-hooks/secHeaderHandle.ts @@ -27,7 +27,7 @@ export const secHeaderHandle: Handle = async ({ event, resolve }) => { } const cspDirectives = [ - "script-src 'self' 'unsafe-inline' https://unpkg.com", // Allow inline scripts and unpkg CDN (for Swagger) + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com", // Allow inline scripts, WASM modules, and unpkg CDN (for Swagger) "style-src 'self' 'unsafe-inline' https://unpkg.com", "font-src 'self' data: https://unpkg.com", "connect-src 'self'", @@ -39,7 +39,7 @@ export const secHeaderHandle: Handle = async ({ event, resolve }) => { "upgrade-insecure-requests", ]; if (!dev) { - cspDirectives.unshift("default-src 'self'"); + cspDirectives.unshift("default-src 'self' 'wasm-unsafe-eval'"); cspDirectives.push("img-src 'self' data: https:"); } response.headers.set("Content-Security-Policy", cspDirectives.join("; ")); From d5f316b181549e805cfff6867d7452cb23191d8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:58:02 +0100 Subject: [PATCH 11/61] Bump js-yaml from 4.1.0 to 4.1.1 (#135) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.1.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5edcfe2..0c88b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5368,9 +5368,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { From 6a610b96b1a81f885b62c3710ae6ab49b93d841d Mon Sep 17 00:00:00 2001 From: Hendrik Date: Mon, 17 Nov 2025 17:04:57 +0100 Subject: [PATCH 12/61] =?UTF-8?q?When=20client=20selects=20an=20appointmen?= =?UTF-8?q?t=20that=20does=20not=20need=20to=20be=20confirmed=E2=80=A6=20(?= =?UTF-8?q?#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * When client selects an appointment that does not need to be confirmed, send out an email. Also prepared the appointment confirmation call to send out an email. * Removed old client table. Fixed tests. * MIgration for removed table --- src/lib/client/appointment-crypto.ts | 5 + src/lib/server/db/tenant-schema.ts | 24 - src/lib/server/db/tenant-service.ts | 6 +- .../email/__tests__/email-system.test.ts | 1 + .../email/__tests__/generate-base-url.test.ts | 5 +- src/lib/server/email/email-service.ts | 95 ++- src/lib/server/email/mailer.ts | 23 +- src/lib/server/email/template-engine.ts | 5 +- .../templates/appointment-created.de.html | 130 +++ .../templates/appointment-created.de.txt | 26 + .../templates/appointment-created.en.html | 132 ++++ .../templates/appointment-created.en.txt | 26 + .../templates/appointment-request.de.html | 135 ++++ .../templates/appointment-request.de.txt | 28 + .../templates/appointment-request.en.html | 137 ++++ .../templates/appointment-request.en.txt | 28 + .../__tests__/appointment-service.test.ts | 2 + .../server/services/appointment-service.ts | 146 +++- .../[[id]]/(components)/summary.svelte | 2 + .../[appointmentId]/confirm/+server.ts | 57 +- .../confirm/__tests__/confirm-api.test.ts | 6 +- .../appointments/add-to-tunnel/+server.ts | 83 ++ .../appointments/create-new-client/+server.ts | 15 + .../__tests__/create-new-client-api.test.ts | 2 + tenant-migrations/0006_gray_richard_fisk.sql | 1 + tenant-migrations/meta/0006_snapshot.json | 739 ++++++++++++++++++ tenant-migrations/meta/_journal.json | 7 + 27 files changed, 1802 insertions(+), 64 deletions(-) create mode 100644 src/lib/server/email/templates/appointment-created.de.html create mode 100644 src/lib/server/email/templates/appointment-created.de.txt create mode 100644 src/lib/server/email/templates/appointment-created.en.html create mode 100644 src/lib/server/email/templates/appointment-created.en.txt create mode 100644 src/lib/server/email/templates/appointment-request.de.html create mode 100644 src/lib/server/email/templates/appointment-request.de.txt create mode 100644 src/lib/server/email/templates/appointment-request.en.html create mode 100644 src/lib/server/email/templates/appointment-request.en.txt create mode 100644 tenant-migrations/0006_gray_richard_fisk.sql create mode 100644 tenant-migrations/meta/0006_snapshot.json diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index b34df82..ee7c73c 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -259,6 +259,7 @@ export class UnifiedAppointmentCrypto { channelId: string, tenantId: string, isFirstAppointment: boolean = false, + clientLanguage: string = "de", ): Promise { if (!this.clientAuthenticated || !this.tunnelKey) { throw new Error("Client not authenticated"); @@ -281,6 +282,8 @@ export class UnifiedAppointmentCrypto { channelId, appointmentDate, emailHash: this.emailHash, + clientEmail: appointmentData.email, + clientLanguage, clientPublicKey: this.clientKeyPair?.publicKey, privateKeyShare: await this.getPrivateKeyShare(), encryptedAppointment, @@ -295,6 +298,8 @@ export class UnifiedAppointmentCrypto { agentId, channelId, appointmentDate, + clientEmail: appointmentData.email, + clientLanguage, encryptedAppointment, }; diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 9465360..c046482 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -125,27 +125,6 @@ export const channelSlotTemplate = pgTable("channel_slot_template", { .references(() => slotTemplate.id), }); -/** - * Client table - represents end users who book appointments - * Uses end-to-end encryption for privacy protection - * Stored in tenant-specific database - * @table client - */ -export const client = pgTable("client", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Hash of client email for identification without storing plaintext */ - hashKey: text("hash_key").notNull().unique(), - /** Client's public key for end-to-end encryption */ - publicKey: text("public_key").notNull(), - /** Server-side share of client's private key for recovery */ - privateKeyShare: text("private_key_share").notNull(), - /** Client email address (optional for privacy) */ - email: text("email"), - /** Preferred language for communications (de/en) */ - language: text("language"), -}); - /** * Appointment table - represents scheduled appointments between clients and channels * Uses hybrid encryption: symmetric key for data, asymmetric for key sharing @@ -235,9 +214,6 @@ export const appointmentKeyShare = pgTable("appointment_key_share", { * These types represent the shape of data when queried from the database */ -/** Client record type for database queries */ -export type SelectClient = InferSelectModel; - /** Channel record type for database queries */ export type SelectChannel = InferSelectModel; diff --git a/src/lib/server/db/tenant-service.ts b/src/lib/server/db/tenant-service.ts index b38d275..e5c9000 100644 --- a/src/lib/server/db/tenant-service.ts +++ b/src/lib/server/db/tenant-service.ts @@ -26,11 +26,11 @@ export class TenantService { } /** - * Get all clients for this tenant + * Get all client tunnels for this tenant */ - async getClients() { + async getClientTunnels() { const db = await this.getDb(); - return await db.select().from(tenantSchema.client); + return await db.select().from(tenantSchema.clientAppointmentTunnel); } /** diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index 70b6c6d..e4b2cdb 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -10,6 +10,7 @@ vi.mock("$env/dynamic/private", () => ({ SMTP_PASS: "test-password", SMTP_FROM_NAME: "Test App", SMTP_FROM_EMAIL: "noreply@test.com", + DATABASE_URL: "postgresql://test:test@localhost:5432/test", }, })); diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index 7352d3c..1a3cd19 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -3,7 +3,10 @@ import { generateBaseUrl } from "../email-service"; import type { SelectTenant } from "$lib/server/db/central-schema"; // Mock NODE_ENV -const mockEnv = vi.hoisted(() => ({ NODE_ENV: "development" })); +const mockEnv = vi.hoisted(() => ({ + NODE_ENV: "development", + DATABASE_URL: "postgresql://test:test@localhost:5432/test", +})); vi.mock("$env/dynamic/private", () => ({ env: mockEnv, diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 8e43231..574e8fa 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -5,8 +5,50 @@ import { type TemplateData, type Language, } from "./template-engine"; -import type { SelectClient, SelectAppointment } from "$lib/server/db/tenant-schema"; +import type { SelectAppointment } from "$lib/server/db/tenant-schema"; import type { SelectTenant, SelectUser } from "$lib/server/db/central-schema"; +import { getTenantDb } from "$lib/server/db"; +import * as tenantSchema from "$lib/server/db/tenant-schema"; +import { eq } from "drizzle-orm"; + +type SelectClient = { + email: string; + language: string; +}; + +/** + * Get channel title in the user's preferred language + * @param {string} tenantId - Tenant ID + * @param {string} channelId - Channel ID + * @param {string} [userLanguage="de"] - User's preferred language + * @returns {Promise} Channel title or undefined if not found + */ +export async function getChannelTitle( + tenantId: string, + channelId: string, + userLanguage: string = "de", +): Promise { + try { + const db = await getTenantDb(tenantId); + const channelResult = await db + .select({ + names: tenantSchema.channel.names, + }) + .from(tenantSchema.channel) + .where(eq(tenantSchema.channel.id, channelId)) + .limit(1); + + if (channelResult.length === 0 || !channelResult[0].names) { + return undefined; + } + + const names = channelResult[0].names as Record; + return names[userLanguage] || names["de"] || names["en"] || Object.values(names)[0]; + } catch { + // Return undefined on error, don't throw - this is optional information for emails + return undefined; + } +} /** * Send a templated email using the template engine @@ -132,9 +174,10 @@ export async function sendAppointmentReminderEmail( /** * Send appointment confirmation email for newly created appointments - * @param {SelectClient | SelectUser} user - Database user object + * @param {SelectClient | SelectUser} user - Database user object or client data * @param {SelectTenant} tenant - Tenant information for branding * @param {SelectAppointment} appointment - Appointment details + * @param {string} [channelTitle] - Optional channel title/name * @param {string} [cancelUrl] - Optional URL to cancel appointment * @throws {Error} When email sending fails * @returns {Promise} @@ -143,16 +186,56 @@ export async function sendAppointmentCreatedEmail( user: SelectClient | SelectUser, tenant: SelectTenant, appointment: SelectAppointment, + channelTitle?: string, cancelUrl?: string, ): Promise { - const recipient = createEmailRecipient(user); + // Create recipient directly for SelectClient type, use helper for SelectUser + const recipient: EmailRecipient = + "email" in user && typeof user.email === "string" && "language" in user && !("name" in user) + ? { email: user.email, language: user.language } + : createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt"; await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, { appointment, appointmentDate: appointment.appointmentDate, - title: appointment.channelId, // TODO: Get the channel to give back a proper title + title: channelTitle || appointment.channelId, + cancelUrl, + }); +} + +/** + * Send appointment request email (when staff confirmation is required) + * @param {SelectClient | SelectUser} user - Database user object or client data + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Appointment details + * @param {string} [channelTitle] - Optional channel title/name + * @param {string} [cancelUrl] - Optional URL to cancel appointment + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentRequestEmail( + user: SelectClient | SelectUser, + tenant: SelectTenant, + appointment: SelectAppointment, + channelTitle?: string, + cancelUrl?: string, +): Promise { + // Create recipient directly for SelectClient type, use helper for SelectUser + const recipient: EmailRecipient = + "email" in user && typeof user.email === "string" && "language" in user && !("name" in user) + ? { email: user.email, language: user.language } + : createEmailRecipient(user); + + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Appointment Request Received" : "Terminanfrage erhalten"; + + await sendTemplatedEmail("appointment-request", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + title: channelTitle || appointment.channelId, cancelUrl, }); } @@ -162,6 +245,7 @@ export async function sendAppointmentCreatedEmail( * @param {SelectClient | SelectUser} user - Database user object * @param {SelectTenant} tenant - Tenant information for branding * @param {SelectAppointment} appointment - Updated appointment details + * @param {string} [channelTitle] - Optional channel title/name * @param {string} [cancelUrl] - Optional URL to cancel appointment * @throws {Error} When email sending fails * @returns {Promise} @@ -170,6 +254,7 @@ export async function sendAppointmentUpdatedEmail( user: SelectClient | SelectUser, tenant: SelectTenant, appointment: SelectAppointment, + channelTitle?: string, cancelUrl?: string, ): Promise { const recipient = createEmailRecipient(user); @@ -179,7 +264,7 @@ export async function sendAppointmentUpdatedEmail( await sendTemplatedEmail("appointment-updated", recipient, subject, language, tenant, { appointment, appointmentDate: appointment.appointmentDate, - title: appointment.channelId, // TODO: Get the channel to give back a proper title + title: channelTitle || appointment.channelId, cancelUrl, }); } diff --git a/src/lib/server/email/mailer.ts b/src/lib/server/email/mailer.ts index 6870212..164c19a 100644 --- a/src/lib/server/email/mailer.ts +++ b/src/lib/server/email/mailer.ts @@ -1,9 +1,16 @@ import nodemailer from "nodemailer"; import { env } from "$env/dynamic/private"; -import type { SelectClient } from "$lib/server/db/tenant-schema"; import type Mail from "nodemailer/lib/mailer"; import type { SelectUser } from "../db/central-schema"; +/** + * Simple client data type for email sending + */ +type SelectClient = { + email: string; + language?: string; +}; + /** * Email recipient interface * @interface EmailRecipient @@ -29,19 +36,19 @@ export function createEmailRecipient( | SelectUser | { id: string; email: string | null; name: string | null; language?: string | null }, ): EmailRecipient { - // Handle SelectUser type (from central schema) - if ("email" in user && !("publicKey" in user)) { + // Handle SelectClient type (simple object with just email and language) + if ("email" in user && !("name" in user) && !("id" in user)) { return { email: user.email || "", - name: user.name || undefined, - language: user.language || "de", // Use user's language preference + name: undefined, // Clients don't have names for privacy + language: user.language || "de", }; } - // Handle SelectClient type (no name property) + // Handle SelectUser or generic user type (has name and id) else { return { - email: user.email || "", // Client email is optional - name: undefined, // Clients don't have names for privacy + email: user.email || "", + name: "name" in user ? user.name || undefined : undefined, language: user.language || "de", }; } diff --git a/src/lib/server/email/template-engine.ts b/src/lib/server/email/template-engine.ts index a9843d2..b189824 100644 --- a/src/lib/server/email/template-engine.ts +++ b/src/lib/server/email/template-engine.ts @@ -8,14 +8,15 @@ export type Language = "de" | "en"; /** * Available email template types - * @typedef {'user-created' | 'pin-reset' | 'key-reset' | 'appointment-reminder' | 'appointment-created' | 'appointment-updated' | 'confirmation'} EmailTemplateType + * @typedef {'user-created' | 'pin-reset' | 'key-reset' | 'appointment-reminder' | 'appointment-created' | 'appointment-request' | 'appointment-updated' | 'confirmation'} EmailTemplateType */ export type EmailTemplateType = | "user-created" | "pin-reset" // Info that PIN was reset (no code) | "key-reset" | "appointment-reminder" - | "appointment-created" + | "appointment-created" // Appointment confirmed (no staff confirmation needed) + | "appointment-request" // Appointment requested (requires staff confirmation) | "appointment-updated" | "confirmation" // Registration confirmation with one-time code | "tenant-admin-invite" // Invitation for tenant administrator diff --git a/src/lib/server/email/templates/appointment-created.de.html b/src/lib/server/email/templates/appointment-created.de.html new file mode 100644 index 0000000..df29ba5 --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.de.html @@ -0,0 +1,130 @@ + + + + + + Termin bestätigt + + + + + + diff --git a/src/lib/server/email/templates/appointment-created.de.txt b/src/lib/server/email/templates/appointment-created.de.txt new file mode 100644 index 0000000..751b60f --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.de.txt @@ -0,0 +1,26 @@ +Termin bestätigt - {{tenant.longName}} + +✓ Ihr Termin wurde erfolgreich gebucht! + +Vielen Dank für Ihre Buchung. Ihr Termin wurde bestätigt. + +TERMINDETAILS +============= +Datum: {{appointmentDate}} +{{#if title}} +Termin: {{title}} +{{/if}} +{{#if location}} +Ort: {{location}} +{{/if}} + +{{#if cancelUrl}} +Termin absagen: {{cancelUrl}} +{{/if}} + +Wir freuen uns auf Ihren Besuch! + +Bei Fragen kontaktieren Sie uns bitte. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/appointment-created.en.html b/src/lib/server/email/templates/appointment-created.en.html new file mode 100644 index 0000000..0058ae9 --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.en.html @@ -0,0 +1,132 @@ + + + + + + Appointment Confirmed + + + + + + diff --git a/src/lib/server/email/templates/appointment-created.en.txt b/src/lib/server/email/templates/appointment-created.en.txt new file mode 100644 index 0000000..e32f630 --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.en.txt @@ -0,0 +1,26 @@ +Appointment Confirmed - {{tenant.longName}} + +✓ Your appointment has been successfully booked! + +Thank you for your booking. Your appointment has been confirmed. + +APPOINTMENT DETAILS +=================== +Date: {{appointmentDate}} +{{#if title}} +Appointment: {{title}} +{{/if}} +{{#if location}} +Location: {{location}} +{{/if}} + +{{#if cancelUrl}} +Cancel appointment: {{cancelUrl}} +{{/if}} + +We look forward to seeing you! + +If you have any questions, please contact us. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/appointment-request.de.html b/src/lib/server/email/templates/appointment-request.de.html new file mode 100644 index 0000000..ceb86c3 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.de.html @@ -0,0 +1,135 @@ + + + + + + Terminanfrage erhalten + + + + + + diff --git a/src/lib/server/email/templates/appointment-request.de.txt b/src/lib/server/email/templates/appointment-request.de.txt new file mode 100644 index 0000000..1e5c6a9 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.de.txt @@ -0,0 +1,28 @@ +Terminanfrage erhalten - {{tenant.longName}} + +ℹ️ Ihre Terminanfrage wird geprüft + +Vielen Dank für Ihre Terminanfrage. Wir haben Ihre Anfrage erhalten und werden diese in Kürze prüfen. + +Sie erhalten eine weitere E-Mail, sobald Ihr Termin bestätigt oder abgelehnt wurde. + +ANGEFORDERTE TERMINDETAILS +========================== +Datum: {{appointmentDate}} +{{#if title}} +Termin: {{title}} +{{/if}} +{{#if location}} +Ort: {{location}} +{{/if}} + +{{#if cancelUrl}} +Anfrage zurückziehen: {{cancelUrl}} +{{/if}} + +Wir werden uns so schnell wie möglich bei Ihnen melden. + +Bei Fragen kontaktieren Sie uns bitte. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/appointment-request.en.html b/src/lib/server/email/templates/appointment-request.en.html new file mode 100644 index 0000000..b491852 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.en.html @@ -0,0 +1,137 @@ + + + + + + Appointment Request Received + + + + + + diff --git a/src/lib/server/email/templates/appointment-request.en.txt b/src/lib/server/email/templates/appointment-request.en.txt new file mode 100644 index 0000000..1661b30 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.en.txt @@ -0,0 +1,28 @@ +Appointment Request Received - {{tenant.longName}} + +ℹ️ Your appointment request is being reviewed + +Thank you for your appointment request. We have received your request and will review it shortly. + +You will receive another email once your appointment has been confirmed or declined. + +REQUESTED APPOINTMENT DETAILS +============================== +Date: {{appointmentDate}} +{{#if title}} +Appointment: {{title}} +{{/if}} +{{#if location}} +Location: {{location}} +{{/if}} + +{{#if cancelUrl}} +Withdraw request: {{cancelUrl}} +{{/if}} + +We will get back to you as soon as possible. + +If you have any questions, please contact us. + +-- +{{tenant.longName}} diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index c81bb63..49d0178 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -39,6 +39,8 @@ const mockClientTunnelData = { agentId: "agent-123", appointmentDate: "2024-01-15T10:00:00Z", emailHash: "email-hash-123", + clientEmail: "test@example.com", + clientLanguage: "de", clientPublicKey: "client-public-key", privateKeyShare: "private-key-share", encryptedAppointment: { diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index c04c0c8..06678f7 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -1,11 +1,17 @@ import { getTenantDb, centralDb } from "../db"; import * as tenantSchema from "../db/tenant-schema"; import { type SelectAppointment } from "../db/tenant-schema"; -import { user } from "../db/central-schema"; +import * as centralSchema from "../db/central-schema"; import logger from "$lib/logger"; import { ValidationError, NotFoundError, InternalError, ConflictError } from "../utils/errors"; import { and, eq, gte, lte, asc } from "drizzle-orm"; import type { AppointmentResponse } from "$lib/types/appointment"; +import { + sendAppointmentCreatedEmail, + sendAppointmentRequestEmail, + getChannelTitle, +} from "../email/email-service"; +import { TenantAdminService } from "./tenant-admin-service"; export interface ClientTunnelData { tunnelId: string; @@ -13,6 +19,8 @@ export interface ClientTunnelData { agentId: string; appointmentDate: string; emailHash: string; + clientEmail: string; + clientLanguage?: string; clientPublicKey: string; privateKeyShare: string; encryptedAppointment: { @@ -62,21 +70,72 @@ export class AppointmentService { } /** - * Send appointment notification email + * Send appointment notification email to client + * @param appointmentId - The appointment ID + * @param channelId - The channel ID + * @param clientEmail - Client's email address + * @param clientLanguage - Client's preferred language + * @param requiresConfirmation - Whether the appointment requires staff confirmation */ private async sendAppointmentNotification( - email: string, - appointment: SelectAppointment, - status: "NEW" | "CONFIRMED", + appointmentId: string, + channelId: string, + clientEmail: string, + clientLanguage: string, + requiresConfirmation: boolean, ): Promise { - // TODO: Implement email service integration const log = logger.setContext("AppointmentService"); log.debug("Sending appointment notification", { - email, - appointmentId: appointment.id, - status, + appointmentId, + clientEmail, + requiresConfirmation, tenantId: this.tenantId, }); + + try { + // Get tenant information + const tenantService = await TenantAdminService.getTenantById(this.tenantId); + const tenant = tenantService.tenantData; + + if (!tenant) { + log.warn("Cannot send email: Tenant not found", { tenantId: this.tenantId }); + return; + } + + // Get full appointment data + const appointment = await this.getAppointmentById(appointmentId); + + // Create client data object + const clientData = { + email: clientEmail, + language: clientLanguage, + }; + + // Get channel title for the email + const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage); + + // Send appropriate email based on whether confirmation is required + if (requiresConfirmation) { + await sendAppointmentRequestEmail(clientData, tenant, appointment, channelTitle); + log.info("Appointment request email sent", { + appointmentId: appointment.id, + tenantId: this.tenantId, + }); + } else { + await sendAppointmentCreatedEmail(clientData, tenant, appointment, channelTitle); + log.info("Appointment confirmation email sent", { + appointmentId: appointment.id, + tenantId: this.tenantId, + }); + } + } catch (error) { + log.error("Failed to send appointment notification email", { + appointmentId, + tenantId: this.tenantId, + error: String(error), + }); + // Don't throw - email failure shouldn't fail the appointment creation + } } public async getAppointmentById(id: string): Promise { @@ -99,7 +158,11 @@ export class AppointmentService { return row; } - public async confirmAppointment(id: string): Promise { + public async confirmAppointment( + id: string, + clientEmail?: string, + clientLanguage?: string, + ): Promise { const log = logger.setContext("AppointmentService"); log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId }); const db = await this.getDb(); @@ -120,6 +183,23 @@ export class AppointmentService { } const row = result[0]; + + // Send confirmation email to client if email is provided (async, don't wait) + if (clientEmail) { + this.sendAppointmentNotification( + row.id, + row.channelId, + clientEmail, + clientLanguage || "de", + false, + ).catch((error) => { + log.error("Failed to send appointment confirmation email", { + appointmentId: row.id, + error: String(error), + }); + }); + } + return row; } @@ -219,9 +299,14 @@ export class AppointmentService { // Check if there are any authorized users (ACCESS_GRANTED) in this tenant const authorizedUsers = await centralDb - .select({ count: user.id }) - .from(user) - .where(and(eq(user.tenantId, this.tenantId), eq(user.confirmationState, "ACCESS_GRANTED"))) + .select({ count: centralSchema.user.id }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, this.tenantId), + eq(centralSchema.user.confirmationState, "ACCESS_GRANTED"), + ), + ) .limit(1); if (authorizedUsers.length === 0) { @@ -299,6 +384,7 @@ export class AppointmentService { } const initialStatus = channelResult[0].requiresConfirmation ? "NEW" : "CONFIRMED"; + const requiresConfirmation = channelResult[0].requiresConfirmation || false; // 4. Create encrypted appointment const appointmentResult = await tx @@ -317,28 +403,52 @@ export class AppointmentService { id: tenantSchema.appointment.id, appointmentDate: tenantSchema.appointment.appointmentDate, status: tenantSchema.appointment.status, + tunnelId: tenantSchema.appointment.tunnelId, + channelId: tenantSchema.appointment.channelId, + agentId: tenantSchema.appointment.agentId, + encryptedPayload: tenantSchema.appointment.encryptedPayload, + iv: tenantSchema.appointment.iv, + authTag: tenantSchema.appointment.authTag, + expiryDate: tenantSchema.appointment.expiryDate, + createdAt: tenantSchema.appointment.createdAt, + updatedAt: tenantSchema.appointment.updatedAt, }); if (appointmentResult.length === 0) { throw new InternalError("Failed to create appointment"); } - return appointmentResult[0]; + return { appointment: appointmentResult[0], requiresConfirmation }; }); const response: AppointmentResponse = { - id: result.id, - appointmentDate: result.appointmentDate.toISOString(), - status: result.status, + id: result.appointment.id, + appointmentDate: result.appointment.appointmentDate.toISOString(), + status: result.appointment.status, }; log.info("Successfully created new client appointment tunnel", { tenantId: this.tenantId, tunnelId: clientData.tunnelId, - appointmentId: result.id, + appointmentId: result.appointment.id, staffSharesCount: clientData.staffKeyShares.length, }); + // Send email notification to client (async, don't wait) + this.sendAppointmentNotification( + result.appointment.id, + clientData.channelId, + clientData.clientEmail, + clientData.clientLanguage || "de", + result.requiresConfirmation, + ).catch((error) => { + log.error("Failed to send appointment notification", { + tunnelId: clientData.tunnelId, + appointmentId: result.appointment.id, + error: String(error), + }); + }); + return response; } diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index 7a4791a..f63f661 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -1,6 +1,7 @@ sidebar.setOpen(open)}> @@ -22,7 +28,7 @@
-
+
{ @@ -52,10 +58,18 @@ {/if} + {#if headerRight} +
+ {@render headerRight?.()} +
+ {/if}
{@render children?.()} + {#if sidebarRight} + {@render sidebarRight?.()} + {/if}
diff --git a/src/lib/components/ui/button-group/button-group-separator.svelte b/src/lib/components/ui/button-group/button-group-separator.svelte new file mode 100644 index 0000000..5f5bd26 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group-separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/button-group/button-group-text.svelte b/src/lib/components/ui/button-group/button-group-text.svelte new file mode 100644 index 0000000..4ec9e07 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group-text.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render mergedProps.children?.()} +
+{/if} diff --git a/src/lib/components/ui/button-group/button-group.svelte b/src/lib/components/ui/button-group/button-group.svelte new file mode 100644 index 0000000..3d42387 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group.svelte @@ -0,0 +1,46 @@ + + + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/button-group/index.ts b/src/lib/components/ui/button-group/index.ts new file mode 100644 index 0000000..177d11b --- /dev/null +++ b/src/lib/components/ui/button-group/index.ts @@ -0,0 +1,13 @@ +import Root from "./button-group.svelte"; +import Text from "./button-group-text.svelte"; +import Separator from "./button-group-separator.svelte"; + +export { + Root, + Text, + Separator, + // + Root as ButtonGroup, + Text as ButtonGroupText, + Separator as ButtonGroupSeparator, +}; diff --git a/src/lib/components/ui/public/appointment-card.svelte b/src/lib/components/ui/public/appointment-card.svelte index bb26784..c9ccaeb 100644 --- a/src/lib/components/ui/public/appointment-card.svelte +++ b/src/lib/components/ui/public/appointment-card.svelte @@ -65,16 +65,16 @@ {/if} {#if appointment.agent || appointment.agent === null} -
- +
+ {appointment.agent?.name || m["public.anyAgent"]()}
{/if} {#if appointment.slot} -
- +
+ {Intl.DateTimeFormat($publicStore.locale, { year: "numeric", @@ -89,13 +89,17 @@ {/if} {#if appointment.data}
- + {appointment.data.name}
- {appointment.data.email} + + {appointment.data.email} + {#if appointment.data.phone}
- {appointment.data.phone} + + {appointment.data.phone} + {/if}
diff --git a/src/lib/components/ui/radio-group/index.ts b/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 0000000..b608946 --- /dev/null +++ b/src/lib/components/ui/radio-group/index.ts @@ -0,0 +1,10 @@ +import Root from "./radio-group.svelte"; +import Item from "./radio-group-item.svelte"; + +export { + Root, + Item, + // + Root as RadioGroup, + Item as RadioGroupItem, +}; diff --git a/src/lib/components/ui/radio-group/radio-group-item.svelte b/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 0000000..f9238fd --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
+ {#if checked} + + {/if} +
+ {/snippet} +
diff --git a/src/lib/components/ui/radio-group/radio-group.svelte b/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 0000000..c755bf5 --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index c046482..cd44d24 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -148,6 +148,8 @@ export const appointment = pgTable("appointment", { .references(() => agent.id), /** Date and time of the appointment */ appointmentDate: timestamp("appointment_date").notNull(), + /** Duration of the appointment in minutes */ + duration: integer("duration").notNull(), /** When appointment data expires and can be auto-deleted */ expiryDate: date("expiry_date"), /** Current status of the appointment - defaults depend on channel's requiresConfirmation setting */ diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 49d0178..9ed63d2 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -16,6 +16,7 @@ const mockAppointment = { tunnelId: "tunnel-123", channelId: "channel-123", appointmentDate: new Date("2024-01-15T10:00:00Z"), + duration: 10, status: "NEW" as const, encryptedPayload: "encrypted-data", iv: "iv-data", @@ -38,6 +39,7 @@ const mockClientTunnelData = { channelId: "channel-123", agentId: "agent-123", appointmentDate: "2024-01-15T10:00:00Z", + duration: 10, emailHash: "email-hash-123", clientEmail: "test@example.com", clientLanguage: "de", diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 06678f7..3e08eb0 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -18,6 +18,7 @@ export interface ClientTunnelData { channelId: string; agentId: string; appointmentDate: string; + duration: number; emailHash: string; clientEmail: string; clientLanguage?: string; @@ -394,6 +395,7 @@ export class AppointmentService { channelId: clientData.channelId, agentId: clientData.agentId, appointmentDate: new Date(clientData.appointmentDate), + duration: clientData.duration, encryptedPayload: clientData.encryptedAppointment.encryptedPayload, iv: clientData.encryptedAppointment.iv, authTag: clientData.encryptedAppointment.authTag, diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index 9898091..bf35731 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -9,7 +9,18 @@ import { type SelectAgent, type SelectChannel, type SelectSlotTemplate } from ". import { NotFoundError, ValidationError } from "../utils/errors"; import { TenantAdminService } from "./tenant-admin-service"; -const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const; +const CHANNEL_COLORS = [ + "#F3835C", + "#C8CA79", + "#F6DD74", + "#A0A3DC", + "#E9A56D", + "#D89CC8", + "#B0B49B", + "#F9A1B4", + "#88D7EF", + "#AB8A7A", +] as const; const NEXT_COLOR_KEY = "nextChannelColor"; const slotTemplateSchema = z.object({ diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index 7fe72f0..2459b51 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -8,7 +8,7 @@ import { type SelectAgentAbsence, } from "../db/tenant-schema"; -import { eq, and, between, sql, or } from "drizzle-orm"; +import { eq, and, between, sql, or, inArray } from "drizzle-orm"; import logger from "$lib/logger"; import { z } from "zod"; import { ValidationError } from "../utils/errors"; @@ -19,6 +19,7 @@ const scheduleRequestSchema = z.object({ tenantId: z.string().uuid({ message: "Invalid tenant ID format" }), channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(), agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(), + staffUserId: z.string().uuid({ message: "Invalid staff user ID format" }).optional(), }); export type ScheduleRequest = z.infer; @@ -30,12 +31,16 @@ export interface TimeSlot { availableAgents: SelectAgent[]; } +export interface AppointmentWithKeyShare extends SelectAppointment { + staffKeyShare?: string; +} + export interface DaySchedule { date: string; // YYYY-MM-DD format channels: { [channelId: string]: { channel: SelectChannel; - appointments: SelectAppointment[]; + appointments: AppointmentWithKeyShare[]; availableSlots: TimeSlot[]; }; }; @@ -139,6 +144,30 @@ export class ScheduleService { ), ); + // 3a. If staffUserId is provided, get staffKeyShares for all appointment tunnels + let staffKeyShares: Record = {}; + if (request.staffUserId && appointments.length > 0) { + const tunnelIds = [...new Set(appointments.map((apt) => apt.tunnelId))]; + const keyShares = await db + .select() + .from(tenantSchema.clientTunnelStaffKeyShare) + .where( + and( + eq(tenantSchema.clientTunnelStaffKeyShare.userId, request.staffUserId), + inArray(tenantSchema.clientTunnelStaffKeyShare.tunnelId, tunnelIds), + ), + ); + + // Create a map of tunnelId -> encryptedTunnelKey + staffKeyShares = keyShares.reduce( + (acc, share) => { + acc[share.tunnelId] = share.encryptedTunnelKey; + return acc; + }, + {} as Record, + ); + } + // 4. Get agent absences in the date range const absences = await db .select() @@ -187,6 +216,7 @@ export class ScheduleService { appointments, absences, channelAgents, + staffKeyShares, }); log.debug("Schedule generated successfully", { @@ -221,6 +251,7 @@ export class ScheduleService { appointments, absences, channelAgents, + staffKeyShares, }: { startDate: Date; endDate: Date; @@ -229,6 +260,7 @@ export class ScheduleService { appointments: SelectAppointment[]; absences: SelectAgentAbsence[]; channelAgents: { channelId: string; agent: SelectAgent }[]; + staffKeyShares: Record; }): Promise { const dailySchedules: DaySchedule[] = []; @@ -247,13 +279,18 @@ export class ScheduleService { // Process each channel for (const channel of channels) { // Get appointments for this channel on this day - const dayAppointments = appointments.filter( - (appointment) => - appointment.channelId === channel.id && - (typeof appointment.appointmentDate === "string" - ? (appointment.appointmentDate as string).startsWith(dateString) - : appointment.appointmentDate.toISOString().startsWith(dateString)), - ); + const dayAppointments = appointments + .filter( + (appointment) => + appointment.channelId === channel.id && + (typeof appointment.appointmentDate === "string" + ? (appointment.appointmentDate as string).startsWith(dateString) + : appointment.appointmentDate.toISOString().startsWith(dateString)), + ) + .map((appointment) => ({ + ...appointment, + staffKeyShare: staffKeyShares[appointment.tunnelId], + })); // Get slot templates for this channel that apply to this weekday const channelSlotTemplates = slotTemplates diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 41e25f1..0034d46 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -1,6 +1,13 @@ +import { browser } from "$app/environment"; import type { UserRole } from "$lib/server/auth/authorization-service"; import { writable } from "svelte/store"; +export interface PasskeyAuthData { + authenticatorData: string; + passkeyId: string; + email: string; +} + export interface AuthState { isAuthenticated: boolean; isRefreshing: boolean; @@ -12,6 +19,7 @@ export interface AuthState { // The currently selected tenant tenantId?: string | null; }; + passkeyAuthData?: PasskeyAuthData; } function createAuthStore() { @@ -30,6 +38,14 @@ function createAuthStore() { }, setUser: (user: AuthState["user"]) => { store.update((state) => ({ ...state, isAuthenticated: true, user })); + + if (browser && user) { + const storageItem = sessionStorage.getItem("passkeyAuthData"); + if (storageItem) { + const passkeyAuthData: PasskeyAuthData = JSON.parse(storageItem); + store.update((state) => ({ ...state, passkeyAuthData })); + } + } }, setTenantId: (tenantId: string | null) => { store.update((state) => { @@ -42,8 +58,25 @@ function createAuthStore() { isAuthenticated: false, isRefreshing: false, user: undefined, + passkeyAuthData: undefined, }); }, + setPasskeyAuthData: (data: PasskeyAuthData) => { + store.update((state) => ({ ...state, passkeyAuthData: data })); + sessionStorage.setItem("passkeyAuthData", JSON.stringify(data)); + }, + getPasskeyAuthData: (): PasskeyAuthData | undefined => { + let authState: AuthState; + const unsubscribe = store.subscribe((state) => { + authState = state; + }); + unsubscribe(); + return authState!.passkeyAuthData; + }, + clearPasskeyAuthData: () => { + store.update((state) => ({ ...state, passkeyAuthData: undefined })); + sessionStorage.removeItem("passkeyAuthData"); + }, isAuthenticated: () => { let authState: AuthState; const unsubscribe = store.subscribe((state) => { diff --git a/src/lib/stores/calendar.ts b/src/lib/stores/calendar.ts new file mode 100644 index 0000000..98e90d0 --- /dev/null +++ b/src/lib/stores/calendar.ts @@ -0,0 +1,34 @@ +import type { AppointmentData } from "$lib/client/appointment-crypto"; +import { openDialog } from "$lib/components/ui/responsive-dialog"; +import type { TCalendarItem } from "$lib/types/calendar"; +import { writable } from "svelte/store"; + +export type CurAppointmentItem = { + appointment: TCalendarItem; + decrypted: AppointmentData; +}; + +interface CalendarState { + curItem: CurAppointmentItem | null; +} + +const createCalendarStore = () => { + const store = writable({ + curItem: null, + }); + + return { + ...store, + setCurItem: (curItem: CurAppointmentItem | null) => { + store.update((state) => { + return { ...state, curItem }; + }); + + if (curItem) { + openDialog("current-calendar-item"); + } + }, + }; +}; + +export const calendarStore = createCalendarStore(); diff --git a/src/lib/stores/channels.ts b/src/lib/stores/channels.ts new file mode 100644 index 0000000..9dfbb20 --- /dev/null +++ b/src/lib/stores/channels.ts @@ -0,0 +1,52 @@ +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; +import { auth } from "./auth"; +import type { TChannel } from "$lib/types/channel"; + +interface ChannelsState { + channels: TChannel[]; + isLoading: boolean; +} + +const createChannelsStore = () => { + const store = writable({ + channels: [], + isLoading: false, + }); + + return { + ...store, + load: async () => { + if (!browser) return; + + store.update((state) => { + return { ...state, isLoading: true }; + }); + + try { + const tenantId = auth.getTenant(); + const res = await fetch(`/api/tenants/${tenantId}/channels`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }); + + const body = await res.json(); + const channels = body.channels ?? ([] as TChannel[]); + + store.update((state) => { + return { ...state, channels, isLoading: false }; + }); + } catch (error) { + store.update((state) => { + return { ...state, isLoading: false }; + }); + console.error("Failed to parse channels response", { error }); + } + }, + }; +}; + +export const channels = createChannelsStore(); diff --git a/src/lib/stores/sidebar.ts b/src/lib/stores/sidebar.ts index 97d0cab..b6f5ca3 100644 --- a/src/lib/stores/sidebar.ts +++ b/src/lib/stores/sidebar.ts @@ -4,6 +4,7 @@ import { writable } from "svelte/store"; interface AuthState { isOpen: boolean; + isCalendarExpanded: boolean; isEducated: boolean; } @@ -15,6 +16,7 @@ function createSidebarStore() { const isEducatedValue = browser ? getCookie(SIDEBAR_EDUCATION_STORAGE_KEY) : null; const store = writable({ isOpen: isOpenValue === "true" ? true : false, + isCalendarExpanded: false, isEducated: isEducatedValue === "true" ? true : false, }); @@ -26,6 +28,9 @@ function createSidebarStore() { } store.update((state) => ({ ...state, isOpen })); }, + setCalendarExpanded: (isCalendarExpanded: boolean) => { + store.update((state) => ({ ...state, isCalendarExpanded })); + }, setEducated: (isEducated: boolean, isFinal?: boolean) => { if (browser && isFinal) { document.cookie = `${SIDEBAR_EDUCATION_STORAGE_KEY}=${isEducated}; path=/; max-age=604800`; diff --git a/src/lib/stores/staff-crypto.ts b/src/lib/stores/staff-crypto.ts new file mode 100644 index 0000000..00f354a --- /dev/null +++ b/src/lib/stores/staff-crypto.ts @@ -0,0 +1,108 @@ +import { writable } from "svelte/store"; +import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import { auth } from "./auth"; + +interface StaffCryptoState { + crypto: UnifiedAppointmentCrypto | null; + isAuthenticated: boolean; + error: string | null; +} + +const createStaffCryptoStore = () => { + const store = writable({ + crypto: null, + isAuthenticated: false, + error: null, + }); + + return { + ...store, + + /** + * Initialize crypto from stored authenticator data after login + * This reconstructs the private key using the passkey data from auth store + */ + async initFromSession(staffId: string, tenantId: string): Promise { + try { + // Check if we have passkey auth data from login + const passkeyAuthData = auth.getPasskeyAuthData(); + if (!passkeyAuthData) { + console.warn("No passkey auth data found in auth store"); + return false; + } + + const { authenticatorData, passkeyId } = passkeyAuthData; + + // Reconstruct the private key using the stored data + const crypto = new UnifiedAppointmentCrypto(); + + // Use the stored authenticator data to reconstruct keys + await crypto.reconstructStaffKeysFromSession( + staffId, + tenantId, + passkeyId, + authenticatorData, + ); + + store.set({ + crypto, + isAuthenticated: true, + error: null, + }); + + console.log("✅ Staff crypto initialized from session"); + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to init from session"; + store.set({ + crypto: null, + isAuthenticated: false, + error: errorMessage, + }); + console.error("Failed to initialize staff crypto from session:", error); + return false; + } + }, + + /** + * Authenticate with WebAuthn (for initial login or re-authentication) + */ + async authenticate(staffId: string, tenantId: string): Promise { + try { + const crypto = new UnifiedAppointmentCrypto(); + await crypto.authenticateStaff(staffId, tenantId); + + store.set({ + crypto, + isAuthenticated: true, + error: null, + }); + + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Authentication failed"; + store.set({ + crypto: null, + isAuthenticated: false, + error: errorMessage, + }); + console.error("Failed to authenticate staff crypto:", error); + return false; + } + }, + + /** + * Clear the staff crypto state and auth data + */ + clear() { + auth.clearPasskeyAuthData(); + store.set({ + crypto: null, + isAuthenticated: false, + error: null, + }); + }, + }; +}; + +export const staffCrypto = createStaffCryptoStore(); diff --git a/src/lib/stores/tenants.ts b/src/lib/stores/tenants.ts index e58acfa..ef297eb 100644 --- a/src/lib/stores/tenants.ts +++ b/src/lib/stores/tenants.ts @@ -8,6 +8,7 @@ import { m } from "$i18n/messages"; import { goto } from "$app/navigation"; import { ROUTES } from "$lib/const/routes"; import { agents } from "./agents"; +import { channels } from "./channels"; const log = logger.setContext("TenantsStore"); @@ -49,6 +50,7 @@ const createTenantsStore = () => { return { ...state, currentTenant }; }); agents.load(); + channels.load(); // Redirect to dashboard main if tenant changed to avaoid showing data from previous tenant if (tenantId !== curTenant) { diff --git a/src/lib/stores/time.ts b/src/lib/stores/time.ts new file mode 100644 index 0000000..ad61aea --- /dev/null +++ b/src/lib/stores/time.ts @@ -0,0 +1,9 @@ +import { getLocalTimeZone, now } from "@internationalized/date"; +import { readable } from "svelte/store"; + +export const clock = readable(now(getLocalTimeZone()), (set) => { + const tick = () => set(now(getLocalTimeZone())); + const id = setInterval(tick, 10_000); + tick(); + return () => clearInterval(id); +}); diff --git a/src/lib/types/calendar.ts b/src/lib/types/calendar.ts new file mode 100644 index 0000000..bab7a16 --- /dev/null +++ b/src/lib/types/calendar.ts @@ -0,0 +1,32 @@ +import type { DaySchedule } from "$lib/server/services/schedule-service"; + +export type AppointmentStatus = "available" | "booked" | "reserved"; +export type TAppointmentFilter = "all" | AppointmentStatus; + +export type TCalendar = { + period: { + startDate: string; // ISO date-time string + endDate: string; // ISO date-time string + }; + calendar: DaySchedule[]; +}; + +export type TCalendarItem = { + date: string; // YYYY-MM-DD + id: string; + start: string; // HH:mm + duration: number; // in minutes + status: AppointmentStatus; + color: string | null; + column: number; + channelId: string; + appointment?: { + dateTime: Date; + encryptedPayload: string | null; + tunnelId: string; + agentId: string; + staffKeyShare?: string; + iv?: string; + authTag?: string; + }; +}; diff --git a/src/lib/types/public.ts b/src/lib/types/public.ts index a46588a..9528cfc 100644 --- a/src/lib/types/public.ts +++ b/src/lib/types/public.ts @@ -36,6 +36,7 @@ export type TPublicAppointment = { } | null; slot?: { datetime: CalendarDateTime; + duration: number; }; data?: { name: string; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte index e3e7616..36defa8 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -145,6 +145,7 @@ hour: parseInt(slot.from.split(":")[0], 10), minute: parseInt(slot.from.split(":")[1], 10), }), + duration: slot.duration, }, }); } diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index f63f661..65152ee 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -32,6 +32,7 @@ toZoned(appointment.slot.datetime, getLocalTimeZone()).toAbsoluteString(), appointment.agent.id, channel.id, + appointment.slot.duration, tenant.id, Boolean(appointment.isNewClient), getLocale() || "de", diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 0efd20a..a9c29e3 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -29,6 +29,7 @@ let tenantId: string | undefined = $state(); let passkeyId: string | undefined = $state(); let authenticatorData: ArrayBuffer | undefined = $state(); + let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state(); const form = superForm(data.form, { validators: zodClient(formSchema), @@ -65,6 +66,12 @@ const onSetPasskey = async () => { $passkeyLoading = "loading"; + + // Generate Kyber keypair BEFORE passkey registration + // This keypair will be used to create the dbShard after authenticatorData is available + const { KyberCrypto } = await import("$lib/crypto/utils"); + kyberKeyPair = KyberCrypto.generateKeyPair(); + const challenge = await fetchChallenge($formData.email); if (!challenge) { @@ -129,10 +136,10 @@ }); const storeStaffKeyPair = async () => { - if (tenantId && passkeyId && authenticatorData) { + if (tenantId && passkeyId && authenticatorData && kyberKeyPair) { const crypto = new UnifiedAppointmentCrypto(); return await crypto - .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData) + .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData, kyberKeyPair) .then(() => { toast.success(m["setupPasskey.successKeyPairSaved"]()); }) @@ -150,6 +157,7 @@ tenantId, userId: $formData.userId, passkeyId, + hasKyberKeyPair: !!kyberKeyPair, }); toast.error(m["setupPasskey.errorKeyPairDataMissing"]()); } diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 0c6385c..a2f884c 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -4,6 +4,7 @@ import type { LayoutProps } from "./$types"; import { auth } from "$lib/stores/auth"; import { tenants } from "$lib/stores/tenants"; + import { staffCrypto } from "$lib/stores/staff-crypto"; let { data, children }: LayoutProps = $props(); @@ -15,6 +16,7 @@ } initTenants(); + initStaffCrypto(); const unsubscribe = () => { if (!intervalId) { @@ -44,6 +46,18 @@ } } }; + + const initStaffCrypto = async () => { + if (data?.user?.id && data?.user?.tenantId) { + // Try to initialize from session storage (from login) + const success = await staffCrypto.initFromSession(data.user.id, data.user.tenantId); + if (success) { + console.log("✅ Staff crypto initialized successfully"); + } else { + console.log("ℹ️ Staff crypto not initialized (no session data or error)"); + } + } + }; {@render children()} diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index f4c8994..2e9a33d 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -19,6 +19,7 @@ import { AddAgentForm } from "./(components)/add-agent-form"; import { DeleteAgentForm } from "./(components)/delete-agent-form"; import { EditAgentForm } from "./(components)/edit-agent-form"; + import { channels } from "$lib/stores/channels"; import { tenants } from "$lib/stores/tenants"; const { data } = $props(); @@ -61,6 +62,7 @@ { agents.load(); + channels.load(); tenants.reload(); invalidate(ROUTES.DASHBOARD.AGENTS); closeDialog("add"); @@ -116,6 +118,7 @@ closeDialog("edit"); curItem = null; agents.load(); + channels.load(); invalidate(ROUTES.DASHBOARD.AGENTS); }} /> @@ -129,6 +132,7 @@ closeDialog("delete"); curItem = null; agents.load(); + channels.load(); tenants.reload(); invalidate(ROUTES.DASHBOARD.AGENTS); }} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte new file mode 100644 index 0000000..a019021 --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -0,0 +1,53 @@ + + +{#if item.appointment.appointment} +
+
+ + {#if item.decrypted.phone} + + {/if} +
+
+ + + {Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(item.appointment.appointment.dateTime)} + +
+
+{/if} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte new file mode 100644 index 0000000..e0488fa --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte @@ -0,0 +1,102 @@ + + +{#if error} +
+ ⚠️ +
+{:else if decrypted === undefined} +
+ + {m["calendar.decrypting"]()} +
+{:else if decrypted} + +{/if} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte new file mode 100644 index 0000000..5b4f04e --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte @@ -0,0 +1,142 @@ + + +
+
+ +
+ {#each shownHours as hour (`hour-${hour}`)} +
+ + {Intl.DateTimeFormat(getLocale(), { + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))} + + + +
+ {/each} + + + {#if items === undefined} +
+ + {m["calendar.loading"]()} +
+ {/if} + + +
+ {#each processedItems as item (item.id)} + {@const top = + (item.startMinutes / 60) * hourSize + focusAdjustment - earliestStartHour * hourSize} + {@const height = item.duration * scale} + {@const width = 100 / item.totalColumns} + {@const left = item.column * width} +
+
+ {#if ["booked", "reserved"].includes(item.status)} + + {/if} +
+
+ {/each} +
+ + {#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour + hourSize / 2 > curTimeIndicator.hour} + {@const top = + focusAdjustment + + curTimeIndicator.hour * hourSize + + (curTimeIndicator.minute / 60) * hourSize - + earliestStartHour * hourSize} +
+ + {Intl.DateTimeFormat(getLocale(), { + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))} + +
+
+ {/if} +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte new file mode 100644 index 0000000..afea0ef --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte @@ -0,0 +1,155 @@ + + + + +
+ + + + + +
+
+ + + {m["calendar.shownAppointments.title"]()} + + {#each appointmentStates as state (state.value)} +
+ + +
+ {/each} +
+
+ + + {#if channels.length > 1} +
+ {m["channels.title"]()} + {#each channels as channel (channel.id)} + {@const locale = getLocale()} + {@const name = channel.names[locale] || Object.values(channel.names)[0]} +
+ { + if (v) { + shownChannels = [...shownChannels, channel.id]; + } else { + shownChannels = shownChannels.filter((id) => id !== channel.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} + {#if agents.length > 1} +
+ {m["agents.title"]()} + {#each agents as agent (agent.id)} +
+ { + if (v) { + shownAgents = [...shownAgents, agent.id]; + } else { + shownAgents = shownAgents.filter((id) => id !== agent.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte new file mode 100644 index 0000000..e3d4e9d --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte @@ -0,0 +1,62 @@ + + +
+
+ +
+ {Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + timeZone: getLocalTimeZone().toString(), + }).format(startDate.toDate(getLocalTimeZone()))} +
+ +
+ +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts new file mode 100644 index 0000000..12457fd --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -0,0 +1,116 @@ +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { ROUTES } from "$lib/const/routes"; +import type { TCalendar, TCalendarItem } from "$lib/types/calendar"; +import { toCalendarDateTime, toZoned, type CalendarDate } from "@internationalized/date"; + +export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => { + if (!browser) return; + + const params = new URLSearchParams({ + startDate: toZoned(opts.startDate, "UTC").toAbsoluteString(), + endDate: toZoned( + toCalendarDateTime(opts.startDate).set({ + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + }), + "UTC", + ).toAbsoluteString(), + }); + const res = await fetch(`/api/tenants/${opts.tenant}/calendar?${params}`, { + method: "GET", + }); + + if (res.status < 400) { + try { + const data = await res.json(); + return data as TCalendar; + } catch (error) { + console.error("Unable to parse calendar response", error); + } + } else { + if (res.status === 401) { + goto(ROUTES.LOGIN); + } else { + console.error("Unable to fetch calendar", res.status, res.statusText); + } + } +}; + +// Convert time string to minutes since midnight +function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +export function positionItems(items: TCalendarItem[] | undefined) { + if (!items) return []; + + // Sort items by start time + const sortedItems = [...items] + .sort((a, b) => b.duration - a.duration) + .sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start)); + + // Calculate layout positions + const processedItems = sortedItems.map((item) => { + const startMinutes = timeToMinutes(item.start); + const endMinutes = startMinutes + item.duration; + + return { + ...item, + startMinutes, + endMinutes, + totalColumns: 1, + }; + }); + + // Find overlapping groups and assign columns + for (let i = 0; i < processedItems.length; i++) { + const currentItem = processedItems[i]; + const overlappingItems = [currentItem]; + + // Find all items that overlap with current item's time range + for (let j = i + 1; j < processedItems.length; j++) { + const nextItem = processedItems[j]; + + // Check if items overlap + if (nextItem.startMinutes < currentItem.endMinutes) { + overlappingItems.push(nextItem); + } else { + break; + } + } + + // Assign columns to overlapping items + if (overlappingItems.length > 1) { + const columns: number[] = []; + + overlappingItems.forEach((item) => { + // Find the first available column + let column = 0; + while (columns[column] && columns[column] > item.startMinutes) { + column++; + } + + // Don't change columns back + if (item.column !== undefined && item.column > column) { + column = item.column; + } + + item.column = column; + item.totalColumns = Math.max(item.totalColumns, column + 1); + columns[column] = item.endMinutes; + }); + + // Update totalColumns for all overlapping items + const maxColumns = Math.max(...overlappingItems.map((item) => item.column)) + 1; + overlappingItems.forEach((item) => { + item.totalColumns = maxColumns; + }); + } + } + + return processedItems; +} diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 6c5174d..a94eff2 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -1,8 +1,179 @@ - + + +
+ +
+ +
+
+
+ {#snippet headerRight()} + + {/snippet} + {#snippet sidebarRight()} + + {/snippet} +
+ +{#if curItem} + {@const channel = channels.find((c) => c.id === curItem.appointment.channelId)} + + + +{/if} diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 949e24f..9a6e492 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -118,6 +118,14 @@ signatureBase64, }; + // Store authenticatorData for later key reconstruction + const passkeyId = credentialResp.id; + auth.setPasskeyAuthData({ + authenticatorData: authenticatorDataBase64, + passkeyId, + email: $formData.email, + }); + // Update UI to show passkey is ready $passkeyLoading = "success"; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte index 48cb9ba..f92b15d 100644 --- a/src/routes/(pages)/logout/+page.svelte +++ b/src/routes/(pages)/logout/+page.svelte @@ -7,6 +7,7 @@ import { Skeleton } from "$lib/components/ui/skeleton"; import { ROUTES } from "$lib/const/routes.js"; import { auth } from "$lib/stores/auth.js"; + import { staffCrypto } from "$lib/stores/staff-crypto.js"; import Check from "@lucide/svelte/icons/check"; import { onMount } from "svelte"; @@ -14,6 +15,7 @@ onMount(() => { auth.reset(); + staffCrypto.clear(); }); diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts index fd53623..725006d 100644 --- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts @@ -26,6 +26,7 @@ const requestSchema = z.object({ channelId: z.string(), agentId: z.string(), appointmentDate: z.string(), + duration: z.number().int().positive(), clientEmail: z.string().email(), clientLanguage: z.string().optional().default("de"), encryptedAppointment: z.object({ @@ -250,6 +251,7 @@ export const POST: RequestHandler = async ({ request, params }) => { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: new Date(validatedData.appointmentDate), + duration: validatedData.duration, encryptedPayload: validatedData.encryptedAppointment.encryptedPayload, iv: validatedData.encryptedAppointment.iv, authTag: validatedData.encryptedAppointment.authTag, @@ -264,6 +266,7 @@ export const POST: RequestHandler = async ({ request, params }) => { expiryDate: appointment.expiryDate, status: appointment.status, encryptedPayload: appointment.encryptedPayload, + duration: appointment.duration, iv: appointment.iv, authTag: appointment.authTag, encryptedData: appointment.encryptedData, diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index d836d3c..bf2dd80 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -10,6 +10,7 @@ const requestSchema = z.object({ channelId: z.string(), agentId: z.string(), appointmentDate: z.string(), + duration: z.number().int().positive(), emailHash: z.string(), clientEmail: z.string().email(), clientLanguage: z.string().optional().default("de"), @@ -232,6 +233,7 @@ export const POST: RequestHandler = async ({ request, params }) => { tenantId, tunnelId: validatedData.tunnelId, appointmentDate: validatedData.appointmentDate, + duration: validatedData.duration, emailHashPrefix: validatedData.emailHash.slice(0, 8), }); diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts index 07f061b..1bd3168 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts @@ -29,6 +29,7 @@ describe("Create New Client API Route", () => { channelId: mockChannelId, agentId: "agent-123", // Missing field added appointmentDate: "2024-12-25T14:30:00.000Z", + duration: 10, emailHash: "test-email-hash", clientEmail: "test@example.com", clientLanguage: "de", @@ -99,6 +100,7 @@ describe("Create New Client API Route", () => { tenantId: mockTenantId, tunnelId: mockTunnelId, appointmentDate: validRequestBody.appointmentDate, + duration: validRequestBody.duration, emailHashPrefix: "test-ema", }); }); @@ -197,6 +199,7 @@ describe("Create New Client API Route", () => { tenantId: mockTenantId, tunnelId: mockTunnelId, appointmentDate: validRequestBody.appointmentDate, + duration: validRequestBody.duration, emailHashPrefix: "test-ema", }); }); @@ -257,6 +260,7 @@ describe("Create New Client API Route", () => { tenantId: mockTenantId, tunnelId: mockTunnelId, appointmentDate: validRequestBody.appointmentDate, + duration: 10, emailHashPrefix: "test-ema", // First 8 chars of "test-email-hash" }); }); diff --git a/src/routes/api/tenants/[id]/calendar/+server.ts b/src/routes/api/tenants/[id]/calendar/+server.ts index 5908d48..a990754 100644 --- a/src/routes/api/tenants/[id]/calendar/+server.ts +++ b/src/routes/api/tenants/[id]/calendar/+server.ts @@ -110,7 +110,7 @@ registerOpenAPIRoute("/tenants/{id}/calendar", "GET", { }, }); -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, url, locals }) => { const log = logger.setContext("CalendarAPI"); try { @@ -138,7 +138,7 @@ export const GET: RequestHandler = async ({ params, url }) => { ); } - if (startDate >= endDate) { + if (startDate > endDate) { throw new ValidationError("Start date must be before end date"); } @@ -155,10 +155,15 @@ export const GET: RequestHandler = async ({ params, url }) => { }); const scheduleService = await ScheduleService.forTenant(tenantId); + + // Get the staff user ID if authenticated (for including staffKeyShares) + const staffUserId = locals.user?.id; + const schedule = await scheduleService.getSchedule({ tenantId, startDate: startDateParam, endDate: endDateParam, + staffUserId, }); // Return full calendar data (including appointments and detailed agent info) diff --git a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts index 65ac679..b795c1c 100644 --- a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts +++ b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts @@ -22,12 +22,17 @@ vi.mock("$lib/logger", () => ({ })); describe("Calendar API", () => { + const mockGetSchedule = vi.fn(); + const mockScheduleService = { - getSchedule: vi.fn(), + getSchedule: mockGetSchedule, + tenantId: "tenant-123", }; beforeEach(() => { vi.clearAllMocks(); + // Reset the mock implementation + mockGetSchedule.mockReset(); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(ScheduleService.forTenant).mockResolvedValue(mockScheduleService as any); }); @@ -40,6 +45,7 @@ describe("Calendar API", () => { return { params: { id: tenantId }, url, + locals: {}, // Add locals object // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; }; @@ -110,7 +116,7 @@ describe("Calendar API", () => { describe("GET", () => { it("should return calendar with full schedule data", async () => { - mockScheduleService.getSchedule.mockResolvedValue(mockScheduleResponse); + mockGetSchedule.mockResolvedValue(mockScheduleResponse); const request = createRequest( "tenant-123", @@ -132,15 +138,16 @@ describe("Calendar API", () => { }); expect(ScheduleService.forTenant).toHaveBeenCalledWith("tenant-123"); - expect(mockScheduleService.getSchedule).toHaveBeenCalledWith({ + expect(mockGetSchedule).toHaveBeenCalledWith({ tenantId: "tenant-123", startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-02T00:00:00.000Z", + staffUserId: undefined, }); }); it("should include full appointment and agent information in calendar response", async () => { - mockScheduleService.getSchedule.mockResolvedValue(mockScheduleResponse); + mockGetSchedule.mockResolvedValue(mockScheduleResponse); const request = createRequest( "tenant-123", @@ -213,7 +220,7 @@ describe("Calendar API", () => { }); it("should handle ScheduleService errors", async () => { - mockScheduleService.getSchedule.mockRejectedValue(new Error("Database error")); + mockGetSchedule.mockRejectedValue(new Error("Database error")); const request = createRequest( "tenant-123", @@ -234,7 +241,7 @@ describe("Calendar API", () => { schedule: [], }; - mockScheduleService.getSchedule.mockResolvedValue(emptyScheduleResponse); + mockGetSchedule.mockResolvedValue(emptyScheduleResponse); const request = createRequest( "tenant-123", @@ -277,7 +284,7 @@ describe("Calendar API", () => { ], }; - mockScheduleService.getSchedule.mockResolvedValue(scheduleWithNoSlots); + mockGetSchedule.mockResolvedValue(scheduleWithNoSlots); const request = createRequest( "tenant-123", diff --git a/src/routes/api/tenants/[id]/channels/+server.ts b/src/routes/api/tenants/[id]/channels/+server.ts index 844734a..eb27132 100644 --- a/src/routes/api/tenants/[id]/channels/+server.ts +++ b/src/routes/api/tenants/[id]/channels/+server.ts @@ -351,7 +351,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID); } - checkPermission(locals, tenantId, true); + checkPermission(locals, tenantId); log.debug("Getting all channels", { tenantId, diff --git a/tenant-migrations/0006_flimsy_zarek.sql b/tenant-migrations/0006_flimsy_zarek.sql new file mode 100644 index 0000000..0105e6d --- /dev/null +++ b/tenant-migrations/0006_flimsy_zarek.sql @@ -0,0 +1 @@ +ALTER TABLE "appointment" ADD COLUMN "duration" integer NOT NULL; \ No newline at end of file diff --git a/tenant-migrations/0006_gray_richard_fisk.sql b/tenant-migrations/0007_mighty_tinkerer.sql similarity index 100% rename from tenant-migrations/0006_gray_richard_fisk.sql rename to tenant-migrations/0007_mighty_tinkerer.sql diff --git a/tenant-migrations/meta/0006_snapshot.json b/tenant-migrations/meta/0006_snapshot.json index 5e4fa51..297fd52 100644 --- a/tenant-migrations/meta/0006_snapshot.json +++ b/tenant-migrations/meta/0006_snapshot.json @@ -1,5 +1,5 @@ { - "id": "8777f842-17fa-4e3d-a697-d13425671771", + "id": "f77f8d01-2ffd-4f99-a059-665ca5f6f4c3", "prevId": "7c47f4a4-f7e8-4a25-933e-4c1356fb8f35", "version": "7", "dialect": "postgresql", @@ -145,6 +145,12 @@ "primaryKey": false, "notNull": true }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, "expiry_date": { "name": "expiry_date", "type": "date", @@ -490,6 +496,62 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.client": { + "name": "client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "hash_key": { + "name": "hash_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_hash_key_unique": { + "name": "client_hash_key_unique", + "nullsNotDistinct": false, + "columns": ["hash_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.client_appointment_tunnel": { "name": "client_appointment_tunnel", "schema": "", diff --git a/tenant-migrations/meta/0007_snapshot.json b/tenant-migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..18a4fd1 --- /dev/null +++ b/tenant-migrations/meta/0007_snapshot.json @@ -0,0 +1,745 @@ +{ + "id": "29c30482-bb21-472c-acbc-6b802aaf0614", + "prevId": "f77f8d01-2ffd-4f99-a059-665ca5f6f4c3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(250000)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data_key": { + "name": "data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "appointment", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_agent_id_agent_id_fk": { + "name": "appointment_agent_id_agent_id_fk", + "tableFrom": "appointment", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_key_share": { + "name": "appointment_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_key_share_appointment_id_appointment_id_fk": { + "name": "appointment_key_share_appointment_id_appointment_id_fk", + "tableFrom": "appointment_key_share", + "tableTo": "appointment", + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_appointment_tunnel": { + "name": "client_appointment_tunnel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_key_share": { + "name": "client_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_appointment_tunnel_email_hash_unique": { + "name": "client_appointment_tunnel_email_hash_unique", + "nullsNotDistinct": false, + "columns": ["email_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tunnel_staff_key_share": { + "name": "client_tunnel_staff_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_tunnel_key": { + "name": "encrypted_tunnel_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "client_tunnel_staff_key_share", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_crypto": { + "name": "staff_crypto", + "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 + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 15ad2f3..5341886 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -47,8 +47,15 @@ { "idx": 6, "version": "7", - "when": 1763052916263, - "tag": "0006_gray_richard_fisk", + "when": 1763111569823, + "tag": "0006_flimsy_zarek", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1763455361426, + "tag": "0007_mighty_tinkerer", "breakpoints": true } ] From a93ee3c7415b9c1c8719ea6d1c69a77fab388aab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:58:52 +0100 Subject: [PATCH 14/61] Bump nodemailer from 7.0.7 to 7.0.11 (#138) Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.7 to 7.0.11. - [Release notes](https://github.com/nodemailer/nodemailer/releases) - [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodemailer/nodemailer/compare/v7.0.7...v7.0.11) --- updated-dependencies: - dependency-name: nodemailer dependency-version: 7.0.11 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 69 +++++++++++++++++++++-------------------------- package.json | 2 +- 2 files changed, 31 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0c88b79..3b5c256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "drizzle-orm": "^0.44.2", "jose": "^6.0.11", "mode-watcher": "^1.0.8", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.11", "postgres": "^3.4.7", "secrets.js-34r7h": "^2.0.2", "uuidv7": "^1.0.2", @@ -388,6 +388,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -411,6 +412,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1671,6 +1673,7 @@ "integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -1839,8 +1842,7 @@ "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@phc/format": { "version": "1.0.0", @@ -2345,6 +2347,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.25.2.tgz", "integrity": "sha512-aKfj82vqEINedoH9Pw4Ip16jj3w8soNq9F3nJqc56kxXW74TcEu/gdTAuLUI+gsl8i+KXfetRqg1F+gG/AZRVQ==", "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", @@ -2376,6 +2379,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.0.tgz", "integrity": "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw==", "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -3047,6 +3051,7 @@ "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", @@ -3410,6 +3415,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3781,19 +3787,6 @@ "node": ">=18" } }, - "node_modules/class-validator": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/validator": "^13.11.8", - "libphonenumber-js": "^1.11.1", - "validator": "^13.9.0" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4349,7 +4342,6 @@ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -4370,6 +4362,7 @@ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4462,6 +4455,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4972,7 +4966,6 @@ "integrity": "sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", @@ -4994,7 +4987,6 @@ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "license": "ISC", "optional": true, - "peer": true, "engines": { "node": ">=16" } @@ -5005,7 +4997,6 @@ "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "license": "ISC", "optional": true, - "peer": true, "dependencies": { "isexe": "^3.1.1" }, @@ -5386,6 +5377,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5507,6 +5499,7 @@ "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -6061,9 +6054,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz", - "integrity": "sha512-jGOaRznodf62TVzdyhKt/f1Q/c3kYynk8629sgJHpRzGZj01ezbgMMWJSAjHADcwTKxco3B68/R+KHJY2T5BaA==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -6287,6 +6280,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6409,6 +6403,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -6433,6 +6428,7 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6449,6 +6445,7 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -6733,6 +6730,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -6934,7 +6932,6 @@ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 0.4" }, @@ -7142,6 +7139,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.7.tgz", "integrity": "sha512-5PEg+QQKce4t1qiOtVUhUS3AQRTtxJyGBTpxLcNWnr0Ve8q4r06bMo0Gv8uhtCPWlztZHoi3Ye7elLhu+PCTMg==", "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -7314,6 +7312,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "devalue": "^5.3.2", "memoize-weak": "^1.0.2", @@ -7411,6 +7410,7 @@ "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -7441,7 +7441,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.2", @@ -7719,6 +7720,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7884,6 +7886,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8014,6 +8017,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -8276,20 +8280,6 @@ "node": ">=18" } }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8328,6 +8318,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 5e12641..9b5f8ed 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "drizzle-orm": "^0.44.2", "jose": "^6.0.11", "mode-watcher": "^1.0.8", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.11", "postgres": "^3.4.7", "secrets.js-34r7h": "^2.0.2", "uuidv7": "^1.0.2", From 98f6a4e9bae5455be7b2cbe194a0e29446302916 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:59:47 +0100 Subject: [PATCH 15/61] Bump validator from 13.15.20 to 13.15.23 (#139) Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.20 to 13.15.23. - [Release notes](https://github.com/validatorjs/validator.js/releases) - [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/validatorjs/validator.js/compare/13.15.20...13.15.23) --- updated-dependencies: - dependency-name: validator dependency-version: 13.15.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b5c256..fc8386f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7837,9 +7837,9 @@ } }, "node_modules/validator": { - "version": "13.15.20", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", - "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", "dev": true, "license": "MIT", "optional": true, From 145e931f388e97d04257842c05a1bc96857f23a6 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Sun, 14 Dec 2025 14:39:36 +0100 Subject: [PATCH 16/61] Do not commit audits --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9fb1414..bbad229 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* CLAUDE.md -.claude/ \ No newline at end of file +.claude/ + +security-audit* \ No newline at end of file From 1959115d70606f0a744fc4d0b4119807a496405e Mon Sep 17 00:00:00 2001 From: Hendrik Date: Tue, 16 Dec 2025 11:25:19 +0100 Subject: [PATCH 17/61] 127 add checkpermission everywhere where needed (#140) * Simplified locals.user Checked routes for accessibilty * Fixed tests * Fixed import errors * Fixed invite test --- src/app.d.ts | 9 +- .../(pages)/dashboard/+layout.server.ts | 2 +- src/routes/api/admin/tenant/+server.ts | 14 +- src/routes/api/admin/tenant/server.test.ts | 12 +- src/routes/api/auth/invite/+server.ts | 10 +- .../api/auth/invite/__tests__/invite.test.ts | 6 +- src/routes/api/auth/logout/+server.ts | 4 +- src/routes/api/auth/session/+server.ts | 6 +- .../api/auth/session/__tests__/server.test.ts | 33 +++- src/routes/api/auth/sessions/+server.ts | 6 +- src/routes/api/auth/sessions/[id]/+server.ts | 12 +- src/routes/api/public/+server.ts | 2 +- src/routes/api/public/channels/+server.ts | 2 +- .../public/channels/[id]/agents/+server.ts | 2 +- src/routes/api/tenants/+server.ts | 8 +- src/routes/api/tenants/[id]/+server.ts | 14 +- src/routes/api/tenants/[id]/agents/+server.ts | 12 +- .../tenants/[id]/agents/[agentId]/+server.ts | 18 +-- .../[id]/agents/[agentId]/absences/+server.ts | 12 +- .../[agentId]/absences/[absenceId]/+server.ts | 18 +-- .../tenants/[id]/agents/absences/+server.ts | 6 +- .../api/tenants/[id]/appointments/+server.ts | 11 +- .../appointments/[appointmentId]/+server.ts | 27 ++-- .../__tests__/appointment-detail-api.test.ts | 148 ++---------------- .../[appointmentId]/cancel/+server.ts | 6 +- .../[appointmentId]/confirm/+server.ts | 6 +- .../[id]/appointments/tunnels/+server.ts | 6 +- .../tunnels/add-staff-key-shares/+server.ts | 6 +- .../api/tenants/[id]/channels/+server.ts | 12 +- .../[id]/channels/[channelId]/+server.ts | 18 +-- src/routes/api/tenants/[id]/config/+server.ts | 4 +- .../api/tenants/[id]/setup-state/+server.ts | 2 +- src/routes/api/tenants/[id]/staff/+server.ts | 2 +- .../tenants/[id]/staff/[staffId]/+server.ts | 8 +- .../[id]/staff/[staffId]/crypto/+server.ts | 6 +- .../[id]/staff/[staffId]/key-shard/+server.ts | 14 +- .../key-shard/__tests__/key-shard-api.test.ts | 14 +- .../staff/[staffId]/public-key/+server.ts | 6 +- src/server-hooks/apiAuthHandle.ts | 7 +- src/server-hooks/authGuard.ts | 7 +- 40 files changed, 220 insertions(+), 298 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index ca4d92b..3424391 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,12 +3,19 @@ import type { SelectUser } from "$lib/server/db/central-schema"; import type { Locale } from "$i18n/runtime"; +interface SessionInfo { + session: { + sessionId: string; + exp: number; + }; +} + // for information about these interfaces declare global { namespace App { // interface Error {} interface Locals { - user?: SelectUser & { userId: string; sessionId: string; exp: number }; + user?: SelectUser & SessionInfo; locale?: Locale; } // interface PageData {} diff --git a/src/routes/(pages)/dashboard/+layout.server.ts b/src/routes/(pages)/dashboard/+layout.server.ts index e26bc16..635d6ad 100644 --- a/src/routes/(pages)/dashboard/+layout.server.ts +++ b/src/routes/(pages)/dashboard/+layout.server.ts @@ -21,7 +21,7 @@ export const load: LayoutServerLoad = async (event) => { let user: AuthState["user"] | null = null; if (event.locals.user) { user = { - id: event.locals.user.userId, + id: event.locals.user.id, name: event.locals.user.name, email: event.locals.user.email, role: event.locals.user.role, diff --git a/src/routes/api/admin/tenant/+server.ts b/src/routes/api/admin/tenant/+server.ts index 2d2bbd7..183d684 100644 --- a/src/routes/api/admin/tenant/+server.ts +++ b/src/routes/api/admin/tenant/+server.ts @@ -32,7 +32,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { // Verify user is authenticated and is global admin checkPermission(locals, null, true); - if (!locals.user?.sessionId) { + if (!locals.user?.session.sessionId) { throw new ValidationError(ERRORS.SECURITY.SESSION_MISSING); } @@ -67,20 +67,20 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { // Current user is already authenticated via authHandle // Update user's active tenant in the database - const updatedUser = await UserService.updateUser(locals.user?.userId as string, { + const updatedUser = await UserService.updateUser(locals.user?.id as string, { tenantId: tenantId || null, }); if (!updatedUser) { logger.error("Failed to update user tenant", { - userId: locals.user.userId, + userId: locals.user.id, tenantId, }); throw new InternalError(ERRORS.USERS.FAILED_TO_UPDATE); } // Generate new access token with updated tenant context - const newAccessToken = await generateAccessToken(updatedUser, locals.user.sessionId); + const newAccessToken = await generateAccessToken(updatedUser, locals.user.session.sessionId); // Update session with new access token await centralDb @@ -89,7 +89,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { accessToken: newAccessToken, lastUsedAt: new Date(), }) - .where(eq(userSession.id, locals.user.sessionId)); + .where(eq(userSession.id, locals.user.session.sessionId)); // Set new access token cookie cookies.set("access_token", newAccessToken, { @@ -101,7 +101,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { }); logger.info("Tenant switched successfully", { - userId: locals.user?.userId, + userId: locals.user?.id, fromTenant: locals.user?.tenantId, toTenant: tenantId, }); @@ -119,7 +119,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => { }, }); } catch (err) { - logError(logger)("Error in tenant switch", err, locals.user?.userId); + logError(logger)("Error in tenant switch", err, locals.user?.id); if (err instanceof BackendError) { return err.toJson(); diff --git a/src/routes/api/admin/tenant/server.test.ts b/src/routes/api/admin/tenant/server.test.ts index 86301b5..23cd891 100644 --- a/src/routes/api/admin/tenant/server.test.ts +++ b/src/routes/api/admin/tenant/server.test.ts @@ -125,7 +125,17 @@ describe("POST /api/admin/tenant", () => { request: { json: () => Promise.resolve(body), } as Request, - locals: { user: user ? { ...user, sessionId: "session-123" } : null }, + locals: { + user: user + ? { + ...user, + session: { + sessionId: "session-123", + exp: Date.now() + 3600000, + }, + } + : null, + }, cookies: mockCookies, params: {}, url: new URL("http://localhost/api/admin/tenant"), diff --git a/src/routes/api/auth/invite/+server.ts b/src/routes/api/auth/invite/+server.ts index c4be1e5..352922f 100644 --- a/src/routes/api/auth/invite/+server.ts +++ b/src/routes/api/auth/invite/+server.ts @@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { if (!validation.success) { logger.warn("Invalid invite request", { - userId: locals.user.userId, + userId: locals.user.id, errors: validation.error.issues, }); throw new ValidationError("Invalid request data"); @@ -157,7 +157,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { // Tenant admin can invite to their own tenant } else { logger.warn("Insufficient permissions for invitation", { - userId: locals.user.userId, + userId: locals.user.id, userRole: locals.user.role, userTenantId: locals.user.tenantId, targetTenantId: tenantId, @@ -196,7 +196,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { name, role, tenantId, - locals.user.userId, + locals.user.id, language, ); @@ -207,7 +207,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language); logger.info("User invitation sent successfully", { - invitedBy: locals.user.userId, + invitedBy: locals.user.id, invitedEmail: email, tenantId, role, @@ -223,7 +223,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { } catch (error) { logger.error("User invitation error:", { error: String(error), - userId: locals.user?.userId, + userId: locals.user?.id, }); if (error instanceof ValidationError) { diff --git a/src/routes/api/auth/invite/__tests__/invite.test.ts b/src/routes/api/auth/invite/__tests__/invite.test.ts index c1f3d6b..4e03a78 100644 --- a/src/routes/api/auth/invite/__tests__/invite.test.ts +++ b/src/routes/api/auth/invite/__tests__/invite.test.ts @@ -108,7 +108,7 @@ describe("POST /api/auth/invite", () => { it("should allow global admin to invite to any tenant", async () => { const globalAdmin = { - userId: "admin-id", + id: "admin-id", role: "GLOBAL_ADMIN", tenantId: null, }; @@ -147,7 +147,7 @@ describe("POST /api/auth/invite", () => { it("should allow tenant admin to invite to their own tenant", async () => { const tenantAdmin = { - userId: "tenant-admin-id", + id: "admin-id", role: "TENANT_ADMIN", tenantId: "12345678-1234-4234-8234-123456789012", }; @@ -173,7 +173,7 @@ describe("POST /api/auth/invite", () => { "Test User", "STAFF", "12345678-1234-4234-8234-123456789012", - "tenant-admin-id", + "admin-id", "de", ); expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith( diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index f5c0ff9..1ae5105 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -51,8 +51,8 @@ export const POST: RequestHandler = async ({ locals, cookies }) => { } // Delete the session from database - if (locals.user.sessionId) { - await SessionService.revokeSession(locals.user.sessionId); + if (locals.user.session.sessionId) { + await SessionService.revokeSession(locals.user.session.sessionId); } // Clear the access token cookie diff --git a/src/routes/api/auth/session/+server.ts b/src/routes/api/auth/session/+server.ts index eee79ef..160fb3c 100644 --- a/src/routes/api/auth/session/+server.ts +++ b/src/routes/api/auth/session/+server.ts @@ -73,18 +73,18 @@ export const GET: RequestHandler = async ({ locals }) => { return json({ authenticated: false, message: "Not authenticated" }, { status: 401 }); } - logger.debug("Session check", { userId: locals.user.userId }); + logger.debug("Session check", { userId: locals.user.id }); return json({ authenticated: true, user: { - id: locals.user.userId, + id: locals.user.id, email: locals.user.email, name: locals.user.name, role: locals.user.role, tenantId: locals.user.tenantId, }, - expiresAt: new Date(locals.user.exp ?? 0).toISOString(), + expiresAt: new Date(locals.user.session.exp ?? 0).toISOString(), }); } catch (error) { logger.error("Session check error:", { error: String(error) }); diff --git a/src/routes/api/auth/session/__tests__/server.test.ts b/src/routes/api/auth/session/__tests__/server.test.ts index 614d6ba..bdd89a8 100644 --- a/src/routes/api/auth/session/__tests__/server.test.ts +++ b/src/routes/api/auth/session/__tests__/server.test.ts @@ -36,6 +36,27 @@ const createMockRequestEvent = (locals: any): RequestEvent => parent: vi.fn(), }) as any; +// Helper to convert old mock user format to new format +const convertUserToLocals = (mockUser: any) => { + if (!mockUser) return { user: null }; + + return { + user: { + id: mockUser.userId, + email: mockUser.email, + name: mockUser.name, + role: mockUser.role, + tenantId: mockUser.tenantId, + isActive: mockUser.isActive, + confirmed: mockUser.confirmed, + session: { + sessionId: mockUser.sessionId, + exp: mockUser.exp, + }, + }, + }; +}; + describe("/api/auth/session GET endpoint", () => { it("should return user session data with correct exp field when user is authenticated", async () => { const mockUser = { @@ -50,7 +71,7 @@ describe("/api/auth/session GET endpoint", () => { confirmed: true, }; - const event = createMockRequestEvent({ user: mockUser }); + const event = createMockRequestEvent(convertUserToLocals(mockUser)); const response = await GET(event as any); const data = await response.json(); @@ -81,7 +102,7 @@ describe("/api/auth/session GET endpoint", () => { confirmed: true, }; - const event = createMockRequestEvent({ user: mockUser }); + const event = createMockRequestEvent(convertUserToLocals(mockUser)); const response = await GET(event as any); const data = await response.json(); @@ -112,7 +133,7 @@ describe("/api/auth/session GET endpoint", () => { confirmed: true, }; - const event = createMockRequestEvent({ user: mockUser }); + const event = createMockRequestEvent(convertUserToLocals(mockUser)); const response = await GET(event as any); const data = await response.json(); @@ -131,7 +152,7 @@ describe("/api/auth/session GET endpoint", () => { }); it("should return 401 when user is not authenticated", async () => { - const event = createMockRequestEvent({ user: null }); + const event = createMockRequestEvent(convertUserToLocals(null)); const response = await GET(event as any); expect(response.status).toBe(401); @@ -155,7 +176,7 @@ describe("/api/auth/session GET endpoint", () => { confirmed: true, }; - const event = createMockRequestEvent({ user: mockGlobalAdmin }); + const event = createMockRequestEvent(convertUserToLocals(mockGlobalAdmin)); const response = await GET(event as any); const data = await response.json(); @@ -186,7 +207,7 @@ describe("/api/auth/session GET endpoint", () => { confirmed: true, }; - const event = createMockRequestEvent({ user: mockUser }); + const event = createMockRequestEvent(convertUserToLocals(mockUser)); const response = await GET(event as any); const data = await response.json(); diff --git a/src/routes/api/auth/sessions/+server.ts b/src/routes/api/auth/sessions/+server.ts index e2653e9..fd6c706 100644 --- a/src/routes/api/auth/sessions/+server.ts +++ b/src/routes/api/auth/sessions/+server.ts @@ -119,10 +119,10 @@ export const GET: RequestHandler = async ({ locals }) => { } // Get all active sessions for the user from database - const activeSessions = await SessionService.getActiveSessions(locals.user.userId); + const activeSessions = await SessionService.getActiveSessions(locals.user.id); // Get current session ID from access token - const currentSessionId = locals.user.sessionId; + const currentSessionId = locals.user.session.sessionId; const sessions = activeSessions.map((session) => ({ id: session.id, @@ -153,7 +153,7 @@ export const DELETE: RequestHandler = async ({ locals, cookies }) => { } // Logout all sessions for the user - await SessionService.logoutAllSessions(locals.user.userId); + await SessionService.logoutAllSessions(locals.user.id); // Clear the current access token cookie cookies.delete("access_token", { diff --git a/src/routes/api/auth/sessions/[id]/+server.ts b/src/routes/api/auth/sessions/[id]/+server.ts index 3bb4374..9912580 100644 --- a/src/routes/api/auth/sessions/[id]/+server.ts +++ b/src/routes/api/auth/sessions/[id]/+server.ts @@ -84,12 +84,12 @@ export const DELETE: RequestHandler = async ({ params, locals, cookies }) => { logger.debug("Revoking session", { sessionId, - requestedBy: locals.user.userId, + requestedBy: locals.user.id, userRole: locals.user.role, }); // Get current session ID from locals (set by auth middleware) - const currentSessionId = locals.user.sessionId; + const currentSessionId = locals.user.session.sessionId; // Check if user is trying to revoke their own session or if they're a global admin if (locals.user.role === "GLOBAL_ADMIN") { @@ -98,7 +98,7 @@ export const DELETE: RequestHandler = async ({ params, locals, cookies }) => { } else { // Regular users can only revoke their own sessions // First, get all user's sessions to verify ownership - const userSessions = await SessionService.getActiveSessions(locals.user.userId); + const userSessions = await SessionService.getActiveSessions(locals.user.id); const sessionToRevoke = userSessions.find((session) => session.id === sessionId); if (!sessionToRevoke) { @@ -118,13 +118,13 @@ export const DELETE: RequestHandler = async ({ params, locals, cookies }) => { }); logger.info("Current session revoked, cookie cleared", { sessionId, - userId: locals.user.userId, + userId: locals.user.id, }); } logger.info("Session revoked successfully", { sessionId, - revokedBy: locals.user.userId, + revokedBy: locals.user.id, wasCurrent: sessionId === currentSessionId, }); @@ -133,7 +133,7 @@ export const DELETE: RequestHandler = async ({ params, locals, cookies }) => { logger.error("Revoke session error:", { error: String(error), sessionId: params.id, - userId: locals.user?.userId, + userId: locals.user?.id, }); return json({ error: "Internal server error" }, { status: 500 }); } diff --git a/src/routes/api/public/+server.ts b/src/routes/api/public/+server.ts index 8299496..fc6d61d 100644 --- a/src/routes/api/public/+server.ts +++ b/src/routes/api/public/+server.ts @@ -103,7 +103,7 @@ export const GET: RequestHandler = async ({ locals, url }) => { }, }); } catch (error) { - logError(log)("Error getting public tenant data", error, locals.user?.userId); + logError(log)("Error getting public tenant data", error, locals.user?.id); return new InternalError().toJson(); } }; diff --git a/src/routes/api/public/channels/+server.ts b/src/routes/api/public/channels/+server.ts index 8fe7042..7e217ad 100644 --- a/src/routes/api/public/channels/+server.ts +++ b/src/routes/api/public/channels/+server.ts @@ -101,7 +101,7 @@ export const GET: RequestHandler = async ({ locals, url }) => { channels: filteredChannels, }); } catch (error) { - logError(log)("Error getting public tenant data", error, locals.user?.userId); + logError(log)("Error getting public tenant data", error, locals.user?.id); return new InternalError().toJson(); } }; diff --git a/src/routes/api/public/channels/[id]/agents/+server.ts b/src/routes/api/public/channels/[id]/agents/+server.ts index 0b59ba9..ee3f764 100644 --- a/src/routes/api/public/channels/[id]/agents/+server.ts +++ b/src/routes/api/public/channels/[id]/agents/+server.ts @@ -82,7 +82,7 @@ export const GET: RequestHandler = async ({ locals, url, params }) => { agents: filteredAgents, }); } catch (error) { - logError(log)("Error getting public agents for channel", error, locals.user?.userId); + logError(log)("Error getting public agents for channel", error, locals.user?.id); return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/+server.ts b/src/routes/api/tenants/+server.ts index 414a11a..a916fd5 100644 --- a/src/routes/api/tenants/+server.ts +++ b/src/routes/api/tenants/+server.ts @@ -183,7 +183,7 @@ export const POST: RequestHandler = async ({ locals, request }) => { { status: 201 }, ); } catch (error) { - logError(log)("Tenant creation error", error, locals.user?.userId); + logError(log)("Tenant creation error", error, locals.user?.id); if (error instanceof BackendError) { return error.toJson(); @@ -209,7 +209,7 @@ export const GET: RequestHandler = async ({ locals }) => { ); log.debug("Getting all tenants", { - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); // Get all tenants from database @@ -226,14 +226,14 @@ export const GET: RequestHandler = async ({ locals }) => { log.debug("Retrieved tenants successfully", { tenantCount: tenants.length, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ tenants: tenants, }); } catch (error) { - logError(log)("Error getting tenants", error, locals.user?.userId); + logError(log)("Error getting tenants", error, locals.user?.id); return new InternalError().toJson(); } }; diff --git a/src/routes/api/tenants/[id]/+server.ts b/src/routes/api/tenants/[id]/+server.ts index 29c3dc8..57fe6de 100644 --- a/src/routes/api/tenants/[id]/+server.ts +++ b/src/routes/api/tenants/[id]/+server.ts @@ -424,7 +424,7 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { tenant: updatedTenant, }); } catch (error) { - logError(log)("Error updating tenant metadata", error, locals.user?.userId, params.id); + logError(log)("Error updating tenant metadata", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -451,7 +451,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Getting tenant details", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); checkPermission(locals, tenantId, true); @@ -467,14 +467,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Tenant details retrieved successfully", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ tenant: tenantData, }); } catch (error) { - logError(log)("Error getting tenant details", error, locals.user?.userId, params.id); + logError(log)("Error getting tenant details", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -499,7 +499,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.info("Attempting tenant deletion", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, userRole: locals.user?.role, }); @@ -513,7 +513,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { deletedUsersCount: result.deletedUsersCount, updatedGlobalAdminsCount: result.updatedGlobalAdminsCount, deletedConfigsCount: result.deletedConfigsCount, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -521,7 +521,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { result, }); } catch (error) { - logError(log)("Error deleting tenant", error, locals.user?.userId, params.id); + logError(log)("Error deleting tenant", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/agents/+server.ts b/src/routes/api/tenants/[id]/agents/+server.ts index c35fdc8..af1f50c 100644 --- a/src/routes/api/tenants/[id]/agents/+server.ts +++ b/src/routes/api/tenants/[id]/agents/+server.ts @@ -214,7 +214,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { log.debug("Creating new agent", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, agentName: body.name, }); @@ -224,7 +224,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { log.debug("Agent created successfully", { tenantId, agentId: newAgent.id, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json( @@ -235,7 +235,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - logError(log)("Error creating agent:", error, locals.user?.userId, params.id); + logError(log)("Error creating agent:", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -260,7 +260,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Getting all agents", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const agentService = await AgentService.forTenant(tenantId); @@ -269,14 +269,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Agents retrieved successfully", { tenantId, count: agents.length, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ agents, }); } catch (error) { - logError(log)("Error getting agents:", error, locals.user?.userId, params.id); + logError(log)("Error getting agents:", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts index bec98cb..397ca82 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts @@ -304,7 +304,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Getting agent details", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const agentService = await AgentService.forTenant(tenantId); @@ -317,14 +317,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Agent details retrieved successfully", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ agent, }); } catch (error) { - logError(log)("Error getting agent details", error, locals.user?.userId, params.id); + logError(log)("Error getting agent details", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -352,7 +352,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { log.debug("Updating agent", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, updateFields: Object.keys(body), }); @@ -362,7 +362,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { log.debug("Agent updated successfully", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -370,7 +370,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { agent: updatedAgent, }); } catch (error) { - logError(log)("Error updating agent", error, locals.user?.userId, params.id); + logError(log)("Error updating agent", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -402,7 +402,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.debug("Deleting agent", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const agentService = await AgentService.forTenant(tenantId); @@ -415,14 +415,14 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.debug("Agent deleted successfully", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ message: "Agent deleted successfully", }); } catch (error) { - logError(log)("Error deleting agent", error, locals.user?.userId, params.id); + logError(log)("Error deleting agent", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts index ee6bd53..5aebfc1 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts @@ -274,7 +274,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { log.debug("Creating agent absence", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, absenceType: body.absenceType, startDate: body.startDate, endDate: body.endDate, @@ -287,7 +287,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { tenantId, agentId, absenceId: newAbsence.id, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json( @@ -298,7 +298,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - logError(log)("Creating agent absence failed", error, locals.user?.userId, params.id); + logError(log)("Creating agent absence failed", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -328,7 +328,7 @@ export const GET: RequestHandler = async ({ params, locals, url }) => { log.debug("Getting agent absences", { tenantId, agentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, startDate, endDate, }); @@ -344,14 +344,14 @@ export const GET: RequestHandler = async ({ params, locals, url }) => { tenantId, agentId, count: absences.length, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ absences, }); } catch (error) { - logError(log)("Getting agent absences failed", error, locals.user?.userId, params.id); + logError(log)("Getting agent absences failed", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts index 9a230a9..a7ecdd4 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts @@ -349,7 +349,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { tenantId, agentId, absenceId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const agentService = await AgentService.forTenant(tenantId); @@ -368,14 +368,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { tenantId, agentId, absenceId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ absence, }); } catch (error) { - logError(log)("Error getting absence details", error, locals.user?.userId, params.id); + logError(log)("Error getting absence details", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -406,7 +406,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { tenantId, agentId, absenceId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, updateFields: Object.keys(body), }); @@ -428,7 +428,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { tenantId, agentId, absenceId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -436,7 +436,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { absence: updatedAbsence, }); } catch (error) { - logError(log)("Error updating agent absence", error, locals.user?.userId, params.id); + logError(log)("Error updating agent absence", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -464,7 +464,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { tenantId, agentId, absenceId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const agentService = await AgentService.forTenant(tenantId); @@ -489,14 +489,14 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { tenantId, agentId, absenceId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ message: "Absence deleted successfully", }); } catch (error) { - logError(log)("Error deleting agent absence", error, locals.user?.userId, params.id); + logError(log)("Error deleting agent absence", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/agents/absences/+server.ts b/src/routes/api/tenants/[id]/agents/absences/+server.ts index c7d5cc9..5754449 100644 --- a/src/routes/api/tenants/[id]/agents/absences/+server.ts +++ b/src/routes/api/tenants/[id]/agents/absences/+server.ts @@ -117,7 +117,7 @@ export const GET: RequestHandler = async ({ params, locals, url }) => { log.debug("Getting all agents absences", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, startDate, endDate, }); @@ -129,14 +129,14 @@ export const GET: RequestHandler = async ({ params, locals, url }) => { log.debug("All Agents absences retrieved successfully", { tenantId, count: absences.length, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ absences, }); } catch (error) { - logError(log)("Getting all agents absences failed", error, locals.user?.userId, params.id); + logError(log)("Getting all agents absences failed", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/appointments/+server.ts b/src/routes/api/tenants/[id]/appointments/+server.ts index e57adef..070fb97 100644 --- a/src/routes/api/tenants/[id]/appointments/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/+server.ts @@ -199,7 +199,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { tenantId, startDate: startDate.toISOString(), endDate: endDate.toISOString(), - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const appointmentService = await AppointmentService.forTenant(tenantId); @@ -210,7 +210,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { count: appointments.length, startDate: startDate.toISOString(), endDate: endDate.toISOString(), - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -222,12 +222,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { }, }); } catch (error) { - logError(log)( - "Error getting appointments by time range", - error, - locals.user?.userId, - params.id, - ); + logError(log)("Error getting appointments by time range", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts index c75eb1b..553149c 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts @@ -1,18 +1,13 @@ import { json } from "@sveltejs/kit"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { - BackendError, - InternalError, - logError, - NotFoundError, - ValidationError, -} from "$lib/server/utils/errors"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "@sveltejs/kit"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; // Register OpenAPI documentation for GET +/* No longer used? registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", { summary: "Get appointment by ID", description: @@ -135,7 +130,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", { }, }, }, -}); +});*/ // Register OpenAPI documentation for DELETE registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", { @@ -216,6 +211,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", { }, }); +/* No longer used? export const GET: RequestHandler = async ({ params, locals }) => { const log = logger.setContext("API"); @@ -227,10 +223,12 @@ export const GET: RequestHandler = async ({ params, locals }) => { throw new ValidationError("Tenant ID and appointment ID are required"); } + // checkPermission(locals, tenantId, true); + log.debug("Getting appointment by ID", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const appointmentService = await AppointmentService.forTenant(tenantId); @@ -243,14 +241,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Appointment retrieved successfully", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ appointment, }); } catch (error) { - logError(log)("Error getting appointment", error, locals.user?.userId, params.id); + logError(log)("Error getting appointment", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -259,6 +257,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { return new InternalError().toJson(); } }; +*/ export const DELETE: RequestHandler = async ({ params, locals }) => { const log = logger.setContext("API"); @@ -276,7 +275,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.debug("Deleting appointment", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const appointmentService = await AppointmentService.forTenant(tenantId); @@ -289,14 +288,14 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.debug("Appointment deleted successfully", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ message: "Appointment deleted successfully", }); } catch (error) { - logError(log)("Error deleting appointment", error, locals.user?.userId, params.id); + logError(log)("Error deleting appointment", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts index b198563..b5c61b4 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GET, DELETE } from "../+server"; +import { DELETE } from "../+server"; import type { RequestEvent } from "@sveltejs/kit"; // Mock dependencies @@ -19,8 +19,13 @@ vi.mock("$lib/logger", () => ({ }, })); +vi.mock("$lib/server/utils/permissions", () => ({ + checkPermission: vi.fn(), +})); + import { AppointmentService } from "$lib/server/services/appointment-service"; -import { NotFoundError, ValidationError } from "$lib/server/utils/errors"; +import { NotFoundError, AuthenticationError, AuthorizationError } from "$lib/server/utils/errors"; +import { checkPermission } from "$lib/server/utils/permissions"; describe("Appointment Detail API Routes", () => { const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; @@ -33,6 +38,8 @@ describe("Appointment Detail API Routes", () => { beforeEach(() => { vi.clearAllMocks(); (AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService); + // Default: checkPermission passes + vi.mocked(checkPermission).mockImplementation(() => {}); }); function createMockRequestEvent(overrides: Partial = {}): RequestEvent { @@ -49,135 +56,6 @@ describe("Appointment Detail API Routes", () => { } as RequestEvent; } - const mockAppointment = { - id: mockAppointmentId, - tunnelId: "tunnel-123", - channelId: "channel-123", - appointmentDate: "2024-01-01T10:00:00.000Z", - expiryDate: null, - status: "CONFIRMED", - encryptedData: null, - dataKey: null, - encryptedPayload: "encrypted-payload", - iv: "iv-data", - authTag: "auth-tag", - createdAt: "2024-01-01T09:00:00.000Z", - updatedAt: "2024-01-01T09:00:00.000Z", - } as any; - - describe("GET /api/tenants/[id]/appointments/[appointmentId]", () => { - it("should return appointment for authenticated user", async () => { - mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment); - - const event = createMockRequestEvent(); - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.appointment).toEqual(mockAppointment); - expect(mockAppointmentService.getAppointmentById).toHaveBeenCalledWith(mockAppointmentId); - }); - - it("should allow staff to view appointments", async () => { - mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment); - - const event = createMockRequestEvent({ - locals: { - user: { - userId: "user123", - role: "STAFF", - tenantId: mockTenantId, - }, - } as any, - }); - - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.appointment).toEqual(mockAppointment); - }); - - it("should allow global admin to view any tenant's appointments", async () => { - mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment); - - const event = createMockRequestEvent({ - locals: { - user: { - userId: "user123", - role: "GLOBAL_ADMIN", - tenantId: "different-tenant", - }, - } as any, - }); - - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.appointment).toEqual(mockAppointment); - }); - - it("should handle missing tenant ID", async () => { - const event = createMockRequestEvent({ - params: { id: undefined, appointmentId: mockAppointmentId }, - }); - - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(422); - expect(data.error).toBe("Tenant ID and appointment ID are required"); - }); - - it("should handle missing appointment ID", async () => { - const event = createMockRequestEvent({ - params: { id: mockTenantId, appointmentId: undefined }, - }); - - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(422); - expect(data.error).toBe("Tenant ID and appointment ID are required"); - }); - - it("should handle appointment not found", async () => { - mockAppointmentService.getAppointmentById.mockResolvedValue(null); - - const event = createMockRequestEvent(); - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(404); - expect(data.error).toBe("Appointment not found"); - }); - - it("should handle service errors", async () => { - mockAppointmentService.getAppointmentById.mockRejectedValue( - new ValidationError("Invalid appointment ID"), - ); - - const event = createMockRequestEvent(); - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(422); - expect(data.error).toBe("Invalid appointment ID"); - }); - - it("should handle internal server errors", async () => { - mockAppointmentService.getAppointmentById.mockRejectedValue(new Error("Database error")); - - const event = createMockRequestEvent(); - const response = await GET(event); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data.error).toBe("Internal server error"); - }); - }); - describe("DELETE /api/tenants/[id]/appointments/[appointmentId]", () => { it("should delete appointment for tenant admin", async () => { mockAppointmentService.deleteAppointment.mockResolvedValue(true); @@ -212,6 +90,10 @@ describe("Appointment Detail API Routes", () => { }); it("should return 403 for staff users trying to delete appointments", async () => { + vi.mocked(checkPermission).mockImplementationOnce(() => { + throw new AuthorizationError("Insufficient permissions"); + }); + const event = createMockRequestEvent({ locals: { user: { @@ -231,6 +113,10 @@ describe("Appointment Detail API Routes", () => { }); it("should return 401 for unauthenticated requests", async () => { + vi.mocked(checkPermission).mockImplementationOnce(() => { + throw new AuthenticationError("Authentication required"); + }); + const event = createMockRequestEvent({ locals: { user: null } as any }); const response = await DELETE(event); const result = await response.json(); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts index ed98059..2a1be64 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts @@ -149,7 +149,7 @@ export const PUT: RequestHandler = async ({ params, locals }) => { log.debug("Cancelling appointment", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const appointmentService = await AppointmentService.forTenant(tenantId); @@ -158,7 +158,7 @@ export const PUT: RequestHandler = async ({ params, locals }) => { log.debug("Appointment cancelled successfully", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -166,7 +166,7 @@ export const PUT: RequestHandler = async ({ params, locals }) => { appointment: cancelledAppointment, }); } catch (error) { - logError(log)("Error cancelling appointment", error, locals.user?.userId, params.id); + logError(log)("Error cancelling appointment", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts index 7d2b997..67e03cc 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts @@ -197,7 +197,7 @@ export const PUT: RequestHandler = async ({ request, params, locals }) => { log.debug("Confirming appointment", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, willSendEmail: !!clientEmail, }); @@ -211,7 +211,7 @@ export const PUT: RequestHandler = async ({ request, params, locals }) => { log.debug("Appointment confirmed successfully", { tenantId, appointmentId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -219,7 +219,7 @@ export const PUT: RequestHandler = async ({ request, params, locals }) => { appointment: confirmedAppointment, }); } catch (error) { - logError(log)("Error confirming appointment", error, locals.user?.userId, params.id); + logError(log)("Error confirming appointment", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); } diff --git a/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts b/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts index 182de55..a01dc2e 100644 --- a/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts @@ -115,20 +115,20 @@ export const GET: RequestHandler = async ({ params, locals }) => { checkPermission(locals, tenantId, false); try { - log.debug("Fetching client tunnels", { tenantId, requesterId: locals.user?.userId }); + log.debug("Fetching client tunnels", { tenantId, requesterId: locals.user?.id }); const appointmentService = await AppointmentService.forTenant(tenantId); const tunnels = await appointmentService.getClientTunnels(); log.debug("Client tunnels retrieved successfully", { tenantId, - requesterId: locals.user?.userId, + requesterId: locals.user?.id, tunnelCount: tunnels.length, }); return json({ tunnels }); } catch (error) { - logError(log)("Error fetching client tunnels", error, locals.user?.userId, tenantId); + logError(log)("Error fetching client tunnels", error, locals.user?.id, tenantId); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts b/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts index 12d92bd..6951275 100644 --- a/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts @@ -176,7 +176,7 @@ export const POST: RequestHandler = async ({ params, locals, request }) => { tenantId, staffUserId, keyShareCount: keyShares.length, - requesterId: locals.user?.userId, + requesterId: locals.user?.id, }); const staffUser = await centralDb @@ -272,7 +272,7 @@ export const POST: RequestHandler = async ({ params, locals, request }) => { staffUserId, addedCount: result.length, skippedCount: duplicateKeyShares.length, - requesterId: locals.user?.userId, + requesterId: locals.user?.id, }); return json({ @@ -286,7 +286,7 @@ export const POST: RequestHandler = async ({ params, locals, request }) => { })), }); } catch (error) { - logError(log)("Error adding staff key shares", error, locals.user?.userId, tenantId); + logError(log)("Error adding staff key shares", error, locals.user?.id, tenantId); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/channels/+server.ts b/src/routes/api/tenants/[id]/channels/+server.ts index eb27132..de82a0d 100644 --- a/src/routes/api/tenants/[id]/channels/+server.ts +++ b/src/routes/api/tenants/[id]/channels/+server.ts @@ -309,7 +309,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { log.debug("Creating new channel", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, channelNames: body.names, languages: body.languages, }); @@ -320,7 +320,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { log.debug("Channel created successfully", { tenantId, channelId: newChannel.id, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json( @@ -331,7 +331,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { { status: 201 }, ); } catch (error) { - logError(log)("Error creating channel", error, locals.user?.userId, params.id); + logError(log)("Error creating channel", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -355,7 +355,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Getting all channels", { tenantId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const channelService = await ChannelService.forTenant(tenantId); @@ -364,14 +364,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Channels retrieved successfully", { tenantId, count: channels.length, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ channels, }); } catch (error) { - logError(log)("Error in getting channels", error, locals.user?.userId, params.id); + logError(log)("Error in getting channels", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts index 5533c73..9de0de3 100644 --- a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts +++ b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts @@ -404,7 +404,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Getting channel details", { tenantId, channelId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const channelService = await ChannelService.forTenant(tenantId); @@ -417,14 +417,14 @@ export const GET: RequestHandler = async ({ params, locals }) => { log.debug("Channel details retrieved successfully", { tenantId, channelId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ channel, }); } catch (error) { - logError(log)("Error getting channel details", error, locals.user?.userId, params.id); + logError(log)("Error getting channel details", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -451,7 +451,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { log.debug("Updating channel", { tenantId, channelId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, updateFields: Object.keys(body), }); @@ -461,7 +461,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { log.debug("Channel updated successfully", { tenantId, channelId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ @@ -469,7 +469,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { channel: updatedChannel, }); } catch (error) { - logError(log)("Error updating channel", error, locals.user?.userId, params.id); + logError(log)("Error updating channel", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -504,7 +504,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.debug("Deleting channel", { tenantId, channelId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); const channelService = await ChannelService.forTenant(tenantId); @@ -517,14 +517,14 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { log.debug("Channel deleted successfully", { tenantId, channelId, - requestedBy: locals.user?.userId, + requestedBy: locals.user?.id, }); return json({ message: "Channel deleted successfully", }); } catch (error) { - logError(log)("Error deleting channel", error, locals.user?.userId, params.id); + logError(log)("Error deleting channel", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/config/+server.ts b/src/routes/api/tenants/[id]/config/+server.ts index 6c99d97..11ed363 100644 --- a/src/routes/api/tenants/[id]/config/+server.ts +++ b/src/routes/api/tenants/[id]/config/+server.ts @@ -195,7 +195,7 @@ export const GET: RequestHandler = async ({ locals, params }) => { return json(config); } catch (error) { - logError(log)("Error getting tenant configuration", error, locals.user?.userId, params.id); + logError(log)("Error getting tenant configuration", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); @@ -236,7 +236,7 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { updatedKeys: Object.keys(body), }); } catch (error) { - logError(log)("Error updating tenant configuration", error, locals.user?.userId, params.id); + logError(log)("Error updating tenant configuration", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/setup-state/+server.ts b/src/routes/api/tenants/[id]/setup-state/+server.ts index c6d2aba..4793ccc 100644 --- a/src/routes/api/tenants/[id]/setup-state/+server.ts +++ b/src/routes/api/tenants/[id]/setup-state/+server.ts @@ -138,7 +138,7 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => { tenant: updatedTenant, }); } catch (error) { - logError(log)("Error updating tenant setup state", error, locals.user?.userId, params.id); + logError(log)("Error updating tenant setup state", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/staff/+server.ts b/src/routes/api/tenants/[id]/staff/+server.ts index c45310c..4fda5b1 100644 --- a/src/routes/api/tenants/[id]/staff/+server.ts +++ b/src/routes/api/tenants/[id]/staff/+server.ts @@ -124,7 +124,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { const staff = await StaffService.getStaffMembers(tenantId); return json({ staff: staff }); } catch (error) { - logError(log)("Error fetching staff data", error, locals.user?.userId, params.id); + logError(log)("Error fetching staff data", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts index fb659ce..2ae2da7 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts @@ -269,13 +269,13 @@ export const DELETE: RequestHandler = async ({ request, params, locals }) => { const result = await StaffService.deleteStaffMember( tenantId, staffId, - locals.user?.userId, + locals.user?.id, confirmationState, ); return json(result); } catch (error) { - logError(log)("Error deleting staff member", error, locals.user?.userId, tenantId); + logError(log)("Error deleting staff member", error, locals.user?.id, tenantId); if (error instanceof BackendError) { return error.toJson(); @@ -325,12 +325,12 @@ export const PUT: RequestHandler = async ({ params, locals, request }) => { tenantId, staffId, updateData, - locals.user?.userId, + locals.user?.id, ); return json(updatedUser); } catch (error) { - logError(log)("Error updating staff member", error, locals.user?.userId, params.id); + logError(log)("Error updating staff member", error, locals.user?.id, params.id); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts index af52e2f..beed199 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts @@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies }) const { passkeyId, publicKey, privateKeyShare, email } = validation.data; // Authentication: Accept either active session OR registration cookie - const isAuthenticated = locals.user && locals.user.userId === staffId; + const isAuthenticated = locals.user && locals.user.id === staffId; const registrationEmail = cookies.get("webauthn-registration-email"); const isRegistration = registrationEmail === email; @@ -148,7 +148,7 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies }) log.warn("Unauthorized crypto key storage attempt", { tenantId, staffId, - requesterId: locals.user?.userId, + requesterId: locals.user?.id, hasRegistrationCookie: !!registrationEmail, emailsMatch: registrationEmail === email, }); @@ -182,7 +182,7 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies }) message: "Cryptographic keys stored successfully", }); } catch (error) { - logError(log)("Failed to store staff crypto keys", error, locals.user?.userId, params.id); + logError(log)("Failed to store staff crypto keys", error, locals.user?.id, params.id); log.error("Additional context", { tenantId, staffId }); if (error instanceof BackendError) { diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/+server.ts index 610add5..371202c 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/+server.ts @@ -132,11 +132,11 @@ export const GET: RequestHandler = async ({ params, locals }) => { checkPermission(locals, tenantId, false); // Additional security check: Only the passkey owner can access their own key shard - if (!locals.user || locals.user.userId !== staffId) { + if (!locals.user || locals.user.id !== staffId) { log.warn("Unauthorized key shard access attempt", { tenantId, staffId, - requesterId: locals.user?.userId, + requesterId: locals.user?.id, }); throw new AuthorizationError("You can only access your own key shard"); } @@ -148,13 +148,13 @@ export const GET: RequestHandler = async ({ params, locals }) => { try { // Get the most recently used passkey for this authenticated user - const recentPasskey = await WebAuthnService.getMostRecentPasskey(locals.user.userId); + const recentPasskey = await WebAuthnService.getMostRecentPasskey(locals.user.id); if (!recentPasskey) { log.warn("No passkey found for authenticated user - security violation", { tenantId, staffId, - userId: locals.user.userId, + userId: locals.user.id, security: "User session exists but no passkey found", }); throw new AuthorizationError("No valid passkey found for authenticated user"); @@ -183,7 +183,7 @@ export const GET: RequestHandler = async ({ params, locals }) => { tenantId, staffId, passkeyId, - requesterId: locals.user.userId, + requesterId: locals.user.id, }); const staffCryptoService = new StaffCryptoService(); @@ -221,8 +221,8 @@ export const GET: RequestHandler = async ({ params, locals }) => { }); } catch (error) { // Note: passkeyId might not be available if error occurred before determination - const passkeyIdForError = locals.user?.sessionId || "unknown"; - logError(log)("Failed to fetch staff key shard", error, locals.user?.userId, params.id); + const passkeyIdForError = locals.user?.session.sessionId || "unknown"; + logError(log)("Failed to fetch staff key shard", error, locals.user?.id, params.id); log.error("Additional context", { tenantId, staffId, passkeyId: passkeyIdForError }); if (error instanceof BackendError) { diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/__tests__/key-shard-api.test.ts b/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/__tests__/key-shard-api.test.ts index 69c21f5..918a2e6 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/__tests__/key-shard-api.test.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/key-shard/__tests__/key-shard-api.test.ts @@ -108,8 +108,10 @@ describe("Staff Key Shard API Route", () => { request: {} as any, locals: { user: { - userId: mockUserId, - sessionId: "session-123", + id: mockUserId, + session: { + sessionId: "session-123", + }, }, } as any, ...overrides, @@ -155,8 +157,8 @@ describe("Staff Key Shard API Route", () => { const event = createMockRequestEvent({ locals: { user: { - userId: "different-user-id", - sessionId: "session-123", + id: "different-user-id", + session: { sessionId: "session-123" }, }, } as any, }); @@ -350,8 +352,8 @@ describe("Staff Key Shard API Route", () => { params: { id: mockTenantId, staffId: testCase.targetStaffId }, locals: { user: { - userId: testCase.requestingUserId, - sessionId: "session-123", + id: testCase.requestingUserId, + session: { sessionId: "session-123" }, }, } as any, }); diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/public-key/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/public-key/+server.ts index af361d4..d6fa24b 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/public-key/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/public-key/+server.ts @@ -107,20 +107,20 @@ export const GET: RequestHandler = async ({ params, locals }) => { checkPermission(locals, tenantId, false); try { - log.debug("Fetching staff public key", { tenantId, staffId, requesterId: locals.user?.userId }); + log.debug("Fetching staff public key", { tenantId, staffId, requesterId: locals.user?.id }); const result = await StaffService.getStaffPublicKey(tenantId, staffId); log.debug("Staff public key retrieved successfully", { tenantId, staffId, - requesterId: locals.user?.userId, + requesterId: locals.user?.id, hasPublicKey: !!result.publicKey, }); return json(result); } catch (error) { - logError(log)("Error fetching staff public key", error, locals.user?.userId, tenantId); + logError(log)("Error fetching staff public key", error, locals.user?.id, tenantId); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index 8f69bdc..eeac0a2 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -39,10 +39,11 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { // Add sessionId to the user object for easy access event.locals.user = { - userId: sessionData.user.id, - exp: sessionData.exp.valueOf(), + session: { + exp: sessionData.exp.valueOf(), + sessionId: sessionData.sessionId, + }, ...sessionData.user, - sessionId: sessionData.sessionId, }; } diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts index 7d69e40..c39ebf0 100644 --- a/src/server-hooks/authGuard.ts +++ b/src/server-hooks/authGuard.ts @@ -27,9 +27,10 @@ export const authGuard: Handle = async ({ event, resolve }) => { // Set user in auth store for SSR event.locals.user = { ...sessionData.user, - sessionId: sessionData.sessionId, - userId: sessionData.user.id, - exp: sessionData.exp.valueOf(), + session: { + sessionId: sessionData.sessionId, + exp: sessionData.exp.valueOf(), + }, }; switch (true) { From 337fc2e70b70c6b8756426958b9886a7feceff1e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:14:40 +0100 Subject: [PATCH 18/61] Add throttling to challenge APIs for brute force protection (#145) * Initial plan * Add throttling implementation for challenge APIs Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> * Add tests for throttling and fix linting issues Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> * Address code review feedback - improve error handling and documentation Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> * Consolidate throttle storage to central DB per review feedback Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> * Fixed errors in challenge-throlle. * Add throttling to frontend. * incorporated Reviewer comments --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> Co-authored-by: Hendrik Belitz --- migrations/0008_tense_gamora.sql | 6 + migrations/meta/0008_snapshot.json | 832 ++++++++++++++++++ migrations/meta/_journal.json | 7 + package-lock.json | 42 +- project.inlang/messages/de.json | 4 +- project.inlang/messages/en.json | 4 +- src/lib/client/appointment-crypto.ts | 35 + src/lib/server/db/central-schema.ts | 17 + .../__tests__/challenge-throttle.test.ts | 335 +++++++ src/lib/server/services/challenge-throttle.ts | 167 ++++ src/lib/stores/pin-throttle.ts | 125 +++ src/lib/utils/passkey.ts | 35 +- .../auth/book-appointment-login-form.svelte | 54 +- .../book-appointment-register-form.svelte | 56 +- src/routes/api/auth/challenge/+server.ts | 42 + src/routes/api/auth/login/+server.ts | 6 + .../[id]/appointments/challenge/+server.ts | 44 +- .../challenge/__tests__/challenge-api.test.ts | 32 + .../appointments/verify-challenge/+server.ts | 8 + tenant-migrations/meta/0008_snapshot.json | 784 +++++++++++++++++ tenant-migrations/meta/_journal.json | 7 + 21 files changed, 2595 insertions(+), 47 deletions(-) create mode 100644 migrations/0008_tense_gamora.sql create mode 100644 migrations/meta/0008_snapshot.json create mode 100644 src/lib/server/services/__tests__/challenge-throttle.test.ts create mode 100644 src/lib/server/services/challenge-throttle.ts create mode 100644 src/lib/stores/pin-throttle.ts create mode 100644 tenant-migrations/meta/0008_snapshot.json diff --git a/migrations/0008_tense_gamora.sql b/migrations/0008_tense_gamora.sql new file mode 100644 index 0000000..0ca17d2 --- /dev/null +++ b/migrations/0008_tense_gamora.sql @@ -0,0 +1,6 @@ +CREATE TABLE "challenge_throttle" ( + "id" text PRIMARY KEY NOT NULL, + "failed_attempts" integer DEFAULT 0 NOT NULL, + "last_attempt_at" timestamp DEFAULT now() NOT NULL, + "reset_at" timestamp NOT NULL +); diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..2174510 --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,832 @@ +{ + "id": "51c74709-63c7-4ddb-b69d-2727f824bd50", + "prevId": "c06d9c90-44ff-4e2e-a834-8533afcfe755", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.passkey_challenge_throttle": { + "name": "passkey_challenge_throttle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "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" + }, + "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"] + } + }, + "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'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "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 6146f3e..c71c6b9 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1761303767674, "tag": "0007_milky_wasp", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1765722019964, + "tag": "0008_tense_gamora", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index fc8386f..511f2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -388,7 +388,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -412,7 +411,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1673,7 +1671,6 @@ "integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -1842,7 +1839,8 @@ "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@phc/format": { "version": "1.0.0", @@ -2347,7 +2345,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.25.2.tgz", "integrity": "sha512-aKfj82vqEINedoH9Pw4Ip16jj3w8soNq9F3nJqc56kxXW74TcEu/gdTAuLUI+gsl8i+KXfetRqg1F+gG/AZRVQ==", "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", @@ -2379,7 +2376,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.0.tgz", "integrity": "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw==", "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -3051,7 +3047,6 @@ "integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.1", "@typescript-eslint/types": "8.34.1", @@ -3415,7 +3410,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4342,6 +4336,7 @@ "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -4362,7 +4357,6 @@ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4455,7 +4449,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4966,6 +4959,7 @@ "integrity": "sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", @@ -4987,6 +4981,7 @@ "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=16" } @@ -4997,6 +4992,7 @@ "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "isexe": "^3.1.1" }, @@ -5377,7 +5373,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5499,7 +5494,6 @@ "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -5518,14 +5512,6 @@ "node": ">= 0.8.0" } }, - "node_modules/libphonenumber-js": { - "version": "1.12.12", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.12.tgz", - "integrity": "sha512-aWVR6xXYYRvnK0v/uIwkf5Lthq9Jpn0N8TISW/oDTWlYB2sOimuiLn9Q26aUw4KxkJoiT8ACdiw44Y8VwKFIfQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -6280,7 +6266,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6403,7 +6388,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -6428,7 +6412,6 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6445,7 +6428,6 @@ "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -6730,7 +6712,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -6932,6 +6913,7 @@ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 0.4" }, @@ -7139,7 +7121,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.7.tgz", "integrity": "sha512-5PEg+QQKce4t1qiOtVUhUS3AQRTtxJyGBTpxLcNWnr0Ve8q4r06bMo0Gv8uhtCPWlztZHoi3Ye7elLhu+PCTMg==", "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -7312,7 +7293,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "devalue": "^5.3.2", "memoize-weak": "^1.0.2", @@ -7410,7 +7390,6 @@ "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -7441,8 +7420,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.2", @@ -7720,7 +7698,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7886,7 +7863,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -8017,7 +7993,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -8318,7 +8293,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index ac6c353..1ffe690 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -859,7 +859,9 @@ "login": { "title": "Anmelden", "description": "Bitte geben Sie Ihre PIN ein:", - "action": "Zusammenfassung anzeigen" + "action": "Zusammenfassung anzeigen", + "throttled": "Zu viele Anmeldeversuche. Bitte warten Sie einige Minuten, bevor Sie es erneut versuchen.", + "retry": "Versuchen Sie es erneut in {seconds} Sekunden" } }, "summary": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 1af7752..2b6e3ee 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -868,7 +868,9 @@ "login": { "title": "Login", "description": "Please enter the PIN number, you've set:", - "action": "Show summary" + "action": "Show summary", + "throttled": "Too many failed attempts. Please wait a few minutes before trying again.", + "retry": "Please try again in {seconds} seconds" } }, "summary": { diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 32d2080..2dd752d 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -48,6 +48,7 @@ */ import { OptimizedArgon2 } from "$lib/crypto/hashing"; +import { pinThrottleStore } from "$lib/stores/pin-throttle"; import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils"; // Type definitions for unified cryptography @@ -203,6 +204,22 @@ export class UnifiedAppointmentCrypto { }); if (!challengeResponse.ok) { + if (challengeResponse.status === 429) { + const errorData = await challengeResponse.json(); + const retryAfterMs = errorData.retryAfterMs || 60000; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + // Store throttle state for frontend to enforce + pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0); + + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } + } throw new Error("Challenge could not be retrieved"); } @@ -233,6 +250,21 @@ export class UnifiedAppointmentCrypto { if (!verificationResponse.ok) { const errorData = await verificationResponse.json(); console.error("❌ Challenge verification failed:", errorData); + if (verificationResponse.status === 429) { + const retryAfterMs = errorData.retryAfterMs || 60000; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + // Store throttle state for frontend to enforce + pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0); + + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } + } throw new Error("Challenge verification failed"); } @@ -243,6 +275,9 @@ export class UnifiedAppointmentCrypto { this.tunnelId = verificationData.tunnelId; this.clientAuthenticated = true; + + // Clear throttle on successful authentication + pinThrottleStore.clearThrottle(); } catch (error) { console.error("❌ Error during client login:", error); throw error; diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 7e79912..c4073ab 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -229,6 +229,23 @@ export const userInvite = pgTable( }), ); +/** + * Challenge Throttle table - tracks failed authentication attempts for all challenge types + * Used to implement rate limiting for both PIN and passkey authentication + * Stored centrally to prevent brute force attacks across all tenants + * @table challenge_throttle + */ +export const challengeThrottle = pgTable("challenge_throttle", { + /** Primary key - identifier (email hash for PIN challenges, email for passkey challenges) */ + id: text("id").primaryKey(), + /** Number of failed attempts */ + failedAttempts: integer("failed_attempts").default(0).notNull(), + /** When the throttle was last updated */ + lastAttemptAt: timestamp("last_attempt_at").defaultNow().notNull(), + /** When the throttle should reset/expire */ + resetAt: timestamp("reset_at").notNull(), +}); + /** * TypeScript type exports for use in application code */ diff --git a/src/lib/server/services/__tests__/challenge-throttle.test.ts b/src/lib/server/services/__tests__/challenge-throttle.test.ts new file mode 100644 index 0000000..afea028 --- /dev/null +++ b/src/lib/server/services/__tests__/challenge-throttle.test.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies BEFORE importing the service +vi.mock("$lib/server/db", () => { + return { + centralDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; +}); + +vi.mock("$lib/logger", () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + return { + default: mockLogger, + logger: mockLogger, + }; +}); + +import { challengeThrottleService } from "../challenge-throttle"; + +describe("ChallengeThrottleService", () => { + let mockCentralDb: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Get the mocked centralDb module + const dbModule = await vi.importMock("$lib/server/db"); + mockCentralDb = dbModule.centralDb; + }); + + describe("PIN challenge throttling", () => { + const emailHash = "test-email-hash"; + + it("should allow request when no throttle record exists", async () => { + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(true); + expect(result.retryAfterMs).toBe(0); + expect(result.failedAttempts).toBe(0); + }); + + it("should allow request when throttle has expired", async () => { + const expiredRecord = { + id: emailHash, + failedAttempts: 3, + lastAttemptAt: new Date(Date.now() - 10000), + resetAt: new Date(Date.now() - 1000), // Expired 1 second ago + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([expiredRecord]), + }; + + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(true); + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + + it("should throttle request when delay has not passed (1st failure)", async () => { + const now = Date.now(); + const record = { + id: emailHash, + failedAttempts: 4, // 4th attempt triggers throttling (1 minute delay) + lastAttemptAt: new Date(now - 30000), // 30 seconds ago (less than 1 minute) + resetAt: new Date(now + 30000), // 30 seconds in future + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(60000); // 1 minute delay for 4th failure + }); + + it("should throttle request when delay has not passed (3rd failure)", async () => { + const now = Date.now(); + const record = { + id: emailHash, + failedAttempts: 5, // 5th attempt triggers 5 minute delay + lastAttemptAt: new Date(now - 120000), // 2 minutes ago (less than 5 minutes) + resetAt: new Date(now + 180000), // 3 minutes in future + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(300000); // 5 minutes delay for 5th failure + }); + + it("should allow request when enough time has passed", async () => { + const now = Date.now(); + const record = { + id: emailHash, + failedAttempts: 1, + lastAttemptAt: new Date(now - 3000), // 3 seconds ago (more than 2 second delay) + resetAt: new Date(now + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(true); + expect(result.failedAttempts).toBe(1); + }); + + it("should record first failed attempt", async () => { + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + + await challengeThrottleService.recordFailedAttempt(emailHash, "pin"); + + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: emailHash, + failedAttempts: 1, + }), + ); + }); + + it("should increment failed attempts on subsequent failures", async () => { + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([ + { + id: emailHash, + failedAttempts: 2, + lastAttemptAt: new Date(), + resetAt: new Date(Date.now() + 60000), + }, + ]), + }; + + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + + await challengeThrottleService.recordFailedAttempt(emailHash, "pin"); + + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: emailHash, + failedAttempts: 1, + }), + ); + }); + + it("should clear throttle on successful authentication", async () => { + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + await challengeThrottleService.clearThrottle(emailHash, "pin"); + + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + }); + + describe("Passkey challenge throttling", () => { + const email = "test@example.com"; + + it("should allow first 3 attempts immediately", async () => { + const record = { + id: email, + failedAttempts: 2, + lastAttemptAt: new Date(Date.now() - 100), + resetAt: new Date(Date.now() + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(email, "passkey"); + + expect(result.allowed).toBe(true); + expect(result.retryAfterMs).toBe(0); + }); + + it("should throttle after 3 failed attempts", async () => { + const now = Date.now(); + const record = { + id: email, + failedAttempts: 3, + lastAttemptAt: new Date(now - 10000), // 10 seconds ago + resetAt: new Date(now + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(email, "passkey"); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(60000); // 1 minute delay + }); + + it("should allow request after 1 minute has passed", async () => { + const now = Date.now(); + const record = { + id: email, + failedAttempts: 3, + lastAttemptAt: new Date(now - 70000), // 70 seconds ago (more than 1 minute) + resetAt: new Date(now + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(email, "passkey"); + + expect(result.allowed).toBe(true); + }); + + it("should record failed passkey attempts", async () => { + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + + await challengeThrottleService.recordFailedAttempt(email, "passkey"); + + expect(mockCentralDb.insert).toHaveBeenCalled(); + }); + + it("should clear passkey throttle on success", async () => { + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + await challengeThrottleService.clearThrottle(email, "passkey"); + + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("should handle cleanup of expired records", async () => { + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + await challengeThrottleService.cleanupExpired(); + + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/lib/server/services/challenge-throttle.ts b/src/lib/server/services/challenge-throttle.ts new file mode 100644 index 0000000..6b7710a --- /dev/null +++ b/src/lib/server/services/challenge-throttle.ts @@ -0,0 +1,167 @@ +/** + * Challenge Throttling Service + * + * Implements throttling for challenge-based authentication to prevent brute force attacks. + * All throttle data is stored centrally in the central database. + * - PIN challenges (appointments): Escalating delays (2s, 10s, 1m, 5m, 30m) + * - Passkey challenges (auth): Fixed delay after 3 attempts (1 minute) + */ + +import { centralDb } from "$lib/server/db"; +import { challengeThrottle } from "$lib/server/db/central-schema"; +import { eq, lt, sql } from "drizzle-orm"; +import { logger } from "$lib/logger"; + +export type ThrottleType = "pin" | "passkey"; + +interface ThrottleResult { + allowed: boolean; + retryAfterMs: number; + failedAttempts: number; +} + +// Throttle reset duration: 1 hour +const THROTTLE_RESET_DURATION_MS = 60 * 60 * 1000; + +/** + * Calculate escalating delay for PIN challenges + * Uses a fixed escalation pattern that increases with each failed attempt: + */ +function calculatePinThrottleDelay(failedAttempts: number): number { + if (failedAttempts < 4) return 0; + if (failedAttempts === 4) return 60 * 1000; // 1 minute + if (failedAttempts === 5) return 5 * 60 * 1000; // 5 minutes + if (failedAttempts === 6) return 30 * 60 * 1000; // 30 minutes + if (failedAttempts >= 7) return 60 * 60 * 1000; // 60 minutes + return 0; +} + +/** + * Calculate throttle delay for passkey challenges + * First 3 tries: immediate + * Afterwards: wait for one minute + */ +function calculatePasskeyThrottleDelay(failedAttempts: number): number { + if (failedAttempts < 3) return 0; + return 60 * 1000; // 1 minute +} + +class ChallengeThrottleService { + /** + * Check if a challenge request should be throttled + * @param identifier - Email hash for PIN challenges, email for passkey challenges + * @param type - Type of challenge (pin or passkey) + */ + async checkThrottle(identifier: string, type: ThrottleType): Promise { + const now = new Date(); + + // Get throttle record from central DB + const records = await centralDb + .select() + .from(challengeThrottle) + .where(eq(challengeThrottle.id, identifier)) + .limit(1); + + if (records.length === 0) { + // No throttle record, allow request + return { allowed: true, retryAfterMs: 0, failedAttempts: 0 }; + } + + const record = records[0]; + + // Check if throttle has expired + if (now > record.resetAt) { + // Throttle expired, clean up and allow + await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + return { allowed: true, retryAfterMs: 0, failedAttempts: 0 }; + } + + // Calculate how long to wait based on challenge type + const delay = + type === "pin" + ? calculatePinThrottleDelay(record.failedAttempts) + : calculatePasskeyThrottleDelay(record.failedAttempts); + const timeSinceLastAttempt = now.getTime() - record.lastAttemptAt.getTime(); + + if (timeSinceLastAttempt < delay) { + // Still throttled + return { + allowed: false, + retryAfterMs: delay - timeSinceLastAttempt, + failedAttempts: record.failedAttempts, + }; + } + + // Enough time has passed, allow the request + return { + allowed: true, + retryAfterMs: 0, + failedAttempts: record.failedAttempts, + }; + } + + /** + * Record a failed challenge attempt + * @param identifier - Email hash for PIN challenges, email for passkey challenges + * @param type - Type of challenge (pin or passkey) + */ + async recordFailedAttempt(identifier: string, type: ThrottleType): Promise { + const now = new Date(); + + const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS); + await centralDb + .insert(challengeThrottle) + .values({ + id: identifier, + failedAttempts: 1, + lastAttemptAt: now, + resetAt, + }) + .onConflictDoUpdate({ + target: challengeThrottle.id, + set: { + failedAttempts: sql`${challengeThrottle.failedAttempts} + 1`, + lastAttemptAt: now, + }, + }); + + logger.info(`Recorded failed ${type} challenge attempt`, { + identifier: identifier.slice(0, 8), + type, + }); + } + + /** + * Clear throttle for successful authentication + * @param identifier - Email hash for PIN challenges, email for passkey challenges + * @param type - Type of challenge (pin or passkey) + */ + async clearThrottle(identifier: string, type: ThrottleType): Promise { + await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + + logger.debug(`Cleared ${type} challenge throttle`, { + identifier: identifier.slice(0, 8), + type, + }); + } + + /** + * Clean up expired throttle records + * Should be called periodically + */ + async cleanupExpired(): Promise { + const now = new Date(); + + try { + await centralDb.delete(challengeThrottle).where(lt(challengeThrottle.resetAt, now)); + + logger.debug("Cleaned up expired challenge throttles"); + } catch (error) { + logger.warn("Failed to cleanup expired throttles", { + error: String(error), + }); + } + } +} + +export const challengeThrottleService = new ChallengeThrottleService(); diff --git a/src/lib/stores/pin-throttle.ts b/src/lib/stores/pin-throttle.ts new file mode 100644 index 0000000..895317f --- /dev/null +++ b/src/lib/stores/pin-throttle.ts @@ -0,0 +1,125 @@ +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; + +/** + * PIN Throttle Store + * + * Persists throttle state across page reloads and component remounts. + * Tracks when the user can next attempt PIN authentication. + */ + +interface PinThrottleState { + emailHash: string | null; + throttleUntil: number | null; // Timestamp when throttle expires (milliseconds) + failedAttempts: number; +} + +const STORAGE_KEY = "pin-throttle-state"; + +const createPinThrottleStore = () => { + // Initialize from localStorage if available + const initialState: PinThrottleState = { + emailHash: null, + throttleUntil: null, + failedAttempts: 0, + }; + + if (!browser) { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Only restore if throttle hasn't expired yet + if (parsed.throttleUntil && parsed.throttleUntil > Date.now()) { + Object.assign(initialState, parsed); + } else { + // Throttle expired, clear storage + localStorage.removeItem(STORAGE_KEY); + } + } catch (e) { + console.error("Failed to parse throttle state:", e); + localStorage.removeItem(STORAGE_KEY); + } + } + } + + const store = writable(initialState); + + return { + subscribe: store.subscribe, + + /** + * Set throttle state when a 429 response is received + * @param emailHash The email hash that is throttled + * @param retryAfterMs How many milliseconds to wait before retry + * @param failedAttempts Current number of failed attempts + */ + setThrottle: (emailHash: string, retryAfterMs: number, failedAttempts: number = 0) => { + const throttleUntil = Date.now() + retryAfterMs; + const newState: PinThrottleState = { + emailHash, + throttleUntil, + failedAttempts, + }; + + store.set(newState); + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + } + }, + + /** + * Clear throttle state after successful authentication + */ + clearThrottle: () => { + store.set({ + emailHash: null, + throttleUntil: null, + failedAttempts: 0, + }); + if (typeof window !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Check if a specific email hash is currently throttled + * @param emailHash The email hash to check + * @returns true if throttled, false otherwise + */ + isThrottled: (emailHash: string): boolean => { + let isThrottled = false; + store.subscribe((state) => { + if ( + state.emailHash === emailHash && + state.throttleUntil && + state.throttleUntil > Date.now() + ) { + isThrottled = true; + } + })(); + return isThrottled; + }, + + /** + * Get remaining throttle time in milliseconds + * @param emailHash The email hash to check + * @returns Milliseconds remaining, or 0 if not throttled + */ + getRemainingTime: (emailHash: string): number => { + let remaining = 0; + store.subscribe((state) => { + if ( + state.emailHash === emailHash && + state.throttleUntil && + state.throttleUntil > Date.now() + ) { + remaining = state.throttleUntil - Date.now(); + } + })(); + return Math.max(0, remaining); + }, + }; +}; + +export const pinThrottleStore = createPinThrottleStore(); diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 9815944..7fb70f7 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -49,16 +49,39 @@ export const fetchChallenge = async (email: string) => { body: JSON.stringify({ email }), }); + let data; try { - const data = await resp.json(); - return { - id: data.rpId, - challenge: data.challenge, - }; - } catch { + data = await resp.json(); + } catch (error) { + logger.error("Failed to parse challenge response", { email, error }); + return null; + } + + // Handle throttling + if (resp.status === 429) { + const retryAfterSeconds = Math.ceil((data.retryAfterMs || 60000) / 1000); + logger.warn("Challenge request throttled", { + email, + retryAfterSeconds, + }); + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } + } + + if (!resp.ok) { logger.error("Failed to fetch challenge", { email, status: resp.status }); return null; } + + return { + id: data.rpId, + challenge: data.challenge, + }; }; export const getCredentialOptions = ({ diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte index 566e1a8..04a2d3b 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -1,9 +1,11 @@ + {#if isThrottled} + + + {m["public.steps.auth.login.throttled"]()} + + + {m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })} + + + {/if} + {#snippet children({ props })} {m["form.pin"]()} - + {/snippet} @@ -63,7 +108,12 @@
- + {m["public.steps.auth.login.action"]()}
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte index 8e92603..a461b73 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte @@ -1,9 +1,11 @@ + {#if isThrottled} + + + {m["public.steps.auth.login.throttled"]()} + + + {m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })} + + + {/if} + {#snippet children({ props })} {m["form.pin"]()} - + {/snippet} @@ -74,7 +121,12 @@
- + {m["public.steps.auth.register.action"]()}
diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts index 914f5ea..17b8528 100644 --- a/src/routes/api/auth/challenge/+server.ts +++ b/src/routes/api/auth/challenge/+server.ts @@ -6,6 +6,7 @@ import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import { UniversalLogger } from "$lib/logger"; import { env } from "$env/dynamic/private"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; const logger = new UniversalLogger().setContext("AuthChallengeAPI"); @@ -75,6 +76,23 @@ registerOpenAPIRoute("/auth/challenge", "POST", { }, }, }, + "429": { + description: "Too many failed attempts - rate limited", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string", description: "Error message" }, + retryAfterMs: { + type: "number", + description: "Milliseconds to wait before retrying", + }, + }, + }, + }, + }, + }, "500": { description: "Internal server error", content: { @@ -114,6 +132,30 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { logger.debug("Generating WebAuthn challenge", { email: body.email }); + // Check throttling for passkey challenges + const throttleResult = await challengeThrottleService.checkThrottle(body.email, "passkey"); + + if (!throttleResult.allowed) { + logger.warn("Passkey challenge throttled", { + email: body.email, + retryAfterMs: throttleResult.retryAfterMs, + failedAttempts: throttleResult.failedAttempts, + }); + + return json( + { + error: "Too many failed attempts. Please try again later.", + retryAfterMs: throttleResult.retryAfterMs, + }, + { + status: 429, + headers: { + "Retry-After": Math.ceil(throttleResult.retryAfterMs / 1000).toString(), + }, + }, + ); + } + // Try to get user by email - but don't fail if not found let user = null; let isRegistration = false; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index bdee2c4..14b26e4 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -9,6 +9,7 @@ import { registerOpenAPIRoute } from "$lib/server/openapi"; import { UniversalLogger } from "$lib/logger"; import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; import { env } from "$env/dynamic/private"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; const logger = new UniversalLogger().setContext("AuthLoginAPI"); @@ -247,6 +248,8 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress, ); if (!verificationResult.verified) { + // Record failed passkey attempt + await challengeThrottleService.recordFailedAttempt(body.email, "passkey"); return json({ error: "Invalid WebAuthn credential" }, { status: 401 }); } @@ -264,6 +267,9 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress, } logger.debug("WebAuthn authentication successful", { userId: user.id }); + + // Clear throttle on successful authentication + await challengeThrottleService.clearThrottle(body.email, "passkey"); } const sessionData = await SessionService.createSession( diff --git a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts index a882f2c..0373dbd 100644 --- a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts @@ -10,6 +10,7 @@ import { KyberCrypto, BufferUtils } from "$lib/crypto/utils"; import { challengeStore } from "$lib/server/services/challenge-store"; import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; const requestSchema = z.object({ emailHash: z.string(), @@ -93,6 +94,23 @@ registerOpenAPIRoute("/tenants/{id}/appointments/challenge", "POST", { }, }, }, + "429": { + description: "Too many failed attempts - rate limited", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string", description: "Error message" }, + retryAfterMs: { + type: "number", + description: "Milliseconds to wait before retrying", + }, + }, + }, + }, + }, + }, "500": { description: "Internal server error", content: { @@ -120,6 +138,31 @@ export const POST: RequestHandler = async ({ request, params }) => { const body = await request.json(); const { emailHash } = requestSchema.parse(body); + // Check throttling + const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + if (!throttleResult.allowed) { + logger.warn("PIN challenge throttled", { + tenantId, + emailHashPrefix: emailHash.slice(0, 8), + retryAfterMs: throttleResult.retryAfterMs, + failedAttempts: throttleResult.failedAttempts, + }); + + return json( + { + error: "Too many failed attempts. Please try again later.", + retryAfterMs: throttleResult.retryAfterMs, + }, + { + status: 429, + headers: { + "Retry-After": Math.ceil(throttleResult.retryAfterMs / 1000).toString(), + }, + }, + ); + } + logger.info("Creating challenge for existing client", { tenantId, emailHashPrefix: emailHash.slice(0, 8), @@ -189,7 +232,6 @@ export const POST: RequestHandler = async ({ request, params }) => { return json(response); } catch (error) { logError(logger)("Failed to create challenge", error); - if (error instanceof z.ZodError) { return new ValidationError("Invalid request data").toJson(); } diff --git a/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts b/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts index 37b8615..153efc5 100644 --- a/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts @@ -40,6 +40,14 @@ vi.mock("$lib/server/services/challenge-store", () => ({ }, })); +vi.mock("$lib/server/services/challenge-throttle", () => ({ + challengeThrottleService: { + checkThrottle: vi.fn(), + recordFailedAttempt: vi.fn(), + clearThrottle: vi.fn(), + }, +})); + vi.mock("crypto", () => ({ randomBytes: vi.fn(), })); @@ -48,6 +56,7 @@ import { getTenantDb } from "$lib/server/db"; import { randomBytes } from "crypto"; import { KyberCrypto, BufferUtils } from "$lib/crypto/utils"; import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; describe("Challenge API Route", () => { const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; @@ -66,6 +75,12 @@ describe("Challenge API Route", () => { if (size === 16) return Buffer.from("challenge-id-16b"); return Buffer.alloc(size); }); + // Mock throttle service to allow all requests by default + (challengeThrottleService.checkThrottle as any).mockResolvedValue({ + allowed: true, + retryAfterMs: 0, + failedAttempts: 0, + }); }); function createMockRequestEvent(overrides: Partial = {}): RequestEvent { @@ -254,5 +269,22 @@ describe("Challenge API Route", () => { expect(response.status).toBe(500); expect(data.error).toBe("Internal server error"); }); + + it("should throttle when too many failed attempts", async () => { + // Mock throttle service to reject the request + (challengeThrottleService.checkThrottle as any).mockResolvedValue({ + allowed: false, + retryAfterMs: 60000, + failedAttempts: 3, + }); + + const event = createMockRequestEvent(); + const response = await POST(event); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data.error).toContain("Too many failed attempts"); + expect(data.retryAfterMs).toBe(60000); + }); }); }); diff --git a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts index 8fa2016..ef50f47 100644 --- a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts @@ -15,6 +15,7 @@ import { ValidationError, } from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; const requestSchema = z.object({ challengeId: z.string(), @@ -161,6 +162,10 @@ export const POST: RequestHandler = async ({ request, params }) => { challengeId, emailHashPrefix: storedChallenge.emailHash.slice(0, 8), }); + + // Record failed attempt for throttling + await challengeThrottleService.recordFailedAttempt(storedChallenge.emailHash, "pin"); + throw new ValidationError("Invalid challenge response"); } @@ -193,6 +198,9 @@ export const POST: RequestHandler = async ({ request, params }) => { tunnelId: tunnel.id, }); + // Clear throttle on successful verification + await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin"); + const response: ChallengeVerificationResponse = { valid: true, encryptedTunnelKey: tunnel.clientEncryptedTunnelKey, diff --git a/tenant-migrations/meta/0008_snapshot.json b/tenant-migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..d60563b --- /dev/null +++ b/tenant-migrations/meta/0008_snapshot.json @@ -0,0 +1,784 @@ +{ + "id": "49c7b1a3-9342-40f8-9548-5a77fe0b18ae", + "prevId": "29c30482-bb21-472c-acbc-6b802aaf0614", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(250000)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data_key": { + "name": "data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "appointment", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_agent_id_agent_id_fk": { + "name": "appointment_agent_id_agent_id_fk", + "tableFrom": "appointment", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_key_share": { + "name": "appointment_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_key_share_appointment_id_appointment_id_fk": { + "name": "appointment_key_share_appointment_id_appointment_id_fk", + "tableFrom": "appointment_key_share", + "tableTo": "appointment", + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_throttle": { + "name": "challenge_throttle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_appointment_tunnel": { + "name": "client_appointment_tunnel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_key_share": { + "name": "client_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_appointment_tunnel_email_hash_unique": { + "name": "client_appointment_tunnel_email_hash_unique", + "nullsNotDistinct": false, + "columns": ["email_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tunnel_staff_key_share": { + "name": "client_tunnel_staff_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_tunnel_key": { + "name": "encrypted_tunnel_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "client_tunnel_staff_key_share", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_crypto": { + "name": "staff_crypto", + "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 + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 5341886..9272855 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1763455361426, "tag": "0007_mighty_tinkerer", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1765722000052, + "tag": "0008_melted_captain_marvel", + "breakpoints": true } ] } From b81706a8d92b9149816b67fa94932415ce6fc5af Mon Sep 17 00:00:00 2001 From: Hendrik Date: Fri, 2 Jan 2026 15:08:23 +0100 Subject: [PATCH 19/61] Security hotfix prf (#146) * Use PRF extension for deterministic Zero Knowledge Shards * Webauthn validation lib * Use attestation and validate passkeys * Use attestation objects and cose-format keys in frontend. Added additional checks so that we don't create orphaned users when validation fails. * Fixed type check error * Fix PRF salt documentation to match implementation (#147) * Initial plan * Fix PRF salt documentation to match implementation Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> * Use dev instead of node env * Migration fixes --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: hbel <7416029+hbel@users.noreply.github.com> --- docs/staff-crypto-worker.md | 138 ++- ...nse_gamora.sql => 0008_wild_whirlwind.sql} | 0 migrations/meta/0008_snapshot.json | 106 ++- migrations/meta/_journal.json | 6 +- package-lock.json | 291 +++++-- package.json | 3 +- src/lib/client/appointment-crypto.ts | 239 +++--- src/lib/server/auth/webauthn-service.ts | 416 +++++----- src/lib/stores/auth.ts | 1 + src/lib/stores/staff-crypto.ts | 50 +- src/lib/utils/passkey.ts | 183 +++- .../auth/book-appointment-login-form.svelte | 4 +- .../confirm/setup-passkey/+page.server.ts | 15 +- .../(pages)/confirm/setup-passkey/schema.ts | 5 +- .../setup-passkey/setup-passkey-form.svelte | 116 ++- src/routes/(pages)/login/login-form.svelte | 42 +- .../create-admin-account/+page.server.ts | 72 +- .../create-account-form.svelte | 35 +- .../setup/create-admin-account/schema.ts | 4 +- src/routes/api/admin/init/+server.ts | 33 +- src/routes/api/auth/challenge/+server.ts | 18 +- src/routes/api/auth/login/+server.ts | 9 +- src/routes/api/auth/register/[id]/+server.ts | 35 +- tenant-migrations/meta/0008_snapshot.json | 784 ------------------ tenant-migrations/meta/_journal.json | 7 - 25 files changed, 1168 insertions(+), 1444 deletions(-) rename migrations/{0008_tense_gamora.sql => 0008_wild_whirlwind.sql} (100%) delete mode 100644 tenant-migrations/meta/0008_snapshot.json diff --git a/docs/staff-crypto-worker.md b/docs/staff-crypto-worker.md index b32c8b9..db7da8f 100644 --- a/docs/staff-crypto-worker.md +++ b/docs/staff-crypto-worker.md @@ -21,43 +21,66 @@ Browser (Staff App) ↔ StaffCryptoService API ↔ Tenant Database - **Browser-Based Cryptography**: All key generation and crypto operations in browser - **Split-Key Architecture**: Private keys split using XOR between database and passkey shards -- **WebAuthn Integration**: Hardware-backed deterministic key derivation from passkey data -- **Zero-Knowledge Server**: Server never sees complete private keys +- **WebAuthn PRF Extension**: Hardware-backed deterministic secret derivation using PRF (Pseudo-Random Function) +- **Zero-Knowledge Server**: Server never sees complete private keys or PRF outputs - **Per-Passkey Keys**: Each staff passkey has its own unique keypair - **ML-KEM-768 Encryption**: Post-quantum cryptography using Kyber +- **Modern Authenticator Required**: Requires CTAP 2.1+ authenticators with PRF support ## Browser Integration Workflow ### 1. Staff Passkey Registration & Key Generation -When a staff member registers a new passkey, the browser automatically generates crypto keys: +When a staff member registers a new passkey, the browser automatically generates crypto keys using the WebAuthn PRF extension: ```javascript import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import { generatePasskey, getPRFOutputAfterRegistration } from "$lib/utils/passkey"; -async function registerStaffPasskey(userId, tenantId) { - // 1. Complete WebAuthn passkey registration - const credential = await navigator.credentials.create({ - publicKey: registrationOptions, +async function registerStaffPasskey(userId, tenantId, email) { + // 1. Complete WebAuthn passkey registration WITH PRF extension + const credential = await generatePasskey({ + id: rpId, + challenge: challengeBase64, + email: email, + enablePRF: true, // CRITICAL: Enable PRF for zero-knowledge key derivation }); + // Verify PRF extension is enabled + const extensionResults = credential.getClientExtensionResults(); + if (!extensionResults.prf?.enabled) { + throw new Error( + "PRF extension not supported. Please use a modern authenticator " + + "(YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)", + ); + } + // 2. Generate Kyber keypair in browser const keyPair = KyberCrypto.generateKeyPair(); - // 3. Derive deterministic shard from WebAuthn authenticatorData + // 3. IMMEDIATELY retrieve PRF output after passkey creation + // This is the ONLY time we can get the PRF output for this passkey + const prfOutput = await getPRFOutputAfterRegistration({ + passkeyId: credential.id, + rpId: rpId, + challengeBase64: freshChallengeBase64, // Fresh challenge from server + email: email, + }); + + // 4. Derive deterministic shard from PRF output (SECRET, zero-knowledge!) const crypto = new UnifiedAppointmentCrypto(); - const passkeyBasedShard = await crypto.derivePasskeyBasedShard( - credential.id, - credential.response.authenticatorData, + const passkeyBasedShard = await crypto.derivePasskeyBasedShardWithPRF( + prfOutput, // 32-byte secret from PRF extension + userId, ); - // 4. Create database shard using XOR + // 5. Create database shard using XOR const dbShard = new Uint8Array(keyPair.privateKey.length); for (let i = 0; i < keyPair.privateKey.length; i++) { dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; } - // 5. Store via StaffCryptoService API + // 6. Store via StaffCryptoService API await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -68,50 +91,101 @@ async function registerStaffPasskey(userId, tenantId) { }), }); - console.log("Staff crypto keys generated and stored"); + console.log("Staff crypto keys generated and stored (PRF-based)"); } ``` +**SECURITY NOTE**: The PRF output is a **secret** 32-byte value that only the passkey owner can derive. Unlike the old `authenticatorData` approach (which used public `RP_ID_HASH`), PRF provides true zero-knowledge security - an attacker with database access **cannot** reconstruct the private key. + ### 2. Staff Authentication & Key Reconstruction -When a staff member authenticates, their private key is reconstructed from shards: +When a staff member authenticates, their private key is reconstructed from shards using the WebAuthn PRF extension: ```javascript async function authenticateStaff(userId, tenantId) { - // 1. Perform WebAuthn authentication - const assertion = await navigator.credentials.get({ - publicKey: authenticationOptions, + const crypto = new UnifiedAppointmentCrypto(); + + // This internally performs the following steps: + // 1. Perform WebAuthn authentication WITH PRF extension + // 2. Get database shard from API + // 3. Derive same passkey-based shard from PRF output + // 4. Reconstruct private key using XOR + await crypto.authenticateStaff(userId, tenantId); + + console.log("Staff authenticated and keys reconstructed (PRF-based)"); + return crypto; +} +``` + +**Internal PRF Authentication Flow** (handled by `UnifiedAppointmentCrypto.authenticateStaff()`): + +```javascript +async authenticateStaff(staffId, tenantId) { + // 1. Get passkey list from session storage + const availablePasskeys = this.getAvailablePasskeys(staffId); + const passkeyId = availablePasskeys[0]; // User can select which passkey to use + + // 2. Perform WebAuthn authentication with PRF extension + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + + const credential = await navigator.credentials.get({ + publicKey: { + challenge: challengeBuffer, + rpId: rpId, + allowCredentials: [{ id: passkeyIdBuffer, type: "public-key" }], + userVerification: "required", + extensions: { + prf: { + eval: { first: prfSalt }, // Request PRF output + }, + }, + }, }); - // 2. Get database shard from API - const response = await fetch(`/api/tenants/${tenantId}/staff/${userId}/key-shard`); - const { publicKey, privateKeyShare, passkeyId } = await response.json(); + // 3. Extract PRF output from WebAuthn response + const extensionResults = credential.getClientExtensionResults(); + const prfOutput = extensionResults.prf?.results?.first; - // 3. Derive same passkey-based shard - const crypto = new UnifiedAppointmentCrypto(); - const passkeyBasedShard = await crypto.derivePasskeyBasedShard( - passkeyId, - assertion.response.authenticatorData, + if (!prfOutput) { + throw new Error("PRF extension not supported by this passkey"); + } + + // 4. Get database shard from API + const response = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passkeyId }), + }); + const { publicKey, privateKeyShare } = await response.json(); + + // 5. Derive passkey-based shard from PRF output (same as during registration) + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF( + prfOutput, // Secret 32-byte PRF output + staffId, ); - // 4. Reconstruct private key using XOR + // 6. Reconstruct private key using XOR const dbShard = base64ToBuffer(privateKeyShare); const privateKey = new Uint8Array(dbShard.length); for (let i = 0; i < dbShard.length; i++) { privateKey[i] = dbShard[i] ^ passkeyBasedShard[i]; } - // 5. Store reconstructed keypair for session - crypto.staffKeyPair = { + // 7. Store reconstructed keypair for session + this.staffKeyPair = { publicKey: base64ToBuffer(publicKey), privateKey: privateKey, }; - - console.log("Staff authenticated and keys reconstructed"); - return crypto; } ``` +**SECURITY NOTE**: The PRF output is derived fresh during each authentication using the same deterministic salt (`open-reception-prf:${email}`). This guarantees: + +- **Determinism**: Same passkey + same salt = same PRF output = same private key +- **Zero-Knowledge**: PRF output never leaves the browser, server cannot derive it +- **Hardware-Backed**: PRF computation happens inside the authenticator's secure element +- **Multi-Passkey Support**: Using email as salt allows all passkeys for the same user to work interchangeably + ## StaffCryptoService API The backend API provides these endpoints for managing staff cryptographic keys: diff --git a/migrations/0008_tense_gamora.sql b/migrations/0008_wild_whirlwind.sql similarity index 100% rename from migrations/0008_tense_gamora.sql rename to migrations/0008_wild_whirlwind.sql diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json index 2174510..5798bdf 100644 --- a/migrations/meta/0008_snapshot.json +++ b/migrations/meta/0008_snapshot.json @@ -1,11 +1,11 @@ { - "id": "51c74709-63c7-4ddb-b69d-2727f824bd50", + "id": "6f7d3653-5075-4baf-ad67-77afa7ae10ff", "prevId": "c06d9c90-44ff-4e2e-a834-8533afcfe755", "version": "7", "dialect": "postgresql", "tables": { - "public.passkey_challenge_throttle": { - "name": "passkey_challenge_throttle", + "public.challenge_throttle": { + "name": "challenge_throttle", "schema": "", "columns": { "id": { @@ -150,7 +150,9 @@ "tenant_short_name_unique": { "name": "tenant_short_name_unique", "nullsNotDistinct": false, - "columns": ["short_name"] + "columns": [ + "short_name" + ] } }, "policies": {}, @@ -236,8 +238,12 @@ "name": "tenant_config_tenant_id_tenant_id_fk", "tableFrom": "tenant_config", "tableTo": "tenant", - "columnsFrom": ["tenant_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -374,8 +380,12 @@ "name": "user_tenant_id_tenant_id_fk", "tableFrom": "user", "tableTo": "tenant", - "columnsFrom": ["tenant_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -385,7 +395,9 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": ["email"] + "columns": [ + "email" + ] } }, "policies": {}, @@ -540,8 +552,12 @@ "name": "user_invite_tenant_id_tenant_id_fk", "tableFrom": "user_invite", "tableTo": "tenant", - "columnsFrom": ["tenant_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -549,8 +565,12 @@ "name": "user_invite_invited_by_user_id_fk", "tableFrom": "user_invite", "tableTo": "user", - "columnsFrom": ["invited_by"], - "columnsTo": ["id"], + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" }, @@ -558,8 +578,12 @@ "name": "user_invite_created_user_id_user_id_fk", "tableFrom": "user_invite", "tableTo": "user", - "columnsFrom": ["created_user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "created_user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "no action", "onUpdate": "no action" } @@ -569,7 +593,9 @@ "user_invite_invite_code_unique": { "name": "user_invite_invite_code_unique", "nullsNotDistinct": false, - "columns": ["invite_code"] + "columns": [ + "invite_code" + ] } }, "policies": {}, @@ -654,8 +680,12 @@ "name": "user_passkey_user_id_user_id_fk", "tableFrom": "user_passkey", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -778,8 +808,12 @@ "name": "user_session_user_id_user_id_fk", "tableFrom": "user_session", "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -789,7 +823,9 @@ "user_session_session_token_unique": { "name": "user_session_session_token_unique", "nullsNotDistinct": false, - "columns": ["session_token"] + "columns": [ + "session_token" + ] } }, "policies": {}, @@ -801,22 +837,40 @@ "public.config_type": { "name": "config_type", "schema": "public", - "values": ["BOOLEAN", "NUMBER", "STRING"] + "values": [ + "BOOLEAN", + "NUMBER", + "STRING" + ] }, "public.confirmation_state": { "name": "confirmation_state", "schema": "public", - "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + "values": [ + "INVITED", + "CONFIRMED", + "ACCESS_GRANTED" + ] }, "public.setup_state": { "name": "setup_state", "schema": "public", - "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + "values": [ + "SETTINGS", + "AGENTS", + "CHANNELS", + "STAFF", + "READY" + ] }, "public.user_role": { "name": "user_role", "schema": "public", - "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + "values": [ + "GLOBAL_ADMIN", + "TENANT_ADMIN", + "STAFF" + ] } }, "schemas": {}, @@ -829,4 +883,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index c71c6b9..7064b7f 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -61,9 +61,9 @@ { "idx": 8, "version": "7", - "when": 1765722019964, - "tag": "0008_tense_gamora", + "when": 1767362589999, + "tag": "0008_wild_whirlwind", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 511f2a5..3e922ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@noble/post-quantum": "^0.4.1", + "@simplewebauthn/server": "^11.0.0", "@sveltejs/adapter-node": "^5.2.12", "argon2": "^0.43.0", "argon2-browser": "^1.18.0", @@ -1553,6 +1554,12 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1736,6 +1743,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lix-js/sdk": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", @@ -1834,13 +1847,63 @@ "node": ">= 8" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", - "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } }, "node_modules/@phc/format": { "version": "1.0.0", @@ -2281,6 +2344,33 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@simplewebauthn/server": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-11.0.0.tgz", + "integrity": "sha512-zu8dxKcPiRUNSN2kmrnNOzNbRI8VaR/rL4ENCHUfC6PEE7SAAdIql9g5GBOd/wOVZolIsaZz3ccFxuGoVP0iaw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^11.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-11.0.0.tgz", + "integrity": "sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.31.28", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", @@ -2959,9 +3049,9 @@ "license": "MIT" }, "node_modules/@types/validator": { - "version": "13.15.2", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", - "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "dev": true, "license": "MIT", "optional": true @@ -3537,6 +3627,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3781,6 +3885,19 @@ "node": ">=18" } }, + "node_modules/class-validator": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3930,6 +4047,15 @@ "@cropper/utils": "^2.0.1" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4330,20 +4456,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -4953,56 +5065,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gel": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/gel/-/gel-2.1.0.tgz", - "integrity": "sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@petamoriken/float16": "^3.8.7", - "debug": "^4.3.4", - "env-paths": "^3.0.0", - "semver": "^7.6.2", - "shell-quote": "^1.8.1", - "which": "^4.0.0" - }, - "bin": { - "gel": "dist/cli.mjs" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/gel/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/gel/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -5512,6 +5574,14 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.33", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz", + "integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -6028,6 +6098,48 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -6576,6 +6688,24 @@ "license": "MIT", "optional": true }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6907,20 +7037,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7644,7 +7760,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tw-animate-css": { diff --git a/package.json b/package.json index 9b5f8ed..fa58ffd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "db:studio": "drizzle-kit studio", "db:generate": "drizzle-kit generate", "db:tenant:generate": "drizzle-kit generate --config=drizzle.tenant.config.ts", - "db:drop": "docker volume ls -q | grep '^open-reception_' | xargs -r docker volume rm", + "db:drop": "docker volume ls -q | grep '^appointment-booking-software_' | xargs -r docker volume rm", "docker:dev:up": "docker compose -f docker-compose.dev.yml up -d", "docker:dev:down": "docker compose -f docker-compose.dev.yml down", "docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f", @@ -83,6 +83,7 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@noble/post-quantum": "^0.4.1", + "@simplewebauthn/server": "^11.0.0", "@sveltejs/adapter-node": "^5.2.12", "argon2": "^0.43.0", "argon2-browser": "^1.18.0", diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 2dd752d..246d2c9 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -410,14 +410,14 @@ export class UnifiedAppointmentCrypto { // ===== STAFF METHODS ===== /** - * Authenticate staff member using WebAuthn and reconstruct private key from shards + * Authenticate staff member using WebAuthn with PRF and reconstruct private key from shards + * + * SECURITY: Uses PRF Extension for zero-knowledge key derivation. + * Requires modern authenticator with PRF support (CTAP 2.1+). */ async authenticateStaff(staffId: string, tenantId: string): Promise { try { - // 1. Perform WebAuthn authentication - const webAuthnResponse = await this.performWebAuthnAuthentication(staffId); - - // 2. Fetch database shard from server + // 1. Fetch database shard from server (to get passkeyId) const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { method: "GET", headers: { "Content-Type": "application/json" }, @@ -429,11 +429,11 @@ export class UnifiedAppointmentCrypto { const shardData = await shardResponse.json(); - // 3. Derive passkey-based shard from WebAuthn response - const passkeyBasedShard = await this.derivePasskeyBasedShard( - shardData.passkeyId, - webAuthnResponse.authenticatorData, - ); + // 2. Get PRF output from session (stored during login) + const prfOutput = await this.getPRFOutputFromSession(staffId, shardData.passkeyId); + + // 3. Derive passkey-based shard from PRF output + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId); // 4. Decode database shard const dbShard = this.base64ToUint8Array(shardData.privateKeyShare); @@ -455,7 +455,7 @@ export class UnifiedAppointmentCrypto { this.staffAuthenticated = true; this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes - console.log("✅ Staff authentication successful"); + console.log("✅ Staff authentication successful with PRF"); } catch (error) { console.error("❌ Staff authentication failed:", error); throw new Error( @@ -464,67 +464,6 @@ export class UnifiedAppointmentCrypto { } } - /** - * Reconstruct staff keys from session data (after login) - * This avoids requiring WebAuthn on every page load - */ - async reconstructStaffKeysFromSession( - staffId: string, - tenantId: string, - passkeyId: string, - authenticatorDataBase64: string, - ): Promise { - try { - // 1. Fetch database shard from server - const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { - method: "GET", - headers: { "Content-Type": "application/json" }, - }); - - if (!shardResponse.ok) { - throw new Error(`Failed to fetch key shard: ${shardResponse.status}`); - } - - const shardData = await shardResponse.json(); - - // 2. Convert base64 authenticatorData back to ArrayBuffer - const authenticatorDataBytes = this.base64ToUint8Array(authenticatorDataBase64); - - // 3. Derive passkey-based shard from stored authenticator data - const passkeyBasedShard = await this.derivePasskeyBasedShard( - passkeyId, - authenticatorDataBytes.buffer as ArrayBuffer, - ); - - // 4. Decode database shard - const dbShard = this.base64ToUint8Array(shardData.privateKeyShare); - - // 5. Reconstruct private key by XORing the two shards - const privateKey = new Uint8Array(dbShard.length); - for (let i = 0; i < dbShard.length; i++) { - privateKey[i] = dbShard[i] ^ passkeyBasedShard[i]; - } - - // 6. Store reconstructed key pair - this.staffKeyPair = { - publicKey: this.base64ToUint8Array(shardData.publicKey), - privateKey: privateKey, - }; - - this.staffId = staffId; - this.tenantId = tenantId; - this.staffAuthenticated = true; - this.keyExpiry = Date.now() + 60 * 60 * 1000; // 1 hour - - console.log("✅ Staff keys reconstructed from session"); - } catch (error) { - console.error("❌ Failed to reconstruct staff keys:", error); - throw new Error( - `Key reconstruction failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - /** * Decrypt appointment data for staff members */ @@ -694,69 +633,81 @@ export class UnifiedAppointmentCrypto { // ===== SHARED PRIVATE METHODS ===== /** - * Perform WebAuthn authentication for staff + * Get PRF output for staff authentication + * + * SECURITY: PRF Extension is REQUIRED for zero-knowledge key derivation. + * This method retrieves PRF output from the auth session (stored during login). + * + * @param staffId - Staff member ID + * @param passkeyId - Passkey credential ID + * @returns PRF output as ArrayBuffer + * @throws Error if PRF output is not available in session */ - private async performWebAuthnAuthentication(staffId: string): Promise<{ - authenticatorData: ArrayBuffer; - signature: ArrayBuffer; - userHandle: ArrayBuffer | null; - }> { - // 1. Get authentication options from server - const optionsResponse = await fetch("/api/auth/webauthn/authenticate/begin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: staffId }), - }); + private async getPRFOutputFromSession(staffId: string, passkeyId: string): Promise { + // Import auth store dynamically to avoid circular dependencies + const { auth } = await import("$lib/stores/auth"); - if (!optionsResponse.ok) { - throw new Error("Failed to get WebAuthn options"); + // Get PRF output from session storage (set during login) + const passkeyAuthData = auth.getPasskeyAuthData(); + + if (!passkeyAuthData) { + throw new Error( + "No passkey authentication data found in session. " + + "Please log in again with your passkey to access encrypted data.", + ); } - const options = await optionsResponse.json(); - - // 2. Perform WebAuthn authentication - const credential = (await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(this.base64ToUint8Array(options.challenge)), - allowCredentials: options.allowCredentials?.map((cred: { id: string; type: string }) => ({ - id: new Uint8Array(this.base64ToUint8Array(cred.id)), - type: cred.type as PublicKeyCredentialType, - })), - timeout: options.timeout, - userVerification: options.userVerification, - }, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error("WebAuthn authentication failed"); + if (!passkeyAuthData.prfOutput) { + throw new Error( + "PRF output not available in session. " + + "This passkey may not support the PRF extension. " + + "Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android).", + ); } - const response = credential.response as AuthenticatorAssertionResponse; + // Verify that the passkeyId matches (security check) + if (passkeyAuthData.passkeyId !== passkeyId) { + throw new Error( + `Passkey ID mismatch: expected ${passkeyId}, got ${passkeyAuthData.passkeyId}. ` + + "Please log out and log in again.", + ); + } - return { - authenticatorData: response.authenticatorData, - signature: response.signature, - userHandle: response.userHandle, - }; + // Decode PRF output from base64 + const prfBytes = this.base64ToUint8Array(passkeyAuthData.prfOutput); + return prfBytes.buffer as ArrayBuffer; } /** - * Store the staff key pair after the registration of a new staff member + * Store the staff key pair after the registration of a new staff member with PRF + * + * SECURITY: Uses PRF Extension for zero-knowledge key derivation. + * The prfOutput parameter MUST come from a WebAuthn assertion with PRF extension. + * + * @param tenantId - Tenant ID + * @param staffId - Staff member ID + * @param passkeyId - Passkey credential ID + * @param prfOutput - 32-byte PRF output from WebAuthn assertion (secret!) + * @param keyPair - ML-KEM-768 keypair generated in browser + * @throws Error if PRF output is invalid or API call fails */ public async storeStaffKeyPair( tenantId: string, staffId: string, passkeyId: string, - authenticatorData: ArrayBuffer, + prfOutput: ArrayBuffer, keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, ): Promise { - const passkeyBasedShard = await this.derivePasskeyBasedShard(passkeyId, authenticatorData); + // Derive passkey-based shard from PRF output + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId); + // Create database shard by XORing private key with passkey-based shard const dbShard = new Uint8Array(keyPair.privateKey.length); for (let i = 0; i < keyPair.privateKey.length; i++) { dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; } + // Store public key and database shard on server await fetch(`/api/tenants/${tenantId}/staff/${staffId}/crypto`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -766,6 +717,8 @@ export class UnifiedAppointmentCrypto { privateKeyShare: this.uint8ArrayToBase64(dbShard), }), }); + + console.log("✅ Staff keypair stored with PRF-based security"); } private uint8ArrayToBase64(array: Uint8Array): string { @@ -773,47 +726,51 @@ export class UnifiedAppointmentCrypto { } /** - * Derive a deterministic shard from passkey authentication data + * Derive a deterministic shard from WebAuthn PRF Extension (CTAP 2.1+) * - * This method creates a consistent private key shard from WebAuthn authenticatorData. + * This method uses the PRF (Pseudo-Random Function) extension to derive a secret shard. * The same passkey will always produce the same shard, enabling key reconstruction. * + * SECURITY: PRF provides ZERO-KNOWLEDGE guarantee: + * - PRF output is SECRET - only the passkey owner can derive it + * - Server NEVER sees the PRF output (only the database shard) + * - Database compromise does NOT reveal private keys (both shards needed) + * * Used in Staff Key Management: * - During registration: Create shard to XOR with private key for database storage * - During authentication: Recreate same shard to reconstruct private key * - * Uses HKDF (HMAC-based Key Derivation Function) with: - * - IKM: First 32 bytes of authenticatorData (rpIdHash only, excluding flags and signCount) - * - Salt: "staff-crypto-shard-v1" (version-specific salt) - * - Info: "passkey:{passkeyId}" (domain separation per passkey) - * - Length: 2400 bytes (ML-KEM-768 private key size) + * Uses PRF Extension + HKDF expansion: + * - Input: 32-byte PRF output from authenticator (secret!) + * - HKDF Salt: "staff-prf-shard-v2" (version-specific) + * - HKDF Info: "staff:{staffId}" (domain separation) + * - Output: 2400 bytes (ML-KEM-768 private key size) + * + * @param prfOutput - 32-byte PRF output from assertion.getClientExtensionResults().prf.results.first + * @param staffId - Staff member ID for domain separation + * @returns 2400-byte shard for XOR-based key reconstruction + * @throws Error if PRF output is not exactly 32 bytes */ - private async derivePasskeyBasedShard( - passkeyId: string, - authenticatorData: ArrayBuffer, + private async derivePasskeyBasedShardWithPRF( + prfOutput: ArrayBuffer, + staffId: string, ): Promise { - // Extract randomness from authenticator data - // IMPORTANT: Use only the first 32 bytes (rpIdHash) - // We CANNOT use flags (byte 32) because the AT flag differs between registration and authentication! - // We CANNOT use signCount (bytes 33-36) because it increments on every authentication! - // authenticatorData structure: - // - Bytes 0-31: rpIdHash (SHA-256 of RP ID) - CONSTANT ✓ - // - Byte 32: flags - DIFFERS (AT flag set during registration) ✗ - // - Bytes 33-36: signCount - CHANGES on every login ✗ - // - Bytes 37+: attestedCredentialData (only during registration) ✗ - const fullData = new Uint8Array(authenticatorData); - const inputKeyMaterial = fullData.slice(0, 32); // Only first 32 bytes (rpIdHash only) + // Validate PRF output length (should always be 32 bytes per CTAP 2.1 spec) + const prfBytes = new Uint8Array(prfOutput); + if (prfBytes.length !== 32) { + throw new Error(`Invalid PRF output length: ${prfBytes.length} bytes (expected 32)`); + } - // Import the IKM as a CryptoKey for HKDF - const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, "HKDF", false, [ - "deriveBits", - ]); + // Import the PRF output as a CryptoKey for HKDF expansion + const ikmKey = await crypto.subtle.importKey("raw", prfBytes, "HKDF", false, ["deriveBits"]); - // Salt for HKDF - const salt = new TextEncoder().encode("staff-crypto-shard-v1"); + // Salt for HKDF (versioned to allow future rotation) + // v2: Uses email-based PRF salts for multi-passkey support + // Each passkey still produces unique PRF output (passkey private key is part of PRF) + const salt = new TextEncoder().encode("staff-prf-shard-v2"); - // Info for HKDF (domain separation with passkey ID) - const info = new TextEncoder().encode(`passkey:${passkeyId}`); + // Info for HKDF (domain separation per staff member) + const info = new TextEncoder().encode(`staff:${staffId}`); // Derive key material with the length needed for Kyber private key (2400 bytes for ML-KEM-768) const keyMaterial = await crypto.subtle.deriveBits( diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index 2dd66c4..650803b 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -1,8 +1,11 @@ import { centralDb as db } from "$lib/server/db"; import { userPasskey } from "$lib/server/db/central-schema"; import { eq, desc } from "drizzle-orm"; -import { randomBytes, createHash } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { UniversalLogger } from "$lib/logger"; +import { verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/types"; +import { dev } from "$app/environment"; const logger = new UniversalLogger().setContext("WebAuthnService"); @@ -25,222 +28,242 @@ export interface WebAuthnVerificationResult { export class WebAuthnService { /** - * Verify a WebAuthn authentication assertion + * Normalize credential ID to base64url format (defensive measure) + * Ensures credential IDs are consistently stored and queried + * @param id - Credential ID (should already be base64url from browser) + * @returns Normalized credential ID in base64url format + */ + private static normalizeCredentialId(id: string): string { + // Convert base64 to base64url if needed + // Base64url: no +, /, or = characters + return id.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + /** + * Verify a WebAuthn authentication assertion using @simplewebauthn/server * @param credential - The WebAuthn credential from the client * @param challengeFromSession - The challenge that was sent to the client (should be stored in session) + * @param url - The request URL for dynamic RP ID and origin resolution * @returns Verification result with user ID if successful */ static async verifyAuthentication( credential: WebAuthnCredential, challengeFromSession: string, + url: URL, ): Promise { try { + // Normalize credential ID to base64url format + const normalizedCredentialId = WebAuthnService.normalizeCredentialId(credential.id); + logger.debug("Verifying WebAuthn authentication", { - credentialId: credential.id, + credentialId: normalizedCredentialId.substring(0, 20) + "...", + credentialIdLength: normalizedCredentialId.length, hasChallenge: !!challengeFromSession, }); - // Get the passkey from database + // Get the passkey from database using normalized ID const passkeyResults = await db .select() .from(userPasskey) - .where(eq(userPasskey.id, credential.id)) + .where(eq(userPasskey.id, normalizedCredentialId)) .limit(1); if (passkeyResults.length === 0) { - logger.warn("Passkey not found", { credentialId: credential.id }); + logger.warn("Passkey not found in database", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + }); return { verified: false }; } const passkey = passkeyResults[0]; - // Parse client data JSON - const clientDataJSON = JSON.parse( - Buffer.from(credential.response.clientDataJSON, "base64").toString(), - ); + // Get RP ID and allowed origins from request URL + const rpID = WebAuthnService.getRPID(url); + const allowedOrigins = WebAuthnService.getAllowedOrigins(url); - // Verify the challenge (convert base64url to base64 for comparison) - const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/"); - const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/"); + // Convert base64 to base64url (WebAuthn sends base64, @simplewebauthn expects base64url) + const base64ToBase64url = (base64: string): string => { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + }; - if (expectedChallenge !== receivedChallenge) { - logger.warn("Challenge mismatch", { - credentialId: credential.id, - expectedChallenge, - receivedChallenge, - }); - return { verified: false }; - } + // Convert our credential format to @simplewebauthn format + const authResponse: AuthenticationResponseJSON = { + id: normalizedCredentialId, + rawId: normalizedCredentialId, + response: { + authenticatorData: base64ToBase64url(credential.response.authenticatorData), + clientDataJSON: base64ToBase64url(credential.response.clientDataJSON), + signature: base64ToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? base64ToBase64url(credential.response.userHandle) + : undefined, + }, + type: "public-key", + clientExtensionResults: {}, + }; - // Verify the origin matches expected origin (critical for security) - const allowedOrigins = WebAuthnService.getAllowedOrigins(); - if (!allowedOrigins.includes(clientDataJSON.origin)) { - logger.warn("Origin verification failed", { - credentialId: credential.id, - receivedOrigin: clientDataJSON.origin, - allowedOrigins, - }); - return { verified: false }; - } - - logger.debug("Origin verification successful", { - origin: clientDataJSON.origin, - }); - - // Parse authenticator data - const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); - - // Parse authenticator data for detailed debugging - const parsedAuthData = WebAuthnService.parseAuthenticatorData(authenticatorDataBuffer); - - logger.debug("Authenticator data analysis", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - bufferHex: authenticatorDataBuffer.toString("hex"), - parsed: parsedAuthData, - }); - - // Extract counter from authenticator data - // Format: rpIdHash(32) + flags(1) + counter(4) + attestedCredentialData(variable) - // Counter is at bytes 33-36 (0-indexed) - if (authenticatorDataBuffer.length < 37) { - logger.warn("Authenticator data too short for counter extraction", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - }); - return { verified: false }; - } - - const newCounter = authenticatorDataBuffer.readUInt32BE(33); - - logger.debug("Counter extraction", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - counterBytes: authenticatorDataBuffer.subarray(33, 37).toString("hex"), - }); - - // Some authenticators (especially software-based ones) always return 0 - // In that case, we skip counter verification but log it - if (newCounter === 0 && passkey.counter === 0) { - logger.info("Authenticator uses zero counter - skipping counter verification", { - credentialId: credential.id, - }); - } else if (newCounter <= passkey.counter) { - logger.warn("Counter verification failed - possible replay attack", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - }); - return { verified: false }; - } - - // Create the data to be signed - const clientDataHash = createHash("sha256") - .update(Buffer.from(credential.response.clientDataJSON, "base64")) - .digest(); - - const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]); - - // Verify the signature + // Create WebAuthnCredential object for @simplewebauthn/server + // Note: WebAuthnCredential has specific field names: id, publicKey, counter + // IMPORTANT: publicKey must be raw COSE-encoded Uint8Array (NOT decoded) + // @simplewebauthn/server will decode it internally during verification const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64"); - const signatureBuffer = Buffer.from(credential.response.signature, "base64"); + const publicKeyUint8Array = new Uint8Array(publicKeyBuffer); - const isSignatureValid = await this.verifySignature( - publicKeyBuffer, - signedData, - signatureBuffer, - ); + const storedCredential = { + id: credential.id, // Base64URL-encoded credential ID + publicKey: publicKeyUint8Array, // COSE-encoded public key as Uint8Array + counter: passkey.counter, // Stored counter value + }; - if (!isSignatureValid) { - logger.warn("Signature verification failed", { credentialId: credential.id }); + logger.debug("WebAuthnCredential object created", { + id: storedCredential.id.substring(0, 20) + "...", + publicKeyLength: publicKeyUint8Array.length, + publicKeyBase64Sample: passkey.publicKey.substring(0, 60) + "...", + publicKeyBytesFirst10: Array.from(publicKeyUint8Array.slice(0, 10)), + counter: storedCredential.counter, + publicKeyType: typeof storedCredential.publicKey, + publicKeyConstructor: storedCredential.publicKey.constructor.name, + }); + + // Verify the authentication response using @simplewebauthn/server + const verification = await verifyAuthenticationResponse({ + response: authResponse, + expectedChallenge: challengeFromSession, + expectedOrigin: allowedOrigins, + expectedRPID: rpID, + credential: storedCredential, // WebAuthnCredential with id, publicKey, counter + requireUserVerification: false, // Set to true for higher security requirements + }); + + if (!verification.verified) { + logger.warn("@simplewebauthn verification failed", { + credentialId: credential.id, + }); return { verified: false }; } + const { authenticationInfo } = verification; + const newCounter = authenticationInfo.newCounter; + // Update the counter in the database to prevent replay attacks - // Only update if the authenticator provides a non-zero counter - if (newCounter > 0) { - await db - .update(userPasskey) - .set({ counter: newCounter }) - .where(eq(userPasskey.id, passkey.id)); + await db + .update(userPasskey) + .set({ + counter: newCounter, + lastUsedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(userPasskey.id, normalizedCredentialId)); - logger.debug("Counter updated in database", { - credentialId: credential.id, - newCounter, - }); - } else { - logger.debug("Counter update skipped (zero counter authenticator)", { - credentialId: credential.id, - }); - } - - logger.debug("WebAuthn authentication successful", { - credentialId: credential.id, + logger.info("WebAuthn authentication successful", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", userId: passkey.userId, - newCounter, - counterUpdated: true, + counterUpdated: `${passkey.counter} → ${newCounter}`, }); return { verified: true, userId: passkey.userId, newCounter, - passkeyId: passkey.id, + passkeyId: normalizedCredentialId, }; } catch (error) { - logger.error("WebAuthn verification error", { + logger.error("WebAuthn authentication failed", { error: String(error), - credentialId: credential.id, + stack: error instanceof Error ? error.stack : undefined, }); return { verified: false }; } } /** - * Verify a signature using the stored public key - * This is a simplified implementation - in production, you should use a proper WebAuthn library - * like @simplewebauthn/server for complete verification + * Verify a WebAuthn registration response using @simplewebauthn/server + * Extracts the COSE public key from the attestation object + * @param url - The request URL for dynamic RP ID and origin resolution + * @returns Object with credentialID (base64url), credentialPublicKey (base64), and counter */ - private static async verifySignature( - publicKey: Buffer, - signedData: Buffer, - signature: Buffer, - ): Promise { + static async verifyRegistration( + credentialId: string, + attestationObjectBase64: string, + clientDataJSONBase64: string, + challengeFromSession: string, + url: URL, + ): Promise<{ credentialID: string; credentialPublicKey: string; counter: number }> { try { - const crypto = await import("node:crypto"); + const rpID = WebAuthnService.getRPID(url); + const allowedOrigins = WebAuthnService.getAllowedOrigins(url); - // This is a simplified implementation - // In production, you should use a proper WebAuthn library that handles: - // - Different key formats (COSE, etc.) - // - Different signature algorithms - // - Proper ASN.1 parsing - // - Certificate chain validation + // Normalize credential ID immediately + const normalizedCredentialId = WebAuthnService.normalizeCredentialId(credentialId); - // For now, we'll assume ES256 (ECDSA P-256 with SHA-256) - // and that the public key is in the correct format + // Convert base64 to base64url + const base64ToBase64url = (base64: string): string => { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + }; - const verify = crypto.createVerify("SHA256"); - verify.update(signedData); - verify.end(); + // Create RegistrationResponseJSON for @simplewebauthn/server + const registrationResponse: RegistrationResponseJSON = { + id: normalizedCredentialId, + rawId: normalizedCredentialId, + response: { + attestationObject: base64ToBase64url(attestationObjectBase64), + clientDataJSON: base64ToBase64url(clientDataJSONBase64), + }, + type: "public-key", + clientExtensionResults: {}, + }; - // This is a placeholder - proper implementation would: - // 1. Parse the COSE key format - // 2. Convert to the correct format for Node.js crypto - // 3. Handle different algorithms properly - - logger.debug("Signature verification (simplified implementation)", { - publicKeyLength: publicKey.length, - signatureLength: signature.length, - signedDataLength: signedData.length, + logger.debug("Verifying registration", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + rpID, + allowedOrigins, }); - // For development, we'll return true if all data is present - // TODO: Replace with proper signature verification - return publicKey.length > 0 && signature.length > 0 && signedData.length > 0; + // Verify the registration response + const verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: challengeFromSession, + expectedOrigin: allowedOrigins, + expectedRPID: rpID, + requireUserVerification: false, + }); + + if (!verification.verified) { + throw new Error("Registration verification failed"); + } + + const { registrationInfo } = verification; + if (!registrationInfo) { + throw new Error("No registration info returned"); + } + + // The credentialPublicKey is already in COSE format (Uint8Array) + // Convert to base64 for storage + // Note: credential is nested inside registrationInfo + const credentialPublicKeyBase64 = Buffer.from(registrationInfo.credential.publicKey).toString( + "base64", + ); + + logger.info("Registration verified successfully", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + credentialIdLength: normalizedCredentialId.length, + counter: registrationInfo.credential.counter, + publicKeyLength: registrationInfo.credential.publicKey.length, + publicKeyBytesFirst10: Array.from(registrationInfo.credential.publicKey.slice(0, 10)), + }); + + return { + credentialID: normalizedCredentialId, // Normalized to base64url for consistency + credentialPublicKey: credentialPublicKeyBase64, + counter: registrationInfo.credential.counter, + }; } catch (error) { - logger.error("Signature verification error", { error: String(error) }); - return false; + logger.error("Registration verification error", { + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + credentialId, + }); + throw error; } } @@ -302,19 +325,49 @@ export class WebAuthnService { } /** - * Get allowed origins for WebAuthn verification - * Uses SERVER_DOMAIN in production, localhost variants in development + * Get Relying Party ID for WebAuthn verification + * Dynamically resolves from request URL to support multi-tenant subdomains + * @param url - The request URL */ - private static getAllowedOrigins(): string[] { + private static getRPID(url: URL): string { + if (process.env.NODE_ENV === "production") { + // In production, use the hostname (without subdomain for main domain) + const hostname = url.hostname; + const parts = hostname.split("."); + + // If it's a subdomain (e.g., tenant.example.com), use the main domain (example.com) + // This allows passkeys to work across all subdomains + if (parts.length > 2) { + return parts.slice(-2).join("."); + } + return hostname; + } + + // Development: use localhost + return "localhost"; + } + + /** + * Get allowed origins for WebAuthn verification + * Dynamically resolves from request URL to support multi-tenant subdomains + * @param url - The request URL + */ + private static getAllowedOrigins(url: URL): string[] { const origins: string[] = []; - // Check if we're in development (NODE_ENV or presence of dev indicators) - const isDevelopment = - process.env.NODE_ENV === "development" || - process.env.NODE_ENV === "dev" || - !process.env.SERVER_DOMAIN; + if (!dev) { + // Production: Use the request origin + const origin = url.origin; // e.g., https://tenant.example.com or https://example.com + origins.push(origin); - if (isDevelopment) { + // Also allow the main domain (without subdomain) to support cross-subdomain passkey usage + const hostname = url.hostname; + const parts = hostname.split("."); + if (parts.length > 2) { + const mainDomain = parts.slice(-2).join("."); + origins.push(`https://${mainDomain}`); + } + } else { // Development origins origins.push( "http://localhost:5173", @@ -322,20 +375,6 @@ export class WebAuthnService { "http://localhost:4173", "http://127.0.0.1:4173", ); - } else { - // Production: Use SERVER_DOMAIN - const serverDomain = process.env.SERVER_DOMAIN; - if (serverDomain) { - // Add main domain with HTTPS - origins.push(`https://${serverDomain}`); - - // Add www variant if it doesn't already start with www - if (!serverDomain.startsWith("www.")) { - origins.push(`https://www.${serverDomain}`); - } - - // TODO: Webauthn does not support wildcard domains. We'll need another solution for that. - } } // Allow manual override via WEBAUTHN_ALLOWED_ORIGINS @@ -344,23 +383,10 @@ export class WebAuthnService { origins.push(...envOrigins.split(",").map((o) => o.trim())); } - // Ensure we have at least one allowed origin - if (origins.length === 0) { - logger.error("No WebAuthn allowed origins configured - this is a security risk!", { - NODE_ENV: process.env.NODE_ENV, - SERVER_DOMAIN: process.env.SERVER_DOMAIN, - WEBAUTHN_ALLOWED_ORIGINS: process.env.WEBAUTHN_ALLOWED_ORIGINS, - }); - // Fallback to localhost in development only - if (isDevelopment) { - origins.push("http://localhost:5173"); - } - } - logger.debug("WebAuthn allowed origins configured", { origins, - isDevelopment, - serverDomain: process.env.SERVER_DOMAIN, + requestOrigin: url.origin, + hostname: url.hostname, }); return origins; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 0034d46..648c23f 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -6,6 +6,7 @@ export interface PasskeyAuthData { authenticatorData: string; passkeyId: string; email: string; + prfOutput?: string; // Base64-encoded PRF output for staff crypto key derivation } export interface AuthState { diff --git a/src/lib/stores/staff-crypto.ts b/src/lib/stores/staff-crypto.ts index 00f354a..e05de96 100644 --- a/src/lib/stores/staff-crypto.ts +++ b/src/lib/stores/staff-crypto.ts @@ -19,49 +19,17 @@ const createStaffCryptoStore = () => { ...store, /** - * Initialize crypto from stored authenticator data after login - * This reconstructs the private key using the passkey data from auth store + * Initialize crypto with WebAuthn PRF authentication + * + * NOTE: With PRF-based key derivation, we MUST perform a live WebAuthn interaction + * to derive the secret shard. Session-based reconstruction is not possible. + * + * This method now delegates to `authenticate()` which performs WebAuthn with PRF. */ async initFromSession(staffId: string, tenantId: string): Promise { - try { - // Check if we have passkey auth data from login - const passkeyAuthData = auth.getPasskeyAuthData(); - if (!passkeyAuthData) { - console.warn("No passkey auth data found in auth store"); - return false; - } - - const { authenticatorData, passkeyId } = passkeyAuthData; - - // Reconstruct the private key using the stored data - const crypto = new UnifiedAppointmentCrypto(); - - // Use the stored authenticator data to reconstruct keys - await crypto.reconstructStaffKeysFromSession( - staffId, - tenantId, - passkeyId, - authenticatorData, - ); - - store.set({ - crypto, - isAuthenticated: true, - error: null, - }); - - console.log("✅ Staff crypto initialized from session"); - return true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to init from session"; - store.set({ - crypto: null, - isAuthenticated: false, - error: errorMessage, - }); - console.error("Failed to initialize staff crypto from session:", error); - return false; - } + // With PRF, we always need WebAuthn interaction - session data is not sufficient + // Call authenticate() directly which will prompt for passkey + return await this.authenticate(staffId, tenantId); }, /** diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 7fb70f7..0375829 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -88,14 +88,18 @@ export const getCredentialOptions = ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; + enablePRF?: boolean; }): { publicKey: PublicKeyCredentialCreationOptions; } => { - return { + const options: { + publicKey: PublicKeyCredentialCreationOptions; + } = { publicKey: { challenge: base64UrlToArrayBuffer(challenge), rp: { @@ -112,41 +116,202 @@ export const getCredentialOptions = ({ ], }, }; + + // Enable PRF extension for zero-knowledge key derivation + if (enablePRF) { + options.publicKey.extensions = { + prf: {}, + }; + } + + return options; }; export type GeneratePasskeyResponse = { response: AuthenticatorAttestationResponse; id: string; - getClientExtensionResults: () => { deviceName?: string }; + getClientExtensionResults: () => { deviceName?: string; prf?: { enabled?: boolean } }; } | null; + export const generatePasskey = async ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; + enablePRF?: boolean; }): Promise => { - const options = getCredentialOptions({ id, challenge, email }); + const options = getCredentialOptions({ id, challenge, email, enablePRF }); return (await navigator.credentials.create(options)) as GeneratePasskeyResponse; }; -export type GetCredentialResponse = PublicKeyCredential & { - response: PublicKeyCredential; - id: string; +/** + * Get PRF output from a passkey immediately after registration + * + * SECURITY: This must be called immediately after passkey creation to derive + * the secret shard for zero-knowledge key splitting. + * + * MULTI-PASSKEY SUPPORT: Uses email as PRF salt so that all passkeys for the same user + * can be used interchangeably. Each passkey will produce a different PRF output for + * the same salt, allowing each to have its own database shard. + * + * @param passkeyId - The credential ID of the newly created passkey + * @param rpId - Relying Party ID (domain) + * @param challengeBase64 - Fresh challenge from server (base64url encoded) + * @param email - User's email address (used as PRF salt for multi-passkey support) + * @returns PRF output (32 bytes) or null if PRF is not supported + * @throws Error if passkey doesn't support PRF or authentication fails + */ +export const getPRFOutputAfterRegistration = async ({ + passkeyId, + rpId, + challengeBase64, + email, +}: { + passkeyId: string; + rpId: string; + challengeBase64: string; + email: string; +}): Promise => { + // Prepare PRF salt (use email as deterministic salt for multi-passkey support) + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + + // Perform WebAuthn get() with PRF extension + const credential = (await navigator.credentials.get({ + publicKey: { + challenge: base64UrlToArrayBuffer(challengeBase64), + rpId: rpId, + allowCredentials: [ + { + id: base64UrlToArrayBuffer(passkeyId), + type: "public-key", + }, + ], + userVerification: "required", + extensions: { + prf: { + eval: { + first: prfSalt, + }, + }, + }, + }, + })) as PublicKeyCredential & { + getClientExtensionResults: () => { + prf?: { + enabled?: boolean; + results?: { + first?: ArrayBuffer; + }; + }; + }; + }; + + if (!credential) { + throw new Error("Failed to authenticate with new passkey"); + } + + // Extract PRF output + const extensionResults = credential.getClientExtensionResults(); + const prfResults = extensionResults.prf; + + if (!prfResults || !prfResults.results || !prfResults.results.first) { + throw new Error( + "PRF extension not supported by this passkey. " + + "Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)", + ); + } + + // PRF output is BufferSource, ensure we return ArrayBuffer + const prfOutput = prfResults.results.first; + if (prfOutput instanceof ArrayBuffer) { + return prfOutput; + } else { + // Convert ArrayBufferView to ArrayBuffer + return prfOutput.buffer.slice( + prfOutput.byteOffset, + prfOutput.byteOffset + prfOutput.byteLength, + ) as ArrayBuffer; + } }; + +export type GetCredentialResponse = PublicKeyCredential & { + prfOutput?: ArrayBuffer; // PRF output if enablePRF was true +}; + export const getCredential = async ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; -}) => { - const options = getCredentialOptions({ id, challenge, email }); - return (await navigator.credentials.get(options)) as GetCredentialResponse; + enablePRF?: boolean; +}): Promise => { + // Build WebAuthn options manually to support PRF + const challengeBuffer = base64UrlToArrayBuffer(challenge); + + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + challenge: challengeBuffer, + rpId: id, + userVerification: "preferred", + }; + + // Add PRF extension if enabled + // MULTI-PASSKEY SUPPORT: Use email as salt so all passkeys for the same user work + if (enablePRF) { + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + publicKeyOptions.extensions = { + prf: { + eval: { + first: prfSalt, + }, + }, + }; + } + + const credential = (await navigator.credentials.get({ + publicKey: publicKeyOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error("Failed to get credential"); + } + + let prfOutput: ArrayBuffer | undefined; + + // Extract PRF output if it was enabled + if (enablePRF) { + const extensionResults = credential.getClientExtensionResults() as { + prf?: { + enabled?: boolean; + results?: { + first?: BufferSource; + }; + }; + }; + + if (extensionResults.prf?.results?.first) { + const prfResult = extensionResults.prf.results.first; + // Convert BufferSource to ArrayBuffer + if (prfResult instanceof ArrayBuffer) { + prfOutput = prfResult; + } else { + prfOutput = prfResult.buffer.slice( + prfResult.byteOffset, + prfResult.byteOffset + prfResult.byteLength, + ) as ArrayBuffer; + } + } + } + + // Return credential with optional prfOutput attached + return Object.assign(credential, { prfOutput }) as GetCredentialResponse; }; export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => { diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte index 04a2d3b..4108bfe 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -85,7 +85,9 @@ {#if isThrottled} - + {m["public.steps.auth.login.throttled"]()} diff --git a/src/routes/(pages)/confirm/setup-passkey/+page.server.ts b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts index 47c0699..168dd9b 100644 --- a/src/routes/(pages)/confirm/setup-passkey/+page.server.ts +++ b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts @@ -4,7 +4,6 @@ import { zod4 as zod } from "sveltekit-superforms/adapters"; import type { Actions, PageServerLoad } from "./$types"; import { formSchema } from "./schema"; import logger from "$lib/logger"; -import { base64ToArrayBuffer, getCounterFromAuthenticatorData } from "$lib/utils/passkey"; const log = logger.setContext(import.meta.filename); @@ -25,26 +24,20 @@ export const actions: Actions = { }); } - const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); - const counter = getCounterFromAuthenticatorData(authenticatorData); const resp = await event.fetch(`/api/auth/register/${form.data.userId}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - id: form.data.id, email: form.data.email, + challenge: form.data.challenge, // Send original registration challenge passkey: { id: form.data.id, - publicKey: form.data.publicKeyBase64, - deviceName: "Unkown Device", - counter, - response: { - authenticatorData: form.data.authenticatorDataBase64, - }, + attestationObject: form.data.attestationObjectBase64, + clientDataJSON: form.data.clientDataJSONBase64, + deviceName: "Unknown Device", }, - counter, }), }); diff --git a/src/routes/(pages)/confirm/setup-passkey/schema.ts b/src/routes/(pages)/confirm/setup-passkey/schema.ts index 23ea4d0..fae8bef 100644 --- a/src/routes/(pages)/confirm/setup-passkey/schema.ts +++ b/src/routes/(pages)/confirm/setup-passkey/schema.ts @@ -5,8 +5,9 @@ export const formSchema = z.object({ userId: z.string().min(3), id: z.string().min(3), email: z.string().email(m["form.errors.email"]()), - publicKeyBase64: z.string().base64(), - authenticatorDataBase64: z.string().base64(), + attestationObjectBase64: z.string().base64(), + clientDataJSONBase64: z.string().base64(), + challenge: z.string().min(20), // Original registration challenge (before PRF challenge overwrites cookie) }); export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index a9c29e3..75afa9a 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -9,7 +9,12 @@ import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; import { ROUTES } from "$lib/const/routes"; import logger from "$lib/logger"; - import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey"; + import { + arrayBufferToBase64, + fetchChallenge, + generatePasskey, + getPRFOutputAfterRegistration, + } from "$lib/utils/passkey"; import { toast } from "svelte-sonner"; import { writable, type Writable } from "svelte/store"; import { type Infer, type SuperValidated } from "sveltekit-superforms"; @@ -28,8 +33,9 @@ let tenantId: string | undefined = $state(); let passkeyId: string | undefined = $state(); - let authenticatorData: ArrayBuffer | undefined = $state(); + let prfOutput: ArrayBuffer | undefined = $state(); let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state(); + let registrationChallenge: string | undefined = $state(); // Store first challenge for form submission const form = superForm(data.form, { validators: zodClient(formSchema), @@ -68,7 +74,7 @@ $passkeyLoading = "loading"; // Generate Kyber keypair BEFORE passkey registration - // This keypair will be used to create the dbShard after authenticatorData is available + // This keypair will be used to create the dbShard after PRF output is available const { KyberCrypto } = await import("$lib/crypto/utils"); kyberKeyPair = KyberCrypto.generateKeyPair(); @@ -78,13 +84,18 @@ logger.error("Failed to fetch challenge", { email: $formData.email }); $passkeyLoading = "error"; } else { + // Store the registration challenge - will be sent with form to avoid cookie overwrite by PRF challenge + registrationChallenge = challenge.challenge; + $passkeyLoading = "user"; - const passkeyResp = await generatePasskey({ ...challenge, email: $formData.email }).catch( - (error) => { - $passkeyLoading = "error"; - logger.error("Failed to generate passkey", { ...challenge, error }); - }, - ); + const passkeyResp = await generatePasskey({ + ...challenge, + email: $formData.email, + enablePRF: true, // CRITICAL: Enable PRF extension for zero-knowledge key derivation + }).catch((error) => { + $passkeyLoading = "error"; + logger.error("Failed to generate passkey", { ...challenge, error }); + }); if (!passkeyResp) { $passkeyLoading = "error"; @@ -92,30 +103,71 @@ return; } - // Returns ArrayBuffer that has to be converted to base64 string - const publicKey = passkeyResp.response.getPublicKey(); - if (!publicKey) { + // Verify PRF extension is enabled + const extensionResults = passkeyResp.getClientExtensionResults(); + if (!extensionResults.prf?.enabled) { $passkeyLoading = "error"; - logger.error("Failed to get public key", { email: $formData.email }); + logger.error("PRF extension not enabled - passkey rejected", { + email: $formData.email, + extensions: extensionResults, + }); + toast.error( + "This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android.", + ); return; } - // May include device name and counter - const authenticatorDataResp = passkeyResp.response.getAuthenticatorData(); + // Get attestationObject and clientDataJSON for @simplewebauthn/server verification + const attestationObjectResp = passkeyResp.response.attestationObject; + const clientDataJSONResp = passkeyResp.response.clientDataJSON; - // Update form data with passkey info - const publicKeyBase64 = arrayBufferToBase64(publicKey); - const authenticatorDataBase64 = arrayBufferToBase64(authenticatorDataResp); + // CRITICAL: Get PRF output immediately after passkey creation + // This is the only time we can retrieve the PRF output + // Uses email as salt for multi-passkey support + try { + const prfChallenge = await fetchChallenge($formData.email); + if (!prfChallenge) { + throw new Error("Failed to fetch PRF challenge"); + } + + const prfOutputResp = await getPRFOutputAfterRegistration({ + passkeyId: passkeyResp.id, + rpId: prfChallenge.id, + challengeBase64: prfChallenge.challenge, + email: $formData.email, // Use email as PRF salt for multi-passkey support + }); + + prfOutput = prfOutputResp; + logger.info("PRF output retrieved successfully", { + passkeyId: passkeyResp.id, + prfOutputLength: prfOutputResp.byteLength, + }); + } catch (error) { + $passkeyLoading = "error"; + logger.error("Failed to get PRF output", { + email: $formData.email, + passkeyId: passkeyResp.id, + error, + }); + toast.error( + "Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension.", + ); + return; + } + + // Update form data with passkey info - send full attestation for proper COSE key extraction + const attestationObjectBase64 = arrayBufferToBase64(attestationObjectResp); + const clientDataJSONBase64 = arrayBufferToBase64(clientDataJSONResp); $formData = { ...$formData, id: passkeyResp.id, - publicKeyBase64, - authenticatorDataBase64, + attestationObjectBase64, + clientDataJSONBase64, + challenge: registrationChallenge!, // Send original registration challenge (not PRF challenge) }; // Set for later use passkeyId = passkeyResp.id; - authenticatorData = authenticatorDataResp; // Update UI to show passkey is ready $passkeyLoading = "success"; @@ -136,10 +188,10 @@ }); const storeStaffKeyPair = async () => { - if (tenantId && passkeyId && authenticatorData && kyberKeyPair) { + if (tenantId && passkeyId && prfOutput && kyberKeyPair) { const crypto = new UnifiedAppointmentCrypto(); return await crypto - .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData, kyberKeyPair) + .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, prfOutput, kyberKeyPair) .then(() => { toast.success(m["setupPasskey.successKeyPairSaved"]()); }) @@ -153,10 +205,11 @@ }); }); } else { - logger.error("Failed to store staff key pair", { + logger.error("Failed to store staff key pair - missing required data", { tenantId, userId: $formData.userId, passkeyId, + hasPrfOutput: !!prfOutput, hasKyberKeyPair: !!kyberKeyPair, }); toast.error(m["setupPasskey.errorKeyPairDataMissing"]()); @@ -189,17 +242,24 @@ {/snippet} - From 0c0ef469893ebee70f8d1ac6b9d43846bbbe5ad1 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 15:35:56 +0100 Subject: [PATCH 34/61] Improve decrypting error message --- project.inlang/messages/de.json | 1 + project.inlang/messages/en.json | 1 + .../dashboard/calendar/(components)/AppointmentPreview.svelte | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 0a61430..1c34a5e 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -901,6 +901,7 @@ "today": "Heute", "loading": "Loading calendar", "decrypting": "wird entschlüsselt...", + "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", "shownAppointments": { "title": "Termine", "options": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 6878aa4..594a899 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -910,6 +910,7 @@ "today": "Today", "loading": "Loading calendar", "decrypting": "decrypting...", + "decryptingError": "Unable to decrypt data. Please log-in again", "shownAppointments": { "title": "Appointments", "options": { diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte index e0488fa..9bd5b71 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte @@ -84,7 +84,9 @@ {#if error}
- ⚠️ + + ⚠️ {m["calendar.decryptingError"]()} +
{:else if decrypted === undefined}
From 1d83f8678b414f5a15043186d8ea67035282eba9 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 15:36:12 +0100 Subject: [PATCH 35/61] Set english as default language --- .../book-appointment/[[id]]/(components)/summary.svelte | 2 +- .../[id]/appointments/[appointmentId]/confirm/+server.ts | 2 +- .../api/tenants/[id]/appointments/add-to-tunnel/+server.ts | 2 +- .../api/tenants/[id]/appointments/create-new-client/+server.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index 65152ee..412dd22 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -35,7 +35,7 @@ appointment.slot.duration, tenant.id, Boolean(appointment.isNewClient), - getLocale() || "de", + getLocale() || "en", ) .then(() => { const isRequest = channel.requiresConfirmation; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts index 67e03cc..fb022c7 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts @@ -9,7 +9,7 @@ import { checkPermission } from "$lib/server/utils/permissions"; const requestSchema = z.object({ clientEmail: z.string().email().optional(), - clientLanguage: z.string().optional().default("de"), + clientLanguage: z.string().optional().default("en"), }); // Register OpenAPI documentation for PUT diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts index 725006d..f8ea48e 100644 --- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts @@ -28,7 +28,7 @@ const requestSchema = z.object({ appointmentDate: z.string(), duration: z.number().int().positive(), clientEmail: z.string().email(), - clientLanguage: z.string().optional().default("de"), + clientLanguage: z.string().optional().default("en"), encryptedAppointment: z.object({ encryptedPayload: z.string(), iv: z.string(), diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index bf2dd80..953a92b 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -13,7 +13,7 @@ const requestSchema = z.object({ duration: z.number().int().positive(), emailHash: z.string(), clientEmail: z.string().email(), - clientLanguage: z.string().optional().default("de"), + clientLanguage: z.string().optional().default("en"), clientPublicKey: z.string(), privateKeyShare: z.string(), encryptedAppointment: z.object({ From ded98597fc7a74196d70e78ab97e95455d9c2fd1 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 15:47:40 +0100 Subject: [PATCH 36/61] Disable book appointment button, when in progress --- .../book-appointment/[[id]]/(components)/summary.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index 412dd22..f59ca4e 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -61,7 +61,7 @@ {m["public.steps.summary.request.hint"]()} {/if} - + {/snippet} From eb4781439519d8bf9ec2007940f2113c3f7517bf Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 16:03:23 +0100 Subject: [PATCH 38/61] Redact user and password from databaseUrl logs --- src/lib/server/services/tenant-admin-service.ts | 3 ++- src/lib/server/utils/url.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/utils/url.ts diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index f0d5466..3ce9f51 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -9,6 +9,7 @@ import { eq, and, not, count, or } from "drizzle-orm"; import logger from "$lib/logger"; import { z } from "zod"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; +import { redactDbUrl } from "../utils/url"; if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); @@ -110,7 +111,7 @@ export class TenantAdminService { } catch (dbError) { log.error("Failed to initialize tenant database", { tenantId: tenant[0].id, - databaseUrl: newTenant.databaseUrl, + databaseUrl: redactDbUrl(newTenant.databaseUrl), error: String(dbError), }); diff --git a/src/lib/server/utils/url.ts b/src/lib/server/utils/url.ts new file mode 100644 index 0000000..66c4a79 --- /dev/null +++ b/src/lib/server/utils/url.ts @@ -0,0 +1,6 @@ +export const redactDbUrl = (input: string) => { + const url = new URL(input); + url.username = "redacted-user"; + url.password = "redacted-pw"; + return url.toString(); +}; From fd7cfe00df2efd1afb60f05c8978ae1ba9abe292 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 16:20:32 +0100 Subject: [PATCH 39/61] Fix tenant database migrations --- tenant-migrations/0003_complex_the_call.sql | 2 +- tenant-migrations/0009_far_korg.sql | 1 - tenant-migrations/meta/0003_snapshot.json | 4 ++-- tenant-migrations/meta/0004_snapshot.json | 4 ++-- tenant-migrations/meta/0005_snapshot.json | 4 ++-- tenant-migrations/meta/0006_snapshot.json | 4 ++-- tenant-migrations/meta/0007_snapshot.json | 4 ++-- tenant-migrations/meta/0008_snapshot.json | 4 ++-- tenant-migrations/meta/0009_snapshot.json | 4 ++-- tenant-migrations/meta/0010_snapshot.json | 4 ++-- 10 files changed, 17 insertions(+), 18 deletions(-) diff --git a/tenant-migrations/0003_complex_the_call.sql b/tenant-migrations/0003_complex_the_call.sql index b5582f4..0ad22e4 100644 --- a/tenant-migrations/0003_complex_the_call.sql +++ b/tenant-migrations/0003_complex_the_call.sql @@ -61,7 +61,7 @@ ALTER TABLE "appointment" ADD COLUMN "auth_tag" text;--> statement-breakpoint ALTER TABLE "appointment" ADD COLUMN "created_at" timestamp DEFAULT now();--> statement-breakpoint ALTER TABLE "appointment" ADD COLUMN "updated_at" timestamp DEFAULT now();--> statement-breakpoint ALTER TABLE "appointment_key_share" ADD CONSTRAINT "appointment_key_share_appointment_id_appointment_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointment"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "client_tun_staff_keyShare_tId_client_app_tId_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "appointment" ADD CONSTRAINT "appointment_tunnel_id_client_appointment_tunnel_id_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "agent" DROP COLUMN "description";--> statement-breakpoint ALTER TABLE "appointment" DROP COLUMN "client_id";--> statement-breakpoint diff --git a/tenant-migrations/0009_far_korg.sql b/tenant-migrations/0009_far_korg.sql index e072f54..5e9a9f0 100644 --- a/tenant-migrations/0009_far_korg.sql +++ b/tenant-migrations/0009_far_korg.sql @@ -1,6 +1,5 @@ CREATE TYPE "public"."notification_type" AS ENUM('APPOINTMENT_CONFIRMED', 'APPOINTMENT_CANCELED');--> statement-breakpoint ALTER TABLE "notification" ADD COLUMN "type" "notification_type" DEFAULT 'APPOINTMENT_CONFIRMED' NOT NULL;--> statement-breakpoint ALTER TABLE "notification" ADD COLUMN "meta_data" json;--> statement-breakpoint -ALTER TABLE "notification" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint ALTER TABLE "notification" DROP COLUMN "title";--> statement-breakpoint ALTER TABLE "notification" DROP COLUMN "description"; \ No newline at end of file diff --git a/tenant-migrations/meta/0003_snapshot.json b/tenant-migrations/meta/0003_snapshot.json index 83b6cc0..003a044 100644 --- a/tenant-migrations/meta/0003_snapshot.json +++ b/tenant-migrations/meta/0003_snapshot.json @@ -620,8 +620,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0004_snapshot.json b/tenant-migrations/meta/0004_snapshot.json index 4924b51..9934f17 100644 --- a/tenant-migrations/meta/0004_snapshot.json +++ b/tenant-migrations/meta/0004_snapshot.json @@ -635,8 +635,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0005_snapshot.json b/tenant-migrations/meta/0005_snapshot.json index b0d71d5..9dd07d9 100644 --- a/tenant-migrations/meta/0005_snapshot.json +++ b/tenant-migrations/meta/0005_snapshot.json @@ -649,8 +649,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0006_snapshot.json b/tenant-migrations/meta/0006_snapshot.json index 297fd52..23eb512 100644 --- a/tenant-migrations/meta/0006_snapshot.json +++ b/tenant-migrations/meta/0006_snapshot.json @@ -655,8 +655,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0007_snapshot.json b/tenant-migrations/meta/0007_snapshot.json index 18a4fd1..c7bf07d 100644 --- a/tenant-migrations/meta/0007_snapshot.json +++ b/tenant-migrations/meta/0007_snapshot.json @@ -599,8 +599,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0008_snapshot.json b/tenant-migrations/meta/0008_snapshot.json index 2ff4af2..c501312 100644 --- a/tenant-migrations/meta/0008_snapshot.json +++ b/tenant-migrations/meta/0008_snapshot.json @@ -634,8 +634,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0009_snapshot.json b/tenant-migrations/meta/0009_snapshot.json index 43ce789..b814a87 100644 --- a/tenant-migrations/meta/0009_snapshot.json +++ b/tenant-migrations/meta/0009_snapshot.json @@ -634,8 +634,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], diff --git a/tenant-migrations/meta/0010_snapshot.json b/tenant-migrations/meta/0010_snapshot.json index dbe1079..fedd03d 100644 --- a/tenant-migrations/meta/0010_snapshot.json +++ b/tenant-migrations/meta/0010_snapshot.json @@ -693,8 +693,8 @@ }, "indexes": {}, "foreignKeys": { - "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { - "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "client_tun_staff_keyShare_tId_client_app_tId_fk": { + "name": "client_tun_staff_keyShare_tId_client_app_tId_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", "columnsFrom": ["tunnel_id"], From 76838eba29eb95375d5cc4893a8dab9f059bd7f7 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 16:45:25 +0100 Subject: [PATCH 40/61] Fix: Legal links in settings are optional --- .../settings/(components)/edit-settings-form/schema.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts b/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts index d324e13..10419de 100644 --- a/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts +++ b/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts @@ -1,6 +1,9 @@ import { m } from "$i18n/messages"; import { z } from "zod"; +const optionalUrl = (errorMessage: string) => + z.union([z.literal(""), z.url({ message: errorMessage })]).optional(); + export const formSchema = z .object({ id: z.string(), @@ -18,9 +21,9 @@ export const formSchema = z city: z.string().min(1, { message: m["form.errors.city"]() }), }), links: z.object({ - website: z.string().url({ message: m["form.errors.url"]() }).optional().default(""), - imprint: z.string().url({ message: m["form.errors.url"]() }).optional().default(""), - privacyStatement: z.string().url({ message: m["form.errors.url"]() }).optional().default(""), + website: optionalUrl(m["form.errors.url"]()), + imprint: optionalUrl(m["form.errors.url"]()), + privacyStatement: optionalUrl(m["form.errors.url"]()), }), settings: z.object({ autoDeleteDays: z.number().min(30, { message: m["form.errors.deleteAfterDays"]() }), From 32fe5c7e4260d384477b7cbc15fcd9e0038dd25e Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 16 Jan 2026 16:56:18 +0100 Subject: [PATCH 41/61] Fix reloads during onboarding --- src/routes/(pages)/dashboard/agents/+page.svelte | 6 +----- src/routes/(pages)/dashboard/channels/+page.svelte | 10 +++++----- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index 2e9a33d..737fa76 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -1,5 +1,4 @@ diff --git a/src/lib/components/ui/popover/popover-content.svelte b/src/lib/components/ui/popover/popover-content.svelte index 3d79f3c..3d867e2 100644 --- a/src/lib/components/ui/popover/popover-content.svelte +++ b/src/lib/components/ui/popover/popover-content.svelte @@ -1,31 +1,31 @@ - + diff --git a/src/lib/components/ui/popover/popover-portal.svelte b/src/lib/components/ui/popover/popover-portal.svelte index dd8265f..8115718 100644 --- a/src/lib/components/ui/popover/popover-portal.svelte +++ b/src/lib/components/ui/popover/popover-portal.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/popover/popover-trigger.svelte b/src/lib/components/ui/popover/popover-trigger.svelte index 586323c..a58180b 100644 --- a/src/lib/components/ui/popover/popover-trigger.svelte +++ b/src/lib/components/ui/popover/popover-trigger.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/popover/popover.svelte b/src/lib/components/ui/popover/popover.svelte index 6b1aa5f..b485e41 100644 --- a/src/lib/components/ui/popover/popover.svelte +++ b/src/lib/components/ui/popover/popover.svelte @@ -1,7 +1,7 @@ From 224d4a953982d6400b2770f5ac91621dfbc708a6 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 21 Jan 2026 17:30:00 +0100 Subject: [PATCH 49/61] Fixed test --- src/routes/api/auth/invite/__tests__/invite.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/api/auth/invite/__tests__/invite.test.ts b/src/routes/api/auth/invite/__tests__/invite.test.ts index 4e03a78..cddf460 100644 --- a/src/routes/api/auth/invite/__tests__/invite.test.ts +++ b/src/routes/api/auth/invite/__tests__/invite.test.ts @@ -140,7 +140,7 @@ describe("POST /api/auth/invite", () => { "STAFF", "12345678-1234-4234-8234-123456789012", "admin-id", - "de", + "en", ); expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled(); }); @@ -174,7 +174,7 @@ describe("POST /api/auth/invite", () => { "STAFF", "12345678-1234-4234-8234-123456789012", "admin-id", - "de", + "en", ); expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith( "user@example.com", @@ -182,7 +182,7 @@ describe("POST /api/auth/invite", () => { mockTenant, "STAFF", expect.stringContaining("invite-code-123"), - "de", + "en", ); }); From 7435f8b6fb18b58327711cfaf7f643467b06bc48 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 21 Jan 2026 17:30:13 +0100 Subject: [PATCH 50/61] Allow client email to be optional --- .../server/services/appointment-service.ts | 28 ++++++------ .../[appointmentId]/confirm/+server.ts | 2 +- .../appointments/add-to-tunnel/+server.ts | 45 ++++++++++--------- .../appointments/create-new-client/+server.ts | 5 +-- 4 files changed, 41 insertions(+), 39 deletions(-) diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 3e08eb0..530aea2 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -20,7 +20,7 @@ export interface ClientTunnelData { appointmentDate: string; duration: number; emailHash: string; - clientEmail: string; + clientEmail?: string; clientLanguage?: string; clientPublicKey: string; privateKeyShare: string; @@ -437,19 +437,21 @@ export class AppointmentService { }); // Send email notification to client (async, don't wait) - this.sendAppointmentNotification( - result.appointment.id, - clientData.channelId, - clientData.clientEmail, - clientData.clientLanguage || "de", - result.requiresConfirmation, - ).catch((error) => { - log.error("Failed to send appointment notification", { - tunnelId: clientData.tunnelId, - appointmentId: result.appointment.id, - error: String(error), + if (clientData.clientEmail) { + this.sendAppointmentNotification( + result.appointment.id, + clientData.channelId, + clientData.clientEmail, + clientData.clientLanguage || "de", + result.requiresConfirmation, + ).catch((error) => { + log.error("Failed to send appointment notification", { + tunnelId: clientData.tunnelId, + appointmentId: result.appointment.id, + error: String(error), + }); }); - }); + } return response; } diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts index fb022c7..1e73a27 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts @@ -8,7 +8,7 @@ import logger from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; const requestSchema = z.object({ - clientEmail: z.string().email().optional(), + clientEmail: z.email().optional(), clientLanguage: z.string().optional().default("en"), }); diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts index f8ea48e..be51b6a 100644 --- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts @@ -27,7 +27,7 @@ const requestSchema = z.object({ agentId: z.string(), appointmentDate: z.string(), duration: z.number().int().positive(), - clientEmail: z.string().email(), + clientEmail: z.email().optional(), clientLanguage: z.string().optional().default("en"), encryptedAppointment: z.object({ encryptedPayload: z.string(), @@ -121,7 +121,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", { "tunnelId", "channelId", "appointmentDate", - "clientEmail", "encryptedAppointment", ], }, @@ -304,12 +303,6 @@ export const POST: RequestHandler = async ({ request, params }) => { if (!tenant) { logger.warn("Cannot send email: Tenant not found", { tenantId }); } else { - // Create client data object from request - const clientData = { - email: validatedData.clientEmail, - language: validatedData.clientLanguage, - }; - // Get channel title for the email const channelTitle = await getChannelTitle( tenantId, @@ -318,20 +311,28 @@ export const POST: RequestHandler = async ({ request, params }) => { ); // Send appropriate email based on whether confirmation is required - if (requiresConfirmation) { - await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle); - logger.info("Appointment request email sent", { - tunnelId: validatedData.tunnelId, - appointmentId: result.id, - tenantId, - }); - } else { - await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle); - logger.info("Appointment confirmation email sent", { - tunnelId: validatedData.tunnelId, - appointmentId: result.id, - tenantId, - }); + if (validatedData.clientEmail) { + // Create client data object from request + const clientData = { + email: validatedData.clientEmail, + language: validatedData.clientLanguage, + }; + + if (requiresConfirmation) { + await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle); + logger.info("Appointment request email sent", { + tunnelId: validatedData.tunnelId, + appointmentId: result.id, + tenantId, + }); + } else { + await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle); + logger.info("Appointment confirmation email sent", { + tunnelId: validatedData.tunnelId, + appointmentId: result.id, + tenantId, + }); + } } } } catch (emailError) { diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index 953a92b..edee9b6 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -12,7 +12,7 @@ const requestSchema = z.object({ appointmentDate: z.string(), duration: z.number().int().positive(), emailHash: z.string(), - clientEmail: z.string().email(), + clientEmail: z.email().optional(), clientLanguage: z.string().optional().default("en"), clientPublicKey: z.string(), privateKeyShare: z.string(), @@ -74,7 +74,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { clientEmail: { type: "string", format: "email", - description: "Client email address for sending confirmation", + description: "Client email address for sending confirmation. Optional.", example: "client@example.com", }, clientLanguage: { @@ -138,7 +138,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { "channelId", "appointmentDate", "emailHash", - "clientEmail", "clientPublicKey", "privateKeyShare", "encryptedAppointment", From 7504dd2ac187eb986881825275eb5b3212802b7c Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 21 Jan 2026 19:50:15 +0100 Subject: [PATCH 51/61] Fix types --- .../ui/checkbox-with-label/checkbox-with-label.svelte | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte b/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte index e43d129..86c2920 100644 --- a/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte +++ b/src/lib/components/ui/checkbox-with-label/checkbox-with-label.svelte @@ -37,10 +37,7 @@
{#if tooltip} - + From 5500ea75c0dea624b6d209330340a443d30c76ae Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 21 Jan 2026 19:58:41 +0100 Subject: [PATCH 52/61] Fix lint --- src/routes/(pages)/login/login-form.svelte | 23 ++++++++++------------ 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index db904e3..de8b126 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -5,20 +5,20 @@ import * as Form from "$lib/components/ui/form"; import type { EventReporter } from "$lib/components/ui/form/form-root.svelte"; import { Input } from "$lib/components/ui/input"; + import { Label } from "$lib/components/ui/label"; + import { Passkey } from "$lib/components/ui/passkey"; + import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; + import { Text } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; + import logger from "$lib/logger"; + import { auth } from "$lib/stores/auth"; + import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey"; + import { onMount } from "svelte"; import { toast } from "svelte-sonner"; import { writable, type Writable } from "svelte/store"; - import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms"; + import { superForm } from "sveltekit-superforms"; import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; - import { baseSchema, formSchema, type FormSchema } from "./schema"; - import { onMount } from "svelte"; - import { Passkey } from "$lib/components/ui/passkey"; - import { Text } from "$lib/components/ui/typography"; - import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte"; - import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey"; - import { Label } from "$lib/components/ui/label"; - import { auth } from "$lib/stores/auth"; - import logger from "$lib/logger"; + import { baseSchema, formSchema } from "./schema"; let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props(); @@ -165,9 +165,6 @@ const { form: formData, enhance } = form; const passkeyLoading: Writable = writable("initial"); - - type FormDataPassphrase = Extract; - type FormDataPasskey = Extract; From b1f96bcf0094fe67bed185134032feec498b0176 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 21 Jan 2026 19:58:57 +0100 Subject: [PATCH 53/61] Separate github pr checks --- .github/workflows/pr-checks.yml | 51 +++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 87fa40e..74bf10b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -6,7 +6,24 @@ on: types: [opened, synchronize, reopened] jobs: - test-and-build: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + - name: Run linting + run: npm run lint + check: runs-on: ubuntu-latest steps: @@ -24,9 +41,21 @@ jobs: - name: Run type checking run: npm run check + tests: + runs-on: ubuntu-latest - - name: Run linting - run: npm run lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci - name: Run unit tests run: npm run test:unit -- --run @@ -41,6 +70,22 @@ jobs: SMTP_FROM_EMAIL: "noreply@example.com" JWT_SECRET: "test" + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + - name: Build application run: npm run build env: From 9c86b28182447cc5080bc0fb9c1e16251f73d5c1 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 21 Jan 2026 21:22:07 +0100 Subject: [PATCH 54/61] Undo svelte warnings --- .../components/templates/empty-state/center-state.svelte | 1 + .../translation-with-component.svelte | 1 + src/lib/emails/AppointmentBooked.svelte | 4 +++- src/lib/emails/AppointmentReminder.svelte | 4 +++- src/lib/emails/AppointmentRequest.svelte | 4 +++- src/lib/emails/Confirmation.svelte | 4 +++- src/lib/emails/PinReset.svelte | 4 +++- src/lib/emails/UserInvite.svelte | 4 +++- .../confirm/resend/resend-confirmation-form.svelte | 1 + .../confirm/setup-passkey/setup-passkey-form.svelte | 1 + .../delete-absence-form/delete-absence-form.svelte | 1 + .../edit-absence-form/edit-absence-form.svelte | 8 ++++++-- .../delete-agent-form/delete-agent-form.svelte | 1 + .../(components)/edit-agent-form/edit-agent-form.svelte | 6 ++++-- .../delete-channel-form/delete-channel-form.svelte | 1 + .../edit-channel-form/edit-channel-form.svelte | 2 ++ .../pause-channel-form/pause-channel-form.svelte | 1 + .../edit-settings-form/edit-settings-form.svelte | 2 ++ .../delete-staff-member-form.svelte | 1 + .../edit-staff-member-form/edit-staff-member-form.svelte | 2 ++ .../(components)/add-tenant-form/add-tenant-form.svelte | 3 ++- .../delete-tenant-form/delete-tenant-form.svelte | 1 + .../(components)/edit-tenant-form/edit-tenant-form.svelte | 1 + src/routes/(pages)/setup/check-email/+page.svelte | 4 +++- .../setup/create-admin-account/create-account-form.svelte | 3 ++- 25 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/lib/components/templates/empty-state/center-state.svelte b/src/lib/components/templates/empty-state/center-state.svelte index f13008b..51579a9 100644 --- a/src/lib/components/templates/empty-state/center-state.svelte +++ b/src/lib/components/templates/empty-state/center-state.svelte @@ -33,6 +33,7 @@ size?: VariantProps["size"]; } = $props(); + // svelte-ignore state_referenced_locally const isSmaller = size === "sm"; diff --git a/src/lib/components/ui/translation-with-component/translation-with-component.svelte b/src/lib/components/ui/translation-with-component/translation-with-component.svelte index a850bbb..f05df86 100644 --- a/src/lib/components/ui/translation-with-component/translation-with-component.svelte +++ b/src/lib/components/ui/translation-with-component/translation-with-component.svelte @@ -77,6 +77,7 @@ return segments; } + // svelte-ignore state_referenced_locally const segments = processTranslation(translation, interpolations); diff --git a/src/lib/emails/AppointmentBooked.svelte b/src/lib/emails/AppointmentBooked.svelte index b8290c6..76ef184 100644 --- a/src/lib/emails/AppointmentBooked.svelte +++ b/src/lib/emails/AppointmentBooked.svelte @@ -35,7 +35,9 @@ cancelUrl: string; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte index 37101c7..0d51729 100644 --- a/src/lib/emails/AppointmentReminder.svelte +++ b/src/lib/emails/AppointmentReminder.svelte @@ -35,7 +35,9 @@ cancelUrl: string; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte index 473eea7..dcc96a0 100644 --- a/src/lib/emails/AppointmentRequest.svelte +++ b/src/lib/emails/AppointmentRequest.svelte @@ -32,7 +32,9 @@ }; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/lib/emails/Confirmation.svelte b/src/lib/emails/Confirmation.svelte index 8e2a279..9897150 100644 --- a/src/lib/emails/Confirmation.svelte +++ b/src/lib/emails/Confirmation.svelte @@ -19,7 +19,9 @@ expirationMinutes: number; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/lib/emails/PinReset.svelte b/src/lib/emails/PinReset.svelte index 5826e5d..3990e09 100644 --- a/src/lib/emails/PinReset.svelte +++ b/src/lib/emails/PinReset.svelte @@ -20,7 +20,9 @@ loginUrl: string; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/lib/emails/UserInvite.svelte b/src/lib/emails/UserInvite.svelte index bb15168..eaac863 100644 --- a/src/lib/emails/UserInvite.svelte +++ b/src/lib/emails/UserInvite.svelte @@ -22,7 +22,9 @@ expirationMinutes: number; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte index 249a9b4..19efa8b 100644 --- a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte +++ b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte @@ -15,6 +15,7 @@ }: { formId: string; onEvent: EventReporter; data: { form: SuperValidated> } } = $props(); + // svelte-ignore state_referenced_locally const form = superForm(data.form, { validators: zodClient(formSchema), onResult: async (event) => { diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 632c7d6..21fc515 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -37,6 +37,7 @@ let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state(); let registrationChallenge: string | undefined = $state(); + // svelte-ignore state_referenced_locally const form = superForm(data.form, { validators: zodClient(formSchema), onChange: (event) => { diff --git a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte index 79309c1..7048746 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte @@ -14,6 +14,7 @@ const agents = $derived($agentsStore.agents ?? []); let { entity, done }: { entity: TAbsence; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, agent: entity.agentId }, { diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte index 88f64b9..aaf5b36 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte @@ -18,6 +18,8 @@ let { entity, done }: { entity: TAbsence; done: () => void } = $props(); const agents = $derived($agentsStore.agents ?? []); + + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, @@ -43,8 +45,10 @@ ); let isSubmitting = $state(false); - let startDate = toInputDateTime(entity.startDate); - let endDate = toInputDateTime(entity.endDate); + // svelte-ignore state_referenced_locally + let startDate = $state(toInputDateTime(entity.startDate)); + // svelte-ignore state_referenced_locally + let endDate = $state(toInputDateTime(entity.endDate)); let isAllDay = $state( startDate.hour === 0 && startDate.minute === 0 && endDate.hour === 0 && endDate.minute === 0, ); diff --git a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte index 7ee8b88..fdc2b4f 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte +++ b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte @@ -14,6 +14,7 @@ let { entity, done }: { entity: TAgent; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, name: "" }, { diff --git a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte index 54872ea..a78218f 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte +++ b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte @@ -17,8 +17,10 @@ let { entity, done }: { entity: TAgent; done: () => void } = $props(); const tenantLocales = get(tenants).currentTenant?.languages ?? []; + + // svelte-ignore state_referenced_locally const form = superForm( - { + $state.snapshot({ id: entity.id, name: entity.name, descriptions: tenantLocales.reduce( @@ -26,7 +28,7 @@ {} as { [key: string]: string }, ), image: entity.image ?? "", - }, + }), { dataType: "json", validators: zodClient(formSchema), diff --git a/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte index 6dcceb1..13d4aef 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte @@ -15,6 +15,7 @@ let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, name: "" }, { diff --git a/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte index 5702911..6568615 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte @@ -23,6 +23,8 @@ const agents = $derived($agentsStore.agents ?? []); const tenantLocales = get(tenants).currentTenant?.languages ?? []; + + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, diff --git a/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte index 95527c1..88c53e0 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte @@ -14,6 +14,7 @@ let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, pause: !entity.pause }, { diff --git a/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/edit-settings-form.svelte b/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/edit-settings-form.svelte index 3a2327d..b76b9e6 100644 --- a/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/edit-settings-form.svelte +++ b/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/edit-settings-form.svelte @@ -30,6 +30,8 @@ key: it, label: translatedLocales[it as keyof typeof translatedLocales], })); + + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte index 960a044..57ee3b8 100644 --- a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte @@ -14,6 +14,7 @@ let { entity, done }: { entity: TStaff; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, email: "", confirmationState: entity.confirmationState }, { diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte index d8c97eb..d5aba0f 100644 --- a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte @@ -14,6 +14,8 @@ let { entity, done }: { entity: TStaff; done: () => void } = $props(); const availableRoles = $derived(roles.filter((it) => it.value !== "GLOBAL_ADMIN")); + + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, diff --git a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte index 1203d9c..75a1303 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte @@ -12,7 +12,8 @@ let { data, done }: { done: () => void; data: { form: SuperValidated> } } = $props(); - const form = superForm(data.form, { + // svelte-ignore state_referenced_locally + const form = superForm($state.snapshot(data.form), { validators: zodClient(formSchema), onResult: async (event) => { if (event.result.type === "success") { diff --git a/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte index 67bf454..79a8d3e 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte @@ -17,6 +17,7 @@ let { entity, done }: { entity: TTenant; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, shortname: "" }, { diff --git a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte index 6938e4f..69e75f0 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte @@ -10,6 +10,7 @@ let { entity, done }: { entity: TTenant; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, shortName: entity.shortName }, { diff --git a/src/routes/(pages)/setup/check-email/+page.svelte b/src/routes/(pages)/setup/check-email/+page.svelte index 6077841..f821222 100644 --- a/src/routes/(pages)/setup/check-email/+page.svelte +++ b/src/routes/(pages)/setup/check-email/+page.svelte @@ -20,7 +20,9 @@ const formId = "resend-confirmation-email"; let email = $state(""); let isSubmitting = $state(false); - const form = superForm(data.form, { + + // svelte-ignore state_referenced_locally + const form = superForm($state.snapshot(data.form), { resetForm: false, validators: zodClient(formSchema), onResult: (event) => { diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index afab9a4..2e43ab1 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -28,7 +28,8 @@ }: { formId: string; onEvent: EventReporter; data: { form: SuperValidated> } } = $props(); - const form = superForm(data.form, { + // svelte-ignore state_referenced_locally + const form = superForm($state.snapshot(data.form), { validators: zodClient(formSchema), onChange: (event) => { if (event.paths.includes("email")) { From 214e201f2170e9f5e438b6deb501fc8540d0451a Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 21 Jan 2026 21:53:53 +0100 Subject: [PATCH 55/61] Fix lint --- .../sidebar-layout/components/nav-primary.svelte | 4 +++- .../sidebar-layout/components/nav-secondary.svelte | 2 ++ .../layouts/sidebar-layout/components/nav-user.svelte | 5 +++-- .../sidebar-layout/components/tenant-switcher.svelte | 6 +++++- src/lib/components/ui/button/button.svelte | 2 ++ .../ui/responsive-dialog/responsive-dialog.svelte | 8 ++++---- src/lib/const/routes.ts | 2 +- src/lib/emails/components/EmailButton.svelte | 1 + src/lib/stores/tenants.ts | 7 ++++--- src/lib/utils/session.ts | 5 +++-- .../[[id]]/(components)/summary.svelte | 3 ++- .../book-appointment/[[id]]/(components)/utils.ts | 5 +++-- src/routes/(pages)/confirm/[token]/+page.svelte | 3 ++- .../confirm/setup-passkey/setup-passkey-form.svelte | 3 ++- src/routes/(pages)/dashboard/+page.svelte | 11 ++++++----- src/routes/(pages)/dashboard/account/+page.svelte | 9 +++++---- .../(pages)/dashboard/calendar/(components)/utils.ts | 3 ++- src/routes/(pages)/login/login-form.svelte | 3 ++- src/routes/(pages)/setup/check-email/+page.svelte | 3 ++- .../create-admin-account/create-account-form.svelte | 3 ++- 20 files changed, 56 insertions(+), 32 deletions(-) diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte index 485d10f..ad7a537 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte @@ -12,6 +12,7 @@ import AgentsIcon from "@lucide/svelte/icons/user-star"; import { m } from "$i18n/messages"; import type { NavItem } from ".."; + import { resolve } from "path"; const items: NavItem[] = [ { @@ -66,7 +67,8 @@ {#snippet child({ props })} - + + {item.title} diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte index 1a7bfa5..45b6fe5 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte @@ -42,6 +42,7 @@ {#if $auth.user && item.roles.includes($auth.user?.role)} + {#snippet child({ props })} {/snippet} + {/if} diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte index 555a5e0..e9743d7 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte @@ -12,6 +12,7 @@ import { nameToAvatarFallback } from "$lib/utils/name"; import { LanguageSwitch } from "$lib/components/templates/language-switch"; import { m } from "$i18n/messages"; + import { resolve } from "$app/paths"; const sidebar = useSidebar(); @@ -61,13 +62,13 @@ - goto(ROUTES.DASHBOARD.ACCOUNT.MAIN)}> + goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}> {m["nav.account"]()} - goto(ROUTES.LOGOUT)}> + goto(resolve(ROUTES.LOGOUT))}> {m["nav.logout"]()} diff --git a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte index d3dba98..7e48087 100644 --- a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte @@ -14,6 +14,7 @@ import UnknownTenantIcon from "@lucide/svelte/icons/landmark"; import Loader from "@lucide/svelte/icons/loader-2"; import { cn } from "$lib/utils"; + import { resolve } from "$app/paths"; const sidebar = useSidebar(); @@ -116,7 +117,10 @@ {/each} {#if $tenants?.tenants.length > maxTenantsToShow} - goto(ROUTES.DASHBOARD.TENANTS)}> + goto(resolve(ROUTES.DASHBOARD.TENANTS))} + >
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 1b15395..a442c51 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -58,6 +58,7 @@ {#if href} + {@render children?.()} + {:else}