diff --git a/public/admin.html b/public/admin.html index 60dbc61..1170bb1 100644 --- a/public/admin.html +++ b/public/admin.html @@ -149,6 +149,7 @@
+ ← Ticketsystem
diff --git a/public/login.css b/public/login.css index 9f99b76..6478d32 100644 --- a/public/login.css +++ b/public/login.css @@ -265,6 +265,13 @@ border-color 0.25s var(--ease-out); } +a.portal-btn-outline { + display: inline-flex; + align-items: center; + line-height: normal; + text-decoration: none; +} + .portal-btn-outline:hover { background: #fff; color: #1c1c1c; diff --git a/server/index.js b/server/index.js index 6b658e3..92943b8 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import adminCustomersRoutes from './routes/admin/customers.js' import adminEmployeesRoutes from './routes/admin/employees.js' import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js' import adminPreviewOpenRoutes from './routes/admin/previewOpen.js' +import adminSwitchLinkRoutes from './routes/admin/switchLink.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') @@ -65,6 +66,7 @@ app.use('/api/admin/customers', adminCustomersRoutes) app.use('/api/admin/employees', adminEmployeesRoutes) app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/preview-open', adminPreviewOpenRoutes) +app.use('/api/admin/switch-link', adminSwitchLinkRoutes) app.use('/webhook', giteaWebhookRoutes) app.get('/api/health', async (_req, res) => { diff --git a/server/routes/admin/switchLink.js b/server/routes/admin/switchLink.js new file mode 100644 index 0000000..4d659e6 --- /dev/null +++ b/server/routes/admin/switchLink.js @@ -0,0 +1,50 @@ +import { Router } from 'express' +import { config } from '../../config.js' +import { createStaffPreviewToken } from '../../services/staffPreviewTokens.js' +import { resolveStaffAccount } from '../../services/staffAccount.js' + +const router = Router() + +/** + * Wechsel Ticketsystem -> Projekt-Admin ohne zweiten Login. Wie requireAppwriteStaff wird das JWT + * ueber /account geprueft (eine Sitzung ohne zweiten Faktor bekommt dort 401), aber ohne Label-Pflicht: + * jeder Mitarbeiter darf rueber, wie beim normalen Portal-Login. + */ +async function appwriteAccountFromJwt(req) { + const match = (req.get('Authorization') || '').match(/^Bearer\s+(.+)$/i) + if (!match) return null + const response = await fetch(`${config.appwrite.endpoint}/account`, { + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': config.appwrite.projectId, + 'X-Appwrite-JWT': match[1], + }, + }) + if (!response.ok) return null + return response.json() +} + +router.post('/', async (req, res) => { + try { + const user = await appwriteAccountFromJwt(req) + if (!user?.$id) return res.status(401).json({ error: 'Bitte im Ticketsystem neu anmelden.' }) + + const staff = await resolveStaffAccount(user.$id, user.email || '', user) + if (!staff) return res.status(403).json({ error: 'Kein Mitarbeiterkonto.' }) + + const token = createStaffPreviewToken({ + purpose: 'switch', + userId: user.$id, + email: staff.email, + name: staff.name, + isAdmin: staff.isAdmin, + }) + return res.json({ + openUrl: `https://${config.preview.baseHost}/api/auth/staff-switch?token=${encodeURIComponent(token)}`, + }) + } catch (err) { + return res.status(500).json({ error: err.message || 'Wechsel fehlgeschlagen' }) + } +}) + +export default router diff --git a/server/routes/auth.js b/server/routes/auth.js index dc2ba87..5b6980f 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -335,6 +335,19 @@ router.get('/staff-preview', async (req, res) => { return res.redirect(previewUrl) }) +// Wechsel aus dem Ticketsystem (POST /api/admin/switch-link): Einmal-Link -> Admin-Sitzung +router.get('/staff-switch', async (req, res) => { + const tokenData = consumeStaffPreviewToken(String(req.query.token || ''), 'switch') + if (!tokenData) { + return res.redirect(`/login.html?error=${encodeURIComponent('Link abgelaufen, bitte anmelden')}`) + } + await startStaffPortalSession(res, { + userId: tokenData.userId, + staff: { isAdmin: Boolean(tokenData.isAdmin), name: tokenData.name, email: tokenData.email }, + }) + return res.redirect('/admin.html') +}) + // Selbstheilung: Portal-Session lebt oft laenger als das preview_session-Cookie // (bzw. das Cookie fehlt nach WebView-/Browser-Datenverlust). Ohne gueltiges // Preview-Cookie laufen die *.project.webklar.com-Hosts in eine 302-Schleife. diff --git a/server/services/staffPreviewTokens.js b/server/services/staffPreviewTokens.js index 70645ed..888f2ef 100644 --- a/server/services/staffPreviewTokens.js +++ b/server/services/staffPreviewTokens.js @@ -20,11 +20,16 @@ export function createStaffPreviewToken(payload) { return token } -export function consumeStaffPreviewToken(token) { +/** + * Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin + * ('switch'); ein Token fuer den falschen Zweck ist trotzdem verbraucht. + */ +export function consumeStaffPreviewToken(token, purpose = 'preview') { purgeExpired() const entry = tokens.get(token) if (!entry) return null tokens.delete(token) if (entry.expiresAt <= Date.now()) return null + if ((entry.purpose || 'preview') !== purpose) return null return entry } diff --git a/test/staffSwitch.test.js b/test/staffSwitch.test.js new file mode 100644 index 0000000..a0ee2e1 --- /dev/null +++ b/test/staffSwitch.test.js @@ -0,0 +1,85 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Wechsel Ticketsystem -> Projekt-Admin per Einmal-Link (purpose 'switch'), Rueckweg-Button im Admin +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post, rawGet } = await import('./support/testApp.js') +const { default: authRoutes } = await import('../server/routes/auth.js') +const { default: switchLinkRoutes } = await import('../server/routes/admin/switchLink.js') +const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js') +const app = await startTestApp({ '/api/auth': authRoutes, '/api/admin/switch-link': switchLinkRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) + +const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } }) +const switchLink = (jwt) => post('/api/admin/switch-link', {}, jwt ? bearer(jwt) : {}) +const tokenOf = (openUrl) => new URL(openUrl).searchParams.get('token') +const portalSession = (res) => { + const cookie = res.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`)) + if (!cookie) return null + const raw = decodeURIComponent(cookie.split(';')[0].slice(config.cookieName.length + 1)) + return JSON.parse(raw.slice(2, raw.lastIndexOf('.'))) +} + +test('switch-link: ohne, ungueltiges und halbes JWT -> 401', async () => { + assert.equal((await switchLink(null)).status, 401) + assert.equal((await switchLink('jwt-falsch')).status, 401) + assert.equal((await switchLink('jwt-halb')).status, 401) +}) + +test('switch-link: kein Mitarbeiter -> 403', async () => { + assert.equal((await switchLink('jwt-kunde')).status, 403) +}) + +test('switch-link: Mitarbeiter ohne Admin-Label bekommt einen Link', async () => { + const res = await switchLink('jwt-staff') + assert.equal(res.status, 200) + assert.match(res.body.openUrl, /^https:\/\/project\.webklar\.com\/api\/auth\/staff-switch\?token=[0-9a-f]{64}$/) +}) + +test('staff-switch: gueltig -> Admin-Sitzung und /admin.html, zweites Mal -> Login', async () => { + const { body } = await switchLink('jwt-staff') + const path = `/api/auth/staff-switch?token=${tokenOf(body.openUrl)}` + const res = await rawGet(path) + assert.equal(res.status, 302) + assert.equal(res.headers.get('location'), '/admin.html') + const session = portalSession(res) + assert.equal(session.role, 'admin') + assert.equal(session.isAdmin, false) + assert.equal(session.appwriteUserId, 'u_staff2') + const again = await rawGet(path) + assert.equal(again.status, 302) + assert.equal(again.headers.get('location'), '/login.html?error=Link%20abgelaufen%2C%20bitte%20anmelden') + assert.equal(portalSession(again), null) +}) + +test('staff-switch: nach 61 Sekunden abgelaufen', async (t) => { + const { body } = await switchLink('jwt-staff') + const now = Date.now() + t.mock.method(Date, 'now', () => now + 61 * 1000) + const res = await rawGet(`/api/auth/staff-switch?token=${tokenOf(body.openUrl)}`) + assert.match(res.headers.get('location'), /^\/login\.html\?error=/) +}) + +test('Tokens nicht vertauschbar: Switch ueber staff-preview, Preview ueber staff-switch', async () => { + const { body } = await switchLink('jwt-staff') + assert.equal((await rawGet(`/api/auth/staff-preview?token=${tokenOf(body.openUrl)}`)).status, 410) + const previewToken = createStaffPreviewToken({ userId: 'u_admin', email: 'kenso@webklar.com', name: 'Kenso', previewUrl: 'https://x.project.webklar.com/' }) + const res = await rawGet(`/api/auth/staff-switch?token=${previewToken}`) + assert.match(res.headers.get('location'), /^\/login\.html\?error=/) + assert.equal(portalSession(res), null) +}) + +test('admin.html: Rueckweg ins Ticketsystem in der Kopfzeile', () => { + const html = readFileSync(new URL('../public/admin.html', import.meta.url), 'utf8') + const actions = html.slice(html.indexOf('portal-header-actions'), html.indexOf('id="logout-btn"')) + assert.match(actions, /← Ticketsystem<\/a>/) +})