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
+18
View File
@@ -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 |
+1 -1
View File
@@ -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",
+16
View File
@@ -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;
+35
View File
@@ -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 };
}
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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).
+2 -1
View File
@@ -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),
+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) {
+1
View File
@@ -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,
+37
View File
@@ -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);
});
+1 -1
View File
@@ -192,7 +192,7 @@ export const Booking: React.FC = () => {
{readOnly && viewedUser && (
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
<Avatar name={viewedUser.name} color={viewedUser.color} id={viewedUser.id} size={28} />
<Avatar name={viewedUser.name} color={viewedUser.color} avatar={viewedUser.avatar} id={viewedUser.id} size={28} />
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
Zurück zu meinen Zeiten
+1 -1
View File
@@ -111,7 +111,7 @@ export const EntryList: React.FC<{
{showUser && (
<td className="px-3 py-2.5 align-top">
<span className="flex items-center gap-2">
<Avatar id={e.user_id} name={e.user_name} size={22} />
<Avatar id={e.user_id} name={e.user_name} avatar={e.user_avatar} size={22} />
<span className="truncate font-semibold text-ink-700">{e.user_name}</span>
</span>
</td>
+1 -1
View File
@@ -296,7 +296,7 @@ const CustomerCard: React.FC<{
<div className="mt-2 flex flex-wrap gap-1.5">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-1.5 rounded-full bg-ink-50 py-0.5 pl-0.5 pr-2 text-[11px] font-semibold text-ink-600">
<Avatar id={m.id} name={m.name} color={m.color} size={18} external={m.is_external} />
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={18} external={m.is_external} />
{m.name}
</span>
))}
+2 -2
View File
@@ -203,7 +203,7 @@ const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: Ran
style={{ height: ROW, transform: `translateY(${pos * ROW}px)` }}
>
<span className="tabular w-5 shrink-0 text-center text-xs font-extrabold text-ink-300">{pos + 1}</span>
<Avatar id={r.user_id} name={r.name} color={r.color} size={34} external={r.is_external} />
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={34} external={r.is_external} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<button onClick={() => navigate('reports')} className="truncate text-left text-sm font-bold text-ink-800 hover:text-brand-700" title="Zu den Reports">
@@ -240,7 +240,7 @@ const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => (
<ol className="relative space-y-1 before:absolute before:bottom-3 before:left-[17px] before:top-3 before:w-px before:bg-ink-100">
{data.recent.map((e, i) => (
<li key={e.id} className="animate-rise relative flex gap-3 rounded-xl p-1.5 hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} size={34} />
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} avatar={e.user_avatar} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-bold text-ink-800">{e.user_name}</p>
+1 -1
View File
@@ -37,7 +37,7 @@ export const MobileNav: React.FC<{ current: ViewKey; onNavigate: (view: ViewKey)
>
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-ink-200" />
<button onClick={() => { setMoreOpen(false); onAccount(); }} className="mb-3 flex w-full items-center gap-3 rounded-2xl bg-ink-50 p-3 text-left">
<Avatar name={user.name} color={user.color} id={user.id} size={40} />
<Avatar name={user.name} color={user.color} avatar={user.avatar} id={user.id} size={40} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
<span className="block text-xs text-ink-400">{ROLE_LABELS[user.role]} · Mein Konto</span>
+1 -1
View File
@@ -89,7 +89,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ current, onNavigate, collapsed
<div className={`shrink-0 border-t border-ink-100 ${collapsed ? 'flex flex-col items-center gap-2 p-3' : 'p-4'}`}>
<div className="flex w-full min-w-0 items-center justify-between gap-2">
<button onClick={onAccount} className="flex min-w-0 items-center gap-3 rounded-xl text-left transition hover:opacity-80" title="Mein Konto">
<Avatar name={user.name} color={user.color} id={user.id} size={36} external={user.is_external} />
<Avatar name={user.name} color={user.color} avatar={user.avatar} id={user.id} size={36} external={user.is_external} />
{!collapsed && (
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-ink-900">{user.name}</span>
+2 -2
View File
@@ -311,7 +311,7 @@ export const ProjectDetail: React.FC<Props> = ({ project: p, canEdit, users, onC
<div className="flex flex-wrap gap-2">
{p.members.map((m) => (
<span key={m.id} className="flex items-center gap-2 rounded-full border border-ink-200 py-1 pl-1 pr-3 text-xs font-bold text-ink-700">
<Avatar id={m.id} name={m.name} color={m.color} size={22} external={m.is_external} />
<Avatar id={m.id} name={m.name} color={m.color} avatar={m.avatar} size={22} external={m.is_external} />
{m.name}
</span>
))}
@@ -446,7 +446,7 @@ const MembersDialog: React.FC<{ project: Project; users: DirectoryUser[]; onClos
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-transparent hover:bg-ink-50'}`}>
<input type="checkbox" checked={checked} onChange={() => setIds(checked ? ids.filter((id) => id !== u.id) : [...ids, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={30} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={30} external={u.is_external} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{u.name}</span>
<span className="block truncate text-xs text-ink-400">{u.is_external ? `Extern · ${u.company ?? ''}` : u.job_title ?? ''}</span>
+1 -1
View File
@@ -372,7 +372,7 @@ export const ProjectForm: React.FC<Props> = ({ project, customers, users, onClos
return (
<label key={u.id} className={`flex cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 transition ${checked ? 'border-brand-300 bg-brand-50' : 'border-ink-200 hover:bg-ink-50'}`}>
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={checked} onChange={() => setMembers(checked ? members.filter((id) => id !== u.id) : [...members, u.id])} />
<Avatar id={u.id} name={u.name} color={u.color} size={24} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={24} external={u.is_external} />
<span className="min-w-0 truncate text-xs font-bold text-ink-700">{u.name}</span>
</label>
);
+1 -1
View File
@@ -77,7 +77,7 @@ export const ReportTable: React.FC<{
<td className="py-3 pl-4 pr-3">
<div className="flex min-w-0 items-center gap-2.5">
{kind === 'users' || kind === 'team' ? (
<Avatar id={r.user_id} name={r.name} color={r.color} size={28} external={r.is_external} />
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={28} external={r.is_external} />
) : kind !== 'tasks' ? (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
) : null}
+1 -1
View File
@@ -300,7 +300,7 @@ const UserEntriesDialog: React.FC<{
}
>
<div className="flex items-center gap-3 pb-4">
<Avatar id={row.user_id} name={row.name} color={row.color} size={40} external={row.is_external} />
<Avatar id={row.user_id} name={row.name} color={row.color} avatar={row.avatar} size={40} external={row.is_external} />
<p className="text-sm font-semibold text-ink-500">
{entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'}
</p>
+20 -4
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar, Button, Field, inputClass } from '../ui/controls';
import { Button, Field, inputClass } from '../ui/controls';
import { AvatarPicker } from '../ui/AvatarPicker';
import { useAuth } from '../../lib/auth';
import { authApi } from '../../lib/api';
import { useToast } from '../Toast';
@@ -8,7 +9,7 @@ import { ROLE_LABELS } from '../../types';
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
/** Mein Konto: Farbe, Passwort, Abmelden - fuer jede Rolle. */
/** Mein Konto: Profilbild, Farbe, Passwort, Abmelden - fuer jede Rolle. */
export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { user, updateUser, logout, settings } = useAuth();
const { addToast } = useToast();
@@ -27,6 +28,21 @@ export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) =>
}
};
/*
* Das Bild wird sofort gespeichert, nicht erst beim Schliessen: der Dialog
* hat keinen Speichern-Knopf, und eine halb uebernommene Aenderung waere
* schlimmer als eine, die sofort steht.
*/
const changeAvatar = async (avatar: string) => {
try {
const { user: saved } = await authApi.updateMe({ avatar: avatar || null });
updateUser(saved);
addToast(avatar ? 'Profilbild gespeichert.' : 'Profilbild entfernt.', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
}
};
const changePassword = async (event: React.FormEvent) => {
event.preventDefault();
if (next !== repeat) return addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
@@ -46,8 +62,8 @@ export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) =>
return (
<Modal title="Mein Konto" subtitle={`${ROLE_LABELS[user.role]} · ${user.username}`} onClose={onClose}>
<div className="flex items-center gap-4">
<Avatar name={user.name} color={user.color} id={user.id} size={56} external={user.is_external} />
<div className="flex flex-wrap items-center gap-4">
<AvatarPicker value={user.avatar ?? ''} onChange={changeAvatar} name={user.name} color={user.color} id={user.id} size={72} />
<div className="min-w-0">
<p className="truncate text-lg font-extrabold text-ink-900">{user.name}</p>
<p className="truncate text-sm text-ink-500">{user.email || '–'}</p>
+2 -2
View File
@@ -135,7 +135,7 @@ export const Team: React.FC<{ embedded?: boolean }> = ({ embedded }) => {
<tr key={u.id} className={`animate-rise border-b border-ink-100 last:border-0 hover:bg-ink-50 ${u.is_active ? '' : 'opacity-50'}`} style={{ '--i': Math.min(i, 12) } as React.CSSProperties}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
<Avatar id={u.id} name={u.name} color={u.color} size={34} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={34} external={u.is_external} />
<div className="min-w-0">
<p className="truncate font-bold text-ink-900">{u.name}</p>
<p className="truncate text-xs text-ink-400">{u.email || u.username}</p>
@@ -236,7 +236,7 @@ const OrgNode: React.FC<{
u.id === meId ? 'border-brand-300' : 'border-ink-200'
} ${u.is_active ? '' : 'opacity-50'}`}
>
<Avatar id={u.id} name={u.name} color={u.color} size={40} external={u.is_external} />
<Avatar id={u.id} name={u.name} color={u.color} avatar={u.avatar} size={40} external={u.is_external} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-extrabold text-ink-900">
{u.name}
+12
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button, Field, Toggle, inputClass } from '../ui/controls';
import { AvatarPicker } from '../ui/AvatarPicker';
import { useAuth } from '../../lib/auth';
import { usersApi } from '../../lib/api';
import { useToast } from '../Toast';
@@ -51,6 +52,7 @@ export const UserForm: React.FC<{
company: user?.company ?? '',
weekly_hours: user?.weekly_hours == null ? '' : String(user.weekly_hours),
color: user?.color ?? COLORS[Math.floor(Math.random() * COLORS.length)],
avatar: user?.avatar ?? '',
is_active: user?.is_active ?? true,
});
const [busy, setBusy] = useState(false);
@@ -75,6 +77,7 @@ export const UserForm: React.FC<{
company: form.is_external ? form.company : null,
weekly_hours: form.weekly_hours === '' ? null : Number(form.weekly_hours.replace(',', '.')),
color: form.color,
avatar: form.avatar || null,
is_active: form.is_active,
};
if (!sso) {
@@ -190,6 +193,15 @@ export const UserForm: React.FC<{
)}
</div>
<AvatarPicker
value={form.avatar}
onChange={(avatar) => set({ avatar })}
name={form.name || 'Neuer Benutzer'}
color={form.color}
id={user?.id}
size={72}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
+94
View File
@@ -0,0 +1,94 @@
import React, { useRef, useState } from 'react';
import { Avatar } from './controls';
import { Icon } from './Icon';
import { useToast } from '../Toast';
import { compressImage, formatBytes } from '../../lib/imageCompress';
/**
* Profilbild auswählen, mit Vorschau. Das Bild wird im Browser auf ein Quadrat
* von 256 px verkleinert, bevor es zum Server geht - so bleiben die Data-URLs
* in der Datenbank klein und das Ergebnis entspricht genau dem Ausschnitt, den
* die runde Anzeige später zeigt.
*/
export const AvatarPicker: React.FC<{
/** Data-URL oder '' für kein Bild. */
value: string;
onChange: (value: string) => void;
name: string;
color?: string | null;
id?: number;
size?: number;
disabled?: boolean;
}> = ({ value, onChange, name, color, id, size = 96, disabled }) => {
const { addToast } = useToast();
const [busy, setBusy] = useState(false);
const input = useRef<HTMLInputElement>(null);
const pick = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
// Zurücksetzen, damit dieselbe Datei nach einem Entfernen erneut geht.
event.target.value = '';
if (!file) return;
setBusy(true);
try {
const result = await compressImage(file);
onChange(result.dataUrl);
addToast(`Bild übernommen (${formatBytes(result.sourceBytes)} → ${formatBytes(result.resultBytes)}).`, 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Das Bild konnte nicht verarbeitet werden.', 'error');
} finally {
setBusy(false);
}
};
return (
<div className="flex items-center gap-4">
<div className="group relative" style={{ width: size, height: size }}>
<Avatar name={name} color={color} avatar={value || null} id={id} size={size} />
{!disabled && (
<button
type="button"
onClick={() => input.current?.click()}
disabled={busy}
aria-label="Profilbild wählen"
className={`absolute inset-0 flex items-center justify-center rounded-full bg-ink-950/50 text-[10px] font-extrabold uppercase tracking-wider text-white transition ${
busy ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
}`}
>
{busy ? <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" /> : 'Ändern'}
</button>
)}
<input ref={input} type="file" accept="image/*" className="hidden" onChange={pick} disabled={disabled || busy} />
</div>
{!disabled && (
<div className="min-w-0">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => input.current?.click()}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-700 transition hover:bg-ink-50 disabled:opacity-60"
>
<Icon name="user" className="h-3.5 w-3.5" />
Bild wählen
</button>
{value && (
<button
type="button"
onClick={() => onChange('')}
disabled={busy}
className="inline-flex items-center gap-1.5 rounded-xl border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-60"
>
<Icon name="trash" className="h-3.5 w-3.5" />
Entfernen
</button>
)}
</div>
<p className="mt-1.5 text-[11px] leading-snug text-ink-400">
JPEG, PNG, GIF, WebP oder AVIF. Das Bild wird mittig auf ein Quadrat zugeschnitten und verkleinert.
</p>
</div>
)}
</div>
);
};
+25 -14
View File
@@ -194,19 +194,30 @@ export const ErrorBox: React.FC<{ message: string; onRetry?: () => void }> = ({
</div>
);
export const Avatar: React.FC<{ name: string; color?: string | null; size?: number; id?: number; external?: boolean; className?: string }> = ({
name,
color,
size = 32,
id,
external,
className = '',
}) => (
export const Avatar: React.FC<{
name: string;
color?: string | null;
/** Profilbild als Data-URL; ohne Bild bleiben die Initialen stehen. */
avatar?: string | null;
size?: number;
id?: number;
external?: boolean;
className?: string;
}> = ({ name, color, avatar, size = 32, id, external, className = '' }) => (
<span
title={external ? `${name} (extern)` : name}
className={`relative inline-flex shrink-0 items-center justify-center rounded-full font-extrabold text-white ring-2 ring-surface ${className}`}
style={{ width: size, height: size, fontSize: Math.max(9, size * 0.36), backgroundColor: colorFor(id ?? name, color) }}
>
{avatar ? (
/*
* Rund wird das Bild selbst, nicht der Rahmen: ein overflow-hidden am
* Rahmen wuerde die Kennzeichnung fuer externe Mitarbeiter abschneiden,
* die bewusst ueber den Rand ragt. Die Initialen bleiben darunter
* stehen - laedt das Bild nicht, sieht man sie.
*/
<img src={avatar} alt="" className="absolute inset-0 h-full w-full rounded-full object-cover" loading="lazy" />
) : null}
{initials(name)}
{external && (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-amber-400 text-[7px] text-white ring-2 ring-surface">
@@ -216,14 +227,14 @@ export const Avatar: React.FC<{ name: string; color?: string | null; size?: numb
</span>
);
export const AvatarStack: React.FC<{ people: { id: number; name: string; color: string | null; is_external?: boolean }[]; max?: number; size?: number }> = ({
people,
max = 5,
size = 26,
}) => (
export const AvatarStack: React.FC<{
people: { id: number; name: string; color: string | null; avatar?: string | null; is_external?: boolean }[];
max?: number;
size?: number;
}> = ({ people, max = 5, size = 26 }) => (
<div className="flex items-center -space-x-1">
{people.slice(0, max).map((p) => (
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} size={size} external={p.is_external} />
<Avatar key={p.id} id={p.id} name={p.name} color={p.color} avatar={p.avatar} size={size} external={p.is_external} />
))}
{people.length > max && (
<span
+4 -1
View File
@@ -125,7 +125,7 @@ export const authApi = {
login: (identifier: string, password: string) =>
send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }),
me: () => get<{ user: AppUser }>('/auth/me'),
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color'>>) =>
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color' | 'avatar'>>) =>
send<{ user: AppUser }>('PATCH', '/auth/me', changes),
changePassword: (current_password: string, new_password: string) =>
send<{ ok: true }>('POST', '/auth/change-password', { current_password, new_password }),
@@ -278,6 +278,7 @@ export interface DashboardData {
user_id: number;
name: string;
color: string | null;
avatar: string | null;
job_title: string | null;
is_external: boolean;
minutes: number;
@@ -293,6 +294,7 @@ export interface DashboardData {
user_id: number;
user_name: string;
user_color: string | null;
user_avatar: string | null;
project_display: string;
customer_color: string | null;
task_name: string | null;
@@ -312,6 +314,7 @@ export interface GroupRow {
key: string | number;
name: string;
color?: string | null;
avatar?: string | null;
minutes: number;
billable_minutes: number;
non_billable_minutes: number;
+89
View File
@@ -0,0 +1,89 @@
/**
* Profilbilder werden als Data-URL in der Datenbank gespeichert. Angezeigt
* werden sie höchstens mit 96 px Kantenlänge, deshalb wird vor dem Hochladen
* im Browser auf ein Quadrat verkleinert - das drückt ein 4-MB-Handyfoto auf
* wenige Kilobyte, und der Server muss kein Bild verarbeiten.
*/
/** Zielkantenlänge; 256 px reicht für die größte Anzeige auch auf Retina. */
const TARGET_SIZE = 256;
/** WebP ist deutlich kleiner als JPEG; ohne Unterstützung wird JPEG genutzt. */
const CANDIDATES: { type: string; quality: number }[] = [
{ type: 'image/webp', quality: 0.82 },
{ type: 'image/jpeg', quality: 0.82 },
];
/** Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den Browser nicht lahmlegen. */
export const MAX_SOURCE_BYTES = 25_000_000;
export interface CompressedImage {
dataUrl: string;
sourceBytes: number;
resultBytes: number;
type: string;
}
function loadImage(file: File): Promise<HTMLImageElement> {
const url = URL.createObjectURL(file);
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Das Bild konnte nicht gelesen werden.'));
};
image.src = url;
});
}
/** Länge einer Data-URL in Bytes (Base64 trägt 4 Zeichen pro 3 Bytes). */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
return Math.round((base64.length * 3) / 4);
}
/**
* Verkleinert das Bild auf ein Quadrat und schneidet dabei mittig zu - genau
* wie die Anzeige später, damit das Ergebnis dem entspricht, was man sieht.
*/
export async function compressImage(file: File): Promise<CompressedImage> {
if (!file.type.startsWith('image/')) throw new Error('Bitte eine Bilddatei wählen.');
if (file.size > MAX_SOURCE_BYTES) throw new Error('Die Datei ist zu groß (max. 25 MB).');
const image = await loadImage(file);
const { naturalWidth: width, naturalHeight: height } = image;
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
// Kleine Bilder werden nicht vergrößert.
const side = Math.min(TARGET_SIZE, width, height);
const canvas = document.createElement('canvas');
canvas.width = side;
canvas.height = side;
const context = canvas.getContext('2d');
if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.');
const crop = Math.min(width, height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
for (const candidate of CANDIDATES) {
const dataUrl = canvas.toDataURL(candidate.type, candidate.quality);
// Liefert der Browser das Format nicht, fällt er still auf PNG zurück.
if (dataUrl.startsWith(`data:${candidate.type}`)) {
return { dataUrl, sourceBytes: file.size, resultBytes: dataUrlBytes(dataUrl), type: candidate.type };
}
}
const png = canvas.toDataURL('image/png');
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
}
/** "2,4 MB" bzw. "18 KB" */
export function formatBytes(bytes: number): string {
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`;
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
}
+5
View File
@@ -39,6 +39,8 @@ export interface AppUser {
source: 'local' | 'sso';
weekly_hours: number | null;
color: string | null;
/** Profilbild als Data-URL; null = Initialen. */
avatar: string | null;
theme: ThemePalette | null;
theme_mode: 'light' | 'dark' | null;
is_active: boolean;
@@ -57,6 +59,7 @@ export interface DirectoryUser {
id: number;
name: string;
color: string | null;
avatar: string | null;
role: Role;
manager_id: number | null;
is_external: boolean;
@@ -133,6 +136,7 @@ export interface MemberRef {
id: number;
name: string;
color: string | null;
avatar: string | null;
is_external: boolean;
}
@@ -174,6 +178,7 @@ export interface TimeEntry {
id: number;
user_id: number;
user_name: string;
user_avatar: string | null;
project_id: number;
project_name: string;
project_code: string;