diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 57e2ff1..0a47e6a 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -20,6 +20,11 @@ const UPLOAD_DIR = join(DATA_DIR, 'uploads'); mkdirSync(UPLOAD_DIR, { recursive: true }); export const photoPath = (file: string) => join(UPLOAD_DIR, file); +// Profile pictures, one per account, named the same way. +const AVATAR_DIR = join(DATA_DIR, 'avatars'); +mkdirSync(AVATAR_DIR, { recursive: true }); +export const avatarPath = (file: string) => join(AVATAR_DIR, file); + export const db = new Database(join(DATA_DIR, 'recipescam.db')); db.pragma('journal_mode = WAL'); @@ -73,7 +78,16 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot => } } -export type User = { id: number; email: string }; +// The avatar column arrived after the first accounts did, same as photos.slot. +{ + const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'avatar')) { + db.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`); + } +} + +// `avatar` is the stored file name, or null for "no picture". +export type User = { id: number; email: string; avatar: string | null }; export type Recipe = { id: number; name: string; @@ -109,7 +123,7 @@ export function createUser(email: string, password: string): User | null { const info = db .prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)') .run(email, hashPassword(password), now()); - return { id: Number(info.lastInsertRowid), email }; + return { id: Number(info.lastInsertRowid), email, avatar: null }; } catch (err) { if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null; throw err; @@ -118,12 +132,28 @@ export function createUser(email: string, password: string): User | null { export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined { return db - .prepare('SELECT id, email, password_hash FROM users WHERE email = ?') + .prepare('SELECT id, email, avatar, password_hash FROM users WHERE email = ?') .get(email) as (User & { password_hash: string }) | undefined; } export function findUserById(id: number): User | undefined { - return db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as User | undefined; + return db.prepare('SELECT id, email, avatar FROM users WHERE id = ?').get(id) as User | undefined; +} + +// Swaps the picture and hands back the file it replaced, so the caller can +// unlink it — the row is the only index of what is on disk. +export function setUserAvatar(id: number, file: string): string | null { + const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined; + if (!row) return null; + db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(file, id); + return row.avatar; +} + +// Avatars are public by nature — they sit next to a name — so this is not +// session-gated. It returns only the row's own file name, never a client path. +export function userAvatar(id: number): string | undefined { + const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined; + return row?.avatar ?? undefined; } export function createSession(userId: number): string { @@ -217,6 +247,38 @@ export function listPhotosWithOwner(): AdminPhoto[] { .all() as AdminPhoto[]; } +// Admin listing: one row per account with how many photos it owns. +export type AdminUser = { id: number; email: string; createdAt: string; photos: number; avatar: string | null }; + +export function listUsersWithCounts(): AdminUser[] { + return db + .prepare( + `SELECT users.id AS id, users.email AS email, users.created_at AS createdAt, + users.avatar AS avatar, COUNT(photos.id) AS photos + FROM users LEFT JOIN photos ON photos.user_id = users.id + GROUP BY users.id + ORDER BY users.id`, + ) + .all() as AdminUser[]; +} + +// Profile edits. The email column is UNIQUE, so a taken address comes back as +// false rather than a thrown constraint; the password uses the same hash the +// sign-up path writes. +export function updateUserEmail(id: number, email: string): boolean { + try { + db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id); + return true; + } catch (err) { + if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false; + throw err; + } +} + +export function setUserPassword(id: number, password: string): void { + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id); +} + export function countPhotos(userId: number): number { return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n; } diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index e6f345a..b966f39 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -23,11 +23,17 @@ import { listPhotos, listPhotosWithOwner, listRecipes, + listUsersWithCounts, + avatarPath, photoFile, photoPath, sessionUser, setPhotoSlot, + setUserAvatar, + setUserPassword, updateRecipe, + updateUserEmail, + userAvatar, verifyPassword, type Recipe, type User, @@ -56,7 +62,15 @@ const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase()); // The public shape of an account. `admin` is the allowlist's answer, so the // client can decide whether to offer /admin without a second round trip — and // the server still enforces it on every admin route below. -const publicUser = (user: User) => ({ id: user.id, email: user.email, admin: isAdmin(user) }); +// `avatar` is a URL the client can drop straight into an , or null when +// the account never picked a picture. The `v` is the stored file's own name, so +// the URL changes with the picture and can be cached hard. +const publicUser = (user: User) => ({ + id: user.id, + email: user.email, + admin: isAdmin(user), + avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null, +}); const app = Fastify({ logger: true, @@ -125,6 +139,7 @@ function sniffImage(buf: Buffer): ImageMime | null { return null; } const EXT: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }; +const AVATAR_MIME: Record = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }; // Single error shape for the whole API: { error: "..." } app.setErrorHandler((err, req, reply) => { @@ -219,7 +234,7 @@ app.post('/api/auth/login', async (req, reply) => { const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH); if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' }); setSession(req, reply, createSession(row.id)); - return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email }) }); + return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email, avatar: row.avatar }) }); }); app.post('/api/auth/logout', async (req, reply) => { @@ -237,6 +252,37 @@ app.get('/api/auth/me', async (req, reply) => { return reply.status(200).send({ user: user ? publicUser(user) : null }); }); +// Profile: the signed-in account edits its own email or password. The current +// password is required either way, so a stolen cookie alone cannot lock the +// owner out — and the login limiter caps guesses at it. +app.patch('/api/auth/me', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + if (!allowLogin(user.email)) return tooMany(reply); + const b = bodyOf(req); + if (!b) return reply.status(400).send({ error: 'invalid body' }); + const row = findUserByEmail(user.email); + const current = typeof b.currentPassword === 'string' ? b.currentPassword : ''; + if (!row || !verifyPassword(current, row.password_hash)) + return reply.status(403).send({ error: 'invalid password' }); + + let email = user.email; + if (b.email !== undefined) { + const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : ''; + if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' }); + if (next !== user.email && !updateUserEmail(user.id, next)) + return reply.status(409).send({ error: 'email already registered' }); + email = next; + } + if (b.password !== undefined) { + const password = typeof b.password === 'string' ? b.password : ''; + if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) + return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` }); + setUserPassword(user.id, password); + } + return reply.status(200).send({ user: publicUser({ id: user.id, email, avatar: user.avatar }) }); +}); + app.get('/api/recipes', async (req, reply) => { const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); @@ -306,6 +352,49 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply return reply.status(201).send({ photo }); }); +// A profile picture is the same deal as a photo: raw bytes, sniffed, written +// under a server-generated name. The picture it replaces goes with it. +app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + if (!allowUpload(String(user.id))) return tooMany(reply); + + const body = req.body; + if (!Buffer.isBuffer(body) || body.length === 0) return reply.status(400).send({ error: 'invalid body' }); + if (body.length > MAX_PHOTO_BYTES) return reply.status(413).send({ error: 'photo too large' }); + + const declared = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); + const mime = sniffImage(body); + if (!mime || mime !== declared) return reply.status(415).send({ error: 'unsupported image type' }); + + const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; + writeFileSync(avatarPath(file), body, { flag: 'wx' }); + const previous = setUserAvatar(user.id, file); + if (previous) unlinkAvatar(previous); + return reply.status(200).send({ user: publicUser({ ...user, avatar: file }) }); +}); + +// Public on purpose: an avatar sits next to a name on the landing page, so +// there is nothing here a session would protect. +app.get<{ Params: { id: string } }>('/api/users/:id/avatar', async (req, reply) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' }); + const file = userAvatar(id); + const type = file ? AVATAR_MIME[file.split('.').pop() ?? ''] : undefined; + if (!file || !type) return reply.status(404).send({ error: 'not_found' }); + let data: Buffer; + try { + data = readFileSync(avatarPath(file)); + } catch { + return reply.status(404).send({ error: 'not_found' }); + } + // The URL carries the file's own name as a version, so it can never go stale. + return reply + .header('content-type', type) + .header('cache-control', 'public, max-age=31536000, immutable') + .send(data); +}); + app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' }); @@ -349,6 +438,28 @@ function unlink(file: string): void { } } +function unlinkAvatar(file: string): void { + try { + unlinkSync(avatarPath(file)); + } catch { + // Already gone; the row is what matters. + } +} + +// Accounts and how much each one has contributed — the "who is this" half of +// moderation. `admin` is the allowlist's answer, not a stored column. +app.get('/api/admin/users', async (req, reply) => { + const user = admin(req); + if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); + return reply.status(200).send({ + users: listUsersWithCounts().map((u) => ({ + ...u, + avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null, + admin: ADMIN_EMAILS.has(u.email), + })), + }); +}); + app.get('/api/admin/photos', async (req, reply) => { const user = admin(req); if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' }); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index 3565316..ddf9c82 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -71,6 +71,9 @@ function actor() { upload(bytes, type) { return this.req('/photos', { method: 'POST', headers: { 'content-type': type }, body: bytes }); }, + avatar(bytes, type) { + return this.req('/auth/avatar', { method: 'POST', headers: { 'content-type': type }, body: bytes }); + }, }; } @@ -269,6 +272,79 @@ try { check('an admin clears the strip in one call', cleared.status === 200 && cleared.body?.removed > 0, JSON.stringify(cleared.body)); check('the strip is empty afterwards', ((await actor().req('/photos')).body?.photos ?? []).length === 0); + // ---- profile: an account edits itself ---------------------------------- + const JSON_HDR = { 'content-type': 'application/json' }; + const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) }); + + const member = actor(); + await member.signup(`profile${stamp}@test.local`); + const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' }); + check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`); + const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' }); + check('a profile edit needs the current password', badCurrent.status === 403, `got ${badCurrent.status}`); + const takenEmail = await edit(member, { email: ADMIN_EMAIL, currentPassword: 'supersecret1' }); + check('a profile edit refuses a taken email', takenEmail.status === 409, `got ${takenEmail.status}`); + const shortNew = await edit(member, { password: 'short', currentPassword: 'supersecret1' }); + check('a profile edit refuses a short password', shortNew.status === 400, `got ${shortNew.status}`); + + const newEmail = `renamed${stamp}@test.local`; + const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' }); + check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body)); + const login = (email, password) => + actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) }); + check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200); + check('the old email no longer logs in', (await login(`profile${stamp}@test.local`, 'supersecret1')).status === 401); + + // The session that made the edit is the same row, so it also changes the password. + const newPassword = 'second-secret-1'; + const rekeyed = await edit(member, { password: newPassword, currentPassword: 'supersecret1' }); + check('an account changes its own password', rekeyed.status === 200, `got ${rekeyed.status}`); + check('the old password stops working', (await login(newEmail, 'supersecret1')).status === 401); + check('the new password works', (await login(newEmail, newPassword)).status === 200); + + // ---- admin: the account list ------------------------------------------- + const anonUsers = await actor().req('/admin/users'); + check('the user list is not public', anonUsers.status === 401, `got ${anonUsers.status}`); + const memberUsers = await member.req('/admin/users'); + check('a member cannot read the user list', memberUsers.status === 403, `got ${memberUsers.status}`); + const adminUsers = await admin.req('/admin/users'); + const adminRow = (adminUsers.body?.users ?? []).find((u) => u.email === ADMIN_EMAIL); + check('an admin reads the user list', adminUsers.status === 200 && Array.isArray(adminUsers.body?.users), `got ${adminUsers.status}`); + check('the list flags the allowlisted account', adminRow?.admin === true, JSON.stringify(adminRow)); + check('the list counts each account’s photos', typeof adminRow?.photos === 'number', JSON.stringify(adminRow)); + + // ---- avatar: the picture beside the name ------------------------------- + const memberId = renamed.body?.user?.id; + const noSession = await actor().avatar(PNG, 'image/png'); + check('an avatar upload needs a session', noSession.status === 401, `got ${noSession.status}`); + const badAvatar = await member.avatar(Buffer.from(''), 'image/png'); + check('an avatar upload sniffs the bytes', badAvatar.status === 415, `got ${badAvatar.status}`); + + const gaveAvatar = await member.avatar(PNG, 'image/png'); + const avatarUrl = gaveAvatar.body?.user?.avatar; + check('a member uploads an avatar', gaveAvatar.status === 200 && typeof avatarUrl === 'string', JSON.stringify(gaveAvatar.body)); + check('the avatar URL points at the account', new RegExp(`^/api/users/${memberId}/avatar\\?v=[0-9a-f]{32}$`).test(String(avatarUrl)), String(avatarUrl)); + + const servedAvatar = await fetch(`http://127.0.0.1:${PORT}${avatarUrl}`); + check('an avatar is served without a session', servedAvatar.status === 200, `got ${servedAvatar.status}`); + check('an avatar carries its image type', servedAvatar.headers.get('content-type') === 'image/png', String(servedAvatar.headers.get('content-type'))); + check('an avatar is cacheable for a long time', (servedAvatar.headers.get('cache-control') ?? '').includes('immutable'), String(servedAvatar.headers.get('cache-control'))); + check('the avatar bytes round-trip intact', Buffer.from(await servedAvatar.arrayBuffer()).equals(PNG)); + + const replaced = await member.avatar(JPEG_HEAD, 'image/jpeg'); + const replacedUrl = replaced.body?.user?.avatar; + check('a second avatar replaces the first', replaced.status === 200 && replacedUrl !== avatarUrl, JSON.stringify(replaced.body)); + check('the replaced avatar file is gone', !existsSync(join(DATA_DIR, 'avatars', `${String(avatarUrl).split('?v=')[1]}.png`))); + check('the new avatar resolves', (await fetch(`http://127.0.0.1:${PORT}${replacedUrl}`)).status === 200); + + const ghost = await actor().req('/users/999999/avatar'); + check('an unknown account has no avatar', ghost.status === 404, `got ${ghost.status}`); + + // The moderation list has to show the same face beside the email. + const listedUsers = (await admin.req('/admin/users')).body?.users ?? []; + const memberRow = listedUsers.find((u) => u.id === memberId); + check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow)); + // ---- pre-existing guarantees still hold --------------------------------- const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' }); check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`); diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 74a4549..76f5749 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -1,15 +1,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { api, type AdminPhoto, type PhotoSlot } from './api'; +import { api, type AdminPhoto, type AdminUser, type PhotoSlot } from './api'; import { useI18n } from './i18n/I18nProvider'; +import { Profile } from './ui/Profile'; import type { MsgKey } from './i18n/vi'; -// Curating screen: list every contributed photo, upload, delete, and say where -// each one may surface on the landing page. It holds no privilege of its own — -// the API answers 403 unless the signed-in account is on the ADMIN_EMAILS -// allowlist, so this page is only a viewer. +// Curating screen: one bordered frame, a left column of what can be managed and +// the pane for the chosen section. +// Profile — the signed-in account's own picture/email/password +// User account — every account: its name, email, picture and contribution count +// Pictures — every photo in the system, with the slot that shows it +// Close — leaves the frame and goes back to the landing page +// The frame holds no privilege of its own — the API answers 403 unless the +// signed-in account is on the ADMIN_EMAILS allowlist, so this is only a viewer. // ponytail: no pagination. The upload quota caps the table at a handful of // rows per account; add a page cursor when the strip outgrows one screen. type State = 'loading' | 'guest' | 'forbidden' | 'ready'; +type Tab = 'profile' | 'users' | 'pictures'; // The four places a photo can be drawn from. `strip` feeds the community reel; // each live slot shows one photo of its set, picked at random per page load. @@ -20,10 +26,20 @@ const SLOTS: { id: PhotoSlot; key: MsgKey }[] = [ { id: 'qr', key: 'adm.slotQr' }, ]; +// The left column, in the order it reads. +const MENU: { id: Tab; key: MsgKey }[] = [ + { id: 'profile', key: 'adm.tabProfile' }, + { id: 'users', key: 'adm.tabUsers' }, + { id: 'pictures', key: 'adm.tabPictures' }, +]; + export function Admin() { const { t } = useI18n(); const [state, setState] = useState('loading'); const [photos, setPhotos] = useState([]); + const [users, setUsers] = useState([]); + const [tab, setTab] = useState('pictures'); + const [owner, setOwner] = useState(null); const [busy, setBusy] = useState(false); const [note, setNote] = useState(null); const filePick = useRef(null); @@ -35,8 +51,9 @@ export function Admin() { return; } try { - const r = await api.adminListPhotos(); - setPhotos(r.photos); + const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]); + setPhotos(p.photos); + setUsers(u.users); setState('ready'); } catch { setState('forbidden'); @@ -61,17 +78,22 @@ export function Admin() { } }; + const refreshPhotos = async () => { + const [p, u] = await Promise.all([api.adminListPhotos(), api.adminListUsers()]); + setPhotos(p.photos); + setUsers(u.users); + }; + const upload = (file: File) => run(async () => { await api.uploadPhoto(file); - const r = await api.adminListPhotos(); - setPhotos(r.photos); + await refreshPhotos(); }); const remove = (id: number) => run(async () => { await api.adminDeletePhoto(id); - setPhotos((prev) => prev.filter((p) => p.id !== id)); + await refreshPhotos(); }); const setSlot = (id: number, slot: PhotoSlot) => @@ -84,11 +106,18 @@ export function Admin() { if (!window.confirm(t('adm.clearConfirm', { n: photos.length }))) return; return run(async () => { const r = await api.adminClearPhotos(); - setPhotos([]); + await refreshPhotos(); setNote(t('adm.removed', { n: r.removed })); }); }; + const showOwner = (email: string) => { + setOwner(email); + setTab('pictures'); + }; + + const shown = owner ? photos.filter((p) => p.email === owner) : photos; + return (
@@ -144,49 +173,132 @@ export function Admin() { {state === 'forbidden' ?

{t('adm.notAdmin')}

: null} {state === 'ready' ? ( - <> -

{t('adm.subtitle')}

-

{t('adm.slotHint')}

- - ) : null} - - {state === 'ready' && photos.length === 0 ?

{t('adm.empty')}

: null} - - {state === 'ready' && photos.length > 0 ? ( -
- {photos.map((p) => ( -
- -
- #{p.id} - {t('adm.owner')}: {p.email} - {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} - {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} -
- +
+
- ))} + ))} + {t('adm.close')} + + +
+ {tab === 'pictures' ? ( + <> +

{t('adm.subtitle')}

+

{t('adm.slotHint')}

+ {owner ? ( +

+ {owner} + +

+ ) : null} + {shown.length === 0 ?

{t('adm.empty')}

: null} +
+ {shown.map((p) => ( +
+ +
+ #{p.id} + {t('adm.owner')}: {p.email} + {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} + {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} +
+ + +
+ ))} +
+ + ) : null} + + {tab === 'users' ? ( + users.length === 0 ?

{t('adm.noUsers')}

: ( +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + + ))} + +
{t('adm.userPicture')}{t('adm.userName')}{t('adm.userEmail')}{t('adm.userRole')}{t('adm.userPhotos')}{t('adm.userJoined')} +
+ {/* The account's own picture, straight from the + public avatar route — nothing to fetch. */} + {u.avatar ? ( + + ) : ( + {(u.email[0] ?? '?').toUpperCase()} + )} + {u.email.split('@')[0]}{u.email} + + {u.admin ? t('adm.roleAdmin') : t('adm.roleMember')} + + {u.photos}{new Date(u.createdAt).toLocaleDateString()} + +
+
+ ) + ) : null} + + {tab === 'profile' ? setNote(msg)} /> : null} +
) : null}
diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 7cc3d9f..5ad049c 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -3,6 +3,7 @@ import { api, type Photo, type PhotoSlot, type User } from './api'; import { useI18n } from './i18n/I18nProvider'; import { FONT_SETS, useTheme } from './theme/ThemeProvider'; import { AuthModal } from './ui/AuthModal'; +import { Avatar } from './ui/Avatar'; // Landing page, built to the landing blueprint: the 10-section funnel. The // marketing prose is bilingual and lives here next to the markup as `Txt` @@ -104,7 +105,9 @@ export function Landing() { const { mode, fonts, setMode, setFonts } = useTheme(); const [menu, setMenu] = useState(false); const [themeMenu, setThemeMenu] = useState(false); + const [accountMenu, setAccountMenu] = useState(false); const tools = useRef(null); + const account = useRef(null); const [presetId, setPresetId] = useState(PRESETS[0].id); const [temp, setTemp] = useState(0); const [sat, setSat] = useState(100); @@ -199,6 +202,23 @@ export function Landing() { return () => document.removeEventListener('mousedown', away); }, [themeMenu]); + // The account popover closes the same way, and signing out puts the chip back + // to its signed-out state without a reload. + useEffect(() => { + if (!accountMenu) return; + const away = (e: MouseEvent) => { + if (!account.current?.contains(e.target as Node)) setAccountMenu(false); + }; + document.addEventListener('mousedown', away); + return () => document.removeEventListener('mousedown', away); + }, [accountMenu]); + + const signOut = useCallback(async () => { + await api.logout().catch(() => undefined); + setUser(null); + setAccountMenu(false); + }, []); + // The simulator's output: three knobs, one CSS filter string, applied live. const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`; @@ -294,14 +314,32 @@ export function Landing() { signed in the same slot names the account, so the session is visible on the page that took the credentials. */} {user ? ( - - {user.email.split('@')[0]} - + + + {accountMenu ? ( +
+

{user.email}

+ {user.admin ? ( + {t('nav.admin')} + ) : ( + {t('nav.profile')} + )} + +
+ ) : null} +
) : ( + + + {note ?

{note}

: null} + + {user ? ( + + ) : loaded ? ( +

+ {t('nav.login')} +

+ ) : null} + + ); +} diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index d4ec6cd..c012d1d 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -8,6 +8,10 @@ export interface User { // True when the account is on the API's ADMIN_EMAILS allowlist. The server // re-checks it on every admin route; this only drives what the UI offers. admin?: boolean; + // A ready-to-use picture URL (`/api/users//avatar?v=`), or null. + // The version segment is the file's own name, so a replacement is never + // served from cache. + avatar?: string | null; } export interface SavedRecipe { @@ -39,6 +43,17 @@ export interface AdminPhoto extends Photo { bytes: number; } +// One account as /api/admin/users reports it. `avatar` is the ready-made URL +// (or null), same shape as on the signed-in user. +export interface AdminUser { + id: number; + email: string; + createdAt: string; + photos: number; + admin: boolean; + avatar: string | null; +} + async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { credentials: 'same-origin', @@ -46,12 +61,23 @@ async function call(path: string, init?: RequestInit): Promise { ...init, }); if (res.status === 204) return undefined as T; - const text = await res.text(); - const body = text ? JSON.parse(text) : {}; + const body = await readJson(res); if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); return body as T; } +// A gateway error (nginx's 502/504 page) arrives as HTML, and the JSON parser's +// "Unexpected token '<'" says nothing useful — degrade to the status instead. +async function readJson(res: Response): Promise<{ error?: string } & Record> { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text) as { error?: string }; + } catch { + return { error: `HTTP ${res.status}` }; + } +} + export const api = { // null user = signed out; the API answers 200 either way. me: () => call<{ user: User | null }>('/auth/me'), @@ -78,16 +104,35 @@ export const api = { headers: { 'content-type': file.type }, body: file, }); - const text = await res.text(); - const body = text ? JSON.parse(text) : {}; + const body = await readJson(res); if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); - return body as { photo: Photo }; + return body as unknown as { photo: Photo }; }, photoUrl: (id: number) => `/api/photos/${id}/file`, + // Admin only: every account, with how many photos it owns. + adminListUsers: () => call<{ users: AdminUser[] }>('/admin/users'), adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'), adminDeletePhoto: (id: number) => call(`/admin/photos/${id}`, { method: 'DELETE' }), adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }), adminSetPhotoSlot: (id: number, slot: PhotoSlot) => call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }), + + // Own profile. The API wants `currentPassword` on every edit, even an email-only one. + updateProfile: (body: { email?: string; password?: string; currentPassword: string }) => + call<{ user: User }>('/auth/me', { method: 'PATCH', body: JSON.stringify(body) }), + + // The profile picture, raw bytes like a photo. Replacing it deletes the old + // file, so the returned User carries a new `?v=` and nothing goes stale. + uploadAvatar: async (file: File) => { + const res = await fetch('/api/auth/avatar', { + method: 'POST', + credentials: 'same-origin', + headers: { 'content-type': file.type }, + body: file, + }); + const body = await readJson(res); + if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); + return body as unknown as { user: User }; + }, }; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 42d81ec..de01f20 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -113,6 +113,7 @@ export const en: Dict = { 'photo.failed': 'Upload failed: {msg}', 'nav.admin': 'Admin', + 'nav.profile': 'Profile', 'adm.title': 'Strip moderation', 'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.', 'adm.upload': 'ADD A PHOTO', @@ -134,6 +135,31 @@ export const en: Dict = { 'adm.clearConfirm': 'Delete all {n} contributions? This cannot be undone.', 'adm.removed': 'Deleted {n} photos.', 'adm.back': 'Back to home', + 'adm.settings': 'SETTINGS', + 'adm.tabPictures': 'Pictures', + 'adm.tabUsers': 'User account', + 'adm.tabProfile': 'Profile', + 'adm.close': 'Close', + 'adm.userPicture': 'Picture', + 'adm.userName': 'Name', + 'adm.userEmail': 'Email', + 'adm.userRole': 'Role', + 'adm.userPhotos': 'Photos', + 'adm.userJoined': 'Joined', + 'adm.roleAdmin': 'Admin', + 'adm.roleMember': 'Member', + 'adm.viewPhotos': 'VIEW PHOTOS', + 'adm.noUsers': 'No accounts yet.', + 'adm.showAll': 'ALL', + 'adm.profileHint': 'Picture, email and password of the signed-in account. Changing the email or password needs the current password.', + 'adm.changeAvatar': 'CHANGE PICTURE', + 'adm.currentPassword': 'Current password', + 'adm.newEmail': 'New email', + 'adm.newPassword': 'New password', + 'adm.save': 'SAVE', + 'adm.saved': 'Profile saved.', + + 'profile.title': 'My profile', 'err.noWebgl': 'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index cb9009b..932de62 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -15,6 +15,7 @@ export const vi = { 'nav.language': 'Ngôn ngữ', 'nav.back': 'Về trang chủ', 'nav.admin': 'Quản trị', + 'nav.profile': 'Hồ sơ', 'nav.guest': 'Khách', 'act.undo': 'HOÀN TÁC', @@ -137,6 +138,31 @@ export const vi = { 'adm.clearConfirm': 'Xoá toàn bộ {n} ảnh đóng góp? Không hoàn tác được.', 'adm.removed': 'Đã xoá {n} ảnh.', 'adm.back': 'Về trang chủ', + 'adm.settings': 'CÀI ĐẶT', + 'adm.tabPictures': 'Ảnh', + 'adm.tabUsers': 'Người dùng', + 'adm.tabProfile': 'Hồ sơ', + 'adm.close': 'Đóng', + 'adm.userPicture': 'Ảnh', + 'adm.userName': 'Tên', + 'adm.userEmail': 'Email', + 'adm.userRole': 'Quyền', + 'adm.userPhotos': 'Số ảnh', + 'adm.userJoined': 'Ngày tạo', + 'adm.roleAdmin': 'Quản trị', + 'adm.roleMember': 'Thành viên', + 'adm.viewPhotos': 'XEM ẢNH', + 'adm.noUsers': 'Chưa có tài khoản nào.', + 'adm.showAll': 'TẤT CẢ', + 'adm.profileHint': 'Ảnh đại diện, email và mật khẩu của tài khoản đang đăng nhập. Đổi email hoặc mật khẩu cần mật khẩu hiện tại.', + 'adm.changeAvatar': 'ĐỔI ẢNH ĐẠI DIỆN', + 'adm.currentPassword': 'Mật khẩu hiện tại', + 'adm.newEmail': 'Email mới', + 'adm.newPassword': 'Mật khẩu mới', + 'adm.save': 'LƯU', + 'adm.saved': 'Đã lưu hồ sơ.', + + 'profile.title': 'Hồ sơ của tôi', 'err.noWebgl': 'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).', diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 8cb113b..307cc8b 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -8,12 +8,21 @@ import { I18nProvider } from './i18n/I18nProvider'; import { Landing } from './Landing'; import { Workspace } from './App'; import { Admin } from './Admin'; +import { ProfilePage } from './ProfilePage'; -// Three routes, no router: the landing page, the workspace and the strip -// moderation screen. nginx serves index.html for all of them (SPA fallback), -// so this is just a pathname check. +// Four routes, no router: the landing page, the workspace, the strip moderation +// screen and the member's own profile. nginx serves index.html for all of them +// (SPA fallback), so this is just a pathname check. const path = window.location.pathname; -const page = path.startsWith('/app') ? : path.startsWith('/admin') ? : ; +const page = path.startsWith('/app') ? ( + +) : path.startsWith('/admin') ? ( + +) : path.startsWith('/profile') ? ( + +) : ( + +); const root = document.getElementById('root'); if (!root) throw new Error('#root missing'); diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index f1b6dba..85cd938 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -337,9 +337,99 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .adm-danger { color: var(--danger); border-color: var(--border); } .adm-danger:hover:not(:disabled) { border-color: var(--danger); } +/* Settings drawer: the tabs that organise the page, plus the three panels. */ +/* The management frame: a bordered box whose left column is the menu and whose + right side is the pane for the chosen section. */ +.adm-frame { + display: flex; + align-items: stretch; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-elev); + overflow: hidden; +} +.adm-side { + flex: 0 0 200px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 10px; + border-right: 1px solid var(--border); + background: var(--bg-sunken); +} +.adm-side-title { + padding: 0 8px 8px; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); +} +.adm-side-item { + display: block; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; +} +.adm-side-item:hover { background: var(--bg-elev); } +.adm-side-item.on { background: var(--accent); color: var(--accent-fg); font-weight: 600; } +.adm-pane { flex: 1 1 auto; min-width: 0; padding: 14px 16px; } +.adm-table-wrap { overflow-x: auto; } +.adm-face { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 999px; + overflow: hidden; + background: var(--bg-sunken); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 11px; + font-weight: 600; +} +.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; } +.adm-filter { display: flex; align-items: center; gap: 8px; } +.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; } +.field-label { font-size: 11.5px; color: var(--text-dim); } + +/* --- profile picture ---------------------------------------------------- */ +/* One face for every control that names the signed-in user: the workspace + TopBar, the landing chip and the profile form. No picture = initial. */ +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 26px; + height: 26px; + border-radius: 999px; + overflow: hidden; + background: var(--bg-sunken); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 11px; + font-weight: 600; + line-height: 1; +} +.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; } +.avatar-sm { width: 20px; height: 20px; font-size: 10px; } +.avatar-row { display: flex; align-items: center; gap: 10px; } +.avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; } +.account-btn { display: inline-flex; align-items: center; gap: 7px; } + /* --- responsive --------------------------------------------------------- */ @media (max-width: 860px) { .workspace { flex-direction: column; } + /* The frame's left column becomes a wrapping row above the pane. */ + .adm-frame { flex-direction: column; } + .adm-side { flex: 0 0 auto; flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--border); } /* The header wraps on narrow screens, so a menu-wrap can land at the start of a row — the popover is right-anchored to its button and would hang off the left of the screen (measured: x=-280 of 390px). Anchor it to the full-width diff --git a/docker/frontend/src/styles/landing.css b/docker/frontend/src/styles/landing.css index 492816a..99a89e4 100644 --- a/docker/frontend/src/styles/landing.css +++ b/docker/frontend/src/styles/landing.css @@ -142,10 +142,26 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; } .lp-theme-pop .chip { background: transparent; border-color: var(--lp-line); color: var(--lp-muted); } .lp-theme-pop .chip:hover { color: var(--lp-text); } .lp-theme-pop .chip.on { color: var(--lp-ink-amber); border-color: var(--lp-ink-amber); background: rgba(251, 191, 36, 0.12); } +/* The account chip: a face + a name, opening the two things that account can + do — its page (Admin / Profile) and log out. */ +.lp-account { display: inline-flex; align-items: center; gap: 7px; } +.lp-account .avatar { border-color: var(--lp-line); background: rgba(255, 255, 255, 0.06); color: var(--lp-muted); } +.lp-acc-pop .hint { color: var(--lp-muted); } +.lp-acc-pop .btn { + display: block; + width: 100%; + margin-top: 6px; + text-align: left; + background: transparent; + border-color: var(--lp-line); + color: var(--lp-muted); + text-decoration: none; +} +.lp-acc-pop .btn:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); } .lp-burger { display: none; } @media (max-width: 1160px) { - .lp-nav-links, .lp-nav .lp-wrap > .lp-btn { display: none; } + .lp-nav-links, .lp-nav .lp-wrap > .lp-btn, .lp-nav .lp-account { display: none; } .lp-burger { display: inline-flex; flex-direction: column; diff --git a/docker/frontend/src/ui/Avatar.tsx b/docker/frontend/src/ui/Avatar.tsx new file mode 100644 index 0000000..167807e --- /dev/null +++ b/docker/frontend/src/ui/Avatar.tsx @@ -0,0 +1,13 @@ +import type { User } from '../api'; + +// The signed-in user's picture, wherever their name is shown: the workspace +// TopBar, the landing chip, the profile form. Falls back to the email's first +// letter, so an account without a picture still has a face. +export function Avatar({ user, small }: { user: User | null; small?: boolean }) { + const url = user?.avatar ?? null; + return ( + + {url ? : (user?.email[0] ?? '?').toUpperCase()} + + ); +} diff --git a/docker/frontend/src/ui/Profile.tsx b/docker/frontend/src/ui/Profile.tsx new file mode 100644 index 0000000..60ceefe --- /dev/null +++ b/docker/frontend/src/ui/Profile.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react'; +import { api, type User } from '../api'; +import { useI18n } from '../i18n/I18nProvider'; +import { Avatar } from './Avatar'; + +// The signed-in account editing itself: picture, email, password. The API +// re-verifies the current password and the login limiter caps guessing, so this +// form holds no secret. Shared by the /admin settings drawer and /profile. +export function Profile({ onSaved, onUser }: { onSaved: (note: string) => void; onUser?: (user: User) => void }) { + const { t } = useI18n(); + const [user, setUser] = useState(null); + const [email, setEmail] = useState(''); + const [current, setCurrent] = useState(''); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const filePick = useRef(null); + + useEffect(() => { + api.me().then((r) => { + setUser(r.user); + setEmail(r.user?.email ?? ''); + }).catch(() => undefined); + }, []); + + const keep = (u: User) => { + setUser(u); + setEmail(u.email); + onUser?.(u); + }; + + const pick = async (file: File) => { + setBusy(true); + setErr(null); + try { + keep((await api.uploadAvatar(file)).user); + onSaved(t('adm.saved')); + } catch (e2) { + setErr(e2 instanceof Error ? e2.message : String(e2)); + } finally { + setBusy(false); + } + }; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setErr(null); + try { + const r = await api.updateProfile({ + email: email.trim(), + currentPassword: current, + ...(password ? { password } : {}), + }); + keep(r.user); + setPassword(''); + setCurrent(''); + onSaved(t('adm.saved')); + } catch (e2) { + setErr(e2 instanceof Error ? e2.message : String(e2)); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t('adm.profileHint')}

+
+ + + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) void pick(file); + }} + /> +
+ + setEmail(e.target.value)} + /> + + setCurrent(e.target.value)} + /> + + setPassword(e.target.value)} + /> + {err ?

{err}

: null} + +
+ ); +} diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 5c14c3f..b8f153c 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useI18n, LANGS } from '../i18n/I18nProvider'; import { ACCENTS, FONT_SETS, useTheme } from '../theme/ThemeProvider'; +import { Avatar } from './Avatar'; import type { User } from '../api'; // Header: the app's TopBar minus everything camera-only, plus the two controls @@ -123,18 +124,35 @@ export function TopBar({
- {menu === 'account' ? (
{user ? ( <>

{t('auth.loggedInAs', { email: user.email })}

+ {/* The account control opens the one page that account can act + on: moderation for an admin, its own profile for a member. */} {user.admin ? ( {t('nav.admin')} - ) : null} - + ) : ( + {t('nav.profile')} + )} + ) : ( <>