From 54631069383927446318bfafc872b7426eda08c3 Mon Sep 17 00:00:00 2001 From: flfeders Date: Tue, 13 Oct 2026 18:13:41 +0200 Subject: [PATCH] fix(portal): add invitation links and correct customer login routing --- backend/src/routes/admin.ts | 17 ++- backend/src/routes/auth/auth.ts | 3 +- backend/src/utils/loginTenant.ts | 33 ++++++ backend/src/utils/portalUrl.ts | 29 +++++ backend/tests/customerPortalLogin.test.ts | 126 ++++++++++++++++++++++ frontend/middleware/auth.global.ts | 5 +- frontend/pages/login.vue | 2 - frontend/pages/password-change.vue | 4 +- frontend/stores/auth.ts | 27 +++-- frontend/tests/dashboard-session.test.cjs | 85 ++++++++++++++- 10 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 backend/src/utils/loginTenant.ts create mode 100644 backend/src/utils/portalUrl.ts create mode 100644 backend/tests/customerPortalLogin.test.ts diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 58e3cec4..df09bb89 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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) {

E-Mail: ${email}

Initialpasswort: ${generatedPassword}

Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.

- ${portalUrl ? `

Login: ${portalUrl}

` : ""} +

Zum Kundenportal anmelden

+

Ihre Login-Adresse: ${portalUrl}

Viele Grüße
${tenantRecord?.name || "FEDEO"}

` ); diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index 43ae5723..ab799a07 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -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); diff --git a/backend/src/utils/loginTenant.ts b/backend/src/utils/loginTenant.ts new file mode 100644 index 00000000..a277c020 --- /dev/null +++ b/backend/src/utils/loginTenant.ts @@ -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 +} diff --git a/backend/src/utils/portalUrl.ts b/backend/src/utils/portalUrl.ts new file mode 100644 index 00000000..4b1822c0 --- /dev/null +++ b/backend/src/utils/portalUrl.ts @@ -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.") +} diff --git a/backend/tests/customerPortalLogin.test.ts b/backend/tests/customerPortalLogin.test.ts new file mode 100644 index 00000000..75efcf6e --- /dev/null +++ b/backend/tests/customerPortalLogin.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict" +import test from "node:test" +import Fastify from "fastify" +import cookie from "@fastify/cookie" +import bcrypt from "bcrypt" +import jwt from "jsonwebtoken" +import { drizzle } from "drizzle-orm/pg-proxy" +import authRoutes from "../src/routes/auth/auth" +import meRoutes from "../src/routes/auth/me" +import { secrets } from "../src/utils/secrets" +import { getPortalLoginUrl } from "../src/utils/portalUrl" + +test("portal invitations always use a configured frontend login URL", () => { + const defaults = { apiBaseUrl: "https://fedeo.example/backend" } + assert.equal(getPortalLoginUrl(defaults), "https://fedeo.example/login") + assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: " kunden.example " }), "https://kunden.example/login") + assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: "https://kunden.example/" }), "https://kunden.example/login") + assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://web.example/fedeo/" }), "https://web.example/fedeo/login") + assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "http://localhost:3000/" }), "http://localhost:3000/login") + assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "javascript:alert(1)" }), "https://fedeo.example/login") + assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://user:password@web.example/" }), "https://fedeo.example/login") + assert.throws(() => getPortalLoginUrl({}), /öffentliche FEDEO-Adresse/) +}) + +const userId = "00000000-0000-4000-8000-000000000001" +const profileId = "00000000-0000-4000-8000-000000000002" +const passwordHash = bcrypt.hashSync("portal-password", 4) + +function selectedRow(sql: string, values: Record) { + return sql.slice(7, sql.indexOf(" from ")).split(", ").map(column => { + const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1] + return values[name!] ?? null + }) +} + +async function fixture(t: any, memberships: Array<[number, number | null]>, domainTenantId?: number) { + const refreshTenants: Array = [] + const db = drizzle(async (sql, params) => { + if (sql.includes('from "auth_users"')) { + return { rows: [selectedRow(sql, { + id: userId, email: "customer@example.com", password_hash: passwordHash, + must_change_password: true, is_admin: false, + })] } + } + if (sql.includes('from "auth_tenant_users"') && sql.includes('"auth_profiles"')) { + assert.ok(params.includes(userId)) + assert.match(sql, /"auth_profiles"\."tenant_id" = "auth_tenant_users"\."tenant_id"/) + assert.match(sql, /"auth_profiles"\."active" =/) + assert.ok(params.includes(true)) + return { rows: memberships } + } + if (sql.startsWith('insert into "auth_refresh_tokens"')) { + refreshTenants.push(params[1] as number | null) + return { rows: [] } + } + if (sql.includes('from "auth_tenant_users"')) { + return { rows: memberships.map(([id]) => selectedRow(sql, { id, name: "Test tenant" })) } + } + if (sql.includes('from "auth_profiles"')) { + const membership = memberships.find(([id]) => params.includes(id)) + return { rows: membership ? [selectedRow(sql, { + id: profileId, user_id: userId, tenant_id: membership[0], + customer_for_portal: membership[1], active: true, + })] : [] } + } + if (sql.includes('from "auth_profile_branches"') || sql.includes('from "auth_user_roles"')) return { rows: [] } + throw new Error(`Unexpected query: ${sql}`) + }) + secrets.JWT_SECRET = "customer-portal-test-secret" + const app = Fastify() + app.decorate("db", db as any) + app.addHook("preHandler", async (req: any) => { + if (domainTenantId) req.tenant = { id: String(domainTenantId) } + if (req.headers.authorization) req.user = jwt.verify(req.headers.authorization.slice(7), secrets.JWT_SECRET) + }) + await app.register(cookie) + await app.register(authRoutes) + await app.register(meRoutes) + t.after(() => app.close()) + const login = (password = "portal-password") => app.inject({ + method: "POST", url: "/auth/login", payload: { email: "customer@example.com", password }, + }) + return { app, login, refreshTenants } +} + +test("shared-address login scopes both tokens and loads the customer's portal profile", async t => { + const f = await fixture(t, [[7, 123]]) + const response = await f.login() + assert.equal(response.statusCode, 200, response.body) + const session = response.json() + assert.equal((jwt.verify(session.token, secrets.JWT_SECRET) as any).tenant_id, 7) + assert.deepEqual(f.refreshTenants, [7]) + assert.ok(session.refreshToken) + const me = await f.app.inject({ url: "/me", headers: { authorization: `Bearer ${session.token}` } }) + assert.equal(me.statusCode, 200, me.body) + assert.equal(me.json().activeTenant, 7) + assert.equal(me.json().profile.customer_for_portal, 123) + assert.equal(me.json().user.must_change_password, true) +}) + +test("internal and ambiguous accounts retain tenant selection", async t => { + for (const memberships of [[], [[7, null]], [[7, 123], [8, 456]]] as Array>) { + const f = await fixture(t, memberships) + const response = await f.login() + assert.equal(response.statusCode, 200, response.body) + assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, null) + assert.deepEqual(f.refreshTenants, [null]) + } +}) + +test("a portal domain selects only a tenant the user belongs to", async t => { + const f = await fixture(t, [[7, 123], [8, 456]], 8) + const response = await f.login() + assert.equal(response.statusCode, 200, response.body) + assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, 8) + assert.deepEqual(f.refreshTenants, [8]) + const other = await fixture(t, [[7, 123]], 99) + assert.equal((await other.login()).statusCode, 403) + assert.deepEqual(other.refreshTenants, []) +}) + +test("invalid credentials never create a portal session", async t => { + const f = await fixture(t, [[7, 123]]) + assert.equal((await f.login("wrong-password")).statusCode, 401) + assert.deepEqual(f.refreshTenants, []) +}) diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts index a9a0a51d..67a782ef 100644 --- a/frontend/middleware/auth.global.ts +++ b/frontend/middleware/auth.global.ts @@ -45,10 +45,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => { // 3. Wenn eingeloggt if (to.path === "/login") { - if (auth.user.must_change_password) { - return navigateTo("/password-change") - } - return navigateTo(isPortalUser ? "/customer-portal" : "/") + return navigateTo(auth.getLandingPath()) } if (auth.user.must_change_password && to.path !== "/password-change") { diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index eec016ff..292f1203 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -7,7 +7,6 @@ definePageMeta({ const auth = useAuthStore() const toast = useToast() -const router = useRouter() const state = reactive({ email: '', @@ -22,7 +21,6 @@ const doLogin = async (event: FormSubmitEvent) => { try { await auth.login(event.data.email, event.data.password, event.data.rememberMe) toast.add({title:"Einloggen erfolgreich"}) - await router.push("/") } catch (err: any) { toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"}) } finally { diff --git a/frontend/pages/password-change.vue b/frontend/pages/password-change.vue index e2ae4b06..a885ce43 100644 --- a/frontend/pages/password-change.vue +++ b/frontend/pages/password-change.vue @@ -27,8 +27,8 @@ const doChange = async (event: FormSubmitEvent) => { }) toast.add({title:"Ändern erfolgreich"}) - await auth.logout() - return navigateTo("/login") + await auth.fetchMe() + return navigateTo(auth.getLandingPath(), { replace: true }) } catch (err: any) { toast.add({title:"Es gab ein Problem beim ändern",color:"error"}) } finally { diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts index 2fb797b3..4db329d3 100644 --- a/frontend/stores/auth.ts +++ b/frontend/stores/auth.ts @@ -31,6 +31,11 @@ export const useAuthStore = defineStore("auth", { }), actions: { + getLandingPath() { + if (this.user?.must_change_password) return "/password-change" + return this.profile?.customer_for_portal ? "/customer-portal" : "/" + }, + tokenCookie() { return useCookie("token", { path: "/" }) }, @@ -265,14 +270,14 @@ export const useAuthStore = defineStore("auth", { const tempStore = useTempStore() if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config) + this.loading = false if(this.activeTenant > 0) { - this.loading = false - - navigateTo("/") + return navigateTo(this.getLandingPath()) } }, async login(email: string, password: string, rememberMe = false) { + this.loading = true try { console.log("Auth login") const { token, refreshToken } = await useNuxtApp().$api("/auth/login", { @@ -289,21 +294,15 @@ export const useAuthStore = defineStore("auth", { await this.fetchMe(token) - // 3. WICHTIG: Jetzt explizit weiterleiten! - // Prüfen, ob der User geladen wurde - if (this.user) { - // Falls Passwort-Änderung erzwungen wird (passend zu deiner Middleware) - if (this.user.must_change_password) { - return navigateTo("/password-change") - } - - // Normaler Login -> Dashboard - return navigateTo("/") - } + if (!this.user) throw new Error("Die Sitzung konnte nicht geladen werden.") + this.loading = false + return await navigateTo(this.getLandingPath(), { replace: true }) } catch (e) { console.log("login error:" + e) throw e + } finally { + this.loading = false } }, diff --git a/frontend/tests/dashboard-session.test.cjs b/frontend/tests/dashboard-session.test.cjs index 5ab8be67..720eb76e 100644 --- a/frontend/tests/dashboard-session.test.cjs +++ b/frontend/tests/dashboard-session.test.cjs @@ -80,7 +80,7 @@ test('an account without assigned tenants receives an explanation without loadin }) function authFixture() { - const logs = [], calls = [], sessions = [] + const logs = [], calls = [], sessions = [], navigations = [] const token = 'test-access-token' const refreshToken = 'test-refresh-token' let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null } @@ -89,10 +89,15 @@ function authFixture() { defineStore: (_, definition) => definition, console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) }, useTempStore: () => ({ setStoredTempConfig() {} }), - navigateTo() {}, + navigateTo: path => { navigations.push(path) }, useNuxtApp: () => ({ $api: async (url, options) => { calls.push({ url, options }) if (url === '/api/me') return me + if (url === '/api/tenant') return { id: me.activeTenant } + if (url === '/api/auth/password/change') { + me = { ...me, user: { ...me.user, must_change_password: false } } + return { success: true } + } if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken } throw Error('Unexpected API request: ' + url) } }), @@ -106,7 +111,7 @@ function authFixture() { store.getStoredRefreshToken = () => refreshToken store.scheduleSessionTimers = () => {} store.setSession = (...args) => sessions.push(args) - return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } } + return { store, context, logs, calls, sessions, navigations, token, refreshToken, setMe: value => { me = value } } } test('refreshing a session without an active tenant clears the previous tenant state', async () => { @@ -130,3 +135,77 @@ test('login and tenant switching store credentials without writing them to the c assert.ok(!output.includes(f.token)) assert.ok(!output.includes(f.refreshToken)) }) + +function portalSession(mustChangePassword = false) { + return { + user: { email: 'customer@example.com', must_change_password: mustChangePassword }, + activeTenant: 7, tenants: [{ id: 7, name: 'Test tenant' }], permissions: [], + profile: { customer_for_portal: 123 }, + } +} + +function runPage(f, name, action, data) { + const { descriptor } = parse(fs.readFileSync(__dirname + `/../pages/${name}.vue`, 'utf8')) + Object.assign(f.context, { + useAuthStore: () => f.store, useToast: () => ({ add() {} }), definePageMeta() {}, + useRouter: () => ({ push: path => { f.navigations.push(path) } }), + reactive: vue.reactive, ref: vue.ref, formData: data, + }) + const source = descriptor.scriptSetup.content.replace(/^import .*$/gm, '') + vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), f.context) + return vm.runInContext(`${action}({ data: formData })`, f.context) +} + +test('the login form navigates portal customers once, directly to their portal', async () => { + const f = authFixture() + f.setMe(portalSession()) + await runPage(f, 'login', 'doLogin', { email: 'customer@example.com', password: 'password', rememberMe: true }) + assert.deepEqual(f.navigations, ['/customer-portal']) + assert.equal(f.store.loading, false) + assert.equal(f.store.profile.customer_for_portal, 123) + assert.equal(f.store.activeTenant, '7') +}) + +test('first login opens password change and continues into the portal after saving', async () => { + const f = authFixture() + f.setMe(portalSession(true)) + await f.store.login('customer@example.com', 'initial-password') + assert.deepEqual(f.navigations, ['/password-change']) + await runPage(f, 'password-change', 'doChange', { oldPassword: 'initial-password', newPassword: 'new-password' }) + assert.deepEqual(f.navigations, ['/password-change', '/customer-portal']) + assert.equal(f.store.user.must_change_password, false) + assert.equal(f.calls.some(call => call.url === '/auth/logout'), false) +}) + +test('internal users still land on the dashboard', async () => { + const f = authFixture() + await runPage(f, 'login', 'doLogin', { email: 'staff@example.com', password: 'password' }) + assert.deepEqual(f.navigations, ['/']) + assert.equal(f.store.loading, false) +}) + +test('restoring a portal session keeps portal routing and password-change precedence', async () => { + const f = authFixture() + f.setMe(portalSession()) + Object.assign(f.context, { defineNuxtRouteMiddleware: fn => fn, useAuthStore: () => f.store }) + const middleware = fs.readFileSync(__dirname + '/../middleware/auth.global.ts', 'utf8') + vm.runInContext(ts.transpile(middleware, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), f.context) + const route = f.context.exports.default + await route({ path: '/' }) + assert.deepEqual(f.navigations, ['/customer-portal']) + await route({ path: '/customer-portal' }) + assert.equal(f.navigations.length, 1) + await route({ path: '/login' }) + assert.equal(f.navigations.at(-1), '/customer-portal') + f.store.user.must_change_password = true + await route({ path: '/customer-portal' }) + assert.equal(f.navigations.at(-1), '/password-change') +}) + +test('selecting a portal tenant also opens its portal', async () => { + const f = authFixture() + f.setMe(portalSession()) + await f.store.switchTenant('7') + assert.deepEqual(f.navigations, ['/customer-portal']) + assert.equal(f.store.loading, false) +})