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 { buildSystemStatus } from "../modules/system-status.service";
|
||||||
import { s3 } from "../utils/s3";
|
import { s3 } from "../utils/s3";
|
||||||
import { secrets } from "../utils/secrets";
|
import { secrets } from "../utils/secrets";
|
||||||
|
import { getPortalLoginUrl } from "../utils/portalUrl";
|
||||||
|
|
||||||
export default async function adminRoutes(server: FastifyInstance) {
|
export default async function adminRoutes(server: FastifyInstance) {
|
||||||
await server.register(multipart, {
|
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" });
|
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 generatedPassword = generateRandomPassword(14);
|
||||||
const passwordHash = await hashPassword(generatedPassword);
|
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(
|
const mailResult = await sendMail(
|
||||||
email,
|
email,
|
||||||
`FEDEO | Einladung ins Kundenportal`,
|
`FEDEO | Einladung ins Kundenportal`,
|
||||||
@@ -1097,7 +1107,8 @@ export default async function adminRoutes(server: FastifyInstance) {
|
|||||||
<p><strong>E-Mail:</strong> ${email}</p>
|
<p><strong>E-Mail:</strong> ${email}</p>
|
||||||
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
|
||||||
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</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>
|
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { authUsers } from "../../../db/schema";
|
|||||||
import { authTenantUsers } from "../../../db/schema";
|
import { authTenantUsers } from "../../../db/schema";
|
||||||
import { tenants } from "../../../db/schema";
|
import { tenants } from "../../../db/schema";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { resolveLoginTenant } from "../../utils/loginTenant";
|
||||||
import {
|
import {
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
issueRefreshToken,
|
issueRefreshToken,
|
||||||
@@ -127,7 +128,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(401).send({ error: "Invalid credentials" });
|
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 token = createAccessToken(user, tenantId);
|
||||||
const refreshToken = await issueRefreshToken(server, user.id, 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.")
|
||||||
|
}
|
||||||
126
backend/tests/customerPortalLogin.test.ts
Normal file
126
backend/tests/customerPortalLogin.test.ts
Normal file
@@ -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<string, any>) {
|
||||||
|
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<number | null> = []
|
||||||
|
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<Array<[number, number | null]>>) {
|
||||||
|
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, [])
|
||||||
|
})
|
||||||
@@ -45,10 +45,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||||||
|
|
||||||
// 3. Wenn eingeloggt
|
// 3. Wenn eingeloggt
|
||||||
if (to.path === "/login") {
|
if (to.path === "/login") {
|
||||||
if (auth.user.must_change_password) {
|
return navigateTo(auth.getLandingPath())
|
||||||
return navigateTo("/password-change")
|
|
||||||
}
|
|
||||||
return navigateTo(isPortalUser ? "/customer-portal" : "/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.user.must_change_password && to.path !== "/password-change") {
|
if (auth.user.must_change_password && to.path !== "/password-change") {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ definePageMeta({
|
|||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -22,7 +21,6 @@ const doLogin = async (event: FormSubmitEvent<typeof state>) => {
|
|||||||
try {
|
try {
|
||||||
await auth.login(event.data.email, event.data.password, event.data.rememberMe)
|
await auth.login(event.data.email, event.data.password, event.data.rememberMe)
|
||||||
toast.add({title:"Einloggen erfolgreich"})
|
toast.add({title:"Einloggen erfolgreich"})
|
||||||
await router.push("/")
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"})
|
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ const doChange = async (event: FormSubmitEvent<typeof state>) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
toast.add({title:"Ändern erfolgreich"})
|
toast.add({title:"Ändern erfolgreich"})
|
||||||
await auth.logout()
|
await auth.fetchMe()
|
||||||
return navigateTo("/login")
|
return navigateTo(auth.getLandingPath(), { replace: true })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.add({title:"Es gab ein Problem beim ändern",color:"error"})
|
toast.add({title:"Es gab ein Problem beim ändern",color:"error"})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
getLandingPath() {
|
||||||
|
if (this.user?.must_change_password) return "/password-change"
|
||||||
|
return this.profile?.customer_for_portal ? "/customer-portal" : "/"
|
||||||
|
},
|
||||||
|
|
||||||
tokenCookie() {
|
tokenCookie() {
|
||||||
return useCookie<string | null>("token", { path: "/" })
|
return useCookie<string | null>("token", { path: "/" })
|
||||||
},
|
},
|
||||||
@@ -265,14 +270,14 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
const tempStore = useTempStore()
|
const tempStore = useTempStore()
|
||||||
|
|
||||||
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
|
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
|
||||||
|
this.loading = false
|
||||||
if(this.activeTenant > 0) {
|
if(this.activeTenant > 0) {
|
||||||
this.loading = false
|
return navigateTo(this.getLandingPath())
|
||||||
|
|
||||||
navigateTo("/")
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async login(email: string, password: string, rememberMe = false) {
|
async login(email: string, password: string, rememberMe = false) {
|
||||||
|
this.loading = true
|
||||||
try {
|
try {
|
||||||
console.log("Auth login")
|
console.log("Auth login")
|
||||||
const { token, refreshToken } = await useNuxtApp().$api("/auth/login", {
|
const { token, refreshToken } = await useNuxtApp().$api("/auth/login", {
|
||||||
@@ -289,21 +294,15 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
await this.fetchMe(token)
|
await this.fetchMe(token)
|
||||||
|
|
||||||
|
|
||||||
// 3. WICHTIG: Jetzt explizit weiterleiten!
|
if (!this.user) throw new Error("Die Sitzung konnte nicht geladen werden.")
|
||||||
// Prüfen, ob der User geladen wurde
|
this.loading = false
|
||||||
if (this.user) {
|
return await navigateTo(this.getLandingPath(), { replace: true })
|
||||||
// Falls Passwort-Änderung erzwungen wird (passend zu deiner Middleware)
|
|
||||||
if (this.user.must_change_password) {
|
|
||||||
return navigateTo("/password-change")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normaler Login -> Dashboard
|
|
||||||
return navigateTo("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("login error:" + e)
|
console.log("login error:" + e)
|
||||||
throw e
|
throw e
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ test('an account without assigned tenants receives an explanation without loadin
|
|||||||
})
|
})
|
||||||
|
|
||||||
function authFixture() {
|
function authFixture() {
|
||||||
const logs = [], calls = [], sessions = []
|
const logs = [], calls = [], sessions = [], navigations = []
|
||||||
const token = 'test-access-token'
|
const token = 'test-access-token'
|
||||||
const refreshToken = 'test-refresh-token'
|
const refreshToken = 'test-refresh-token'
|
||||||
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
|
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
|
||||||
@@ -89,10 +89,15 @@ function authFixture() {
|
|||||||
defineStore: (_, definition) => definition,
|
defineStore: (_, definition) => definition,
|
||||||
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
|
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
|
||||||
useTempStore: () => ({ setStoredTempConfig() {} }),
|
useTempStore: () => ({ setStoredTempConfig() {} }),
|
||||||
navigateTo() {},
|
navigateTo: path => { navigations.push(path) },
|
||||||
useNuxtApp: () => ({ $api: async (url, options) => {
|
useNuxtApp: () => ({ $api: async (url, options) => {
|
||||||
calls.push({ url, options })
|
calls.push({ url, options })
|
||||||
if (url === '/api/me') return me
|
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 }
|
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
|
||||||
throw Error('Unexpected API request: ' + url)
|
throw Error('Unexpected API request: ' + url)
|
||||||
} }),
|
} }),
|
||||||
@@ -106,7 +111,7 @@ function authFixture() {
|
|||||||
store.getStoredRefreshToken = () => refreshToken
|
store.getStoredRefreshToken = () => refreshToken
|
||||||
store.scheduleSessionTimers = () => {}
|
store.scheduleSessionTimers = () => {}
|
||||||
store.setSession = (...args) => sessions.push(args)
|
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 () => {
|
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.token))
|
||||||
assert.ok(!output.includes(f.refreshToken))
|
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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user