feat(profil): eigenes Profilbild hochladen

Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren
auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen,
steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports,
Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen.

Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von
256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto
werden wenige Kilobyte, und der Server muss kein Bild verarbeiten.

Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein
Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten
werden muesste.

Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und
AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann
Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder
Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit
Pulse arbeitet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-19 23:06:02 +02:00
co-authored by Claude Opus 5
parent 2d3d843988
commit 9576d18abc
32 changed files with 397 additions and 44 deletions
+7 -1
View File
@@ -5,6 +5,7 @@ import { pool } from '../db.js';
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
import { getSettings } from '../lib/settings.js';
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
import { normalizeAvatar } from '../lib/avatar.js';
const router = express.Router();
@@ -70,7 +71,7 @@ router.post('/login', async (req, res, next) => {
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
// Eigene Darstellung: Design, Modus, Farbe.
// Eigene Darstellung: Design, Modus, Farbe, Profilbild.
router.patch('/me', requireAuth, async (req, res, next) => {
const updates = {};
if (req.body?.theme !== undefined) {
@@ -90,6 +91,11 @@ router.patch('/me', requireAuth, async (req, res, next) => {
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
updates.color = color;
}
if (req.body?.avatar !== undefined) {
const avatar = normalizeAvatar(req.body.avatar);
if (avatar.error) return res.status(400).json({ error: avatar.error });
updates.avatar = avatar.value;
}
const keys = Object.keys(updates);
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try {
+3 -1
View File
@@ -61,7 +61,7 @@ router.get('/', async (req, res, next) => {
const filter = userFilter(userIds, 'id');
const [users] = await pool.query(
`SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users
`SELECT id, name, color, avatar, role, weekly_hours, is_external, company, job_title FROM pulse_users
WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`,
filter.params
);
@@ -86,6 +86,7 @@ router.get('/', async (req, res, next) => {
user_id: u.id,
name: u.name,
color: u.color,
avatar: u.avatar ?? null,
job_title: u.job_title,
is_external: Boolean(u.is_external),
...totals,
@@ -127,6 +128,7 @@ router.get('/', async (req, res, next) => {
user_id: e.user_id,
user_name: e.user_name,
user_color: e.user_color,
user_avatar: e.user_avatar ?? null,
project_display: e.project_display,
customer_color: e.customer_color,
task_name: e.task_name,
+1
View File
@@ -14,6 +14,7 @@ function publicEntry(entry, showAmounts) {
id: entry.id,
user_id: entry.user_id,
user_name: entry.user_name,
user_avatar: entry.user_avatar ?? null,
project_id: entry.project_id,
project_name: entry.project_name,
project_code: entry.project_code,
+3 -3
View File
@@ -50,7 +50,7 @@ export async function buildTimeReport(viewer, query) {
const filter = userFilter(userIds, 'id');
const [users] = await pool.query(
`SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
`SELECT id, name, color, avatar, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
filter.params
);
const capTo = to > today() ? today() : to;
@@ -60,12 +60,12 @@ export async function buildTimeReport(viewer, query) {
const amount = entries.reduce((s, e) => s + e.amount, 0);
const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0);
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed);
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, avatar: e.user_avatar ?? null, is_external: e.is_external }), includeFixed);
// Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter).
if (!query.customer_id && !query.project_id) {
for (const u of users) {
if (u.is_active && !userRows.some((row) => row.user_id === u.id)) {
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, avatar: u.avatar ?? null, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
}
}
}
+7 -1
View File
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { normalizeAvatar } from '../lib/avatar.js';
const router = express.Router();
@@ -81,6 +82,11 @@ function normalize(body, { creating }) {
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
if (body.company !== undefined) out.company = str(body.company, 160);
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
if (body.avatar !== undefined) {
const avatar = normalizeAvatar(body.avatar);
if (avatar.error) errors.push(avatar.error);
else out.avatar = avatar.value;
}
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
if (body.weekly_hours !== undefined) {
out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours);
@@ -93,7 +99,7 @@ function normalize(body, { creating }) {
router.get('/directory', async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
'SELECT id, name, color, avatar, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
);
res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) })));
} catch (err) {