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

@@ -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") {

View File

@@ -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<typeof state>) => {
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 {

View File

@@ -27,8 +27,8 @@ const doChange = async (event: FormSubmitEvent<typeof state>) => {
})
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 {

View File

@@ -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<string | null>("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
}
},

View File

@@ -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)
})