Kundenansicht: Einmal-Link aus dem Ticketsystem, Sitzung und Preview-Zugang fuer 2 Stunden, Protokoll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdf594706d
commit
1cd776a2ee
10 files changed
+277
-7
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { clearPortalSession, setPortalSession } from '../middleware/session.js'
|
||||
import { logViewAction } from './customerViewLog.js'
|
||||
import {
|
||||
PREVIEW_COOKIE_NAME,
|
||||
clearPreviewSession,
|
||||
createPreviewSessionToken,
|
||||
previewSessionCookieOptions,
|
||||
} from './previewSession.js'
|
||||
|
||||
/**
|
||||
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
|
||||
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
|
||||
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
|
||||
*/
|
||||
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
|
||||
|
||||
export const isKundenansicht = (session) => Boolean(session?.viewAs && session.viewId)
|
||||
|
||||
export const isExpired = (session, now = Date.now()) => now >= Number(session?.viewExpiresAt || 0)
|
||||
|
||||
/** Portal-Sitzung und Preview-Zugang fuer genau diesen Kunden setzen; liefert die Sitzung. */
|
||||
export async function startKundenansicht(res, { staff, customer, now = Date.now() }) {
|
||||
const session = {
|
||||
role: 'admin',
|
||||
viewAs: true,
|
||||
viewId: randomBytes(16).toString('hex'),
|
||||
viewExpiresAt: now + KUNDENANSICHT_TTL_MS,
|
||||
isAdmin: Boolean(staff.isAdmin),
|
||||
appwriteUserId: staff.appwriteUserId,
|
||||
adminName: staff.name || 'Mitarbeiter',
|
||||
adminEmail: staff.email || '',
|
||||
customerId: customer.$id,
|
||||
name: customer.name || '',
|
||||
email: customer.email || '',
|
||||
}
|
||||
setPortalSession(res, session, { maxAgeMs: KUNDENANSICHT_TTL_MS })
|
||||
await setKundenansichtPreview(res, session, KUNDENANSICHT_TTL_MS / 1000)
|
||||
return session
|
||||
}
|
||||
|
||||
/** Preview-Cookie wie beim Kunden selbst (nur seine Projekte), hoechstens bis zum Ende der Kundenansicht. */
|
||||
export async function setKundenansichtPreview(res, session, maxAgeSec) {
|
||||
const token = await createPreviewSessionToken(
|
||||
{
|
||||
userId: session.appwriteUserId,
|
||||
customerId: session.customerId,
|
||||
email: session.email || '',
|
||||
name: session.name || '',
|
||||
role: 'customer',
|
||||
labels: [],
|
||||
authSource: 'kundenansicht',
|
||||
},
|
||||
{ maxAgeSec }
|
||||
)
|
||||
return res.cookie(PREVIEW_COOKIE_NAME, token, previewSessionCookieOptions({ maxAgeSec }))
|
||||
}
|
||||
|
||||
export function endKundenansicht(res) {
|
||||
clearPortalSession(res)
|
||||
clearPreviewSession(res)
|
||||
}
|
||||
|
||||
// "beendet"/"abgelaufen" je Ansicht nur einmal protokollieren (parallele Anfragen eines Tabs)
|
||||
const LOGGED_TTL_MS = 3 * 60 * 60 * 1000
|
||||
const logged = new Map()
|
||||
|
||||
export async function logOnce(session, action) {
|
||||
const now = Date.now()
|
||||
for (const [key, at] of logged) if (now - at > LOGGED_TTL_MS) logged.delete(key)
|
||||
const key = `${session.viewId}:${action}`
|
||||
if (logged.has(key)) return
|
||||
logged.set(key, now)
|
||||
await logViewAction({
|
||||
customerId: session.customerId,
|
||||
staffUserId: session.appwriteUserId,
|
||||
staffName: session.adminName,
|
||||
action,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { config } from '../config.js'
|
||||
import { getDocument, getPortalAccessByCustomerId } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Portal-Zugang eines Kunden fuer das Ticketsystem: hasAccess wie beim Login (customers.portalAccessEnabled und ein
|
||||
* freigeschaltetes customerPortalAccess-Dokument), dazu der letzte Login. customer = null bei unbekanntem Kunden.
|
||||
*/
|
||||
export async function getPortalAccessStatus(customerId) {
|
||||
const customer = await getDocument(config.collections.customers, customerId).catch((err) => {
|
||||
if (err.status === 404) return null
|
||||
throw err
|
||||
})
|
||||
if (!customer) return { customer: null, hasAccess: false, lastLoginAt: null }
|
||||
const access = await getPortalAccessByCustomerId(customer.$id)
|
||||
return {
|
||||
customer,
|
||||
hasAccess: Boolean(customer.portalAccessEnabled && access?.enabled),
|
||||
lastLoginAt: access?.lastLoginAt || null,
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,12 @@ export const PREVIEW_COOKIE_NAME = 'preview_session'
|
||||
|
||||
const secret = new TextEncoder().encode(config.preview.sessionSecret)
|
||||
|
||||
export async function createPreviewSessionToken(payload) {
|
||||
/** maxAgeSec kuerzer als die Standard-Laufzeit z. B. fuer die Kundenansicht (2 Stunden). */
|
||||
export async function createPreviewSessionToken(payload, { maxAgeSec = config.preview.sessionMaxAge } = {}) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${config.preview.sessionMaxAge}s`)
|
||||
.setExpirationTime(`${maxAgeSec}s`)
|
||||
.sign(secret)
|
||||
}
|
||||
|
||||
@@ -22,14 +23,14 @@ export async function verifyPreviewSessionToken(token) {
|
||||
}
|
||||
}
|
||||
|
||||
export function previewSessionCookieOptions() {
|
||||
export function previewSessionCookieOptions({ maxAgeSec = config.preview.sessionMaxAge } = {}) {
|
||||
return {
|
||||
domain: config.preview.cookieDomain,
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: config.preview.sessionMaxAge * 1000,
|
||||
maxAge: maxAgeSec * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ export function createStaffPreviewToken(payload) {
|
||||
|
||||
/**
|
||||
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
|
||||
* ('switch'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
|
||||
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
|
||||
* Zweck ist trotzdem verbraucht.
|
||||
*/
|
||||
export function consumeStaffPreviewToken(token, purpose = 'preview') {
|
||||
purgeExpired()
|
||||
|
||||
Reference in new issue
Block a user