fix: clear every open Sonar security issue, and half the reliability ones (#2037)

* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
This commit is contained in:
Maurice
2026-08-25 15:22:38 +02:00
committed by GitHub
parent 7da0080557
commit 2c2d008dc2
239 changed files with 1303 additions and 1097 deletions
@@ -48,7 +48,7 @@ export function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
const [r, g, b] = m.map(x => Number.parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
+3 -3
View File
@@ -46,7 +46,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
<Plus size={16} />
</button>
@@ -87,7 +87,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
<button type="button" onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
@@ -101,7 +101,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</button>
</div>
)}
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
@@ -19,7 +19,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
onAdd({ name: name.trim(), total_price: Number.parseFloat(String(price).replace(',', '.')) || 0, persons: Number.parseInt(persons) || null, days: Number.parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
setTimeout(() => nameRef.current?.focus(), 50)
}
@@ -57,7 +57,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
<button type="button" onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
<Plus size={14} />
@@ -111,7 +111,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<>
<span style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{cat}</span>
{canEdit && (
<button onClick={() => setEditingCat({ name: cat, value: cat })}
<button type="button" onClick={() => setEditingCat({ name: cat, value: cat })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
<Pencil size={10} />
@@ -123,7 +123,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
{canEdit && (
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
<button type="button" onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
@@ -225,11 +225,11 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
readOnly={!canEdit}
/>
) : (
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
)}
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? Number.parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
@@ -246,7 +246,7 @@ export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEd
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
<td style={{ ...td, textAlign: 'center' }}>
{canEdit && (
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
<button type="button" onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
@@ -23,7 +23,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
const save = () => {
setEditing(false)
let v: string | number | null = editValue
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (type === 'number') { const p = Number.parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (v !== value) onSave(v)
}
@@ -131,7 +131,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
/>
))}
{!readOnly && (
<button ref={btnRef} onClick={openDropdown}
<button type="button" ref={btnRef} onClick={openDropdown}
style={{
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -149,7 +149,7 @@ export default function BudgetMemberChips({ members = [], tripMembers = [], onSe
{tripMembers.map(tm => {
const isActive = memberIds.includes(tm.id)
return (
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
<button type="button" key={tm.id} onClick={() => toggleMember(tm.id)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
fontFamily: 'inherit', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-primary)', textAlign: 'left',
@@ -75,7 +75,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
<button type="button" onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: theme.sub, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 0.5,
@@ -22,7 +22,7 @@ export function splitCents(amount: number, n: number): number[] {
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
}
/** True when the payer amounts add up to the expense total, to the cent. */
@@ -45,7 +45,7 @@ export function rebalancePayers(
if (free.length === 0) return amounts
const pinnedSum = all
.filter(id => pinned.has(id))
.reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
.reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
const shares = splitCents(total - pinnedSum, free.length)
const next = { ...amounts }
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
@@ -153,7 +153,7 @@ export function calculateTicketShares(items: TicketItem[]): { shares: Record<num
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
for (const item of items) {
const priceNum = parseFloat(item.price) || 0
const priceNum = Number.parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
+25 -25
View File
@@ -348,12 +348,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 10 }}>
<button onClick={settleAll} disabled={!(settlement?.flows || []).length}
<button type="button" onClick={settleAll} disabled={!(settlement?.flows || []).length}
className="bg-surface-card border border-edge text-content disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 16px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Check size={16} /> {t('costs.settleUp')}
</button>
<button onClick={() => { setEditing(null); setModalOpen(true) }}
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }}
className="bg-[var(--text-primary)] text-[var(--bg-primary)]"
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 18px', borderRadius: 12, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={16} /> {t('costs.addExpense')}
@@ -401,14 +401,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{filterControls}
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 9, padding: 3 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
<button type="button" key={f} onClick={() => setFilter(f)}
className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'}
style={{ padding: '6px 11px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
{t('costs.filter.' + f)}
</button>
))}
</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
className="bg-surface-input border border-edge text-content-muted disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
<Download size={15} />
@@ -451,7 +451,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)}
<button type="button" onClick={() => setAddingPayment(true)}
className="text-content-muted bg-surface-secondary border border-edge"
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={13} /> {t('costs.addPayment')}
@@ -560,7 +560,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(f.amount)}</span>
{canEdit && <button onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
{canEdit && <button type="button" onClick={() => settleFlow(f.from.user_id, f.to.user_id, f.amount)} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '7px 12px', borderRadius: 9, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>{t('costs.settle')}</button>}
</div>
</div>
))}
@@ -589,7 +589,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span>{t('costs.youPaid')} · <b style={{ color: '#fff', fontWeight: 600 }}>{fmt0(totals.myPaid)}</b></span>
</div>
{canEdit && (
<button onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<button type="button" onClick={() => { setEditing(null); setModalOpen(true) }} style={{ marginTop: 16, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, background: 'rgba(255,255,255,0.14)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff', padding: 13, borderRadius: 14, fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={17} /> {t('costs.addExpense')}
</button>
)}
@@ -628,7 +628,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
{canEdit && (
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
<button type="button" onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
)}
</div>
{SettleFlows()}
@@ -638,7 +638,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div className="text-content" style={{ fontSize: 'calc(19px * var(--fs-scale-subtitle, 1))', fontWeight: 700, letterSpacing: '-0.02em' }}>{t('costs.expenses')}</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
<button type="button" onClick={handleExportCsv} title={t('budget.exportCsv')} disabled={!budgetItems.length}
className="bg-surface-card border border-edge text-content-muted disabled:opacity-40"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 34, height: 34, borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0 }}>
<Download size={15} />
@@ -650,7 +650,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 11, padding: 3, gap: 2 }}>
{(['all', 'mine', 'owed'] as const).map(f => (
<button key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
<button type="button" key={f} onClick={() => setFilter(f)} className={filter === f ? 'bg-surface-card text-content' : 'text-content-muted'} style={{ flex: 1, padding: '8px 6px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, borderRadius: 8, border: 0, cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}>{t('costs.filter.' + f)}</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
@@ -705,7 +705,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
}
return (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button type="button" title={t('common.edit')} onClick={onEdit} className="text-content-muted transition-colors" style={btn}><Pencil size={13} /></button>
<button title={deleteLabel} onClick={onDelete} className="exp-action-danger transition-colors" style={{ ...btn, color: '#dc2626' }}><Trash2 size={13} /></button>
</div>
)
@@ -839,8 +839,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
</div>
{canEdit && (
<div className="exp-actions bg-surface-card border border-edge" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, flexShrink: 0, borderRadius: 999, padding: 5 }}>
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
<button type="button" title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="text-content-muted transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0 }}><Pencil size={13} /></button>
<button type="button" title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="exp-action-danger transition-colors" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 999, border: 0, cursor: 'pointer', padding: 0, color: '#dc2626' }}><RotateCcw size={13} /></button>
</div>
)}
</div>
@@ -970,7 +970,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
const [saving, setSaving] = useState(false)
const amt = parseFloat(amount) || 0
const amt = Number.parseFloat(amount) || 0
const valid = amt > 0 && fromId !== toId
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
@@ -991,8 +991,8 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -1116,8 +1116,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
return calculateTicketShares(ticketItems)
}, [ticketItems])
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
const each = participants.size > 0 ? totalNum / participants.size : 0
const equalShares = useMemo(() => {
@@ -1130,13 +1130,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const enteredSum = [...participants]
.filter(id => customAmounts[id])
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
const remaining = Math.max(0, totalNum - enteredSum)
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
}, [totalNum, participants, customAmounts, editing])
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
const valid = name.trim().length > 0 && payersOk && (
isTicketMode
@@ -1263,7 +1263,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
// dropping the payer would store the entry with a total of 0.
const payerList = multiPayer
? [...payerIds]
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
.filter(p => p.amount > 0)
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
// A receipt line can name somebody who is not ticked as a participant. Sending
@@ -1275,7 +1275,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const memberList = memberIds.map(id => ({
user_id: id,
amount: splitMode === 'custom'
? (parseFloat(customAmounts[id]) || 0)
? (Number.parseFloat(customAmounts[id]) || 0)
: splitMode === 'ticket'
? (ticketInfo.shares[id] || 0)
: null
@@ -1315,8 +1315,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<Modal isOpen onClose={onClose} title={editing ? t('costs.editExpense') : t('costs.addExpense')} size={isMobile ? '2xl' : '5xl'}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
<button type="button" onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addExpense')}</button>
</div>
}>
{/* Two columns on desktop: what the expense was on the left, how it is
@@ -1370,7 +1370,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{COST_CATEGORY_LIST.map(c => {
const Icon = c.Icon; const on = cat === c.key
return (
<button key={c.key} onClick={() => setCat(c.key)}
<button type="button" key={c.key} onClick={() => setCat(c.key)}
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 11px 6px 7px', borderRadius: 999, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
<span style={{ width: 20, height: 20, borderRadius: 6, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={12} /></span>