diff --git a/app/(auth)/auth/callback/__tests__/route.test.ts b/app/(auth)/auth/callback/__tests__/route.test.ts
new file mode 100644
index 00000000..e101e5b0
--- /dev/null
+++ b/app/(auth)/auth/callback/__tests__/route.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextRequest } from 'next/server'
+
+const verifyOtp = vi.fn()
+const exchangeCodeForSession = vi.fn()
+
+vi.mock('@supabase/ssr', () => ({
+ createServerClient: vi.fn(() => ({
+ auth: {
+ verifyOtp,
+ exchangeCodeForSession,
+ getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
+ mfa: {
+ getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: null }),
+ listFactors: vi.fn().mockResolvedValue({ data: null }),
+ },
+ },
+ from: vi.fn(),
+ rpc: vi.fn(),
+ })),
+}))
+
+vi.mock('@/lib/auth/invite-tokens', () => ({
+ hashInviteToken: vi.fn(),
+}))
+
+import { GET } from '../route'
+
+describe('GET /auth/callback — recovery flow', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('redirects to /reset-password after a successful recovery OTP (token-hash flow)', async () => {
+ verifyOtp.mockResolvedValue({ error: null })
+
+ const request = new NextRequest(
+ 'http://localhost:3000/auth/callback?token_hash=abc&type=recovery&next=/reset-password'
+ )
+ const response = await GET(request)
+
+ expect(response.status).toBe(307)
+ expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
+ expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'abc', type: 'recovery' })
+ })
+
+ it('redirects to /reset-password after a successful PKCE exchange when next=/reset-password (no type param)', async () => {
+ exchangeCodeForSession.mockResolvedValue({ error: null })
+
+ const request = new NextRequest(
+ 'http://localhost:3000/auth/callback?code=xyz&next=/reset-password'
+ )
+ const response = await GET(request)
+
+ expect(response.status).toBe(307)
+ expect(response.headers.get('location')).toBe('http://localhost:3000/reset-password')
+ expect(exchangeCodeForSession).toHaveBeenCalledWith('xyz')
+ })
+
+ it('redirects to /login?error=auth_error when the recovery OTP is expired or already consumed', async () => {
+ verifyOtp.mockResolvedValue({ error: { message: 'Token has expired or is invalid' } })
+
+ const request = new NextRequest(
+ 'http://localhost:3000/auth/callback?token_hash=expired&type=recovery&next=/reset-password'
+ )
+ const response = await GET(request)
+
+ expect(response.status).toBe(307)
+ expect(response.headers.get('location')).toBe('http://localhost:3000/login?error=auth_error')
+ })
+})
diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts
index 26a819b0..224c9ac8 100644
--- a/app/(auth)/auth/callback/route.ts
+++ b/app/(auth)/auth/callback/route.ts
@@ -54,6 +54,19 @@ export async function GET(request: NextRequest) {
if (authenticated) {
let redirectPath = next
+ // Password recovery flow: the user just exchanged a recovery token, so they
+ // have a fresh session whose only purpose is to call updateUser({ password })
+ // on /reset-password. Skip onboarding / team setup / dashboard redirect.
+ // The token-hash flow signals this via type=recovery; PKCE has no type, so
+ // also gate on next === '/reset-password' (only the reset request sets it).
+ if (type === 'recovery' || next === '/reset-password') {
+ const response = NextResponse.redirect(new URL('/reset-password', origin))
+ for (const { name, value, options } of pendingCookies) {
+ response.cookies.set({ name, value, ...options })
+ }
+ return response
+ }
+
const { data: { user } } = await supabase.auth.getUser()
if (user) {
// Check MFA status — redirect to verify if factor is enrolled but session is AAL1
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index 5cc9fbc6..d6bc1f9f 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -1,7 +1,7 @@
'use client'
-import { useState, useEffect } from 'react'
-import { useRouter } from 'next/navigation'
+import { Suspense, useState, useEffect } from 'react'
+import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
@@ -18,7 +18,17 @@ import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth'
+// Wrapping in Suspense is required because useSearchParams() forces
+// dynamic rendering in Next.js 16; static prerender bails out otherwise.
export default function LoginPage() {
+ return (
+
+
+
+ )
+}
+
+function LoginPageContent() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
@@ -29,6 +39,8 @@ export default function LoginPage() {
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
const { toast } = useToast()
const router = useRouter()
+ const searchParams = useSearchParams()
+ const callbackError = searchParams.get('error')
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
@@ -353,6 +365,24 @@ export default function LoginPage() {
+ {callbackError === 'auth_error' && (
+
+
+ Återställningslänken fungerade inte
+
+
+ Länken har gått ut eller använts redan.{' '}
+
+ .
+
+
+ )}
{bankIdEnabled && (
<>
{bankIdNoAccount ? (
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/__tests__/route.test.ts
new file mode 100644
index 00000000..e9f72274
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/__tests__/route.test.ts
@@ -0,0 +1,332 @@
+/**
+ * Integration tests for POST /api/v1/companies/:companyId/invoices/:id/credit.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeAll(() => {
+ if (process.env.NODE_ENV !== 'test') {
+ throw new Error(
+ `credit route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
+ )
+ }
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
+})
+
+vi.mock('@/lib/auth/api-keys', async () => {
+ const actual = await vi.importActual
('@/lib/auth/api-keys')
+ return {
+ ...actual,
+ validateApiKey: vi.fn(),
+ createServiceClientNoCookies: vi.fn(),
+ }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@supabase/supabase-js')
+ return { ...actual, createClient: vi.fn().mockReturnValue({}) }
+})
+
+vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
+ createCreditNoteJournalEntry: vi.fn().mockResolvedValue({
+ id: 'mmmmmmmm-mmmm-4mmm-8mmm-mmmmmmmmmmmm',
+ }),
+}))
+
+import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
+import {
+ createCreditNoteJournalEntry as mockedCreditEntry,
+} from '@/lib/bookkeeping/invoice-entries'
+import { POST as creditInvoice } from '../route'
+
+const mockValidate = validateApiKey as ReturnType
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+const mockCreditEntry = mockedCreditEntry as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler