Commit Graph
21 Commits
Author SHA1 Message Date
04406edb0f fix(mfa): hard-navigate after TOTP enrolment so the user is not stranded on the QR screen (#1948) (#1984)
leave() in /mfa/enroll ended with router.push(returnTo) followed by
router.refresh(). Enrolling raises the session to aal2, which
lib/supabase/middleware.ts only re-evaluates on a fresh document request;
the push and the refresh raced, the refresh won, and the user was left on
the QR screen with 2FA already active and no way forward but the address
bar. Always window.location.assign(returnTo) instead, the way the same
function already did for /api/ destinations and /mfa/verify does for its
invite and route-handler paths. returnTo is already validated by
safeReturnTo, so the unconditional hard navigation stays same-origin.


Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:19:20 +02:00
MattssonandClaude Fable 5 fdb5f6f891 feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation

- brands table: one white-label identity per byra team (unique mutable
  domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
  ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
  demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
  chrome tone and WCAG contrast gate; no brand row = default appearance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-request brand theming, wordmark slot and source footer

- root layout resolves the brand from the Host header and injects a
  server-rendered style block (light + dark), font pair classes and a
  BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
  images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
  for non-default entries)
- AGPL source-code footer link on login and public pages, both brands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra team invites, member management and team billing

- team invites unfrozen behind a kind gate (byra teams only, owner/admin
  invite); members route handles multi-team membership; members/[id]
  unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
  read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding

- every outbound mail is sent in the brand of the company it concerns:
  getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
  "via Accounted" fallback, canonical default) wired into invites,
  payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
  dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra cockpit, home-domain rule and tab guard

- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
  next deadline via the status engine, last booked) for byra team members,
  who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
  guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
  is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
  signpost page for companies homed elsewhere

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware app name across UI strings

- 24 message keys per locale converted to the {appName} ICU parameter,
  27 call sites pass the active brand name (useBranding client-side,
  getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
  surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
  guard, signpost and source footer (sv/en parity verified)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(white-label): domain glossary and decision log entries

- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
  home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): lean byra cockpit sidebar with company-mode back link

Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings

In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): keep company picker in cockpit with nothing selected

The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): renumber white-label migrations past main and add byra settings scope

Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.

Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): cross-client nyckeltal view in the cockpit

Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra self-service brand logo and app name

New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): route root layout through the shared brand resolver

app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): automations roadmap teaser and cockpit i18n strings

The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company

After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.

The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(white-label): drop brand color theming, keep monochrome everywhere

White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook

ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(byra): drop the 'what's coming' tail from the automations intro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins with zero companies land in the empty cockpit

Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): auth wordmark shows the brand logo alone

Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-brand favicon via brands.favicon_url

Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): wire the villkor and integritetspolicy footer links

Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popup for the team role dropdowns

The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded sender shows the brand name alone, no via-platform

Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): byra landing applies to every team member, not only owners/admins

An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded team invite names the byra, not "ett team pa <platform>"

Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar keeps cockpit mode after refresh on settings

The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): hide the active-company chip in byra-scoped settings

The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): tab guard no longer fires in the tab that initiated the switch

BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popups for every settings dropdown

SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): home-domain affinity redirect in middleware

Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): render hero brand logo at 64px on auth pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): shareable invite link and re-send for byra team invites

A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:

- persistent share-link line after invite create/re-send: ochre attn line
  with a copy action when the mail did not go out, quiet muted line with
  the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
  token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
  by create and re-send so the two paths cannot drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label

Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): close the four skeptic refutations before merge

- trial seed: migration 130300 now carries the seven-key PAID body from
  20260818170000 plus the byra guard, instead of silently reverting it;
  pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
  create_company_for_user (v1 API + MCP path), and both surfaces resolve
  the default team personal-only, so a consultant's private company can
  never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
  longer redirected off the platform host; the signpost handles per-company
  homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
  (ROT/RUT work-type picker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect

The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:56:39 +02:00
a717f03898 feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup (#1814 PR 1) (#1855)
* feat(mcp-oauth): let an account with no company connect and sign up from the OAuth popup

Identity unlock for agent-first onboarding (#1814, shape B+). A person
with no Accounted account can now connect from an MCP client, create the
account inside the Connect popup and finish the OAuth dance.

- authorize/token no longer require a company: consent renders a
  companyless variant and the key is minted with company_id NULL.
- validateApiKey returns companyId string|null and binds an unbound key
  to the user's first company on the first validation after it exists.
- MCP server: company-dependent tools and data resources answer with a
  structured NO_COMPANY_YET error; the company-independent tools still
  run; telemetry skips when there is no company scope.
- /api/events fails closed instead of throwing for an unbound key.
- authorize forces TOTP enrollment (not just verification) for password
  accounts with no factor, since the middleware skips enrollment for
  zero-company users; BankID-linked accounts stay exempt.
- /login forwards next to /register; register, GoogleAuthButton and
  /auth/callback carry it back to the consent page (callback honours
  only /api/mcp-oauth/authorize, via safeReturnTo); /mfa/enroll
  hard-navigates to /api/* destinations like /mfa/verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* refactor(company): move getActiveCompanyId out of the next/headers module

lib/auth/api-keys.ts needs the resolver for unbound-key binding, but
lib/company/context.ts imports next/headers for the legacy company cookie
and Turbopack refuses that import on some of api-keys' import paths (the
preview build failed). The resolver and CompanyContextError now live in
lib/company/active-company.ts; context.ts re-exports them so every caller
and test mock is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp-oauth): fail closed on a failed assurance lookup; enroll Back aborts instead of looping

Review findings on #1855: requireAal2 let consent through at AAL1 when
getAuthenticatorAssuranceLevel() returned nothing and a verified factor
existed. Only a positive AAL2 answer passes now; a failed lookup and the
inconsistent verified-factor-at-AAL1 case both step up to /mfa/verify.
Back on /mfa/enroll with the consent page as returnTo went straight back
into the redirect loop; it now aborts to the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:25:42 +02:00
9686b54b41 refactor(design): lock the border-radius ladder, one radius per role (#1607)
Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for
which went where; one toolbar row on /transactions mixed four shape
languages. This locks a 4-tier ladder (design.md convention 16):

- pill: interactive toolbar controls (buttons, chips, pickers, segmented
  controls, toolbar search, count nubs)
- rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs
- rounded-lg (8px): cards, form fields, popover/menu content, boxes
- rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs)

Changes:
- New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the
  hand-rolled bg-muted/70 tablist copied across 11 files
- New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars;
  dialog/picker searches keep the rounded-lg Input
- dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette
- ContextPicker chips at the shared h-8 toolbar height
- ~300 rounded-md / bare rounded call sites remapped by role; auth icon
  tiles and the mobile nav sheet come down from 16px to 12px
- rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead
  vocabulary, enforced by a new off-ladder-radius check in check:guards

Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc
clean on all changed files, sandbox screenshots of transactions/
bookkeeping/granskning toolbars and the Ny verifikation dialog.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:55:37 +02:00
314efe8b22 fix(design): stop synthesizing bold on Hedvig display headings (#1555)
* fix(design): stop synthesizing bold on Hedvig display headings

Hedvig Letters Serif ships weight 400 only, but the h1-h3 base rule
forced font-weight 500 and DialogTitle/SheetTitle stacked font-semibold
on top, so every display heading rendered browser-synthesized bold: the
smudged heavy look on dialog titles and page headings. Drop the base
rule to 400, remove the weight utilities from the title primitives, and
sweep the 41 files that hand-set font-medium/semibold/bold on serif
headings (a pattern design.md already forbids). Headings that opt into
font-sans keep their weight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(design): drop empty className left by the weight sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:27:25 +02:00
8f1b1fb5cd feat(ui): company monogram in user menu + mobile web touch polish (#1531)
* feat(ui): replace generic building icon with company monogram in user menu

The company row and switcher flyout in the sidebar user menu showed
lucide Building2 for every company. Render the company's initial in a
small rounded square instead (square = company, circle = person), so
each company gets a mark of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mobile): touch behavior polish for the mobile web experience

- kill -webkit-tap-highlight-color flash; touch-action: manipulation on
  interactive elements (no double-tap-to-zoom wait); user-select: none
  on buttons (long-press no longer enters text selection)
- overscroll-behavior-y: contain on html/body: pull-to-refresh no longer
  hijacks list scrolling, inner scrollers stop chaining to the document
  (contain, not none, so iOS rubber-banding survives)
- 16px font-size floor for form fields on coarse pointers: iOS Safari
  stops zooming into focused inputs; desktop keeps text-sm
- min-h-screen -> min-h-dvh everywhere: correct height under collapsing
  mobile browser chrome, identical on desktop
- active: variants mirror hover: on Button: Tailwind 4 gates hover:
  behind (hover: hover), so touch devices previously got zero pointer
  feedback
- theme-color now tracks the app: default was a leftover blue #304D83;
  SSR emits white and ThemeColorSync mirrors the computed --background
  into the meta tag across dark mode and palette switches

Hover-stuck-after-tap and viewport-fit/safe-area were already covered
(Tailwind 4 hover gating; existing viewportFit: cover + safe-area
utilities).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log monogram and overscroll decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:41:45 +02:00
645ed0a53e feat(login): method-state login panel with quiet inline errors (#1469)
* feat(login): method-state login panel with quiet inline errors

The login panel now shows one method at a time (the pattern Swedish
users know from banks, Kivra and Fortnox): BankID as the hero state,
the email form as a peer state, and the remaining methods as two quiet
half-width chips under a single divider. The last successful method is
remembered in an accounted-login-method cookie, read server-side so a
returning password user gets the form on the first paint with no flash.

Error display drops the boxed banner everywhere: credential failures
render as one destructive sentence directly under the password field
(fields keep aria-invalid), and the reset-password action surfaces from
the second consecutive failure. BankID/Google failures, callback errors
and the session-timeout notice are single quiet lines at the top of the
panel (AttnLine for the informational one).

Also: password visibility toggle, webkit autofill repaint to the theme
surface, auth pages move from the gradient background to the app frame
tone, register/MFA/reset get the same backdrop for cross-page
coherence, and Skapa konto moves out of the panel into a footer line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(register): mirror the method-state panel on signup

Same treatment as the login page: BankID signup as the hero state, the
email form as a peer state (live password checklist kept), alternatives
as half-width chips under one divider, quiet-line notices instead of
the blue box, subtitle dropped, footer harmonized. Successful signup
persists the method hint so the user's first login opens correctly.
The BankID-verified email-collection step keeps its panel takeover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:33:50 +02:00
MattssonandClaude Fable 5 f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
MattssonandClaude Fable 5 d840257c0c Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login

Add chatgpt.com/connector/oauth/* (per-instance) and the legacy
chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth
redirect allowlist so ChatGPT MCP connectors can register and authorize.

Fix the login page dropping the ?next= destination: an OAuth-initiated
visit that required login previously ended on the dashboard and the
connection flow silently died. Login now resumes to the sanitized next
path (hard navigation, since the consent page is route-handler HTML),
carries it through the MFA step-up as returnTo, and /mfa/verify
hard-navigates for /api/ destinations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): dedup incoming feed rows against booked hand-entered twins

Users who bookkeep via MCP/chat first and connect their bank afterwards got
the same movement twice: the synced row's external_id lives in a different
namespace, the free-form manual title never text-bridges the bank's raw
string, and the cross-channel mirror deliberately excluded manual/mcp rows.

Extend the mirror with a booked-hand-entered track: an incoming feed row is
skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-
symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be
booked (staged rows never consume an import), currencies must not contradict
(bucket key is date+ore only), the cash-account guard applies to the count
exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming
count so an already-stored row cannot inflate it. Consumption stamps the
batch cash_account_id onto an account-unbound hand row, so one hand row can
never consume feed rows on other accounts in later syncs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit)

Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style:
strike lines inside a posted verifikat with replacements in the same
voucher, and correct description/entry_date without an andringsverifikat.
Envelope: posted entries, open unlocked periods, company lock date,
same-period date moves, structural/FX/doc-linked lines excluded, and a
reconciliation guard preserving per-account net on bank/reskontra sides of
externally linked entries. Every rattelse writes an immutable who/when row
(journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and
struck originals render struck-through in the verifikat; list rows and the
detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the
swedish-accounting-compliance skill are amended to state the two-track
rule. Staging carries the DDL; prod gets it on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB

- Manual journal entry: saldo column now shows before -> after computed
  from the typed debit/credit amounts (direction feedback while booking)
- Resultatrapport: a narrowed date range now compares against the same
  window shifted one year back (#862), merged across fiscal periods for
  brutet rakenskapsar; P&L rows report window activity instead of
  rolled-forward YTD closing
- Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab,
  settings > assistant), sidebar entry unaffected; collapsed sessions keep
  their reopen handle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(stripe): sync balance transactions as a bank feed on 1686

Import the connected Stripe balance into the transactions inbox, opt-in
per connection (transaction_sync_enabled on stripe_connections):

- Balance transactions map to feed rows with the two-row gross+fee split
  and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on
  created, bound to a provisioned "Stripe-saldo" cash account on 1686 so
  booking settles against the clearing account by construction.
- Double-booking protection: settled payment-link charges import
  pre-linked to their settlement entry; payout rows import pre-linked to
  the payout entry; processPayoutPaidEvent claims the payout's fee rows
  at booking time (linkPayoutFeedRows, idempotent from both directions).
- Cursor last_balance_txn_synced_at with 24h overlap; first run
  backfills 90 days floored at the day after the company lock date.
- Nightly cron /api/extensions/stripe/transactions/cron (03:30),
  transaction-sync toggle route, "Synka nu" covers both feeds, settings
  panel toggle with last-synced/backfill note, sv+en strings.
- Migration 20260723200000 (applied to staging).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): offer match-to-voucher on unbooked history rows

Unbooked transactions with is_business already set (e.g. left behind when
a voucher was removed without a full uncategorize) land in the history
list instead of the inbox, where the match-against-existing-voucher
action did not exist, leaving them with no path back to voucher
matching. Add the same menu item to the history list for unbooked rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(transactions): enhance ownership checks and error handling in journal entry routes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:16:20 +02:00
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob WennbergandClaude Opus 4.8 b800dcd403 style(ui): system-wide UX/UI polish pass — design-system conformance + copy cleanup (#835)
* style(ui): system-wide UX/UI polish pass — design-system conformance + copy cleanup

Multi-agent scan of all 404 UI files against the locked design system, then
141 verified surgical fixes across 109 files (net -32 lines):

- Remove forbidden elevation/motion: shadow-* and rounded-xl on cards, active:scale
  bounce, hover:shadow on list items, transition-all -> transition-colors.
- Drop font-medium from single-weight Hedvig display headings/numerals.
- Replace raw rainbow Tailwind status colors with Badge variants / brand tokens /
  neutral surfaces (achromatic chrome, semantic colors stay data-only).
- Route raw dates through formatDate(), hand-rolled currency through formatCurrency(),
  add tabular-nums to financial figures; text-gray-* -> text-foreground tokens.
- Swap hand-rolled skeletons for the Skeleton primitive; off-scale spacing -> token scale.
- Fix copy: mislabeled "Leverantörsfakturor" -> "Utgifter" on bank-import outflow total,
  collapse no-op identical-branch ternaries, broken Swedish diacritics (mojibake),
  correct mismatch-password toast, correct supplier currency-field label.
- Remove PII-leaking debug console.log on register, stray console.logs.

Verified: tsc clean on all changed files, eslint clean, production build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth): sanitize residual error logs in register flow

Follow-up to PR review (compliance swarm V16 / GDPR Art.5(1)(f)): the
remaining console.error calls in the register flow passed raw error
objects, which Supabase may populate with PII (email) in nested fields.
Log only sanitized message strings instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:14:13 +02:00
MattssonandClaude Opus 4.7 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00
Mattsson e4488a900b feat: add user locale preference to user_preferences table (#555)
* feat: add user locale preference to user_preferences table

- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.

chore: declare CSS module support in TypeScript

- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.

* feat: add Swish as an invoice payment method in company settings
2026-05-21 21:33:22 +02:00
Jakob WennbergandClaude Opus 4.7 cc351158f8 Invoicing & account-security polish bundle (#550)
* feat: invoicing & account-security polish bundle

Five independent improvements bundled to ship together:

- BankID/password lockout fix: BankID-only users could enroll MFA and
  brick themselves (Supabase requires AAL2 to change password or unenroll
  MFA, and AAL2 needs a password sign-in). New app_metadata.has_password
  flag tracks this; middleware gates /mfa/enroll behind it, /account/set-
  password is the unlock path, SecuritySettings shows a banner, and
  /api/account/password is the single write path that flips the flag.
  Backfill script for existing users.

- Swish invoice payment method: company_settings.swish + invoice_show_swish
  columns, validation in lib/api/schemas.ts (accepts 123XXXXXXX företag or
  07XXXXXXXX mobile, strips whitespace/hyphens), rendered on invoice PDFs.

- Send-reminders kill switch: per-company company_settings.send_invoice_
  reminders toggle in PdfPrintSettings/Automatisering. Reminder processor
  also tightened: positive status allowlist (sent + overdue) so terminal
  statuses can never match; skip when customer already responded via
  reminder link; race-window re-check before send.

- First-invoice logo prompt: one-shot dialog when creating the first
  invoice without a logo (issue #520). Self-limits via head-only count.

- SIE export opening-balance fallback: route IB through getOpeningBalances
  so the compute_prior_opening_balances RPC supplies #IB after multi-year
  imports where opening_balance_entry_id is intentionally NULL. Previously
  #IB silently went to zero and #UB collapsed to current-period movements.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(account-polish): address PR review feedback

- BankID-link path (extensions/general/tic/index.ts): read-merge-write
  app_metadata instead of passing { bankid_linked: true } alone.
  updateUserById REPLACES app_metadata wholesale, so the previous code
  would have wiped has_password for any user who later linked BankID,
  causing the set-password banner to (incorrectly) reappear and blocking
  the standard MFA enrollment button. The comment is now corrected.

- Middleware (lib/supabase/middleware.ts): thread inner returnTo through
  the /mfa/enroll → /account/set-password redirect so the user lands on
  their original destination after the full chain completes, not on /.

- safeReturnTo helper (lib/auth/safe-return-to.ts): replace the
  starts-with-/-but-not-// guard on mfa/enroll and set-password pages.
  The previous guard let /\evil.com and /@evil.com through. The new
  helper parses against a synthetic base origin and verifies it matches.

- set-password page (app/(auth)/account/set-password/page.tsx): remove
  CLAUDE.md design system violations — bg-gradient-to-b on page bg,
  inline shadow-md style on the card, space-y-5, font-medium on the h1,
  rounded-xl on the card. Flat surface, hairline border, font-display
  h1 per the design tokens.

- Swish dedup (lib/payments/swish.ts): extract normaliseSwish() and
  isValidSwish() helpers and use them in lib/api/schemas.ts,
  components/settings/BankDetailsForm.tsx, and the invoicing settings
  page. Single source of truth for the regex.

- Password route (app/api/account/password/route.ts): emit a structured
  success log so the audit pipeline can detect password-set events, not
  just failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:44:09 +02:00
MattssonandClaude Opus 4.7 064fb7f7a9 Add/white label (#381)
* feat(branding): add BrandingService with default-preserving env layer

Introduce lib/branding/service.ts mirroring lib/email/service.ts. Defaults
match current gnubok values exactly, so production behaviour is unchanged
unless an env var (NEXT_PUBLIC_BRANDING_*, BRANDING_*) or extension override
(via registerBrandingService) is set.

Resolution order: defaults < env vars < extension override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(branding): route root layout, manifest, and PWA assets through branding service

- app/layout.tsx now reads title, description, themeColor, and apple-touch-icon
  from getBranding() instead of hardcoded values.
- public/manifest.json replaced by dynamic app/manifest.ts so PWA name,
  short_name, description, theme_color, background_color, and icon paths
  are resolved at request time.

The manifest now serves at /manifest.webmanifest (Next.js convention for
the metadata file route). The previous /manifest.json URL is no longer
populated; nothing in core references it after this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(branding): route email service and templates through branding service

- resend-service.ts: From line uses getBranding().appName instead of
  hardcoded "Gnubok" in both the with-fromName and bare cases.
- invite-templates.ts: subject, HTML header, body, plain text, and the
  team-invite variants all read from branding (sentence case in prose,
  uppercased for the styled <p> header).
- consent-notification-templates.ts: signature fallback (companyName ||
  branding) for both HTML and plain text variants.

Defaults preserve the exact current strings ("Gnubok", "GNUBOK", "gnubok"
in their respective contexts) so no email content changes for production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(branding): route OAuth consent page through branding service

The MCP OAuth consent page rendered for Claude Desktop / Claude.ai
connector flows now reads the app name from getBranding() for both the
HTML <title> and the body copy. Default still produces "gnubok" in
lowercase prose, matching current behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(branding): route auth, dashboard, and onboarding text through branding service

Replace user-visible "gnubok" / "Gnubok" references with calls to
getBranding(). Touches:

- Auth pages (login, register, mfa/enroll): logo src/alt, MFA TOTP
  friendlyName.
- Onboarding (companies/new, invite, sandbox, WelcomeOnboarding,
  Step2CompanyDetails, NewUserChecklist, BankIdCompanyPicker,
  ArcimMigrationWorkspace): logo, headings, error/help text.
- Dashboard fallback (companyName="gnubok") and settings (backup copy,
  ApiKeysPanel MCP connector name + login note, CompanyDangerZone,
  retention-notice).
- API routes (support contact subject prefix, enable-banking consent
  email companyName fallback, AI inbox receipt-request appUrl,
  pain001 messageId prefix).
- MCP server "open the gnubok web app" review message.
- Salary/reports filings (AGI Programnamn, KU10 Programnamn,
  payslip footer, full-archive system metadata, SRU #PROGRAM line).

Internal identifiers (cookie names gnubok-company-id /
gnubok-invite-token, API key prefix gnubok_sk_, invite token prefix
gnubok_inv_, MCP tool names, npm package gnubok-mcp, GNUBOK_API_KEY
env name) are deliberately left unchanged — they're stable contracts
that whitelabels must not break.

Defaults match current behaviour exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(branding): support legal page field-level swaps for entity and contact

Privacy and DPA pages now interpolate appName, legalEntity, and
privacyEmail from the branding service instead of hardcoding "Gnubok",
"Arcim", and "privacy@gnubok.se". Page metadata uses generateMetadata()
so titles also reflect the brand.

lib/support.ts now falls back to getBranding().supportEmail when
SUPPORT_RECIPIENT_EMAIL is unset, so a single BRANDING_SUPPORT_EMAIL
env var configures both the support form recipient and the displayed
support address.

Whitelabels with a different legal jurisdiction or entirely different
DPA text should override the page route from an extension. Phase 1
intentionally only supports field-level swaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(branding): add WHITELABEL.md and example branding extension

WHITELABEL.md: fork checklist, env var reference, the "do not change"
list (cookies, API key prefixes, invite token prefixes, MCP tool names,
gnubok-mcp npm package, GNUBOK_API_KEY env name), out-of-scope items,
the upstream sync workflow YAML to copy into a fork, conflict avoidance
guidance, and a verification checklist.

extensions/general/_example-branding/: copy-paste starter extension with
index.ts (commented placeholder values for registerBrandingService),
manifest.json, and README.md. Disabled by default (not added to
extensions.config.json); whitelabels cp the folder, edit, and enable.

sectors.test.ts: bumped expected extension count 12 -> 13 to account
for the new starter extension on disk. The generated registry is
unchanged because the example is disabled.

The sync workflow YAML is documented inline in WHITELABEL.md rather
than checked in as a workflow file. It's only meaningful in a fork --
gnubok itself has nothing to sync from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(branding): address PR review — lazy support email + escape brand in HTML/XML

Three issues from code review:

P1 — lib/support.ts: SUPPORT_RECIPIENT_EMAIL was a module-level const,
evaluated at import time before extensions register branding overrides
via ensureInitialized(). Convert to getSupportRecipientEmail() lazy
accessor; update the only caller in app/api/support/contact/route.ts.
Extension-supplied supportEmail values now route correctly.

P2 — app/api/mcp-oauth/authorize/route.ts: appName was interpolated
into the consent page HTML without escapeHtml(), inconsistent with
the existing escaping of companyName. Wrap appName.toLowerCase() in
escapeHtml() at use sites in <title> and the body paragraph.

P2 — lib/salary/agi/xml-generator.ts and lib/salary/ku/ku10-generator.ts:
appName placed inside <gem:Programnamn> / <Programnamn> XML elements
without escapeXml(), the helper already used for other admin-controlled
fields in the same files. Wrap accordingly to prevent malformed XML if
a brand name contains XML reserved characters.

All admin-controlled inputs only — no user-exploitable path. Defense in
depth, not a known incident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(branding): security follow-up — lazy metadata, SRU/email header sanitization

Self-audit after the PR review surfaced four more concerns. Fixes them
with the same defense-in-depth posture as the prior review fixes.

1. app/layout.tsx — same eager-evaluation class as P1 support.ts. The
   module-level `const branding = getBranding()` froze branding before
   extensions registered, so extension-based overrides for title,
   description, themeColor, and apple-touch-icon silently never applied.
   - Convert to generateMetadata() / generateViewport() (lazy, run per
     request, see extension-registered overrides).
   - Inline getBranding() inside RootLayout for the apple-touch-icon
     href so it picks up overrides too.
   - Add ensureInitialized() at module level so extensions are loaded
     before the first metadata call. Mirrors the API route pattern.

2. app/manifest.ts — same class. The dynamic manifest function reads
   getBranding() per request, but if the manifest is requested before
   any other module has triggered ensureInitialized(), extensions are
   still unloaded. Add ensureInitialized() at module level.

3. lib/reports/ink2/sru-generator.ts — appName interpolated into the
   SRU `#PROGRAM` directive without sanitization. SRU's reserved char
   is `#` (directive marker) and CRLF injects new directives. Wrap in
   the existing sanitizeString() helper to match the pattern used for
   other admin-controlled fields in this file (#NAMN, #ADRESS, etc.).

4. extensions/general/email/lib/resend-service.ts — appName and the
   user-controlled fromName both flow into the From header. Resend's
   API does its own validation, but defense in depth: strip CRLF and
   angle brackets via a small sanitizeHeaderPart() helper before
   building the header string. fromName was a pre-existing surface;
   appName is new with this whitelabel work.

All four are admin-controlled inputs (env vars or extension code),
not user-exploitable. No known incidents — defense in depth, and
correctness for extension-based whitelabels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:32:26 +02:00
Jakob WennbergandClaude Opus 4.6 e8928b7885 feat: enable company member invitations (#211)
* feat: enable company member invitations

Activate the invite form in company settings, show pending invitations,
fix the existing-user invite flow, and process invite tokens after login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — MFA invite flow and secure cookie flag

Process invite token after MFA verification so MFA-enabled users
joining via invite are not silently dropped. Add secure flag on
the invite cookie when served over HTTPS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:08:52 +02:00
MattssonandClaude Opus 4.6 550cadcb06 fix: harden auth, cron secrets, and provider flows (GNU-17) (#148)
- Replace === with crypto.timingSafeEqual in all 7 cron routes via shared lib/auth/cron.ts
- Add in-memory rate limiting (60 req/min) and expires_at support to calendar feed
- Add exponential backoff on MFA verify after 3 failed attempts
- Add 60s cooldown on password reset requests
- Validate bank callback auth code format before API call
- Redact session IDs from bank sync and callback logs
- Validate OAuth redirect_uris against allowlist (claude.ai, claude.com, localhost)
- Remove excessive PII/debug console logging from login page

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:49:09 +02:00
Jakob WennbergandClaude Opus 4.6 5cb8c3c1e4 feat: support contact links and SIE import UX polish (#138)
* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add support contact links and improve SIE import UX

Add a SupportLink component with a contact dialog throughout the app
(nav, help page, settings, MFA, error pages, empty states). Improve
SIE import flow with phased loading states, structured skip breakdowns,
and an elapsed-time counter. Fix MFA enroll stale factor cleanup and
URL encoding for settings return path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — open redirect, XSS, test cleanup, fallback email

- Validate returnTo is a relative path in MFA enroll (prevents open redirect)
- Add afterEach import to event-log-handler tests (fixes handler leak)
- HTML-escape user-supplied subject and message in support email body
- Replace hardcoded personal email with support@gnubok.se fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:14:17 +01:00
Jakob WennbergandClaude Opus 4.6 8dcb3d47e4 fix: build errors in auth — remove stale ref, add Suspense boundary
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:23:55 +01:00
Jakob WennbergandClaude Opus 4.6 928a145f9a feat: upgrade auth to email+password with optional TOTP MFA
- Replace magic-link-only login with email+password (primary) and magic link (toggle)
- Add registration page with strong password validation
- Add MFA enrollment (/mfa/enroll) with QR code and manual secret
- Add MFA verification (/mfa/verify) with 6-digit TOTP input
- Add password reset flow (/reset-password)
- Add middleware MFA enforcement gated by NEXT_PUBLIC_REQUIRE_MFA env var
- Self-hosted deployments (NEXT_PUBLIC_SELF_HOSTED=true) skip MFA entirely
- Add Security tab in Settings for password change and MFA management
- Add requireAuth() API route helper with MFA check
- Update CLAUDE.md with Authentication section and env var docs
- Update Dockerfile and docker-entrypoint.sh for new env var placeholders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:17:49 +01:00