Projekte: alle Mitarbeiter, private Repos, Archiv fuer Kunden ausgeblendet
Entscheidungen vom 04.10.2026: - Projekt-Routen (website-projects, preview, gitea, services, analyse) fuer jeden Mitarbeiter (Label mitarbeiter oder admin); Kunden-, Mitarbeiter- und Finanz-Verwaltung bleiben nur fuer Admins - Neue Repos aus der Vorlage werden privat angelegt - Archivierte Projekte sieht der Kunde nicht mehr: Projektliste, Projektdetails, Preview-Zugang, Funktionen (Mitarbeiter-Ansicht schon) - Projektliste des Kunden mit Limit (Appwrite liefert sonst nur 25) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
800630ea4b
commit
3156bb790a
14 files changed
+123
-22
No files matched your search
@@ -1,6 +1,20 @@
|
|||||||
import { config } from '../config.js'
|
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 authHeader = req.get('Authorization') || ''
|
||||||
const match = authHeader.match(/^Bearer\s+(.+)$/i)
|
const match = authHeader.match(/^Bearer\s+(.+)$/i)
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -32,8 +46,8 @@ export async function requireAppwriteStaff(req, res, next) {
|
|||||||
return res.status(401).json({ error: data?.message || 'Ungültiges JWT' })
|
return res.status(401).json({ error: data?.message || 'Ungültiges JWT' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) {
|
if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) {
|
||||||
return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' })
|
return res.status(403).json({ error: deniedMessage })
|
||||||
}
|
}
|
||||||
|
|
||||||
req.appwriteUser = data
|
req.appwriteUser = data
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { getDocument } from '../../services/appwriteAdmin.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 { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
|
||||||
import { getFeatureDoc } from '../../services/siteFeatures.js'
|
import { getFeatureDoc } from '../../services/siteFeatures.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(requireAppwriteStaff)
|
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||||
|
router.use(requireAppwriteTeam)
|
||||||
|
|
||||||
function sendError(res, err) {
|
function sendError(res, err) {
|
||||||
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
|
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function ticketExists(ticketId) {
|
|||||||
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
|
// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet
|
||||||
async function adminView(customerId) {
|
async function adminView(customerId) {
|
||||||
const docs = await listCustomerFeatureDocs(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 p of projects) {
|
||||||
for (const key of FEATURE_KEYS) {
|
for (const key of FEATURE_KEYS) {
|
||||||
const view = p.features[key]
|
const view = p.features[key]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
|
import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js'
|
||||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.use(requireAppwriteStaff)
|
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||||
|
router.use(requireAppwriteTeam)
|
||||||
|
|
||||||
router.post('/sync-repos', async (_req, res) => {
|
router.post('/sync-repos', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.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 { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||||||
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||||||
import * as runnerClient from '../../services/previewRunnerClient.js'
|
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||||||
@@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js'
|
|||||||
import { repoHasDevScript } from '../../services/previewDevScript.js'
|
import { repoHasDevScript } from '../../services/previewDevScript.js'
|
||||||
|
|
||||||
const router = Router()
|
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 BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
||||||
const MODES = ['auto', 'build', 'dev', 'off']
|
const MODES = ['auto', 'build', 'dev', 'off']
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { Router } from 'express'
|
|||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
import { listDocuments, Query } from '../../services/appwriteAdmin.js'
|
||||||
import { servicesCatalog } from '../../services/projectKinds.js'
|
import { servicesCatalog } from '../../services/projectKinds.js'
|
||||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.use(requireAppwriteStaff)
|
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||||
|
router.use(requireAppwriteTeam)
|
||||||
|
|
||||||
const CHUNK = 100
|
const CHUNK = 100
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
} from '../../services/giteaAdmin.js'
|
} from '../../services/giteaAdmin.js'
|
||||||
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
||||||
import { completeAssignment } from '../../services/projectKindAssign.js'
|
import { completeAssignment } from '../../services/projectKindAssign.js'
|
||||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
router.use(requireAppwriteStaff)
|
// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins)
|
||||||
|
router.use(requireAppwriteTeam)
|
||||||
|
|
||||||
router.post('/create-from-template', async (req, res) => {
|
router.post('/create-from-template', async (req, res) => {
|
||||||
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {}
|
const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {}
|
||||||
|
|||||||
@@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => {
|
|||||||
|
|
||||||
const projects = await listDocuments(config.collections.websiteProjects, queries)
|
const projects = await listDocuments(config.collections.websiteProjects, queries)
|
||||||
const project = projects[0]
|
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' })
|
return res.json({ allowed: false, reason: 'no_project' })
|
||||||
}
|
}
|
||||||
if (!isPreviewProjectReady(project)) {
|
if (!isPreviewProjectReady(project)) {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const router = Router()
|
|||||||
async function loadOwnProject(req, res, next) {
|
async function loadOwnProject(req, res, next) {
|
||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null)
|
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' })
|
return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||||
}
|
}
|
||||||
req.project = project
|
req.project = project
|
||||||
@@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => {
|
|||||||
const projects = await listDocuments(config.collections.websiteProjects, [
|
const projects = await listDocuments(config.collections.websiteProjects, [
|
||||||
Query.equal('customerId', customerId),
|
Query.equal('customerId', customerId),
|
||||||
Query.orderDesc('$createdAt'),
|
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,
|
id: p.$id,
|
||||||
projectName: p.projectName || '',
|
projectName: p.projectName || '',
|
||||||
subdomain: p.subdomain || '',
|
subdomain: p.subdomain || '',
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ export async function generateRepoFromTemplate({ owner, repoName, description })
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
owner: targetOwner,
|
owner: targetOwner,
|
||||||
name: repoName,
|
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)
|
// Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort)
|
||||||
description: description || `Website-Projekt ${repoName}`,
|
description: description || `Website-Projekt ${repoName}`,
|
||||||
git_content: true,
|
git_content: true,
|
||||||
|
|||||||
@@ -129,13 +129,16 @@ export function listCustomerFeatureDocs(customerId) {
|
|||||||
return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)])
|
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 } = {}) {
|
* docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden.
|
||||||
const projects = await listDocuments(config.collections.websiteProjects, [
|
* 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.equal('customerId', customerId),
|
||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
Query.limit(1000),
|
Query.limit(1000),
|
||||||
])
|
])).filter((p) => includeArchived || !p.archived)
|
||||||
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
|
const docs = geladen ?? (await listCustomerFeatureDocs(customerId))
|
||||||
const result = []
|
const result = []
|
||||||
for (const p of projects) {
|
for (const p of projects) {
|
||||||
|
|||||||
@@ -67,3 +67,14 @@ test('Route: anderer Kunde bleibt ausgesperrt', async () => {
|
|||||||
assert.equal(r.allowed, false)
|
assert.equal(r.allowed, false)
|
||||||
assert.equal(r.reason, 'no_project')
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
// Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort)
|
||||||
const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate'))
|
const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate'))
|
||||||
assert.equal(gen.body.description, 'Zweites ohne Website')
|
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 () => {
|
test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => {
|
||||||
|
|||||||
@@ -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']
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user