Commit Graph

12 Commits

Author SHA1 Message Date
Jakob Wennberg 2296c0cd59 fix(auth): provision invitees server-side when signups are disabled (#1404)
* fix(auth): provision invitees server-side when signups are disabled

Self-hosted installations with GoTrue disable_signup broke the invite
flow silently: invitees without an account were routed to /register,
where supabase.auth.signUp fails with "Signups not allowed for this
instance", surfaced only as a generic toast.

New server-only env flag AUTH_SIGNUPS_DISABLED (documented in
.env.example) mirrors the GoTrue setting. When true, POST
/api/company/members/invite checks check_email_exists and, for invitees
without an account, provisions one via auth.admin.inviteUserByEmail
with a redirect back to /invite/<token>, before the Resend email and
before the invitation row is written so a provisioning failure leaves
nothing half-created and the admin can retry. The response now carries
user_provisioned alongside email_sent, and a provisioning failure
returns 502 with a Swedish message mapped through getErrorMessage
instead of a silently-successful invite.

/auth/callback now routes type=invite verifications to /reset-password
(the existing set-password surface) instead of dropping the
passwordless user on the dashboard, and preserves the invite token from
next=/invite/<token> as the pre-auth invite cookie so the existing
reset-password invite handoff accepts the membership right after the
password is saved.

getErrorMessage learns two GoTrue patterns: "Signups not allowed"
(account creation closed on this installation, contact your inviter or
administrator) so the /register dead end is explained even for flows
that bypass provisioning, and "Error sending ... email" (GoTrue SMTP
not configured) so the 502 above is actionable.

Hosted is untouched: the flag is unset there and every new code path is
gated on it.

Fixes #1335

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

* fix(auth): restore check_email_exists RPC and harden self-host invite config

Adversarial review of #1404 found that the check_email_exists function the
invite flow depends on does not exist anywhere: it shipped in PR #229 and
was lost in the #244 migration consolidation before ever reaching prod
(verified missing on the hosted production database directly). Today
app/api/team/accept destructures only { data } from the RPC call, so
alreadyHasAccount is silently null on every deployment and the invite page
routes even existing-account invitees toward /register.

- New migration 20260804140000 restores the function exactly as originally
  shipped: SECURITY DEFINER over auth.users, EXECUTE revoked from PUBLIC,
  anon and authenticated, granted to service_role only (prevents email
  enumeration). Fixes hosted prod behavior too once applied.
- New tests/pg/check-email-exists.pg.test.ts locks in existence,
  case-insensitive matching, false-for-unknown, and the role grants.
- .env.docker.example gains the AUTH_SIGNUPS_DISABLED block self-hosters
  actually use; both env templates now note that the GoTrue redirect URI
  allow-list must include /invite/* or the invite email redirect silently
  falls back to SITE_URL.
- Invite route test for the existsError branch: RPC failure logs a warning
  and provisioning proceeds anyway (GoTrue is authoritative).

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

* fix(auth): mask invitee email in provisioning-failure log (#1335)

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-04 19:34:54 +02:00
Jakob Wennberg 15300aa8e2 fix(invites): accept invite on BankID signup, recover missed invites on onboarding surfaces (#1157)
An invited user who registered via BankID was funneled into creating a
company instead of joining the one they were invited to: the register
page's BankID path never processed the gnubok-invite-token cookie
(unlike the login, MFA-verify, and auth-callback paths). Observed in
production 2026-07-24.

- register: BankID signup now accepts the pending invite before routing
  to /select-company, mirroring the login page's BankID path.
- lib/company/pending-invites: acceptPendingInviteByToken retries a
  missed acceptance from the cookie (pending + unexpired + email match,
  same rules as POST /api/team/accept); hasPendingInviteForEmail detects
  a stranded invitee whose cookie is gone.
- /onboarding and /select-company retry acceptance from the cookie and
  redirect to the dashboard on success, making the auth callback's
  long-promised fallback real; with no cookie but a pending invitation,
  both surfaces show a 'join via the link in the invitation email' hint
  instead of silently asking the invitee to create a company.
- No new accept path without the token: the hint deliberately points
  back to the mailed link, so mailbox possession stays required and no
  company name is leaked to unverified emails.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:36:09 +02:00
Jakob Wennberg d704714eef fix(auth): show confirmation-specific copy when a signup link fails (#1027)
A failed email-verification link redirected to /login?error=auth_error with no
flow context, so the login page framed every callback failure as a broken
password-reset link and pushed new users into a reset form for an account that
was never confirmed. The callback now forwards a coarse flow hint (recovery vs
signup); the login page renders confirmation copy without the reset CTA for the
signup case. The new copy names the likely cause (link opened in a different
browser than signup, or a one-time token consumed by a mail scanner) instead of
only "expired or already used".

Silent-team creation is also wrapped in try/catch so a transient insert failure
cannot turn an otherwise-successful first-time confirmation into a 500.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:18 +02:00
Jonas Flodén 185b6f799c fix(build): pin turbopack.root, update comment for middleware to proxy rename (#897)
Pins turbopack.root to the project directory so a parent-directory lockfile cannot make Turbopack misdetect the workspace root, and updates a stale comment reference (middleware.ts is now proxy.ts). The rename itself already landed on main.
2026-07-06 12:13:59 +02:00
Jakob Wennberg 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 Wennberg 4253afc343 fix(company): validate user_preferences write when switching company (#708)
Fixes #701. setActiveCompany upserted active_company_id without checking
the result, then set the gnubok-company-id cookie unconditionally. A failed
write — including an RLS-filtered UPDATE, which affects zero rows without
raising an error — looked like a successful switch: switchCompany returned
{}, the UI hard-reloaded, and middleware (which reads user_preferences, not
the cookie) resolved the old company.

- setActiveCompany now verifies the upsert with .select().single() and
  throws a typed CompanyContextError ('not_member' | 'persist_failed');
  the cookie is only set after the write is confirmed, so it can no longer
  diverge from the database.
- switchCompany logs the failure and returns distinct error codes instead
  of reporting every failure as a permissions problem.
- CompanySwitcher now shows a destructive toast on failure (it previously
  failed with no feedback); BankIdCompanyPicker translates the codes.
  Messages added to sv/en under company_switcher and select_company.
- The remaining fire-and-forget user_preferences writers (middleware
  fallback write-back, team invite accept, auth callback invite accept)
  now check and log errors; non-fatal by design since each has a working
  fallback path.
- New tests cover every failure mode, including cookie-not-set on a failed
  write and the silent zero-row write caught by the read-back.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:54:04 +02:00
Jakob Wennberg cd96e5ec26 feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined) (#455)
* feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined)

Bigger PR per the user's request. Lands the remaining two journal-entry-
centric action verbs together — they share the same lifecycle pattern
established in :mark-sent (idempotent, dry-runnable, scope-gated,
warnings on partial-state failures).

POST /api/v1/companies/:companyId/invoices/:id/mark-paid
- Books a payment against a sent / overdue invoice. Updates status to
  paid (or partially_paid when remaining_amount > 0). Three booking paths:
  - Faktureringsmetoden (accrual default): Debit 1930 / Credit 1510 via
    createInvoicePaymentJournalEntry — settles AR.
  - Kontantmetoden (cash): Debit 1930 / Credit revenue + Credit VAT via
    createInvoiceCashEntry — revenue recognition happens HERE under cash.
  - Custom lines (partial payment): caller-supplied balanced journal lines
    via createJournalEntry directly. Validated for balance (sum debits ==
    sum credits, both > 0) → 400 INVOICE_PAID_LINES_UNBALANCED otherwise.
- Optional body: { payment_date?, exchange_rate_difference?, lines? }
- Race-condition guard: status update matches .in(['sent','overdue',
  'partially_paid']) so a concurrent payment returns 409 INVOICE_PAID_RACE.
- Emits invoice.paid (new event type, added to lib/events/types.ts with
  paymentAmount + paymentDate in the payload).

POST /api/v1/companies/:companyId/invoices/:id/credit
- Issues a kreditfaktura against a sent / paid / overdue invoice
  (ML 17 kap 22–23§). Creates a NEW invoice row with:
  - invoice_number = "KR-<original>"
  - credited_invoice_id = original id
  - status = 'sent'
  - All amounts negated (subtotal, vat_amount, total, items quantities/totals)
- Items mirror the original with negated values; inserted in a separate
  step with company-scoped rollback DELETE on failure.
- Flips original invoice to status='credited'. Warns ORIGINAL_NOT_FLIPPED
  if the flip fails (the credit note still exists; operator reconciles).
- Posts reverse journal entry via createCreditNoteJournalEntry (accrual
  only; cash basis defers to refund time).
- Emits credit_note.created (existing event in the bus).

Both endpoints:
- Use the established wrapper + Idempotency-Key + dry-run + warnings
  pattern from :mark-sent.
- Validate document_type (no delivery_notes), credited_invoice_id (no
  recursive credits), and status before any mutation.
- Use explicit column projections (no SELECT *).
- Sanitize pg_message from client responses (kept in logs).
- Emit error-level logs on partial-state failures + surface warnings to
  the caller via meta.warnings.

Event types union (lib/events/types.ts) gains invoice.paid; credit
uses the existing credit_note.created event.

URL convention: plain /verb subpaths (e.g. /invoices/:id/mark-paid),
consistent with :mark-sent. Stripe/QuickBooks pattern, not the
AIP-style :verb that Next.js routing fights.

17 new tests covering happy paths (accrual + cash for mark-paid),
custom-lines balance validation, dry-run preview, document-shape
guards, scope, idempotency, race conditions, and credit-of-credit /
delivery-note rejection.

3194/3194 vitest pass; build clean; lint clean on v1 paths.

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

* fix(api): address PR #455 review + include password-recovery fixes

PR #455 review fixes:

- Greptile P1 (CLAUDE.md architecture rule): API routes that emit events
  via eventBus must call ensureInitialized() at module level to wire
  extension event handlers. Neither :mark-paid (invoice.paid) nor :credit
  (credit_note.created) had it — nor did the already-merged :mark-sent,
  POST /invoices, POST /customers, etc. Fixed once at the wrapper layer:
  ensureInitialized() now runs at module import of lib/api/v1/with-api-v1.ts,
  so EVERY v1 route gets the init at import time. Single source of truth
  prevents future routes from forgetting (idempotent guard makes the
  repeated call safe). Cleaner than per-route copy of the call.

- Swarm PI1.3 (low): 0.005 epsilon in mark-paid was undocumented. Added
  a comment explaining: after rounding to 2 decimals, newRemaining is in
  steps of 0.01; values ≤ half-an-öre only arise from float artefacts.

Pushing back (recurring triage, consistent with prior PRs):
- V8.2.1 + CC6.3 × 4 "ctx.companyId vs params.companyId mismatch" —
  impossible by construction. The wrapper sets ctx.companyId FROM the URL
  params after the membership check. They are guaranteed equal.
- V2.3 + A.8.15 + A.8.28 atomicity / floating-point / partial-failure
  alerts — same architectural / cross-surface deferred work as prior PRs;
  matches internal /api/invoices pattern precisely.
- V4.5 account_number allowlist — engine validates it.
- V2.4 idempotency TOCTOU — wrapper handles via DB unique constraint.
- Art.5(1)(f) PII in logs, A.8.11 dry-run preview scope, A.8.15 partial-
  failure naming, test scope coverage — all recurring triage.

Password-recovery flow fixes (included per request — pre-existing
working-tree changes the user authored):

- app/(auth)/auth/callback/route.ts: when the callback exchanges a
  recovery token (type='recovery' or next='/reset-password'), redirect
  directly to /reset-password instead of running onboarding/MFA/
  dashboard checks. Previously users clicking the password-reset email
  got bounced through onboarding.
- lib/supabase/middleware.ts: /reset-password no longer bounces
  authenticated users to / (the recovery flow lands here with a fresh
  session by design — the user is *supposed* to call updateUser({
  password }) on this page).
- app/(auth)/login/page.tsx: shows an error banner when ?error=auth_error
  is set (expired/used recovery link), with a button to request a new
  one. Wrapped the page in <Suspense> because useSearchParams() now
  forces dynamic rendering (Next.js 16 static-prerender bail-out
  otherwise).
- app/(auth)/auth/callback/__tests__/route.test.ts: new test file
  covering the recovery callback path.

3197/3197 vitest pass (3194 prior + 3 from the new auth-callback tests).
Build clean.

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

* fix(api): mark-paid uses remaining_amount as default payment, not total

Real correctness fix from Swedish-compliance review on PR #455. When no
customLines is supplied, mark-paid previously defaulted paymentAmount to
typed.total. Combined with the race-condition guard that allows the
status UPDATE to flip a partially_paid invoice to paid, this could
over-credit AR in a race scenario:

1. Invoice in 'sent' status, total=12500, remaining=12500.
2. Concurrent partial payment lands first → status='partially_paid',
   remaining=7500.
3. The full-payment request's pre-flight saw 'sent' and passed; its
   UPDATE matches partially_paid (race guard allows it). With the old
   logic the journal entry was for total=12500 against an AR balance
   of only 7500 — a 5000 over-credit.

Using remaining_amount as the default eliminates this. Same end state
in the common case (no prior partial); correct booking in the race.

3197/3197 vitest pass.

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-12 23:52:18 +02:00
Mattsson bf5a8d9195 Fix/multiple company (#203)
* Refactor onboarding and dashboard logic; add silent team creation for users

- Removed unnecessary useCompany context in DashboardContent and SettingsSidebar components.
- Simplified onboarding setup logic to allow direct access to the dashboard for users without companies.
- Introduced WelcomeOnboarding component to handle user onboarding steps.
- Added migration to create silent teams for all users at signup, backfilling existing users without teams, and cleaning up incomplete companies.

* fix: update greeting logic and improve email handling in TIC extension

* Redirect to onboarding for users without companies and update onboarding flow

* Build issue fix

* Enhance onboarding experience by adding existing companies check
2026-04-09 11:54:12 +02:00
Mattsson 0dd1f5ebc1 feat: multi-tenant company refactor (GNU-19) (#153)
* feat: multi-tenant company refactor (GNU-19)

Introduce companies table, company_members, and user_preferences to
support multiple companies per user. All data scoping changes from
user_id to company_id across the entire codebase.

Key changes:
- Database migration: new tables, company_id on 40+ tables, backfill,
  RLS rewrite from user_id to company-member-based, updated RPCs
- Types: Company, CompanyMember, CompanyRole, UserPreferences types;
  company_id added to all entity interfaces; companyId on all events
- Engine: all 7 core functions take companyId; storno, period, year-end
  services updated; 16 report generators updated
- Middleware: company context resolution (cookie → prefs → first company)
- API routes: ~120 routes updated with requireCompanyId()
- Frontend: CompanyProvider context, layout/dashboard/onboarding updated
- Extensions: context factory, 9 extensions, all lib files updated
- Tests: 1880 tests passing, all helpers updated with company_id defaults

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

* feat: add database migrations for multi-tenant company and team system (GNU-19)

Adds company_invitations, company creation RPC, team_members, account
deletion RPC, and teams table refactor migrations. Updates base
multi-tenant migration with cascading FKs and onboarding_step column.

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

* feat: add team types and update core infrastructure for multi-tenancy (GNU-19)

Adds TeamRole, MemberSource, and Team types. Refactors Supabase service
client to be stateless, updates middleware for team-aware routing, extends
CompanyContext with team/role fields, and updates extension service types
to accept companyId.

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

* refactor: thread company_id through business logic functions (GNU-19)

Replaces user_id scoping with company_id across all lib modules:
bookkeeping, documents, transactions, invoices, reconciliation, tax,
deadlines, and import. Updates corresponding tests.

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

* refactor: thread company_id through API routes and extensions (GNU-19)

Updates all existing API routes to extract and pass companyId. Updates
enable-banking and arcim-migration extensions for company-scoped
transaction ingestion and sync.

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

* feat: add company and team management API routes (GNU-19)

Adds CRUD endpoints for company members, company invitations, team
members, and team invitations. Includes invite token utilities, email
templates, and company switch server action.

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

* feat: add team/company UI components, pages, and dashboard updates (GNU-19)

Adds CompanySwitcher, ConsultantEmptyState, Step0RoleChoice, company
members and team management panels. Updates dashboard layout for
team-aware routing, onboarding for multi-step role choice, and auth
callback for team invite acceptance. Ignores supabase/.branches/.

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

* fix: add null guards for company in import page (GNU-19)

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

* fix: move appUrl declaration to outer scope in invite route (GNU-19)

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

* fix: add optional chaining for company.name in members section (GNU-19)

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

* fix: add optional chaining for second company.name in members section (GNU-19)

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

* fix: add null guards for company in extension components (GNU-19)

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

* fix: pass companyId to executeSIEImport in arcim-migration extension (GNU-19)

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

* fix: update tests to use companyId instead of userId and improve type handling

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:41:52 +02:00
Jakob Wennberg 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
Emil 6f6fbb19d0 Merging 2026-02-21 16:08:01 +01:00
Jakob Wennberg a25d10a528 Initial copy from influencer-biz 2026-02-13 14:00:46 +01:00