diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index d9ca95e..b9382a4 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -1,6 +1,20 @@ import { config } from '../config.js' +import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js' -export async function requireAppwriteStaff(req, res, next) { +/** + * Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`. + * requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...). + * requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen + * (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04. + */ +function requireAppwriteLabels(allowed, deniedMessage) { + return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage) +} + +export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich') +export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter') + +async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) { const authHeader = req.get('Authorization') || '' const match = authHeader.match(/^Bearer\s+(.+)$/i) if (!match) { @@ -32,8 +46,8 @@ export async function requireAppwriteStaff(req, res, next) { return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } - if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { - return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) { + return res.status(403).json({ error: deniedMessage }) } req.appwriteUser = data diff --git a/server/routes/admin/analyse.js b/server/routes/admin/analyse.js index f9f2e47..b1cec8e 100644 --- a/server/routes/admin/analyse.js +++ b/server/routes/admin/analyse.js @@ -2,12 +2,13 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js' import { getFeatureDoc } from '../../services/siteFeatures.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) function sendError(res, err) { const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 065145e..6125e5d 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -49,7 +49,7 @@ async function ticketExists(ticketId) { // Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) - const projects = await listCustomerFeatures(customerId, { docs }) + const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true }) for (const p of projects) { for (const key of FEATURE_KEYS) { const view = p.features[key] diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index efecce8..ad9b83c 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,10 +1,11 @@ import { Router } from 'express' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/sync-repos', async (_req, res) => { try { diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 3f135f5..ded1461 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -2,7 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' @@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const BUSY = 'Server ausgelastet, versuche es gleich noch mal' const MODES = ['auto', 'build', 'dev', 'off'] diff --git a/server/routes/admin/services.js b/server/routes/admin/services.js index b9dc194..21bdcc7 100644 --- a/server/routes/admin/services.js +++ b/server/routes/admin/services.js @@ -2,11 +2,12 @@ import { Router } from 'express' import { config } from '../../config.js' import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { servicesCatalog } from '../../services/projectKinds.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const CHUNK = 100 diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index f55aa04..c119c98 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -6,11 +6,12 @@ import { } from '../../services/giteaAdmin.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { completeAssignment } from '../../services/projectKindAssign.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/create-from-template', async (req, res) => { const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} diff --git a/server/routes/auth.js b/server/routes/auth.js index 578dcf4..6ab0708 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, queries) const project = projects[0] - if (!project) { + // Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus) + if (!project || (project.archived && !admin)) { return res.json({ allowed: false, reason: 'no_project' }) } if (!isPreviewProjectReady(project)) { diff --git a/server/routes/projects.js b/server/routes/projects.js index 093d24a..70697e6 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -14,7 +14,8 @@ const router = Router() async function loadOwnProject(req, res, next) { const customerId = getSessionCustomerId(req) const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) - if (!project || !customerId || project.customerId !== customerId) { + // Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste) + if (!project || !customerId || project.customerId !== customerId || project.archived) { return res.status(404).json({ error: 'Projekt nicht gefunden' }) } req.project = project @@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), + Query.limit(500), // ohne Limit liefert Appwrite nur 25 ]) - const sanitized = projects.map((p) => ({ + // Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04) + const sanitized = projects.filter((p) => !p.archived).map((p) => ({ id: p.$id, projectName: p.projectName || '', subdomain: p.subdomain || '', diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 0506862..c0c75a4 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -218,7 +218,9 @@ export async function generateRepoFromTemplate({ owner, repoName, description }) body: JSON.stringify({ owner: targetOwner, name: repoName, - private: false, + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner + // lesen Gitea mit dem System-Schluessel, Mitarbeiter sehen Org-Repos ueber das Team Owners. + private: true, // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) description: description || `Website-Projekt ${repoName}`, git_content: true, diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 85350dd..27f4ec9 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -129,13 +129,16 @@ export function listCustomerFeatureDocs(customerId) { return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) } -/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */ -export async function listCustomerFeatures(customerId, { docs: geladen } = {}) { - const projects = await listDocuments(config.collections.websiteProjects, [ +/** + * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. + * includeArchived: nur fuer die Mitarbeiter-Ansicht - der Kunde sieht archivierte Projekte nicht. + */ +export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { + const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), - ]) + ])).filter((p) => includeArchived || !p.archived) const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) const result = [] for (const p of projects) { diff --git a/test/previewReadiness.test.js b/test/previewReadiness.test.js index 949ad8c..d4414e7 100644 --- a/test/previewReadiness.test.js +++ b/test/previewReadiness.test.js @@ -67,3 +67,14 @@ test('Route: anderer Kunde bleibt ausgesperrt', async () => { assert.equal(r.allowed, false) assert.equal(r.reason, 'no_project') }) +test('Route: archiviertes Projekt ist fuer den Kunden gesperrt (Entscheidung 2026-10-04)', async () => { + const doc = aw.all('websiteProjects').find((d) => d.$id === 'p1') + doc.archived = true + try { + const r = await access(token.c1) + assert.equal(r.allowed, false) + assert.equal(r.reason, 'no_project') + } finally { + doc.archived = false + } +}) diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js index a12032e..ad9c7ba 100644 --- a/test/projektAblauf.test.js +++ b/test/projektAblauf.test.js @@ -76,6 +76,8 @@ test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt d // Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort) const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate')) assert.equal(gen.body.description, 'Zweites ohne Website') + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04) + assert.equal(gen.body.private, true) }) test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => { diff --git a/test/rechtePrivatArchiv.test.js b/test/rechtePrivatArchiv.test.js new file mode 100644 index 0000000..df01400 --- /dev/null +++ b/test/rechtePrivatArchiv.test.js @@ -0,0 +1,60 @@ +// Entscheidungen vom 04.10.2026: Projekt-Aktionen fuer alle Mitarbeiter, archivierte Projekte im +// Kundenportal ausgeblendet. (Private Repos: siehe projektAblauf.test.js, generate-Aufruf.) +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, rawGet, get, sessionCookie } = await import('./support/testApp.js') +const { default: projectsRouter } = await import('../server/routes/projects.js') +const { default: servicesRouter } = await import('../server/routes/admin/services.js') +const { default: customersRouter } = await import('../server/routes/admin/customers.js') +const app = await startTestApp({ + '/api/projects': projectsRouter, + '/api/admin/services': servicesRouter, + '/api/admin/customers': customersRouter, +}) +after(() => Promise.all([aw.close(), st.close(), app.close()])) + +aw.seed('websiteProjects', { $id: 'p_aktiv', customerId: 'c1', projectName: 'Aktiv', subdomain: 'aktiv', status: 'deployed' }) +aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', subdomain: 'alt', status: 'archived', archived: true }) +const kunde = { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) } +const bearer = (jwt) => ({ headers: { Authorization: `Bearer ${jwt}` } }) + +test('Kundenportal: archivierte Projekte stehen nicht in der Liste', async () => { + const { projects } = await get('/api/projects', kunde) + assert.deepEqual(projects.map((p) => p.id), ['p_aktiv']) +}) + +test('Kundenportal: archiviertes Projekt auch per Direktaufruf nicht erreichbar', async () => { + assert.equal((await rawGet('/api/projects/p_archiv/work', kunde)).status, 404) + assert.equal((await rawGet('/api/projects/p_aktiv/work', kunde)).status, 200) +}) + +test('Projekt-Routen: Mitarbeiter ohne Admin-Label kommen rein, Kunden-Konto nicht', async () => { + assert.equal((await rawGet('/api/admin/services', bearer('jwt-staff'))).status, 200) + assert.equal((await rawGet('/api/admin/services', bearer('jwt-admin'))).status, 200) + const kundeKonto = await rawGet('/api/admin/services', bearer('jwt-kunde')) + assert.equal(kundeKonto.status, 403) + assert.equal(kundeKonto.body.error, 'Nur für Mitarbeiter') + assert.equal((await rawGet('/api/admin/services')).status, 401) +}) + +test('Kunden-Verwaltung bleibt nur fuer Admins', async () => { + const r = await rawGet('/api/admin/customers', bearer('jwt-staff')) + assert.equal(r.status, 403) + assert.equal(r.body.error, 'Admin-Berechtigung erforderlich') +}) + +test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansicht schon', async () => { + const { listCustomerFeatures } = await import('../server/services/siteFeatures.js') + assert.deepEqual((await listCustomerFeatures('c1', { docs: [] })).map((p) => p.projectId), ['p_aktiv']) + assert.deepEqual( + (await listCustomerFeatures('c1', { docs: [], includeArchived: true })).map((p) => p.projectId).sort(), + ['p_aktiv', 'p_archiv'] + ) +})