diff --git a/server/api/birthdays.get.js b/server/api/birthdays.get.js index 657bc2d..2b7b45b 100644 --- a/server/api/birthdays.get.js +++ b/server/api/birthdays.get.js @@ -1,5 +1,5 @@ import { readMembers } from '../utils/members.js' -import { readUsers, getUserFromToken, verifyToken } from '../utils/auth.js' +import { readUsers, getUserFromToken, verifyToken, isHiddenUser, normalizeUserEmail } from '../utils/auth.js' // Helper: returns array of upcoming birthdays within daysAhead (inclusive) function getUpcomingBirthdays(entries, daysAhead = 28) { @@ -53,10 +53,14 @@ export default defineEventHandler(async (event) => { const manualMembers = await readMembers() const registeredUsers = await readUsers() + const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean)) + // Build unified list of candidates with geburtsdatum and visibility const candidates = [] for (const m of manualMembers) { + const memberEmail = normalizeUserEmail(m.email) + if (m.hidden === true || m.invisible === true || m.isHidden === true || hiddenUserEmails.has(memberEmail)) continue const normalizedStatus = m.status ? String(m.status).toLowerCase() : '' const hasExplicitAcceptanceFlag = m.active !== undefined || m.accepted !== undefined || normalizedStatus !== '' const isAccepted = hasExplicitAcceptanceFlag @@ -73,7 +77,7 @@ export default defineEventHandler(async (event) => { } for (const u of registeredUsers) { - if (!u.active) continue + if (!u.active || isHiddenUser(u)) continue const vis = u.visibility || {} const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' }) diff --git a/server/api/cms/password-reset-diagnostics.get.js b/server/api/cms/password-reset-diagnostics.get.js index dbaa679..b50fc36 100644 --- a/server/api/cms/password-reset-diagnostics.get.js +++ b/server/api/cms/password-reset-diagnostics.get.js @@ -1,4 +1,4 @@ -import { getUserFromToken, hasRole, readUsers } from '../../utils/auth.js' +import { getUserFromToken, hasRole, readUsers, isHiddenUser } from '../../utils/auth.js' import { fingerprintResetEmail, normalizeResetEmail, @@ -59,17 +59,20 @@ export default defineEventHandler(async (event) => { const email = normalizeResetEmail(query.email) const failedOnly = query.failedOnly !== 'false' const users = await readUsers() + const visibleUsers = users.filter(user => !isHiddenUser(user)) + const hiddenEmailFingerprints = new Set(users.filter(isHiddenUser).map(user => fingerprintResetEmail(user.email)).filter(Boolean)) const logs = await readPasswordResetLogs() - const filteredLogs = email + const filteredLogs = (email ? logs.filter(entry => entry.emailFingerprint === fingerprintResetEmail(email)) - : logs + : logs) + .filter(entry => !hiddenEmailFingerprints.has(entry.emailFingerprint)) const attempts = summarizeAttempts(filteredLogs) .filter(attempt => !failedOnly || attempt.failed) let matchingUsers = [] if (email) { const term = email.toLowerCase() - matchingUsers = users + matchingUsers = visibleUsers .filter(user => { const userEmail = normalizeResetEmail(user.email) const name = String(user.name || '').toLowerCase() diff --git a/server/api/cms/users/list.get.js b/server/api/cms/users/list.get.js index a844064..79b0d61 100644 --- a/server/api/cms/users/list.get.js +++ b/server/api/cms/users/list.get.js @@ -1,4 +1,4 @@ -import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js' +import { getUserFromToken, readUsers, hasAnyRole, migrateUserRoles, isHiddenUser } from '../../../utils/auth.js' export default defineEventHandler(async (event) => { try { @@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => { // Nur Admin oder Vorstand duerfen vollen Benutzer-Contact und Rollen sehen. const canSeePrivate = hasAnyRole(currentUser, 'admin', 'vorstand') - const safeUsers = users.map(u => { + const safeUsers = users.filter(u => !isHiddenUser(u)).map(u => { const migrated = migrateUserRoles({ ...u }) const roles = Array.isArray(migrated.roles) ? migrated.roles : (migrated.role ? [migrated.role] : ['mitglied']) diff --git a/server/api/contact.post.js b/server/api/contact.post.js index 1eb1e3c..7bf2307 100644 --- a/server/api/contact.post.js +++ b/server/api/contact.post.js @@ -2,7 +2,7 @@ import nodemailer from 'nodemailer' import { promises as fs } from 'fs' import path from 'path' import { createContactRequest } from '../utils/contact-requests.js' -import { readUsers, migrateUserRoles } from '../utils/auth.js' +import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant ('config.json'), never user input @@ -54,6 +54,7 @@ async function collectRecipients(config) { try { const users = await readUsers() for (const rawUser of users) { + if (isHiddenUser(rawUser)) continue const user = migrateUserRoles({ ...rawUser }) const roles = Array.isArray(user.roles) ? user.roles : [] if (roles.includes('trainer') && user.email && String(user.email).trim()) { diff --git a/server/api/members.get.js b/server/api/members.get.js index a08e6c0..e588122 100644 --- a/server/api/members.get.js +++ b/server/api/members.get.js @@ -1,6 +1,6 @@ import { verifyToken, getUserFromToken, hasRole } from '../utils/auth.js' import { readMembers } from '../utils/members.js' -import { readUsers, migrateUserRoles } from '../utils/auth.js' +import { readUsers, migrateUserRoles, isHiddenUser, normalizeUserEmail } from '../utils/auth.js' export default defineEventHandler(async (event) => { try { @@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => { // Skip applications that are not yet accepted continue } - const normalizedEmail = member.email?.toLowerCase().trim() || '' + const normalizedEmail = normalizeUserEmail(member.email) const fullName = `${member.firstName || ''} ${member.lastName || ''}`.trim() const normalizedName = fullName.toLowerCase() @@ -90,11 +90,13 @@ export default defineEventHandler(async (event) => { } } + const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean)) + // Then add registered users (only active ones) for (const user of registeredUsers) { - if (!user.active) continue + if (!user.active || isHiddenUser(user)) continue - const normalizedEmail = user.email?.toLowerCase().trim() || '' + const normalizedEmail = normalizeUserEmail(user.email) const normalizedName = user.name?.toLowerCase().trim() || '' // Hilfsfunktion: Extrahiere Vorname/Nachname aus user.name @@ -208,7 +210,10 @@ export default defineEventHandler(async (event) => { const isPrivilegedViewer = currentUser ? hasRole(currentUser, 'vorstand') : false // Filtere den Admin-Account heraus - const filteredMembers = mergedMembers.filter(m => m.email?.toLowerCase() !== 'admin@harheimertc.de') + const filteredMembers = mergedMembers.filter(m => { + const email = normalizeUserEmail(m.email || m.loginEmail) + return email !== 'admin@harheimertc.de' && !hiddenUserEmails.has(email) && !m.hidden && !m.invisible && !m.isHidden + }) const sanitizedMembers = filteredMembers.map(member => { // Default: show email/phone/address to other logged-in members unless member.visibility explicitly hides them const visibility = member.visibility || {} diff --git a/server/api/mitgliederbereich/qttr.get.js b/server/api/mitgliederbereich/qttr.get.js index d944bc3..85ad60c 100644 --- a/server/api/mitgliederbereich/qttr.get.js +++ b/server/api/mitgliederbereich/qttr.get.js @@ -1,8 +1,7 @@ import { readFile } from 'fs/promises' import { getServerDataPath } from '../../utils/paths.js' -import { getUserFromToken, verifyToken } from '../../utils/auth.js' +import { getUserFromToken, verifyToken, readUsers, isHiddenUser, normalizeUserEmail } from '../../utils/auth.js' import { readMembers } from '../../utils/members.js' -import { readUsers } from '../../utils/auth.js' const QTTR_FILE = getServerDataPath('qttr-values.json') @@ -62,15 +61,27 @@ export default defineEventHandler(async (event) => { readMembers(), readUsers() ]) - const birthdateLookup = buildBirthdateLookup([...manualMembers, ...registeredUsers]) + const hiddenUserEmails = new Set(registeredUsers.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean)) + const visibleManualMembers = manualMembers.filter(member => { + const email = normalizeUserEmail(member.email) + return member.hidden !== true && member.invisible !== true && member.isHidden !== true && !hiddenUserEmails.has(email) + }) + const visibleUsers = registeredUsers.filter(user => !isHiddenUser(user)) + const hiddenNames = new Set([ + ...manualMembers.filter(member => member.hidden === true || member.invisible === true || member.isHidden === true || hiddenUserEmails.has(normalizeUserEmail(member.email))), + ...registeredUsers.filter(isHiddenUser) + ].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean)) + const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers]) return { ...payload, rows: Array.isArray(payload.rows) - ? payload.rows.map((row) => ({ - ...row, - birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' - })) + ? payload.rows + .filter(row => !hiddenNames.has(normalizeName(row.playerName))) + .map((row) => ({ + ...row, + birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' + })) : [] } } catch (error) { diff --git a/server/utils/auth.js b/server/utils/auth.js index d101886..c5fec68 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -27,6 +27,28 @@ export function migrateUserRoles(user) { return user } + +export function normalizeUserEmail(email) { + return String(email || '').trim().toLowerCase() +} + +function configuredHiddenUserEmails() { + return [process.env.PLAYSTORE_REVIEW_EMAIL, process.env.HIDDEN_USER_EMAILS] + .filter(Boolean) + .flatMap(value => String(value).split(',')) + .map(normalizeUserEmail) + .filter(Boolean) +} + +export function isHiddenUser(user) { + if (!user) return false + if (user.hidden === true || user.invisible === true || user.isHidden === true || user.systemAccount === true) return true + if (String(user.accountType || '').toLowerCase() === 'playstore_review') return true + + const email = normalizeUserEmail(user.email) + return email ? configuredHiddenUserEmails().includes(email) : false +} + const JWT_SECRET = process.env.JWT_SECRET || 'harheimertc-secret-key-change-in-production' // Handle both dev and production paths diff --git a/server/utils/newsletter.js b/server/utils/newsletter.js index e46fd1a..d76e815 100644 --- a/server/utils/newsletter.js +++ b/server/utils/newsletter.js @@ -1,7 +1,7 @@ import fs from 'fs/promises' import path from 'path' import { readMembers } from './members.js' -import { readUsers } from './auth.js' +import { readUsers, isHiddenUser, normalizeUserEmail } from './auth.js' import { encryptObject, decryptObject } from './encryption.js' import crypto from 'crypto' import { writeDataFileWithRotation } from './data-file-rotation.js' @@ -162,11 +162,11 @@ function calculateAge(geburtsdatum) { } } -// Filtert den Admin-User aus Empfängerliste heraus -function filterAdminUser(recipients) { +// Filtert interne System-/Hidden-Accounts aus Empfängerliste heraus +function filterInternalUsers(recipients, hiddenEmails = new Set()) { return recipients.filter(r => { - const email = (r.email || '').toLowerCase().trim() - return email !== 'admin@harheimertc.de' + const email = normalizeUserEmail(r.email) + return email && email !== 'admin@harheimertc.de' && !hiddenEmails.has(email) }) } @@ -174,20 +174,26 @@ function filterAdminUser(recipients) { export async function getRecipientsByGroup(targetGroup) { const members = await readMembers() const users = await readUsers() + const hiddenUserEmails = new Set(users.filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean)) + const visibleUsers = users.filter(user => !isHiddenUser(user)) + const visibleMembers = members.filter(member => { + const email = normalizeUserEmail(member.email) + return member.hidden !== true && member.invisible !== true && member.isHidden !== true && !hiddenUserEmails.has(email) + }) let recipients = [] switch (targetGroup) { case 'alle': // Alle Mitglieder mit E-Mail - recipients = members + recipients = visibleMembers .filter(m => m.email && m.email.trim() !== '') .map(m => ({ email: m.email, name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) // Auch alle aktiven Benutzer hinzufügen - users + visibleUsers .filter(u => u.active && u.email) .forEach(u => { if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) { @@ -201,7 +207,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'erwachsene': // Mitglieder über 18 Jahre - recipients = members + recipients = visibleMembers .filter(m => { if (!m.email || !m.email.trim()) return false const age = calculateAge(m.geburtsdatum) @@ -212,7 +218,7 @@ export async function getRecipientsByGroup(targetGroup) { name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) // Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum) - users + visibleUsers .filter(u => u.active && u.email && u.email.trim()) .forEach(u => { // Prüfe ob bereits vorhanden @@ -227,7 +233,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'nachwuchs': // Mitglieder unter 18 Jahre - recipients = members + recipients = visibleMembers .filter(m => { if (!m.email || !m.email.trim()) return false const age = calculateAge(m.geburtsdatum) @@ -239,7 +245,7 @@ export async function getRecipientsByGroup(targetGroup) { })) // Zusätzlich aktive Trainer aus users.json anschreiben - users + visibleUsers .filter(u => { if (!u.active || !u.email || !u.email.trim()) return false const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) @@ -257,7 +263,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'mannschaftsspieler': // Mitglieder die in einer Mannschaft spielen - recipients = members + recipients = visibleMembers .filter(m => { if (!m.email || !m.email.trim()) return false // Prüfe ob als Mannschaftsspieler markiert @@ -276,7 +282,7 @@ export async function getRecipientsByGroup(targetGroup) { case 'vorstand': // Nur Vorstand (aus users.json) - recipients = users + recipients = visibleUsers .filter(u => { if (!u.active || !u.email) return false const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) @@ -293,12 +299,13 @@ export async function getRecipientsByGroup(targetGroup) { } // Admin-User herausfiltern - return filterAdminUser(recipients) + return filterInternalUsers(recipients, hiddenUserEmails) } // Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet) export async function getNewsletterSubscribers(internalOnly = false, groupId = null) { const subscribers = await readSubscribers() + const hiddenUserEmails = new Set((await readUsers()).filter(isHiddenUser).map(user => normalizeUserEmail(user.email)).filter(Boolean)) let confirmedSubscribers = subscribers.filter(s => { if (!s.confirmed || s.unsubscribedAt) { @@ -329,12 +336,12 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n const members = await readMembers() const memberEmails = new Set( members - .filter(m => m.email) - .map(m => m.email.toLowerCase()) + .filter(m => m.email && m.hidden !== true && m.invisible !== true && m.isHidden !== true && !hiddenUserEmails.has(normalizeUserEmail(m.email))) + .map(m => normalizeUserEmail(m.email)) ) confirmedSubscribers = confirmedSubscribers.filter(s => - memberEmails.has(s.email.toLowerCase()) + memberEmails.has(normalizeUserEmail(s.email)) ) } @@ -344,7 +351,7 @@ export async function getNewsletterSubscribers(internalOnly = false, groupId = n })) // Admin-User herausfiltern - return filterAdminUser(result) + return filterInternalUsers(result, hiddenUserEmails) } // Generiert Abmelde-Token für Abonnenten diff --git a/tests/cms-users-endpoints.spec.ts b/tests/cms-users-endpoints.spec.ts index ef1cbbd..0f65645 100644 --- a/tests/cms-users-endpoints.spec.ts +++ b/tests/cms-users-endpoints.spec.ts @@ -26,7 +26,8 @@ vi.mock('../server/utils/auth.js', () => ({ user.roles = ['mitglied'] } return user - }) + }), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') })) vi.mock('nodemailer', () => { @@ -96,6 +97,20 @@ describe('CMS User Management Endpoints', () => { expect(response.users[0]).not.toHaveProperty('password') expect(response.users).toHaveLength(1) }) + + + it('blendet unsichtbare Playstore-Benutzer auch für Admins aus', async () => { + const event = adminEvent() + authUtils.readUsers.mockResolvedValue([ + { id: '1', email: 'a@b.de', name: 'Anna', roles: ['mitglied'], active: true }, + { id: '2', email: 'review@club.de', name: 'Playstore Review', roles: ['mitglied'], active: true, accountType: 'playstore_review' } + ]) + + const response = await usersListHandler(event) + + expect(response.users).toHaveLength(1) + expect(response.users[0].email).toBe('a@b.de') + }) }) describe('POST /api/cms/users/approve', () => { @@ -239,5 +254,31 @@ describe('CMS User Management Endpoints', () => { expect(response.attempts).toHaveLength(1) expect(response.attempts[0]).toMatchObject({ requestId: 'r1', failed: true }) }) + + + it('blendet unsichtbare Benutzer und ihre Reset-Logs in der Diagnose aus', async () => { + const event = adminEvent() + event.__query = { email: 'review@club.de', failedOnly: 'false' } + authUtils.hasRole.mockReturnValue(true) + authUtils.readUsers.mockResolvedValue([ + { id: '2', email: 'review@club.de', name: 'Playstore Review', active: true, accountType: 'playstore_review' } + ]) + passwordResetLog.readPasswordResetLogs.mockResolvedValue([ + { + requestId: 'r-hidden', + ts: '2026-05-27T10:00:01.000Z', + emailMasked: 're***@cl***.de', + emailFingerprint: 'fingerprint:review@club.de', + ip: '127.0.0.1', + step: 'request_completed', + status: 'failed' + } + ]) + + const response = await passwordResetDiagnosticsHandler(event) + + expect(response.matchingUsers).toHaveLength(0) + expect(response.attempts).toHaveLength(0) + }) }) }) diff --git a/tests/members-endpoints.spec.ts b/tests/members-endpoints.spec.ts index 8e69eeb..6ec1c9e 100644 --- a/tests/members-endpoints.spec.ts +++ b/tests/members-endpoints.spec.ts @@ -31,7 +31,9 @@ vi.mock('../server/utils/auth.js', () => ({ if (!user) return false const userRoles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []) return userRoles.includes(role) - }) + }), + normalizeUserEmail: vi.fn(email => String(email || '').trim().toLowerCase()), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') })) vi.mock('../server/utils/members.js', () => ({ @@ -97,6 +99,28 @@ describe('Members API Endpoints', () => { expect(response.members).toHaveLength(1) expect(response.members[0].name).toBe('Anna Muster') }) + + + it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + memberUtils.readMembers.mockResolvedValue([ + { id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de' }, + { id: 'm2', firstName: 'Play', lastName: 'Store', email: 'review@club.de' } + ]) + authUtils.readUsers.mockResolvedValue([ + { id: 'u1', name: 'Ben Nutzer', email: 'ben@club.de', role: 'mitglied', active: true }, + { id: 'u2', name: 'Playstore Review', email: 'review@club.de', roles: ['mitglied'], active: true, accountType: 'playstore_review' } + ]) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'mitglied' }) + + const response = await membersGetHandler(event) + + expect(response.members.map(member => member.email || member.name)).not.toContain('review@club.de') + expect(response.members.map(member => member.name)).not.toContain('Play Store') + expect(response.members.map(member => member.name)).not.toContain('Playstore Review') + expect(response.members).toHaveLength(2) + }) }) describe('POST /api/members', () => { diff --git a/tests/spielplan-public-endpoints.spec.ts b/tests/spielplan-public-endpoints.spec.ts index 7cff8d9..b588d8a 100644 --- a/tests/spielplan-public-endpoints.spec.ts +++ b/tests/spielplan-public-endpoints.spec.ts @@ -19,7 +19,9 @@ vi.mock('../server/utils/auth.js', () => ({ verifyToken: vi.fn(), getUserFromToken: vi.fn(), readUsers: vi.fn().mockResolvedValue([]), - migrateUserRoles: vi.fn(user => user) + migrateUserRoles: vi.fn(user => user), + normalizeUserEmail: vi.fn(email => String(email || '').trim().toLowerCase()), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') })) vi.mock('../server/utils/members.js', () => ({ @@ -264,5 +266,27 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => { expect(result.birthdays).toHaveLength(0) }) + + + it('blendet unsichtbare Playstore-Benutzer auch für Vorstand aus', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + const inDays = 7 + const targetDate = new Date() + targetDate.setDate(targetDate.getDate() + inDays) + const geburtsdatum = `${targetDate.getFullYear() - 30}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}` + + authUtils.verifyToken.mockReturnValue({ id: 'v1' }) + authUtils.getUserFromToken.mockResolvedValue({ id: 'v1', roles: ['vorstand'], active: true }) + authUtils.readUsers.mockResolvedValue([ + { id: 'u2', name: 'Playstore Review', email: 'review@club.de', active: true, geburtsdatum, accountType: 'playstore_review' } + ]) + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Play', lastName: 'Store', email: 'review@club.de', geburtsdatum, visibility: { showBirthday: true } } + ]) + + const result = await birthdaysHandler(event) + + expect(result.birthdays).toHaveLength(0) + }) }) })