Mitarbeiter nur noch per Label: Kunden-Konten bekommen keinen Team-Zugang
Kunden haben Konten im selben Appwrite-Projekt wie das Ticketsystem. Bisher galt als Mitarbeiter, wer einen employees-Eintrag hatte - den legte das Ticketsystem beim Login jedes Kontos automatisch an, und jedes angemeldete Konto konnte ihn auch selbst anlegen. Damit kam ein Kunde ueber /api/team an Chats, Kundenansicht, Angebote und Finanzen. - staffLabels.js: Mitarbeiter = Label "mitarbeiter" oder "admin" (setzt nur der Server-Key) - Team-Eingang (requireStaffJwt) und Portal-Login-Sperre nur noch per Label; kein Abgleich ueber employees-Eintrag oder Mail mehr - Portal-Login: Konto-Abruf gestoert -> 503 fuer alle (fail closed) - Mitarbeiter anlegen/bearbeiten setzt das Label "mitarbeiter" - scripts/mitarbeiter-zugang.mjs: --status (markiert employees-Eintraege von Kunden), --label, --eintrag-loeschen (nur Eintrag, nie das Konto), --rechte (Probelauf, --anwenden mit Sicherung, --zurueck): any/guests/users -> label:mitarbeiter + label:admin in Collections, Buckets und bei Dokument-/ Datei-Sicherheit auch einzelnen Dokumenten und Dateien Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be7f9b1aa3
commit
2659385205
12 files changed
+602
-65
No files matched your search
@@ -68,6 +68,11 @@ async function adminFetch(path, { method = 'GET', body, queries = [], raw = fals
|
||||
return data
|
||||
}
|
||||
|
||||
/** Roher Server-Key-Aufruf (Body unverpackt), z. B. fuer Collection- und Bucket-Rechte (staffAccessAdmin.js). */
|
||||
export async function appwriteAdminRequest(path, options = {}) {
|
||||
return adminFetch(path, { ...options, raw: true })
|
||||
}
|
||||
|
||||
/** Appwrite Query-Helper (kompatibel zu bisherigen Aufrufen) */
|
||||
export const Query = {
|
||||
equal: (attribute, value) => ({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateDocument,
|
||||
Query,
|
||||
} from './appwriteAdmin.js'
|
||||
import { ADMIN_LABEL, STAFF_LABEL, isAdminAccount } from './staffLabels.js'
|
||||
|
||||
function httpError(status, message) {
|
||||
const error = new Error(message)
|
||||
@@ -36,7 +37,7 @@ function sanitizeEmployee(employee, user) {
|
||||
displayName: employee.displayName || user?.name || '',
|
||||
email: employee.email || user?.email || '',
|
||||
shortcode: employee.shortcode || '',
|
||||
isAdmin: Boolean(user?.labels?.includes('admin')),
|
||||
isAdmin: isAdminAccount(user),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +46,14 @@ async function employeeWithUser(employee) {
|
||||
return sanitizeEmployee(employee, user)
|
||||
}
|
||||
|
||||
async function setAdminLabel(userId, isAdmin) {
|
||||
// Jeder Mitarbeiter traegt das Label "mitarbeiter" (Zugang zum Ticketsystem, siehe staffLabels.js);
|
||||
// isAdmin undefined laesst "admin" unveraendert
|
||||
async function setStaffLabels(userId, isAdmin) {
|
||||
const user = await getUserById(userId)
|
||||
const labels = new Set(user.labels || [])
|
||||
if (isAdmin) labels.add('admin')
|
||||
else labels.delete('admin')
|
||||
labels.add(STAFF_LABEL)
|
||||
if (isAdmin === true) labels.add(ADMIN_LABEL)
|
||||
if (isAdmin === false) labels.delete(ADMIN_LABEL)
|
||||
await updateAppwriteUserLabels(userId, [...labels])
|
||||
}
|
||||
|
||||
@@ -77,7 +81,7 @@ export async function createEmployeeWithLogin(payload) {
|
||||
const user = await createAppwriteUser({ email, password, name: displayName })
|
||||
let employee
|
||||
try {
|
||||
await setAdminLabel(user.$id, isAdmin)
|
||||
await setStaffLabels(user.$id, isAdmin)
|
||||
employee = await createDocument(config.collections.employees, {
|
||||
userId: user.$id,
|
||||
displayName,
|
||||
@@ -131,9 +135,7 @@ export async function updateEmployeeWithLogin(employeeId, payload, actorUserId)
|
||||
if (password) {
|
||||
await updateAppwriteUserPassword(employee.userId, password)
|
||||
}
|
||||
if (payload?.isAdmin !== undefined) {
|
||||
await setAdminLabel(employee.userId, Boolean(payload.isAdmin))
|
||||
}
|
||||
await setStaffLabels(employee.userId, payload?.isAdmin === undefined ? undefined : Boolean(payload.isAdmin))
|
||||
|
||||
const updated = await updateDocument(config.collections.employees, employeeId, {
|
||||
displayName,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { config } from '../config.js'
|
||||
import { appwriteAdminRequest, Query } from './appwriteAdmin.js'
|
||||
import { STAFF_LABEL, STAFF_LABELS, isStaffAccount } from './staffLabels.js'
|
||||
|
||||
/**
|
||||
* Zugang zum Ticketsystem nur fuer Mitarbeiter (scripts/mitarbeiter-zugang.mjs). Kunden haben Konten im selben
|
||||
* Appwrite-Projekt; Rechte fuer "users"/"any"/"guests" galten deshalb auch fuer jeden Kunden. Danach gehoeren
|
||||
* diese Rechte nur noch den Labels "mitarbeiter" und "admin" (staffLabels.js).
|
||||
*/
|
||||
|
||||
const OPEN_ROLES = new Set(['any', 'guests', 'users', 'users/verified', 'users/unverified'])
|
||||
const PERMISSION = /^(read|create|update|delete|write)\("([^"]+)"\)$/
|
||||
const PAGE = 100
|
||||
|
||||
/** Neue Rechteliste: jede Freigabe fuer any/guests/users geht an die Mitarbeiter-Labels, andere Rollen bleiben. */
|
||||
export function staffOnlyPermissions(permissions = []) {
|
||||
const result = []
|
||||
for (const permission of permissions) {
|
||||
const match = String(permission).match(PERMISSION)
|
||||
if (match && OPEN_ROLES.has(match[2])) {
|
||||
for (const label of STAFF_LABELS) result.push(`${match[1]}("label:${label}")`)
|
||||
} else {
|
||||
result.push(permission)
|
||||
}
|
||||
}
|
||||
return [...new Set(result)]
|
||||
}
|
||||
|
||||
const samePermissions = (a = [], b = []) => a.length === b.length && a.every((p, i) => p === b[i])
|
||||
|
||||
async function listAll(request, path, key) {
|
||||
const all = []
|
||||
let cursor = null
|
||||
for (;;) {
|
||||
const queries = [Query.limit(PAGE), ...(cursor ? [Query.cursorAfter(cursor)] : [])]
|
||||
const page = (await request(path, { queries }))[key] || []
|
||||
all.push(...page)
|
||||
if (page.length < PAGE) return all
|
||||
cursor = page.at(-1).$id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitarbeiter-Status: jeder employees-Eintrag mit seinem Konto, dazu Konten mit Mitarbeiter-Label ohne Eintrag.
|
||||
* customer = das Konto gehoert zu einem Kunden (customers.appwriteUserId) - ein solcher employees-Eintrag ist beim
|
||||
* Login eines Kunden ins Ticketsystem automatisch entstanden und muss weg.
|
||||
*/
|
||||
export async function staffAccessStatus(request = appwriteAdminRequest) {
|
||||
const db = `/databases/${config.appwrite.databaseId}/collections`
|
||||
const [users, employees, customers] = await Promise.all([
|
||||
listAll(request, '/users', 'users'),
|
||||
listAll(request, `${db}/${config.collections.employees}/documents`, 'documents'),
|
||||
listAll(request, `${db}/${config.collections.customers}/documents`, 'documents'),
|
||||
])
|
||||
const userById = new Map(users.map((u) => [u.$id, u]))
|
||||
const customerByUser = new Map(customers.filter((c) => c.appwriteUserId).map((c) => [c.appwriteUserId, c]))
|
||||
|
||||
const rows = employees.map((employee) => {
|
||||
const user = userById.get(employee.userId) || null
|
||||
const customer = customerByUser.get(employee.userId) || null
|
||||
return {
|
||||
employeeId: employee.$id,
|
||||
userId: employee.userId || '',
|
||||
name: employee.displayName || user?.name || '',
|
||||
email: user?.email || employee.email || '',
|
||||
labels: user?.labels || [],
|
||||
staff: isStaffAccount(user),
|
||||
customer: customer ? customer.name || customer.$id : '',
|
||||
login: Boolean(user),
|
||||
}
|
||||
})
|
||||
const withEntry = new Set(employees.map((e) => e.userId))
|
||||
const labelOnly = users
|
||||
.filter((u) => isStaffAccount(u) && !withEntry.has(u.$id))
|
||||
.map((u) => ({ userId: u.$id, name: u.name || '', email: u.email || '', labels: u.labels || [] }))
|
||||
return { rows, labelOnly }
|
||||
}
|
||||
|
||||
/** Label "mitarbeiter" an ein Konto haengen (vorhandene Labels bleiben). */
|
||||
export async function grantStaffLabel(userId, request = appwriteAdminRequest) {
|
||||
const user = await request(`/users/${userId}`)
|
||||
const labels = new Set(user.labels || [])
|
||||
labels.add(STAFF_LABEL)
|
||||
await request(`/users/${userId}/labels`, { method: 'PUT', body: { labels: [...labels] } })
|
||||
return { userId, name: user.name || '', email: user.email || '', labels: [...labels] }
|
||||
}
|
||||
|
||||
/**
|
||||
* employees-Eintrag eines Kunden loeschen - nur den Eintrag, nie das Konto (das ist der Portal-Login des Kunden).
|
||||
* Verweigert bei einem Konto mit Mitarbeiter-Label.
|
||||
*/
|
||||
export async function removeEmployeeEntry(employeeId, request = appwriteAdminRequest) {
|
||||
const path = `/databases/${config.appwrite.databaseId}/collections/${config.collections.employees}/documents/${employeeId}`
|
||||
const employee = await request(path)
|
||||
const user = employee.userId ? await request(`/users/${employee.userId}`).catch(() => null) : null
|
||||
if (isStaffAccount(user)) throw new Error(`${employee.displayName || employeeId} hat das Mitarbeiter-Label - Eintrag bleibt.`)
|
||||
await request(path, { method: 'DELETE' })
|
||||
return { employeeId, name: employee.displayName || '', userId: employee.userId || '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Stellen mit offenen Rechten: Collections und Buckets, bei Dokument- bzw. Datei-Sicherheit auch einzelne
|
||||
* Dokumente und Dateien. Jede Aenderung traegt before/after, damit sie sich zuruecknehmen laesst.
|
||||
*/
|
||||
export async function planPermissionChanges(request = appwriteAdminRequest) {
|
||||
const changes = []
|
||||
const add = (change, before) => {
|
||||
const after = staffOnlyPermissions(before)
|
||||
if (!samePermissions(before, after)) changes.push({ ...change, before, after })
|
||||
}
|
||||
|
||||
for (const database of await listAll(request, '/databases', 'databases')) {
|
||||
const collectionsPath = `/databases/${database.$id}/collections`
|
||||
for (const collection of await listAll(request, collectionsPath, 'collections')) {
|
||||
add(
|
||||
{
|
||||
kind: 'collection',
|
||||
databaseId: database.$id,
|
||||
id: collection.$id,
|
||||
name: collection.name,
|
||||
settings: { name: collection.name, documentSecurity: collection.documentSecurity, enabled: collection.enabled },
|
||||
},
|
||||
collection.$permissions || []
|
||||
)
|
||||
if (!collection.documentSecurity) continue
|
||||
for (const document of await listAll(request, `${collectionsPath}/${collection.$id}/documents`, 'documents')) {
|
||||
add({ kind: 'document', databaseId: database.$id, collectionId: collection.$id, id: document.$id }, document.$permissions || [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of await listAll(request, '/storage/buckets', 'buckets')) {
|
||||
add(
|
||||
{
|
||||
kind: 'bucket',
|
||||
id: bucket.$id,
|
||||
name: bucket.name,
|
||||
settings: {
|
||||
name: bucket.name,
|
||||
fileSecurity: bucket.fileSecurity,
|
||||
enabled: bucket.enabled,
|
||||
maximumFileSize: bucket.maximumFileSize,
|
||||
allowedFileExtensions: bucket.allowedFileExtensions,
|
||||
compression: bucket.compression,
|
||||
encryption: bucket.encryption,
|
||||
antivirus: bucket.antivirus,
|
||||
},
|
||||
},
|
||||
bucket.$permissions || []
|
||||
)
|
||||
if (!bucket.fileSecurity) continue
|
||||
for (const file of await listAll(request, `/storage/buckets/${bucket.$id}/files`, 'files')) {
|
||||
add({ kind: 'file', bucketId: bucket.$id, id: file.$id }, file.$permissions || [])
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function writeChange(change, permissions, request) {
|
||||
if (change.kind === 'collection') {
|
||||
return request(`/databases/${change.databaseId}/collections/${change.id}`, {
|
||||
method: 'PUT',
|
||||
body: { ...change.settings, permissions },
|
||||
})
|
||||
}
|
||||
if (change.kind === 'document') {
|
||||
return request(`/databases/${change.databaseId}/collections/${change.collectionId}/documents/${change.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { permissions },
|
||||
})
|
||||
}
|
||||
if (change.kind === 'bucket') {
|
||||
return request(`/storage/buckets/${change.id}`, { method: 'PUT', body: { ...change.settings, permissions } })
|
||||
}
|
||||
if (change.kind === 'file') {
|
||||
return request(`/storage/buckets/${change.bucketId}/files/${change.id}`, { method: 'PUT', body: { permissions } })
|
||||
}
|
||||
throw new Error(`Unbekannte Art ${change.kind}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aenderungen schreiben (direction 'after') oder zuruecknehmen ('before'). Ein Fehler stoppt nichts: jede Stelle
|
||||
* meldet ok oder error (z. B. fehlender Key-Scope fuer Buckets).
|
||||
*/
|
||||
export async function applyPermissionChanges(changes, { direction = 'after', request = appwriteAdminRequest } = {}) {
|
||||
const results = []
|
||||
for (const change of changes) {
|
||||
try {
|
||||
await writeChange(change, change[direction], request)
|
||||
results.push({ change, ok: true })
|
||||
} catch (err) {
|
||||
results.push({ change, ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getEmployeeByEmail, getEmployeeByUserId } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Ticketsystem-Mitarbeiter (employees-Collection) oder Appwrite-Label "admin" -> Admin-Ansicht im Portal.
|
||||
* isAdmin = Label "admin"; null = kein Mitarbeiter.
|
||||
*/
|
||||
export async function resolveStaffAccount(appwriteUserId, email, account) {
|
||||
const isAdmin = Array.isArray(account?.labels) && account.labels.includes('admin')
|
||||
|
||||
let employee = await getEmployeeByUserId(appwriteUserId).catch(() => null)
|
||||
if (!employee) {
|
||||
employee = await getEmployeeByEmail(account?.email || email).catch(() => null)
|
||||
}
|
||||
|
||||
if (!employee && !isAdmin) return null
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
name: employee?.displayName || account?.name || 'Mitarbeiter',
|
||||
email: account?.email || employee?.email || email,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Wer ist Mitarbeiter? Nur das Appwrite-Label "mitarbeiter" (oder "admin") am Konto. Labels setzt allein der
|
||||
* Server-Key - anders als ein employees-Eintrag, den jedes angemeldete Konto (auch ein Kunde) selbst anlegen
|
||||
* konnte. Dieselben Labels stehen in den Appwrite-Rechten der Ticketsystem-Collections
|
||||
* (scripts/mitarbeiter-zugang.mjs) und im Login des Ticketsystems.
|
||||
*/
|
||||
export const STAFF_LABEL = 'mitarbeiter'
|
||||
export const ADMIN_LABEL = 'admin'
|
||||
export const STAFF_LABELS = [STAFF_LABEL, ADMIN_LABEL]
|
||||
|
||||
function labelsOf(account) {
|
||||
return Array.isArray(account?.labels) ? account.labels : []
|
||||
}
|
||||
|
||||
export function isStaffAccount(account) {
|
||||
const labels = labelsOf(account)
|
||||
return STAFF_LABELS.some((label) => labels.includes(label))
|
||||
}
|
||||
|
||||
export function isAdminAccount(account) {
|
||||
return labelsOf(account).includes(ADMIN_LABEL)
|
||||
}
|
||||
Reference in new issue
Block a user