fix(portal): add invitation links and correct customer login routing
This commit is contained in:
@@ -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>
|
||||
`
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
33
backend/src/utils/loginTenant.ts
Normal file
33
backend/src/utils/loginTenant.ts
Normal 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
|
||||
}
|
||||
29
backend/src/utils/portalUrl.ts
Normal file
29
backend/src/utils/portalUrl.ts
Normal 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.")
|
||||
}
|
||||
Reference in New Issue
Block a user