Add/transaction deletion (#695)
* feat(salary): add remove-employee button to draft salary runs
The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already
existed (draft-only, cascades to the employee's line items) but had no UI
trigger, so a mistakenly added employee could only be cleared by deleting
the whole draft. Add a trash-icon action column to the "Anställda" table,
gated on draft status + write permission to match the endpoint's guard,
with a confirm prompt and success/error toast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): prevent horizontal overflow on mobile
The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(bookkeeping): move journal entry filters into a filter dialog
The ledger toolbar showed every filter inline (fiscal year, sort, series,
date range, missing-documents toggle), which felt cluttered. Keep only the
search field visible and move the rest into a "Filtrera" dialog with an
active-filter count badge.
- JournalEntryList now owns the fiscal-year scope, restored from the same
localStorage key FiscalYearSelector writes, so the page no longer renders
the selector separately.
- Filters apply live and the dialog stays open; "Rensa alla filter" clears them.
- Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the
list reuses the persisted selection without duplicating the key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(transactions): implement imported transaction guard for deletion
- Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads.
- Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported.
- Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions.
- Enhanced transaction history and inbox components to reflect the new deletion rules.
- Added tests for transaction origin determination and deletion behavior.
- Updated UI components to include a confirmation dialog for clearing journal entry forms.
- Localized new strings for clearing form functionality in English and Swedish.
* feat(transactions): enhance transaction deletion guard and improve fiscal year visibility
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
679b154ad2
commit
64991eb3c9
+239
-138
@@ -40,6 +40,78 @@ function isMobile(): boolean {
|
||||
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage key holding an in-flight BankID session across the mobile
|
||||
* return-redirect. On iOS the BankID app returns to the SAME Safari tab by
|
||||
* reloading it (see launchBankIdApp), which wipes React state — so we stash the
|
||||
* session here and resume polling on the next mount.
|
||||
*/
|
||||
const PENDING_KEY = 'bankid:pending'
|
||||
/** Ignore a stashed session older than this (BankID orders expire in ~3 min). */
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface PendingBankId {
|
||||
session: BankIdSession
|
||||
mode: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
function persistPending(session: BankIdSession, mode: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
PENDING_KEY,
|
||||
JSON.stringify({ session, mode, ts: Date.now() } satisfies PendingBankId)
|
||||
)
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / quota) — auto-resume just won't
|
||||
// fire; the user can still switch back to the tab manually as before.
|
||||
}
|
||||
}
|
||||
|
||||
function readPending(mode: string): PendingBankId | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as PendingBankId
|
||||
if (parsed?.mode !== mode) return null
|
||||
if (typeof parsed.ts !== 'number' || Date.now() - parsed.ts > PENDING_TTL_MS) return null
|
||||
if (!parsed.session?.sessionId) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearPending(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the BankID app on the same (mobile) device.
|
||||
*
|
||||
* Uses the universal link https://app.bankid.com/ — NOT the bankid:/// custom
|
||||
* scheme. A custom-scheme launch has no association with the originating Safari
|
||||
* tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit
|
||||
* 3bc652cc reverted a redirect for exactly that reason). The universal link is
|
||||
* tied to the originating tab, so BankID returns the user to it.
|
||||
*
|
||||
* redirect:
|
||||
* • iOS → current URL, so the app navigates this tab back here on success
|
||||
* (the resume effect then completes the flow).
|
||||
* • Android → "null": the BankID app returns via the task stack, and a real
|
||||
* redirect URL would spawn a new tab / Chrome instance instead.
|
||||
*/
|
||||
function launchBankIdApp(autoStartToken: string): void {
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua)
|
||||
const redirect = isIOS ? encodeURIComponent(window.location.href) : 'null'
|
||||
window.location.href = `https://app.bankid.com/?autostarttoken=${autoStartToken}&redirect=${redirect}`
|
||||
}
|
||||
|
||||
/**
|
||||
* BankID authentication flow component.
|
||||
* Handles QR code display (desktop) or app deep link (mobile),
|
||||
@@ -72,6 +144,149 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
|
||||
useEffect(() => cleanup, [cleanup])
|
||||
|
||||
// Poll an in-flight BankID session until it completes, fails, or the service
|
||||
// gives up. Extracted from startSession so the resume effect (mobile return)
|
||||
// can re-attach to a session that was started before the tab reloaded.
|
||||
const beginPolling = useCallback((session: BankIdSession) => {
|
||||
abortRef.current = new AbortController()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(`${API_BASE}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: session.sessionId }),
|
||||
signal: abortRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!pollRes.ok) {
|
||||
const pollErr = await pollRes.json().catch(() => ({}))
|
||||
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reset failure counter on successful poll
|
||||
pollFailureCount.current = 0
|
||||
|
||||
const pollJson = await pollRes.json()
|
||||
const pollData = pollJson.data
|
||||
|
||||
if (!pollData) {
|
||||
console.warn('[bankid] poll returned no data:', pollJson)
|
||||
return
|
||||
}
|
||||
|
||||
// Update hint message from TIC API
|
||||
if (pollData.message) {
|
||||
setHintMessage(pollData.message)
|
||||
}
|
||||
|
||||
// Handle token refresh (order regeneration ~25s)
|
||||
if (pollData.qrStartToken && pollData.qrStartSecret) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
|
||||
if (pollData.status === 'complete') {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('complete')
|
||||
|
||||
if (mode === 'login') {
|
||||
// For login, call /complete to exchange for Supabase session
|
||||
try {
|
||||
const completeRes = await fetch(`${API_BASE}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: session.sessionId,
|
||||
mode: 'login',
|
||||
}),
|
||||
})
|
||||
const completeJson = await completeRes.json()
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
|
||||
? 'service_unavailable' as const
|
||||
: completeJson.error
|
||||
if (errorCode === 'service_unavailable') {
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
}
|
||||
onCompleteRef.current({
|
||||
error: errorCode,
|
||||
givenName: completeJson.givenName,
|
||||
surname: completeJson.surname,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({
|
||||
tokenHash: completeJson.data.tokenHash,
|
||||
type: completeJson.data.type,
|
||||
isNewUser: completeJson.data.isNewUser,
|
||||
})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// For link, call /link to associate BankID with current user
|
||||
try {
|
||||
const linkRes = await fetch(`${API_BASE}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: session.sessionId }),
|
||||
})
|
||||
const linkJson = await linkRes.json()
|
||||
|
||||
if (!linkRes.ok) {
|
||||
onCompleteRef.current({ error: linkJson.error })
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else {
|
||||
// For signup, return user data + sessionId so parent can collect email
|
||||
onCompleteRef.current({
|
||||
givenName: pollData.user?.givenName,
|
||||
surname: pollData.user?.surname,
|
||||
sessionId: session.sessionId,
|
||||
})
|
||||
}
|
||||
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('failed')
|
||||
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
}, [cleanup, mode])
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
// Prevent rapid restarts (each start = billable TIC session)
|
||||
const now = Date.now()
|
||||
@@ -79,6 +294,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
lastStartRef.current = now
|
||||
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('scanning')
|
||||
setHintMessage('Starta BankID-appen')
|
||||
setErrorMessage('')
|
||||
@@ -101,157 +317,42 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
const newSession: BankIdSession = data
|
||||
setSession(newSession)
|
||||
|
||||
// On mobile, open BankID app — redirect=null so it doesn't open a new tab;
|
||||
// the polling in this tab detects completion when the user switches back.
|
||||
// On mobile, open the BankID app on this device.
|
||||
if (isMobile()) {
|
||||
window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=null`
|
||||
// Persist BEFORE launching: on iOS the BankID app returns by reloading
|
||||
// THIS tab (universal link → same tab), which wipes in-memory state.
|
||||
// The resume effect re-attaches polling on load. See launchBankIdApp.
|
||||
persistPending(newSession, mode)
|
||||
launchBankIdApp(newSession.autoStartToken)
|
||||
}
|
||||
|
||||
// Start polling
|
||||
abortRef.current = new AbortController()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(`${API_BASE}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
signal: abortRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!pollRes.ok) {
|
||||
const pollErr = await pollRes.json().catch(() => ({}))
|
||||
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reset failure counter on successful poll
|
||||
pollFailureCount.current = 0
|
||||
|
||||
const pollJson = await pollRes.json()
|
||||
const pollData = pollJson.data
|
||||
|
||||
if (!pollData) {
|
||||
console.warn('[bankid] poll returned no data:', pollJson)
|
||||
return
|
||||
}
|
||||
|
||||
// Update hint message from TIC API
|
||||
if (pollData.message) {
|
||||
setHintMessage(pollData.message)
|
||||
}
|
||||
|
||||
// Handle token refresh (order regeneration ~25s)
|
||||
if (pollData.qrStartToken && pollData.qrStartSecret) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
|
||||
if (pollData.status === 'complete') {
|
||||
cleanup()
|
||||
setStatus('complete')
|
||||
|
||||
if (mode === 'login') {
|
||||
// For login, call /complete to exchange for Supabase session
|
||||
try {
|
||||
const completeRes = await fetch(`${API_BASE}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: newSession.sessionId,
|
||||
mode: 'login',
|
||||
}),
|
||||
})
|
||||
const completeJson = await completeRes.json()
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
|
||||
? 'service_unavailable' as const
|
||||
: completeJson.error
|
||||
if (errorCode === 'service_unavailable') {
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
}
|
||||
onCompleteRef.current({
|
||||
error: errorCode,
|
||||
givenName: completeJson.givenName,
|
||||
surname: completeJson.surname,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({
|
||||
tokenHash: completeJson.data.tokenHash,
|
||||
type: completeJson.data.type,
|
||||
isNewUser: completeJson.data.isNewUser,
|
||||
})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// For link, call /link to associate BankID with current user
|
||||
try {
|
||||
const linkRes = await fetch(`${API_BASE}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
})
|
||||
const linkJson = await linkRes.json()
|
||||
|
||||
if (!linkRes.ok) {
|
||||
onCompleteRef.current({ error: linkJson.error })
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else {
|
||||
// For signup, return user data + sessionId so parent can collect email
|
||||
onCompleteRef.current({
|
||||
givenName: pollData.user?.givenName,
|
||||
surname: pollData.user?.surname,
|
||||
sessionId: newSession.sessionId,
|
||||
})
|
||||
}
|
||||
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
|
||||
cleanup()
|
||||
setStatus('failed')
|
||||
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
beginPolling(newSession)
|
||||
} catch (error) {
|
||||
setStatus('failed')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
|
||||
}
|
||||
}, [cleanup, mode])
|
||||
}, [cleanup, mode, beginPolling])
|
||||
|
||||
// After returning from the BankID app on mobile, iOS reloads this tab. Pick up
|
||||
// the session we persisted before launching and resume polling so the flow
|
||||
// completes without the user having to tap "Logga in med BankID" again.
|
||||
useEffect(() => {
|
||||
const pending = readPending(mode)
|
||||
if (!pending) return
|
||||
setSession(pending.session)
|
||||
setStatus('scanning')
|
||||
setHintMessage('Slutför BankID-verifieringen...')
|
||||
beginPolling(pending.session)
|
||||
// Run once on mount: we're recovering state that the return-reload destroyed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (session) {
|
||||
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
|
||||
}
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('idle')
|
||||
setSession(null)
|
||||
}, [session, cleanup])
|
||||
|
||||
Reference in New Issue
Block a user