diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 63fa36a..b0a5d51 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -29,6 +29,8 @@ class AuthController { } catch (error) { if (error.message === 'credentialsinvalid') { res.status(404).json({ error: error.message }); + } else if (error.message === 'userblocked') { + res.status(403).json({ error: error.message }); } else { res.status(500).json({ error: error.message }); } diff --git a/backend/controllers/moderationController.js b/backend/controllers/moderationController.js index e9a34c6..9de02ce 100644 --- a/backend/controllers/moderationController.js +++ b/backend/controllers/moderationController.js @@ -3,12 +3,23 @@ import moderationService from '../services/moderationService.js'; const moderationController = { async createReport(req, res) { + const allowedTargetTypes = [ + 'forum_message', + 'gallery_image', + 'guestbook_entry', + 'one_to_one_message', + 'diary_entry', + 'user_profile', + 'blog', + 'blog_post' + ]; const schema = Joi.object({ - targetType: Joi.string().valid('forum_message').required(), - targetId: Joi.number().integer().min(1).required(), + targetType: Joi.string().valid(...allowedTargetTypes).required(), + targetId: Joi.number().integer().min(1).optional(), + targetRef: Joi.string().trim().max(255).allow('').optional(), reason: Joi.string().trim().min(3).max(120).required(), details: Joi.string().allow('').max(2000).optional() - }); + }).or('targetId', 'targetRef'); const { error, value } = schema.validate(req.body || {}); if (error) { return res.status(400).json({ error: error.details[0].message }); @@ -51,6 +62,17 @@ const moderationController = { console.error('Error in updateReportStatus:', err); return res.status(400).json({ error: err.message }); } + }, + + async getOpenReportCount(req, res) { + try { + const { userid: userId } = req.headers; + const result = await moderationService.getOpenReportCount(userId); + return res.status(200).json(result); + } catch (err) { + console.error('Error in getOpenReportCount:', err); + return res.status(400).json({ error: err.message }); + } } }; diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js index 2164a5c..f8bf796 100644 --- a/backend/middleware/authMiddleware.js +++ b/backend/middleware/authMiddleware.js @@ -10,6 +10,9 @@ export const authenticate = async (req, res, next) => { if (!user) { return res.status(401).json({ error: 'Unauthorized: Invalid credentials' }); } + if (!user.active) { + return res.status(403).json({ error: 'Unauthorized: User blocked' }); + } try { await updateUserTimestamp(user.id); } catch (error) { diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 1286342..b30dc34 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -70,6 +70,7 @@ router.get('/falukant/region-distances', authenticate, adminController.getRegion router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance); router.get('/moderation/reports', authenticate, moderationController.listReports); +router.get('/moderation/reports/open-count', authenticate, moderationController.getOpenReportCount); router.post('/moderation/reports/:reportId/status', authenticate, moderationController.updateReportStatus); router.post('/falukant/npcs/create', authenticate, adminController.createNPCs); router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index ebdc1e7..ad721b8 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -1663,9 +1663,15 @@ class AdminService { } if (typeof data.active === 'boolean') { updates.active = data.active; + if (data.active === false) { + updates.authCode = null; + } } if (Object.keys(updates).length === 0) return { id: user.hashedId, username: user.username, active: user.active }; await user.update(updates); + if (typeof data.active === 'boolean') { + await notifyUser(user.hashedId, 'userAccessChanged', { active: !!data.active }); + } return { id: user.hashedId, username: user.username, active: user.active }; } diff --git a/backend/services/authService.js b/backend/services/authService.js index 1c45785..30c98a0 100644 --- a/backend/services/authService.js +++ b/backend/services/authService.js @@ -105,6 +105,9 @@ export const loginUser = async ({ username, password }) => { if (!user) { throw new Error('credentialsinvalid'); } + if (!user.active) { + throw new Error('userblocked'); + } const match = await bcrypt.compare(password, user.password); if (!match) { throw new Error('credentialsinvalid'); diff --git a/backend/services/moderationService.js b/backend/services/moderationService.js index 04cc566..bf4a3bf 100644 --- a/backend/services/moderationService.js +++ b/backend/services/moderationService.js @@ -1,7 +1,17 @@ import BaseService from './BaseService.js'; import { sequelize } from '../utils/sequelize.js'; +import { notifyUser } from '../utils/socket.js'; -const ALLOWED_TARGET_TYPES = new Set(['forum_message']); +const ALLOWED_TARGET_TYPES = new Set([ + 'forum_message', + 'gallery_image', + 'guestbook_entry', + 'one_to_one_message', + 'diary_entry', + 'user_profile', + 'blog', + 'blog_post' +]); const ALLOWED_STATUS = new Set(['open', 'in_review', 'resolved', 'rejected']); class ModerationService extends BaseService { @@ -16,7 +26,8 @@ class ModerationService extends BaseService { CREATE TABLE IF NOT EXISTS community.moderation_report ( id bigserial PRIMARY KEY, target_type varchar(64) NOT NULL, - target_id bigint NOT NULL, + target_id bigint NULL, + target_ref varchar(255) NULL, reason varchar(120) NOT NULL, details text NULL, status varchar(32) NOT NULL DEFAULT 'open', @@ -31,6 +42,14 @@ class ModerationService extends BaseService { CREATE INDEX IF NOT EXISTS moderation_report_target_idx ON community.moderation_report (target_type, target_id); `); + await sequelize.query(` + ALTER TABLE community.moderation_report + ADD COLUMN IF NOT EXISTS target_ref varchar(255) NULL; + `); + await sequelize.query(` + ALTER TABLE community.moderation_report + ALTER COLUMN target_id DROP NOT NULL; + `); await sequelize.query(` CREATE INDEX IF NOT EXISTS moderation_report_status_idx ON community.moderation_report (status, created_at DESC); @@ -38,7 +57,7 @@ class ModerationService extends BaseService { this._tableEnsured = true; } - async createReport(hashedUserId, { targetType, targetId, reason, details }) { + async createReport(hashedUserId, { targetType, targetId, targetRef, reason, details }) { await this._ensureTable(); const user = await this.getUserByHashedId(hashedUserId); const normalizedTargetType = String(targetType || '').trim(); @@ -46,8 +65,10 @@ class ModerationService extends BaseService { throw new Error('Unsupported target type'); } const numericTargetId = Number(targetId); - if (!Number.isFinite(numericTargetId) || numericTargetId < 1) { - throw new Error('Invalid target id'); + const hasTargetId = Number.isFinite(numericTargetId) && numericTargetId >= 1; + const normalizedTargetRef = String(targetRef || '').trim().slice(0, 255); + if (!hasTargetId && !normalizedTargetRef) { + throw new Error('Invalid target'); } const normalizedReason = String(reason || '').trim().slice(0, 120); if (!normalizedReason) { @@ -58,15 +79,16 @@ class ModerationService extends BaseService { const rows = await sequelize.query( ` INSERT INTO community.moderation_report - (target_type, target_id, reason, details, status, reporter_user_id) + (target_type, target_id, target_ref, reason, details, status, reporter_user_id) VALUES - (:targetType, :targetId, :reason, :details, 'open', :reporterUserId) - RETURNING id, target_type AS "targetType", target_id AS "targetId", reason, details, status, created_at AS "createdAt" + (:targetType, :targetId, :targetRef, :reason, :details, 'open', :reporterUserId) + RETURNING id, target_type AS "targetType", target_id AS "targetId", target_ref AS "targetRef", reason, details, status, created_at AS "createdAt" `, { replacements: { targetType: normalizedTargetType, - targetId: numericTargetId, + targetId: hasTargetId ? numericTargetId : null, + targetRef: normalizedTargetRef || null, reason: normalizedReason, details: normalizedDetails || null, reporterUserId: user.id @@ -75,7 +97,9 @@ class ModerationService extends BaseService { } ); - return rows[0]; + const created = rows[0]; + await this._notifyModeratorsAboutChange('created', created.id); + return created; } async listReports(hashedUserId, { status = 'open', limit = 50 } = {}) { @@ -95,6 +119,7 @@ class ModerationService extends BaseService { r.id, r.target_type AS "targetType", r.target_id AS "targetId", + r.target_ref AS "targetRef", fm.title_id AS "topicId", ft.forum_id AS "forumId", r.reason, @@ -167,7 +192,66 @@ class ModerationService extends BaseService { if (!rows.length) { throw new Error('Report not found'); } - return rows[0]; + const updated = rows[0]; + await this._notifyModeratorsAboutChange('status_changed', numericReportId); + return updated; + } + + async getOpenReportCount(hashedUserId) { + await this._ensureTable(); + const user = await this.getUserByHashedId(hashedUserId); + const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']); + if (!isAdmin) { + throw new Error('Access denied'); + } + const rows = await sequelize.query( + ` + SELECT COUNT(*)::int AS count + FROM community.moderation_report + WHERE status = 'open' + `, + { type: sequelize.QueryTypes.SELECT } + ); + return { openCount: Number(rows?.[0]?.count || 0) }; + } + + async _notifyModeratorsAboutChange(kind, reportId) { + try { + const moderatorRows = await sequelize.query( + ` + SELECT DISTINCT u.hashed_id AS "hashedId" + FROM community.user u + JOIN community.user_right ur ON ur.user_id = u.id + JOIN type.user_right urt ON urt.id = ur.right_type_id + WHERE u.active = true + AND urt.title IN ('mainadmin', 'forum') + `, + { type: sequelize.QueryTypes.SELECT } + ); + const countRows = await sequelize.query( + ` + SELECT COUNT(*)::int AS count + FROM community.moderation_report + WHERE status = 'open' + `, + { type: sequelize.QueryTypes.SELECT } + ); + const openCount = Number(countRows?.[0]?.count || 0); + await Promise.all( + (moderatorRows || []) + .map((row) => row?.hashedId) + .filter(Boolean) + .map((hashedId) => + notifyUser(hashedId, 'moderationReportChanged', { + kind, + reportId, + openCount + }) + ) + ); + } catch (error) { + console.error('Failed to notify moderators about moderation report change:', error); + } } } diff --git a/frontend/src/components/AppNavigation.vue b/frontend/src/components/AppNavigation.vue index d8281e6..68562bc 100644 --- a/frontend/src/components/AppNavigation.vue +++ b/frontend/src/components/AppNavigation.vue @@ -48,7 +48,17 @@ :style="`background-image:url('/images/icons/${subitem.icon}')`" class="submenu-icon" >  - {{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }} + + {{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }} + + + {{ moderationOpenCount }} + + 18+ { + if (Number.isFinite(Number(payload.openCount))) { + this.moderationOpenCount = Number(payload.openCount); + return; + } + await this.fetchModerationOpenCount(); + }; + this._userAccessChangedHandler = async (payload = {}) => { + if (payload.active === false) { + this.$root?.$refs?.messageDialog?.open?.('Dein Account wurde gesperrt.'); + await this.logout(); + } + }; sock.on('forumschanged', this._forumsChangedHandler); sock.on('friendloginchanged', this._friendLoginChangedHandler); sock.on('reloadmenu', this._reloadMenuHandler); sock.on('adultVerificationChanged', this._adultVerificationChangedHandler); + sock.on('moderationReportChanged', this._moderationReportChangedHandler); + sock.on('userAccessChanged', this._userAccessChangedHandler); }, unregisterSocketListeners() { @@ -276,10 +311,14 @@ export default { if (this._friendLoginChangedHandler) sock.off('friendloginchanged', this._friendLoginChangedHandler); if (this._reloadMenuHandler) sock.off('reloadmenu', this._reloadMenuHandler); if (this._adultVerificationChangedHandler) sock.off('adultVerificationChanged', this._adultVerificationChangedHandler); + if (this._moderationReportChangedHandler) sock.off('moderationReportChanged', this._moderationReportChangedHandler); + if (this._userAccessChangedHandler) sock.off('userAccessChanged', this._userAccessChangedHandler); this._forumsChangedHandler = null; this._friendLoginChangedHandler = null; this._reloadMenuHandler = null; this._adultVerificationChangedHandler = null; + this._moderationReportChangedHandler = null; + this._userAccessChangedHandler = null; }, updateViewportState() { @@ -461,6 +500,19 @@ export default { this.vocabLanguagesList = []; } }, + async fetchModerationOpenCount() { + try { + const adminChildren = this.menu?.administration?.children || {}; + if (!adminChildren.moderationReports) { + this.moderationOpenCount = 0; + return; + } + const res = await apiClient.get('/api/admin/moderation/reports/open-count'); + this.moderationOpenCount = Number(res?.data?.openCount || 0); + } catch (_) { + this.moderationOpenCount = 0; + } + }, openForum(forumId) { this.$router.push({ name: 'Forum', params: { id: forumId } }); @@ -800,6 +852,29 @@ a { border-radius: 14px; } +.submenu-label-row { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.moderation-alert-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: #d32f2f; + color: #fff; + border-radius: 999px; + padding: 2px 7px; + font-weight: 800; + font-size: 0.72rem; + line-height: 1; +} + +.moderation-alert-icon { + font-size: 0.78rem; +} + .submenu1 > li:hover { color: var(--color-text-primary); background-color: rgba(248, 162, 43, 0.12); diff --git a/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue b/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue index bb4f7cb..3b53cd5 100644 --- a/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue +++ b/frontend/src/dialogues/socialnetwork/UserProfileDialog.vue @@ -17,6 +17,11 @@
+
+ +
@@ -44,6 +49,9 @@
  • {{ image.title }}

    +
  • @@ -82,6 +90,19 @@ {{ entry.sender }} +
    + + +
    {{ $t('blog.view.edit') }} +
    @@ -22,6 +23,9 @@

    {{ p.title }}

    +
    + +
    {{ $t(`socialnetwork.profile.${key}`) }}