fix(portal): add invitation links and correct customer login routing
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m7s
Build and Release FEDEO / build-and-release (push) Successful in 6m24s

This commit is contained in:
2026-10-13 18:13:41 +02:00
parent 070be08207
commit 5463106938
10 changed files with 302 additions and 29 deletions

View File

@@ -33,6 +33,7 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { buildSystemStatus } from "../modules/system-status.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
import { getPortalLoginUrl } from "../utils/portalUrl";
export default async function adminRoutes(server: FastifyInstance) {
await server.register(multipart, {
@@ -931,6 +932,17 @@ export default async function adminRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "Customer has no email address" });
}
let portalUrl: string;
try {
portalUrl = getPortalLoginUrl({
portalDomain: tenantRecord?.portalDomain,
publicUrl: process.env.FEDEO_PUBLIC_URL,
apiBaseUrl: secrets.API_BASE_URL,
});
} catch (err: any) {
return reply.code(503).send({ error: err.message });
}
const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword);
@@ -1086,8 +1098,6 @@ export default async function adminRoutes(server: FastifyInstance) {
});
}
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail(
email,
`FEDEO | Einladung ins Kundenportal`,
@@ -1097,7 +1107,8 @@ export default async function adminRoutes(server: FastifyInstance) {
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
<p><a href="${portalUrl}">Zum Kundenportal anmelden</a></p>
<p>Ihre Login-Adresse: ${portalUrl}</p>
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
`
);

View File

@@ -7,6 +7,7 @@ import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema";
import { tenants } from "../../../db/schema";
import { and, eq } from "drizzle-orm";
import { resolveLoginTenant } from "../../utils/loginTenant";
import {
createAccessToken,
issueRefreshToken,
@@ -127,7 +128,7 @@ export default async function authRoutes(server: FastifyInstance) {
return reply.code(401).send({ error: "Invalid credentials" });
}
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
const tenantId = await resolveLoginTenant(server, user.id, req.tenant?.id ? Number(req.tenant.id) : null);
const token = createAccessToken(user, tenantId);
const refreshToken = await issueRefreshToken(server, user.id, tenantId);

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, authTenantUsers } from "../../db/schema"
export async function resolveLoginTenant(server: FastifyInstance, userId: string, domainTenantId: number | null) {
const memberships = await server.db
.selectDistinct({
tenantId: authTenantUsers.tenant_id,
portalCustomerId: authProfiles.customer_for_portal,
})
.from(authTenantUsers)
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, authTenantUsers.tenant_id),
eq(authProfiles.active, true)
))
.where(eq(authTenantUsers.user_id, userId))
if (domainTenantId) {
if (!memberships.some((membership) => membership.tenantId === domainTenantId)) {
throw Object.assign(new Error("Not a member of this tenant"), { statusCode: 403 })
}
return domainTenantId
}
// Portal invitations assign one customer account. Load its profile immediately,
// even when the customer signs in through the shared FEDEO address.
if (memberships.length === 1 && memberships[0].portalCustomerId) {
return memberships[0].tenantId
}
return null
}

View File

@@ -0,0 +1,29 @@
type PortalUrlSettings = {
portalDomain?: string | null
publicUrl?: string
apiBaseUrl?: string
}
/** Use deployment configuration, never request headers, for links sent by email. */
export function getPortalLoginUrl({ portalDomain, publicUrl, apiBaseUrl }: PortalUrlSettings) {
const domain = portalDomain?.trim()
const candidates = [
{ value: domain ? (/^https?:\/\//i.test(domain) ? domain : `https://${domain}`) : undefined },
{ value: publicUrl?.trim() },
{ value: apiBaseUrl?.trim(), originOnly: true },
]
for (const { value, originOnly } of candidates) {
if (!value) continue
try {
const url = new URL(value)
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) continue
url.pathname = `${originOnly ? "" : url.pathname.replace(/\/+$/, "")}/login`
url.search = ""
url.hash = ""
return url.toString()
} catch { /* Try the next configured address. */ }
}
throw new Error("Für die Portal-Einladung fehlt eine gültige öffentliche FEDEO-Adresse.")
}