diff --git a/README.md b/README.md
index 571d651..6be9c2c 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,23 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
mindestens Vorgesetzter ist.
+## Profilbilder
+
+Jeder kann sein eigenes Bild hochladen (*Mein Konto*), Vorgesetzte und Administratoren
+auch für die Benutzer, die sie verwalten (*Team → Bearbeiten*). Der Browser verkleinert
+das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu
+(`src/lib/imageCompress.ts`); aus einem 4-MB-Handyfoto werden so wenige Kilobyte.
+
+Gespeichert wird es als Data-URL in `pulse_users.avatar` (LONGTEXT) – kein Upload-Verzeichnis,
+das gesichert und beim Deployment am Leben gehalten werden müsste. Der Server prüft in
+`server/src/lib/avatar.js`: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, höchstens 2 MB.
+**SVG ist bewusst nicht erlaubt** – es kann Skripte tragen. Externe http(s)-Adressen ebenso
+wenig: sonst lädt jeder Seitenaufruf ein Bild von einem fremden Server und verrät, wer wann
+mit Pulse arbeitet.
+
+Angezeigt wird es überall dort, wo bisher die Initialen standen: Seitenleiste, Kopfzeile,
+Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Initialen.
+
## SSO / Active Directory
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
@@ -155,6 +172,7 @@ Aus Auftrag und Call-Mitschrift.
| Buchung: zusätzliche Listenansicht (Kunde, Projekt, Task, Billable/Non-billable Zeit) für Woche/Monat/Jahr | ✅ | Buchung → Kalender/Liste |
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
+| Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten |
| Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste |
| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog |
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
diff --git a/server/package.json b/server/package.json
index 22f5dbc..1d406e9 100644
--- a/server/package.json
+++ b/server/package.json
@@ -8,7 +8,7 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
- "test": "node --test test/sso-filter.test.mjs"
+ "test": "node --test \"test/*.test.mjs\""
},
"dependencies": {
"bcryptjs": "^2.4.3",
diff --git a/server/src/db.js b/server/src/db.js
index a83e855..03db3bd 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -40,6 +40,7 @@ export async function initDatabase() {
}
await migrateAbrufType();
+ await migrateAvatarColumn();
await ensureAdminUser();
await ensureInternalCustomer();
}
@@ -69,6 +70,21 @@ async function migrateAbrufType() {
}
}
+/*
+ * Profilbild am Benutzer. Steht als Data-URL in der Spalte - siehe
+ * lib/avatar.js, warum kein Dateisystem.
+ */
+async function migrateAvatarColumn() {
+ const [columns] = await pool.query(
+ `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_users' AND COLUMN_NAME = 'avatar'`
+ );
+ if (!columns.length) {
+ await pool.query('ALTER TABLE pulse_users ADD COLUMN avatar LONGTEXT NULL AFTER color');
+ console.log('[Pulse] Spalte pulse_users.avatar ergaenzt.');
+ }
+}
+
async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
if (rows[0].n > 0) return;
diff --git a/server/src/lib/avatar.js b/server/src/lib/avatar.js
new file mode 100644
index 0000000..a201466
--- /dev/null
+++ b/server/src/lib/avatar.js
@@ -0,0 +1,35 @@
+/*
+ * Profilbild eines Benutzers.
+ *
+ * Das Bild liegt als Data-URL in der Datenbank (pulse_users.avatar, LONGTEXT).
+ * Kein Dateisystem: der Dienst laeuft als eigener Benutzer in /var/www/pulse,
+ * ein Upload-Verzeichnis muesste gesichert, gesichert-gesichert und beim Deploy
+ * am Leben gehalten werden. Die Bilder sind nach dem Verkleinern im Browser
+ * wenige Kilobyte gross, das traegt eine Spalte problemlos.
+ *
+ * Erlaubt sind nur Bild-Data-URLs. Eine externe http(s)-Adresse waere bequem,
+ * wuerde aber bedeuten, dass jeder Seitenaufruf ein Bild von einem fremden
+ * Server nachlaedt - das gibt die Benutzung von Pulse nach draussen preis.
+ */
+
+/** Data-URL eines Bildes. SVG ist bewusst nicht dabei: es kann Skripte tragen. */
+const AVATAR_RE = /^data:image\/(png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=\s]+$/i;
+
+/**
+ * Obergrenze der Data-URL. Das JSON-Limit der Anwendung ist 4 MB; hier bleibt
+ * bewusst Luft fuer den Rest des Requests.
+ */
+export const AVATAR_MAX_LENGTH = 2_000_000;
+
+/** Prueft ein Profilbild; null bzw. '' entfernt es. */
+export function normalizeAvatar(value) {
+ if (value === null || value === undefined || String(value).trim() === '') return { value: null };
+ const avatar = String(value).trim();
+ if (avatar.length > AVATAR_MAX_LENGTH) {
+ return { error: 'Das Bild ist zu groß. Bitte ein kleineres verwenden.' };
+ }
+ if (!AVATAR_RE.test(avatar)) {
+ return { error: 'Ungültiges Bild (erwartet PNG, JPEG, GIF, WebP oder AVIF).' };
+ }
+ return { value: avatar };
+}
diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js
index 295baae..d875cec 100644
--- a/server/src/lib/entries.js
+++ b/server/src/lib/entries.js
@@ -43,7 +43,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
const [rows] = await pool.query(
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
e.created_at, e.updated_at,
- u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
+ u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar, u.is_external, u.company AS user_company, u.weekly_hours,
u.manager_id,
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
p.rate AS project_rate,
diff --git a/server/src/lib/projects.js b/server/src/lib/projects.js
index 77dc85f..10ec4d0 100644
--- a/server/src/lib/projects.js
+++ b/server/src/lib/projects.js
@@ -186,7 +186,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids),
pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids),
pool.query(
- `SELECT m.project_id, u.id, u.name, u.color, u.is_external
+ `SELECT m.project_id, u.id, u.name, u.color, u.avatar, u.is_external
FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id
WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`,
ids
@@ -242,7 +242,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
members: members
.filter((m) => m.project_id === p.id)
- .map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })),
+ .map((m) => ({ id: m.id, name: m.name, color: m.color, avatar: m.avatar ?? null, is_external: Boolean(m.is_external) })),
used_minutes: usedMinutes,
billable_minutes: Number(usage?.billable_minutes || 0),
// Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden).
diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js
index dccdfa2..353c006 100644
--- a/server/src/middleware/auth.js
+++ b/server/src/middleware/auth.js
@@ -25,7 +25,7 @@ export function verifyShort(token) {
}
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
- source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
+ source, external_id, weekly_hours, color, avatar, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
/**
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
@@ -84,6 +84,7 @@ export function publicUser(row) {
source: row.source,
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
color: row.color ?? null,
+ avatar: row.avatar ?? null,
theme: row.theme ?? null,
theme_mode: row.theme_mode ?? null,
is_active: Boolean(row.is_active),
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index c1fd806..905f3e1 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -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 {
diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js
index 48b2116..ec90aa5 100644
--- a/server/src/routes/dashboard.js
+++ b/server/src/routes/dashboard.js
@@ -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,
diff --git a/server/src/routes/entries.js b/server/src/routes/entries.js
index f575cb9..26beb62 100644
--- a/server/src/routes/entries.js
+++ b/server/src/routes/entries.js
@@ -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,
diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js
index 05f7272..dba22f0 100644
--- a/server/src/routes/reports.js
+++ b/server/src/routes/reports.js
@@ -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 });
}
}
}
diff --git a/server/src/routes/users.js b/server/src/routes/users.js
index 7f0e3fb..cd1ca0b 100644
--- a/server/src/routes/users.js
+++ b/server/src/routes/users.js
@@ -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) {
diff --git a/server/src/schema.sql b/server/src/schema.sql
index a557c7c..7c268e7 100644
--- a/server/src/schema.sql
+++ b/server/src/schema.sql
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS pulse_users (
external_id VARCHAR(64) NULL,
weekly_hours DECIMAL(5,2) NULL,
color VARCHAR(7) NULL,
+ avatar LONGTEXT NULL,
theme VARCHAR(20) NULL,
theme_mode VARCHAR(10) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
diff --git a/server/test/avatar.test.mjs b/server/test/avatar.test.mjs
new file mode 100644
index 0000000..75bc82f
--- /dev/null
+++ b/server/test/avatar.test.mjs
@@ -0,0 +1,37 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { normalizeAvatar, AVATAR_MAX_LENGTH } from '../src/lib/avatar.js';
+
+const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==';
+
+test('nimmt Bild-Data-URLs an', () => {
+ for (const typ of ['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif']) {
+ const url = `data:image/${typ};base64,AAAA`;
+ assert.equal(normalizeAvatar(url).value, url, typ);
+ }
+});
+
+test('leer entfernt das Bild', () => {
+ for (const wert of [null, undefined, '', ' ']) {
+ assert.deepEqual(normalizeAvatar(wert), { value: null });
+ }
+});
+
+test('weist alles ab, was kein Bild ist', () => {
+ // SVG kann Skripte tragen, eine externe Adresse verraet die Benutzung nach draussen.
+ for (const wert of [
+ 'data:image/svg+xml;base64,PHN2Zy8+',
+ 'data:text/html;base64,PGgxPmhpPC9oMT4',
+ 'https://example.com/foto.png',
+ 'javascript:alert(1)',
+ 'hallo',
+ ]) {
+ assert.ok(normalizeAvatar(wert).error, wert);
+ }
+});
+
+test('begrenzt die Groesse', () => {
+ const zuGross = `data:image/png;base64,${'A'.repeat(AVATAR_MAX_LENGTH)}`;
+ assert.match(normalizeAvatar(zuGross).error, /zu groß/);
+ assert.equal(normalizeAvatar(PNG).error, undefined);
+});
diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx
index ab43a8f..39cbea4 100644
--- a/src/components/booking/Booking.tsx
+++ b/src/components/booking/Booking.tsx
@@ -192,7 +192,7 @@ export const Booking: React.FC = () => {
{readOnly && viewedUser && (
-
+
Du siehst die Zeiten von {viewedUser.name} (nur lesen).