diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts new file mode 100644 index 0000000..83c1799 --- /dev/null +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { generateBaseUrl } from "../email-service"; +import type { SelectTenant } from "$lib/server/db/central-schema"; + +// Mock NODE_ENV +const mockEnv = vi.hoisted(() => ({ NODE_ENV: "development" })); + +vi.mock("$env/dynamic/private", () => ({ + env: mockEnv +})); + +describe("generateBaseUrl", () => { + beforeEach(() => { + // Reset NODE_ENV to development for each test + mockEnv.NODE_ENV = "development"; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Development/Local Environment", () => { + it("should return localhost URL regardless of tenant", () => { + const requestUrl = new URL("http://localhost:5173"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://localhost:5173"); + }); + + it("should return localhost URL for null tenant", () => { + const requestUrl = new URL("http://localhost:3000"); + + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("http://localhost:3000"); + }); + + it("should preserve port for localhost", () => { + const requestUrl = new URL("http://localhost:8080"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://localhost:8080"); + }); + + it("should handle 127.x.x.x addresses", () => { + const requestUrl = new URL("http://127.0.0.1:3000"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://127.0.0.1:3000"); + }); + + it("should handle 192.168.x.x addresses", () => { + const requestUrl = new URL("http://192.168.1.100:8080"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://192.168.1.100:8080"); + }); + }); + + describe("Production Environment", () => { + beforeEach(() => { + mockEnv.NODE_ENV = "production"; + }); + + it("should return main domain for null tenant", () => { + const requestUrl = new URL("https://example.com"); + + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("https://example.com"); + }); + + it("should return main domain with port for null tenant", () => { + const requestUrl = new URL("https://example.com:8443"); + + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("https://example.com:8443"); + }); + + it("should create subdomain URL for tenant on main domain", () => { + const requestUrl = new URL("https://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.example.com"); + }); + + it("should create subdomain URL with port", () => { + const requestUrl = new URL("https://example.com:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.example.com:8443"); + }); + + it("should replace existing subdomain with tenant shortName", () => { + const requestUrl = new URL("https://old-tenant.example.com"); + const tenant: SelectTenant = { + id: "tenant-2", + shortName: "new-tenant", + longName: "New Tenant Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://new-tenant.example.com"); + }); + + it("should replace existing subdomain with port", () => { + const requestUrl = new URL("https://old-tenant.example.com:8443"); + const tenant: SelectTenant = { + id: "tenant-2", + shortName: "new-tenant", + longName: "New Tenant Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://new-tenant.example.com:8443"); + }); + + it("should handle complex subdomains (keep last two parts)", () => { + const requestUrl = new URL("https://admin.api.example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "tenant", + longName: "Tenant Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://tenant.example.com"); + }); + + it("should handle http protocol", () => { + const requestUrl = new URL("http://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://acme.example.com"); + }); + + it("should return main domain when tenant has no shortName", () => { + const requestUrl = new URL("https://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "", // Empty shortName + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://example.com"); + }); + }); + + describe("Edge Cases", () => { + it("should handle single domain names in production", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://app"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.app"); + }); + + it("should handle localhost in production (still treated as development)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://localhost:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://localhost:8443"); + }); + + it("should handle IP addresses as development", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://192.168.1.100:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://192.168.1.100:8443"); + }); + + it("should handle default HTTP port (80)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("http://example.com:80"); + + // URL constructor should handle port 80 correctly + const result = generateBaseUrl(requestUrl, null); + // Default HTTP port shouldn't be included in URL + expect(result).toBe("http://example.com"); + }); + + it("should handle default HTTPS port (443)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://example.com:443"); + + // URL constructor should handle port 443 correctly + const result = generateBaseUrl(requestUrl, null); + // Default HTTPS port shouldn't be included in URL + expect(result).toBe("https://example.com"); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 949cab4..2d5e2c3 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -187,12 +187,47 @@ export async function sendAppointmentUpdatedEmail( }); } +/** + * Generate base URL for email templates based on request URL and tenant + * @param {URL} requestUrl - The request URL + * @param {SelectTenant | null} tenant - Tenant information (null for global admin) + * @returns {string} The appropriate base URL + */ +export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string { + const protocol = requestUrl.protocol; + const port = requestUrl.port ? `:${requestUrl.port}` : ''; + const hostname = requestUrl.hostname; + + // In development, always use the original hostname regardless of tenant + if (hostname === 'localhost' || hostname.startsWith('127.') || hostname.startsWith('192.168.')) { + return `${protocol}//${hostname}${port}`; + } + + // In production, handle tenant subdomains + if (tenant?.shortName) { + const parts = hostname.split('.'); + + if (parts.length > 2) { + // Complex subdomain - use only the last two parts (domain.tld) and add tenant + const domain = parts.slice(-2).join('.'); + return `${protocol}//${tenant.shortName}.${domain}${port}`; + } else { + // Main domain, prepend tenant subdomain + return `${protocol}//${tenant.shortName}.${hostname}${port}`; + } + } + + // For global admin or no tenant, use main domain + return `${protocol}//${hostname}${port}`; +} + /** * Send registration confirmation email with one-time code * @param {SelectClient | SelectStaff} user - Database user object * @param {SelectTenant} tenant - Tenant information for branding * @param {string} confirmationCode - One-time confirmation code * @param {number} [expirationMinutes=15] - Code expiration time in minutes + * @param {URL} [requestUrl] - Request URL for generating baseUrl * @throws {Error} When email sending fails * @returns {Promise} */ @@ -200,13 +235,23 @@ export async function sendConfirmationEmail( user: { id: string; email: string | null; name: string | null; language?: string | null }, tenant: SelectTenant, confirmationCode: string, - expirationMinutes: number = 15 + expirationMinutes: number = 15, + requestUrl?: URL ): Promise { const recipient = createEmailRecipient(user); const language = (recipient.language as Language) || "en"; const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; - await sendTemplatedEmail("confirmation", recipient, subject, language, tenant, { + // Generate appropriate base URL if request URL is provided + const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : 'http://localhost:5173'; + + // Create enhanced tenant object with baseUrl + const tenantWithBaseUrl = { + ...tenant, + baseUrl + }; + + await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, { confirmationCode, expirationMinutes }); diff --git a/src/lib/server/email/templates/confirmation.de.html b/src/lib/server/email/templates/confirmation.de.html index 98dfd68..7a15d11 100644 --- a/src/lib/server/email/templates/confirmation.de.html +++ b/src/lib/server/email/templates/confirmation.de.html @@ -1,173 +1,174 @@ + + + + Registrierung bestätigen + - + border: 2px solid { + { + tenant.primaryColor + } + } - -
-
- {{#if tenant.logo}} - {{tenant.longName}} - {{/if}} - -

Registrierung bestätigen

-
+ ; + border-radius: 8px; + padding: 20px; + text-align: center; + margin: 20px 0; + } -
-

Hallo,

+ .code { + font-family: 'Courier New', monospace; + font-size: 32px; + font-weight: bold; -

- vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre - Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode: -

+ color: { + { + tenant.primaryColor + } + } -
-
Ihr Bestätigungscode:
-
{{confirmationCode}}
-
- Geben Sie diesen Code in das Bestätigungsfeld ein + ; + letter-spacing: 4px; + margin: 10px 0; + } + + .footer { + background-color: #f8f9fa; + padding: 20px; + text-align: center; + font-size: 14px; + color: #666; + } + + .footer a { + color: { + { + tenant.primaryColor + } + } + + ; + text-decoration: none; + } + + .warning { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + color: #856404; + } + + + + +
+
+ {{#if tenant.logo}} + {{tenant.longName}} + {{/if}} + +

Registrierung bestätigen

+
+ +
+

Hallo,

+ +

+ vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre + Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode: +

+ +
+
Ihr Bestätigungscode:
+
{{confirmationCode}}
+
+ Geben Sie diesen Code in das Bestätigungsfeld ein +
+ Oder hier klicken.
- {{tenant.baseUrl}}/confirm/{{confirmationCode}} + +
+ Wichtiger Hinweis: Dieser Code ist nur für + {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet + werden. +
+ +

+ Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail + ignorieren. +

+ +

Bei Fragen wenden Sie sich gerne an unser Support-Team.

+ +

+ Mit freundlichen Grüßen
+ Das {{tenant.longName}} Team +

-
- Wichtiger Hinweis: Dieser Code ist nur für - {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet - werden. + - -

- Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail - ignorieren. -

- -

Bei Fragen wenden Sie sich gerne an unser Support-Team.

- -

- Mit freundlichen Grüßen
- Das {{tenant.longName}} Team -

- - -
- - - \ No newline at end of file + + diff --git a/src/lib/server/email/templates/confirmation.de.txt b/src/lib/server/email/templates/confirmation.de.txt index 084f14c..b2da80d 100644 --- a/src/lib/server/email/templates/confirmation.de.txt +++ b/src/lib/server/email/templates/confirmation.de.txt @@ -10,6 +10,8 @@ vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre Registrieru Geben Sie diesen Code in das Bestätigungsfeld ein, um Ihre Registrierung zu vervollständigen. +Alternativ können Sie direkt diese Seite besuchen: {{tenant.baseUrl}}/confirm/{{confirmationCode}} + WICHTIGER HINWEIS: Dieser Code ist nur für {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet werden. Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail ignorieren. diff --git a/src/lib/server/email/templates/confirmation.en.html b/src/lib/server/email/templates/confirmation.en.html index cadd7ce..10cd866 100644 --- a/src/lib/server/email/templates/confirmation.en.html +++ b/src/lib/server/email/templates/confirmation.en.html @@ -100,6 +100,7 @@
Enter this code in the confirmation field
+ Or simply click here.
diff --git a/src/lib/server/email/templates/confirmation.en.txt b/src/lib/server/email/templates/confirmation.en.txt index 1b575e9..ae7c750 100644 --- a/src/lib/server/email/templates/confirmation.en.txt +++ b/src/lib/server/email/templates/confirmation.en.txt @@ -10,6 +10,8 @@ Thank you for registering with {{tenant.longName}}. To complete your registratio Enter this code in the confirmation field to complete your registration. +Or simply visit this site: {{tenant.baseUrl}}/confirm/{{confirmationCode}} + IMPORTANT NOTICE: This code is valid for {{expirationMinutes}} minutes only and can be used only once. If you did not register with {{tenant.longName}}, you can safely ignore this email. diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 99e16d5..3458c85 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -71,7 +71,7 @@ export class UserService { /** * Create a new user */ - static async createUser(userData: UserCreation) { + static async createUser(userData: UserCreation, requestUrl?: URL) { const log = logger.setContext("UserService"); log.debug("Creating new user account", { email: userData.email, @@ -138,7 +138,8 @@ export class UserService { result[0], tenant, result[0].token, - 10 // 10 minutes expiration to match tokenValidUntil + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl ); log.debug("Confirmation email sent successfully", { userId: result[0].id, @@ -165,8 +166,9 @@ export class UserService { /** * Resend the confirmation email for a user * @param email - Email of the user to confirm + * @param requestUrl - Optional request URL for generating correct baseUrl */ - static async resendConfirmationEmail(email: string): Promise { + static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise { const log = logger.setContext("UserService"); log.debug("Resending confirmation email", { email }); @@ -195,7 +197,8 @@ export class UserService { user, tenant, token, - 10 // 10 minutes expiration to match tokenValidUntil + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl ); log.debug("Confirmation email sent successfully", { userId: user.id, diff --git a/src/routes/(pages)/setup/check-email/+page.server.ts b/src/routes/(pages)/setup/check-email/+page.server.ts index 679ae30..4f5f243 100644 --- a/src/routes/(pages)/setup/check-email/+page.server.ts +++ b/src/routes/(pages)/setup/check-email/+page.server.ts @@ -24,7 +24,7 @@ export const actions: Actions = { }); } - await UserService.resendConfirmationEmail(form.data.email); + await UserService.resendConfirmationEmail(form.data.email, event.url); log.debug("Resent confirmation e-mail", { email: form.data.email diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 29ffbed..3cce61a 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -30,7 +30,7 @@ export const actions: Actions = { email: form.data.email, passphrase: form.data.passphrase, language: form.data.language - }); + }, event.url); const hasPasskey = false; log.debug("Admin account created successfully", { diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index cc7f832..db1f765 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -105,7 +105,7 @@ registerOpenAPIRoute("/admin/init", "POST", { } }); -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, url }) => { const log = logger.setContext("API"); try { @@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { email: body.email, passphrase: body.passphrase, // Will be undefined if passkey is used language: body.language || "de" - }); + }, url); // Add the passkey to the admin account if provided if (hasPasskey) { diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index c983070..f06f8f3 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -123,7 +123,7 @@ registerOpenAPIRoute("/auth/register", "POST", { } }); -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, url }) => { const log = logger.setContext("API"); try { @@ -187,7 +187,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { tenantId: finalTenantId, passphrase: body.passphrase, language: inviteUsed?.language || body.language || "de" - }); + }, url); // Add the passkey to the user account if provided if (body.passkey) { diff --git a/src/routes/api/auth/resend-confirmation/+server.ts b/src/routes/api/auth/resend-confirmation/+server.ts index 4b4b405..8d0d288 100644 --- a/src/routes/api/auth/resend-confirmation/+server.ts +++ b/src/routes/api/auth/resend-confirmation/+server.ts @@ -68,12 +68,12 @@ registerOpenAPIRoute("/auth/resend-confirmation", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, url }) => { try { const body = await request.json(); // Resend confirmation email - await UserService.resendConfirmationEmail(body.email); + await UserService.resendConfirmationEmail(body.email, url); return json( { diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/authHandle.ts index d3123c4..b1f398c 100644 --- a/src/server-hooks/authHandle.ts +++ b/src/server-hooks/authHandle.ts @@ -104,6 +104,8 @@ export const authHandle: Handle = async ({ event, resolve }) => { sessionId: sessionData.sessionId }; + logger.debug(`Added user information for ${sessionData.user.id}`); + if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { return new Response(JSON.stringify({ error: "Authentication failed" }), { status: 403,