Use the normal invitation flow when inviting an initial tenant admin.

This commit is contained in:
Hendrik Belitz
2025-10-26 17:09:28 +01:00
parent 8ff2238f0f
commit d1dffd7ffb
10 changed files with 61 additions and 431 deletions
@@ -1,55 +0,0 @@
import { describe, it, expect } from "vitest";
import { sendTenantAdminInviteEmail } from "../email-service";
describe("sendTenantAdminInviteEmail", () => {
const mockTenant = {
id: "test-tenant-id",
shortName: "testcorp",
longName: "Test Corporation GmbH",
descriptions: { en: "A test corporation" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "postgresql://test",
setupState: "SETTINGS" as const,
createdAt: new Date(),
updatedAt: new Date(),
logo: null,
links: {
website: "https://testcorp.com",
imprint: "https://testcorp.com/imprint",
privacyStatement: "https://testcorp.com/privacy",
},
};
it("should accept correct parameters and not throw for German language", () => {
const adminEmail = "admin@testcorp.com";
const adminName = "Test Admin";
const registrationUrl = "http://localhost:5173/register?tenant=test";
// This test just ensures the function accepts the right parameters
// and doesn't throw on setup (actual email sending will fail without SMTP config)
expect(() => {
sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "de");
}).not.toThrow();
});
it("should accept correct parameters and not throw for English language", () => {
const adminEmail = "admin@testcorp.com";
const adminName = "Test Admin";
const registrationUrl = "http://localhost:5173/register?tenant=test";
expect(() => {
sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "en");
}).not.toThrow();
});
it("should accept correct parameters and not throw with default language", () => {
const adminEmail = "admin@testcorp.com";
const adminName = "Test Admin";
const registrationUrl = "http://localhost:5173/register?tenant=test";
expect(() => {
sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl);
}).not.toThrow();
});
});
-31
View File
@@ -254,37 +254,6 @@ export async function sendConfirmationEmail(
});
}
/**
* Send tenant administrator invitation email
* @param {string} adminEmail - Email address of the invited administrator
* @param {string} adminName - Name of the invited administrator
* @param {SelectTenant} tenant - Tenant information for branding
* @param {string} registrationUrl - URL for administrator to register
* @param {Language} [language="en"] - Email language
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendTenantAdminInviteEmail(
adminEmail: string,
adminName: string,
tenant: SelectTenant,
registrationUrl: string,
language: Language = "en",
): Promise<void> {
const recipient: EmailRecipient = {
email: adminEmail,
name: adminName,
language,
};
const subject =
language === "en" ? "Invitation as Tenant Administrator" : "Einladung als Tenant-Administrator";
await sendTemplatedEmail("tenant-admin-invite", recipient, subject, language, tenant, {
registrationUrl,
});
}
/**
* Send user invitation email for existing tenant
* @param {string} userEmail - Email address of the invited user
@@ -1,113 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Einladung als Tenant-Administrator</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f3f4f6;
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #2563eb;
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
.button {
display: inline-block;
background-color: #2563eb;
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 10px 0;
}
.button:hover {
background-color: #1d4ed8;
}
.tenant-info {
background-color: #f9fafb;
padding: 20px;
border-radius: 6px;
margin: 20px 0;
border-left: 4px solid #2563eb;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Open Reception</div>
</div>
<h1>Einladung als Tenant-Administrator</h1>
<p>Hallo {{recipient.name}},</p>
<p>Sie wurden als Administrator für einen neuen Tenant in Open Reception eingeladen!</p>
<div class="tenant-info">
<h3>Tenant-Details:</h3>
<p><strong>Name:</strong> {{tenant.shortName}}</p>
<p><strong>Beschreibung:</strong> {{tenant.longName}}</p>
</div>
<p>Als Tenant-Administrator können Sie:</p>
<ul>
<li>Die Konfiguration Ihres Tenants verwalten</li>
<li>Mitarbeiter einladen und verwalten</li>
<li>Kanäle und Services einrichten</li>
<li>Termine und Buchungen überwachen</li>
</ul>
<p><strong>Registrieren Sie sich jetzt, um loszulegen:</strong></p>
<p><a href="{{registrationUrl}}" class="button">Jetzt registrieren</a></p>
<p>
Wenn der Button nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br />
{{registrationUrl}}
</p>
<div class="footer">
<p>
Diese Einladung ist 7 Tage gültig. Falls Sie diese E-Mail nicht angefordert haben, können
Sie sie ignorieren.
</p>
<p>Open Reception Team</p>
</div>
</div>
</body>
</html>
@@ -1,23 +0,0 @@
Einladung als Tenant-Administrator
Hallo {{recipient.name}},
Sie wurden als Administrator für einen neuen Tenant in Open Reception eingeladen!
Tenant-Details:
- Name: {{tenant.shortName}}
- Beschreibung: {{tenant.longName}}
Als Tenant-Administrator können Sie:
- Die Konfiguration Ihres Tenants verwalten
- Mitarbeiter einladen und verwalten
- Kanäle und Services einrichten
- Termine und Buchungen überwachen
Registrieren Sie sich jetzt, um loszulegen:
{{registrationUrl}}
Diese Einladung ist 7 Tage gültig. Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.
--
Open Reception Team
@@ -1,112 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Invitation as Tenant Administrator</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f3f4f6;
}
.email-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #2563eb;
}
h1 {
color: #1f2937;
font-size: 24px;
margin-bottom: 20px;
}
.button {
display: inline-block;
background-color: #2563eb;
color: white !important;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 10px 0;
}
.button:hover {
background-color: #1d4ed8;
}
.tenant-info {
background-color: #f9fafb;
padding: 20px;
border-radius: 6px;
margin: 20px 0;
border-left: 4px solid #2563eb;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 14px;
color: #6b7280;
text-align: center;
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Open Reception</div>
</div>
<h1>Invitation as Tenant Administrator</h1>
<p>Hello {{recipient.name}},</p>
<p>You have been invited as an administrator for a new tenant in Open Reception!</p>
<div class="tenant-info">
<h3>Tenant Details:</h3>
<p><strong>Name:</strong> {{tenant.shortName}}</p>
<p><strong>Description:</strong> {{tenant.longName}}</p>
</div>
<p>As a tenant administrator, you can:</p>
<ul>
<li>Manage your tenant's configuration</li>
<li>Invite and manage staff members</li>
<li>Set up channels and services</li>
<li>Monitor appointments and bookings</li>
</ul>
<p><strong>Register now to get started:</strong></p>
<p><a href="{{registrationUrl}}" class="button">Register Now</a></p>
<p>
If the button doesn't work, copy this link into your browser:<br />
{{registrationUrl}}
</p>
<div class="footer">
<p>
This invitation is valid for 7 days. If you didn't request this email, you can ignore it.
</p>
<p>Open Reception Team</p>
</div>
</div>
</body>
</html>
@@ -1,23 +0,0 @@
Invitation as Tenant Administrator
Hello {{recipient.name}},
You have been invited as an administrator for a new tenant in Open Reception!
Tenant Details:
- Name: {{tenant.shortName}}
- Description: {{tenant.longName}}
As a tenant administrator, you can:
- Manage your tenant's configuration
- Invite and manage staff members
- Set up channels and services
- Monitor appointments and bookings
Register now to get started:
{{registrationUrl}}
This invitation is valid for 7 days. If you didn't request this email, you can ignore it.
--
Open Reception Team
@@ -9,8 +9,6 @@ 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";
import { sendTenantAdminInviteEmail } from "../email/email-service";
import { ERRORS } from "$lib/errors";
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
@@ -20,7 +18,6 @@ const tenantCreationSchema = z.object({
.min(4)
.max(15)
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
inviteAdmin: z.email().optional(),
});
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
@@ -67,7 +64,6 @@ export class TenantAdminService {
// Check if tenant can be created without any duplications. Do not create tenant if:
// - short name already exists
// - invited tenant admin email already exists
const tenantExists = await centralDb
.select()
.from(centralSchema.tenant)
@@ -75,15 +71,6 @@ export class TenantAdminService {
if (tenantExists.length > 0) {
throw new ConflictError("Tenant with shortname already exists");
}
if (request.inviteAdmin) {
const adminExists = await centralDb
.select()
.from(centralSchema.user)
.where(eq(centralSchema.user.email, request.inviteAdmin));
if (adminExists.length > 0) {
throw new ConflictError(ERRORS.USERS.EMAIL_EXISTS);
}
}
const configuration = TenantAdminService.getConfigDefaults();
@@ -150,40 +137,6 @@ export class TenantAdminService {
log.debug("Tenant service created successfully", { tenantId: tenant[0].id });
// Send tenant admin invitation email if email is provided
if (request.inviteAdmin) {
try {
// For now, we'll use the email as name. In a real implementation,
// you might want to collect the name separately or parse it from the email
const adminName = request.inviteAdmin.split("@")[0];
// Generate registration URL for the tenant admin
// This should point to a registration page that pre-fills tenant info
const registrationUrl = `${env.PUBLIC_APP_URL || "http://localhost:5173"}/register?tenant=${tenant[0].id}&email=${encodeURIComponent(request.inviteAdmin)}&role=TENANT_ADMIN`;
await sendTenantAdminInviteEmail(
request.inviteAdmin,
adminName,
tenant[0],
registrationUrl,
);
log.info("Tenant admin invitation email sent successfully", {
tenantId: tenant[0].id,
adminEmail: request.inviteAdmin,
});
} catch (emailError) {
log.error("Failed to send tenant admin invitation email", {
tenantId: tenant[0].id,
adminEmail: request.inviteAdmin,
error: String(emailError),
});
// Don't fail the tenant creation if email fails
// Just log the error and continue
}
}
return tenantService;
} catch (error) {
log.error("Failed to create tenant", {
@@ -53,7 +53,8 @@ export const actions: Actions = {
});
}
const resp = await event.fetch(`/api/tenants`, {
// Create tenant first
const tenantResponse = await event.fetch(`/api/tenants`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -61,16 +62,13 @@ export const actions: Actions = {
credentials: "same-origin",
body: JSON.stringify({
shortName: form.data.shortName,
inviteAdmin: form.data.email,
}),
});
if (resp.status < 400) {
return { form };
} else {
if (tenantResponse.status >= 400) {
let error = "Unknown error";
try {
const body = await resp.json();
const body = await tenantResponse.json();
error = body.error;
} catch (e) {
log.error("Failed to parse add tenant error response", { error: e });
@@ -80,6 +78,38 @@ export const actions: Actions = {
error,
});
}
// If admin invitation requested, create user invitation
if (form.data.inviteAdmin && form.data.email) {
const tenantData = await tenantResponse.json();
const tenantId = tenantData.tenant?.id;
if (tenantId) {
const userInviteResponse = await event.fetch(`/api/auth/invite`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
email: form.data.email,
name: form.data.email.split("@")[0],
role: "TENANT_ADMIN",
tenantId: tenantId,
}),
});
if (userInviteResponse.status >= 400) {
log.warn("Tenant created but user invitation failed", {
tenantId,
email: form.data.email,
});
// Don't fail the entire operation, just log the warning
}
}
}
return { form };
},
edit: async (event) => {
const form = await superValidate(event, zod(editFormSchema));
+17 -15
View File
@@ -28,12 +28,6 @@ registerOpenAPIRoute("/tenants", "POST", {
description: "Short name for the tenant (4-15 characters)",
example: "acme-corp",
},
inviteAdmin: {
type: "string",
format: "email",
description: "Email address to invite as tenant admin",
example: "admin@acme-corp.com",
},
},
required: ["shortName"],
},
@@ -49,15 +43,23 @@ registerOpenAPIRoute("/tenants", "POST", {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
tenantId: { type: "string", description: "Generated tenant ID" },
shortName: { type: "string", description: "Tenant short name" },
tenant: {
type: "object",
properties: {
id: { type: "string", description: "Generated tenant ID" },
shortName: { type: "string", description: "Tenant short name" },
},
required: ["id", "shortName"],
},
},
required: ["message", "tenantId", "shortName"],
required: ["message", "tenant"],
},
example: {
message: "Tenant created successfully",
tenantId: "01234567-89ab-cdef-0123-456789abcdef",
shortName: "acme-corp",
tenant: {
id: "01234567-89ab-cdef-0123-456789abcdef",
shortName: "acme-corp",
},
},
},
},
@@ -157,14 +159,12 @@ export const POST: RequestHandler = async ({ locals, request }) => {
log.debug("Creating tenant", {
shortName: body.shortName,
hasInviteAdmin: !!body.inviteAdmin,
});
checkPermission(locals, null, true);
const tenantService = await TenantAdminService.createTenant({
shortName: body.shortName,
inviteAdmin: body.inviteAdmin,
});
log.debug("Tenant created successfully", {
@@ -175,8 +175,10 @@ export const POST: RequestHandler = async ({ locals, request }) => {
return json(
{
message: "Tenant created successfully",
tenantId: tenantService.tenantId,
shortName: body.shortName,
tenant: {
id: tenantService.tenantId,
shortName: body.shortName,
},
},
{ status: 201 },
);
+8 -6
View File
@@ -59,7 +59,6 @@ describe("/api/tenants", () => {
json: () =>
Promise.resolve({
shortName: "test-tenant",
inviteAdmin: "admin@test.com",
}),
};
@@ -87,13 +86,14 @@ describe("/api/tenants", () => {
expect(TenantAdminService.createTenant).toHaveBeenCalledWith({
shortName: "test-tenant",
inviteAdmin: "admin@test.com",
});
expect(data).toEqual({
message: "Tenant created successfully",
tenantId: "test-tenant-id",
shortName: "test-tenant",
tenant: {
id: "test-tenant-id",
shortName: "test-tenant",
},
});
expect(response.status).toBe(201);
});
@@ -142,8 +142,10 @@ describe("/api/tenants", () => {
expect(data).toEqual({
message: "Tenant created successfully",
tenantId: "test-tenant-id-2",
shortName: "test-tenant-2",
tenant: {
id: "test-tenant-id-2",
shortName: "test-tenant-2",
},
});
expect(response.status).toBe(201);
});