feat: BankID authentication via TIC Identity API (#192)
* feat: add BankID authentication via TIC Identity API Integrate BankID as a login/signup method using the TIC Identity API. Users can authenticate with BankID QR codes (desktop) or deep links (mobile), link BankID to existing accounts, and skip TOTP MFA when BankID is linked. Removes Step 0 (role choice) from onboarding for all users. Adds enrichment data support for pre-filling company details from Bolagsverket during signup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — server-side rate limit, unlink clears MFA bypass - Add per-IP rate limit (5s cooldown) on /bankid/start to prevent unbounded billable TIC sessions from unauthenticated callers - Add /bankid/unlink endpoint that deletes bankid_identities AND clears app_metadata.bankid_linked so MFA enforcement resumes after unlink - Update BankIdSettings to call server-side unlink instead of client-side delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move rate limiter to module scope, add BankID logo and year-end skill Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: swedish-year-end-closing
|
||||
description: >
|
||||
Swedish year-end closing (bokslut) for AB and Enskild firma. Covers legal framework (BFL/ÅRL/K2/K3), step-by-step closing with BAS account numbers, all bokslutstransaktioner, tax calculations (bolagsskatt, egenavgifter, räntefördelning, expansionsfond, periodiseringsfond), reporting (årsredovisning/NE-bilaga), filing deadlines, SIE4 export, K2 vs K3 differences, and compliance pitfalls. Trigger on bokslut, årsbokslut, årsredovisning, closing entries, resultatdisposition, year-end accruals, tax provisions, överavskrivningar, accounts 2099/2091/8910/8811/2512/21xx/29xx in closing context, or any question about closing books for a Swedish company. Also trigger for "how do I book tax at year-end", "periodiseringsfond AB vs EF", "deadline årsredovisning", "what accounts for accruals".
|
||||
---
|
||||
|
||||
# Swedish Year-End Closing (Bokslut)
|
||||
|
||||
This skill provides everything needed to perform or implement a complete Swedish year-end closing for **Aktiebolag (AB)** and **Enskild firma**.
|
||||
|
||||
## Quick decision tree
|
||||
|
||||
1. **AB** → always årsredovisning → K2 (if mindre and eligible) or K3
|
||||
2. **Enskild firma, revenue ≤ 3 MSEK** → K1 förenklat årsbokslut
|
||||
3. **Enskild firma, revenue > 3 MSEK** → full årsbokslut (BFNAR 2017:3)
|
||||
|
||||
## Reference files
|
||||
|
||||
This skill contains detailed reference material split by topic. Read the relevant file(s) based on the user's question:
|
||||
|
||||
- **`references/legal-framework.md`** — BFL, ÅRL, K1/K2/K3 framework rules, entity obligations, större/mindre företag thresholds, 2026 K2 changes
|
||||
- **`references/closing-process.md`** — Complete 8-phase closing process with BAS account numbers: avstämningar, periodiseringar, avskrivningar, lagervärdering, obeskattade reserver, avsättningar/skatt, equity handling, result closing
|
||||
- **`references/journal-entries.md`** — All specific bokslutstransaktioner with debit/credit pairs for software implementation
|
||||
- **`references/tax-calculations.md`** — Bolagsskatt for AB, egenavgifter/räntefördelning/expansionsfond/periodiseringsfond for enskild firma, schablonintäkt, schablonavdrag
|
||||
- **`references/reporting-and-filing.md`** — Årsredovisning structure, NE-bilaga, filing deadlines, penalties, Bolagsverket/Skatteverket requirements, SIE4 export, audit thresholds
|
||||
- **`references/k2-vs-k3.md`** — Implementation differences: component depreciation, deferred tax, intangibles, leasing, format restrictions, account visibility
|
||||
- **`references/pitfalls-and-rates.md`** — Common mistakes, compliance traps, kontrollbalansräkning, and reference rate table (2025/2026)
|
||||
|
||||
## How to use this skill
|
||||
|
||||
When a user asks a bokslut question:
|
||||
|
||||
1. Identify whether it's about AB or Enskild firma (or both)
|
||||
2. Identify which phase/topic the question relates to
|
||||
3. Read the relevant reference file(s)
|
||||
4. Answer with specific BAS account numbers and journal entries where applicable
|
||||
5. Flag K2 vs K3 differences when relevant
|
||||
6. Include current rates/thresholds from the rates table
|
||||
|
||||
Always distinguish between items that are **booked** in the accounting vs items that exist **only in the tax declaration**:
|
||||
- **Booked**: överavskrivningar, periodiseringsfond (AB only), skatt på årets resultat (AB only)
|
||||
- **Declaration only**: periodiseringsfond (EF), expansionsfond, räntefördelning, schablonintäkt on periodiseringsfond, egenavgifter schablonavdrag
|
||||
@@ -0,0 +1,156 @@
|
||||
# Step-by-Step Closing Process with BAS Account Numbers
|
||||
|
||||
The closing process (bokslutsarbete) proceeds in eight phases, each depending on prior phases.
|
||||
|
||||
## Phase 1: Reconciliations (avstämningar)
|
||||
|
||||
Every balance sheet account must be reconciled against external documentation.
|
||||
|
||||
| What | Account(s) | Reconcile against |
|
||||
|------|-----------|-------------------|
|
||||
| Bank | **1920** Plusgiro, **1930** Företagskonto | Bank statements |
|
||||
| Cash | **1910** Kassa | Physical count |
|
||||
| Skattekonto | **1630** Avräkning för skatter och avgifter | Skatteverket kontoutdrag |
|
||||
| Moms | **2610–2650** | Declared and transferred amounts to 1630 |
|
||||
| Kundfordringar | **1510** | Kundreskontra (open invoices) |
|
||||
| Leverantörsskulder | **2440** | Leverantörsreskontra |
|
||||
| Doubtful receivables | **1519** Nedskrivning / **6352** Befarade förluster | Assessment of collectability |
|
||||
|
||||
## Phase 2: Accruals and deferrals (periodiseringar)
|
||||
|
||||
### Interimsfordringar (assets, 17xx)
|
||||
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **1710** | Förutbetalda hyreskostnader |
|
||||
| **1720** | Förutbetalda leasingavgifter |
|
||||
| **1730** | Förutbetalda försäkringspremier |
|
||||
| **1740** | Förutbetalda räntekostnader |
|
||||
| **1750** | Upplupna hyresintäkter |
|
||||
| **1760** | Upplupna ränteintäkter |
|
||||
| **1790** | Övriga förutbetalda kostnader och upplupna intäkter |
|
||||
|
||||
### Interimsskulder (liabilities, 29xx)
|
||||
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **2910** | Upplupna löner |
|
||||
| **2920** | Upplupna semesterlöner |
|
||||
| **2940** | Beräknade upplupna sociala avgifter |
|
||||
| **2943** | Beräknad upplupen SLP |
|
||||
| **2960** | Upplupna räntekostnader |
|
||||
| **2970** | Förutbetalda intäkter |
|
||||
| **2991** | Beräknat arvode för bokslut |
|
||||
| **2992** | Beräknat arvode för revision |
|
||||
| **2990** | Övriga upplupna kostnader |
|
||||
|
||||
### K2 simplification
|
||||
Individual recurring costs below **5,000 SEK** that don't fluctuate >20% year-over-year need not be accrued. Personnel costs must ALWAYS be accrued regardless of amount. K3 has no such threshold.
|
||||
|
||||
## Phase 3: Depreciation (avskrivningar)
|
||||
|
||||
| Asset type | Cost account | Accumulated depreciation |
|
||||
|------------|-------------|------------------------|
|
||||
| Immateriella tillgångar | **7810–7819** | **1019, 1029** etc. |
|
||||
| Byggnader | **7821** | **1119** |
|
||||
| Markanläggningar | **7824** | **1129** |
|
||||
| Maskiner | **7831** | **1219** |
|
||||
| Inventarier | **7832** | **1229** |
|
||||
| Bilar/transportmedel | **7834** | **1249** |
|
||||
| Datorer | **7833** | **1259** |
|
||||
|
||||
### K2 vs K3 depreciation
|
||||
- **K2**: Schablonmässiga nyttjandeperioder allowed (5 years for inventarier, tax rates for buildings). Component depreciation FORBIDDEN.
|
||||
- **K3**: Individual assessment of useful life and residual value required. **Component depreciation mandatory** for assets with significant components having different consumption patterns.
|
||||
|
||||
## Phase 4: Inventory valuation (lagervärdering)
|
||||
|
||||
Physical inventory count at balance date required. Valuation: **lägsta värdets princip (LVP)** using FIFO.
|
||||
|
||||
Alternative: **97% schablon rule** (3% inkuransavdrag).
|
||||
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **1410** | Råvaror |
|
||||
| **1440** | Produkter i arbete |
|
||||
| **1450** | Färdiga varor |
|
||||
| **1460** | Lager av handelsvaror |
|
||||
| **4990** | Lagerförändring |
|
||||
|
||||
K1: inventory below half a prisbasbelopp (29,600 SEK for 2026) need not be valued.
|
||||
K3: indirect manufacturing overhead must be included when material. K2: optional.
|
||||
|
||||
## Phase 5: Untaxed reserves (obeskattade reserver)
|
||||
|
||||
### Periodiseringsfonder (AB only — booked)
|
||||
|
||||
AB can defer up to **25%** of skattemässigt resultat for up to 6 years.
|
||||
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **2110–2129** | Periodiseringsfond per year (e.g. 2125 = tax year 2025) |
|
||||
| **8811** | Avsättning till periodiseringsfond |
|
||||
| **8819** | Återföring från periodiseringsfond |
|
||||
|
||||
Entry: Debit 8811 / Credit 21xx (avsättning). Debit 21xx / Credit 8819 (återföring).
|
||||
|
||||
**Enskild firma**: 30% periodiseringsfond, handled ONLY in NE-bilaga (R29/R30), NEVER booked.
|
||||
|
||||
### Överavskrivningar
|
||||
|
||||
Excess of tax-allowed depreciation over planned depreciation.
|
||||
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **8850** (or 8851–8853) | Förändring av överavskrivningar |
|
||||
| **2150** (or 2151–2153) | Ackumulerade överavskrivningar |
|
||||
|
||||
Two methods: **30-rule** (declining balance on pool) and **20-rule** (straight-line per asset over 5 years). Use whichever produces lowest allowable residual value.
|
||||
|
||||
## Phase 6: Provisions and tax (avsättningar, skatt)
|
||||
|
||||
### Provisions
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **2210** | Avsättningar för pensioner |
|
||||
| **2220** | Avsättningar för garantier |
|
||||
| **2230, 2250** | Other provisions |
|
||||
| **7533** | Särskild löneskatt on pensions |
|
||||
| **2514** | Beräknad SLP |
|
||||
|
||||
SLP rate: **24.26%** on pension costs. Entry: Debit 7533 / Credit 2514.
|
||||
|
||||
### Tax provision (AB only)
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **8910** | Skatt på årets resultat |
|
||||
| **2512** | Beräknad inkomstskatt |
|
||||
| **2518** | Betald F-skatt (debit balance during year) |
|
||||
|
||||
Entry: Debit 8910 / Credit 2512. Net 2518 against 2510/2512 at year-end.
|
||||
|
||||
**Enskild firma does NOT book any tax** — owner taxed personally via NE-bilaga.
|
||||
|
||||
### Deferred tax (K3 only)
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| **8940** | Uppskjuten skatt |
|
||||
| **2240** | Avsättningar för uppskjutna skatter |
|
||||
| **1370** | Uppskjuten skattefordran |
|
||||
|
||||
All marked **[Ej K2]** in BAS kontoplan. K2 must NEVER recognize deferred tax.
|
||||
|
||||
## Phase 7–8: Equity handling and result closing
|
||||
|
||||
All P&L accounts (classes 3–8) net to **8999** (Årets resultat).
|
||||
|
||||
### AB equity flow
|
||||
1. Year-end: Debit 8999 / Credit **2099** (Årets resultat) for profit
|
||||
2. New year start: 2099 → **2098** (Vinst/förlust från föregående år)
|
||||
3. After bolagsstämma: 2098 → **2091** (Balanserad vinst eller förlust)
|
||||
4. Declared dividend: 2098 → **2898** (Outtagen vinstutdelning)
|
||||
|
||||
### Enskild firma equity flow
|
||||
1. Year-end: Debit 8999 / Credit **2019** (Årets resultat, delägare 1)
|
||||
2. New year start: Zero all sub-accounts (**2011** egna varuuttag, **2012** avräkning skatter, **2013** övriga egna uttag, **2017** årets kapitaltillskott, **2018** övriga egna insättningar, **2019** årets resultat) → net into **2010** (Eget kapital)
|
||||
3. No bolagsstämma required
|
||||
@@ -0,0 +1,133 @@
|
||||
# Specific Closing Journal Entries (Bokslutstransaktioner)
|
||||
|
||||
All entries dated on balance sheet date (e.g., 2025-12-31). These are the core entries a software system must generate.
|
||||
|
||||
## Salary accruals
|
||||
December salary earned but paid in January:
|
||||
```
|
||||
Debit 7010 Löner till kollektivanställda
|
||||
Credit 2910 Upplupna löner
|
||||
```
|
||||
Alternatives: 7210 (tjänstemän), 7220 (företagsledare).
|
||||
|
||||
## Vacation pay accruals
|
||||
Year-end adjustment of semesterlöneskuld:
|
||||
```
|
||||
Debit 7090 Förändring av semesterlöneskuld
|
||||
Credit 2920 Upplupna semesterlöner
|
||||
```
|
||||
Book the difference between calculated vacation pay liability at year-end and opening balance of 2920.
|
||||
|
||||
## Social fees on accrued salary and vacation
|
||||
```
|
||||
Debit 7510 Lagstadgade sociala avgifter
|
||||
Credit 2940 Beräknade upplupna sociala avgifter
|
||||
```
|
||||
Calculate as: (upplupen lön + upplupen semester) × **31.42%**
|
||||
|
||||
## Depreciation (example: machinery)
|
||||
```
|
||||
Debit 7831 Avskrivningar på maskiner
|
||||
Credit 1219 Ack. avskrivningar maskiner
|
||||
```
|
||||
Repeat per asset category with appropriate account pairs.
|
||||
|
||||
## Överavskrivningar (excess tax depreciation)
|
||||
```
|
||||
Debit 8853 Förändring av överavskrivningar maskiner & inventarier
|
||||
Credit 2153 Ack. överavskrivningar maskiner & inventarier
|
||||
```
|
||||
|
||||
## Inventory increase
|
||||
```
|
||||
Debit 1460 Lager av handelsvaror
|
||||
Credit 4990 Lagerförändring
|
||||
```
|
||||
For decrease: reverse the entry. Use separate accounts per inventory type for manufacturing.
|
||||
|
||||
## Prepaid expenses (e.g., insurance covering next year)
|
||||
```
|
||||
Debit 1730 Förutbetalda försäkringspremier
|
||||
Credit 6310 Företagsförsäkringar
|
||||
```
|
||||
Reversal on January 1 mirrors the entry. Similar for 1710 (rent), 1790 (other).
|
||||
|
||||
## Accrued income (work performed, invoice pending)
|
||||
```
|
||||
Debit 1790 Övriga förutbetalda kostnader och upplupna intäkter
|
||||
Credit 3010 Försäljning
|
||||
```
|
||||
Booked without moms; moms recognized when invoice is issued.
|
||||
|
||||
## Deferred revenue (advance payment for future service)
|
||||
```
|
||||
Debit 3010 Försäljning
|
||||
Credit 2970 Förutbetalda intäkter
|
||||
```
|
||||
Recognized monthly: Debit 2970 / Credit 3010.
|
||||
|
||||
## Accrued audit/accounting fees
|
||||
```
|
||||
Debit 6420 Revision och bokslut
|
||||
Credit 2992 Beräknat arvode för revision
|
||||
```
|
||||
|
||||
## Periodiseringsfond avsättning (AB, 25% of 400,000 = 100,000)
|
||||
```
|
||||
Debit 8811 Avsättning till periodiseringsfond
|
||||
Credit 2126 Periodiseringsfond tax year 2026
|
||||
```
|
||||
Återföring of oldest fund:
|
||||
```
|
||||
Debit 2120 Periodiseringsfond [oldest year]
|
||||
Credit 8819 Återföring från periodiseringsfond
|
||||
```
|
||||
|
||||
## Tax provision (AB, taxable profit 500,000 × 20.6% = 103,000)
|
||||
```
|
||||
Debit 8910 Skatt som belastar årets resultat
|
||||
Credit 2512 Beräknad inkomstskatt
|
||||
```
|
||||
|
||||
## Result closing (AB, profit 397,000 after tax)
|
||||
```
|
||||
Debit 8999 Årets resultat
|
||||
Credit 2099 Årets resultat
|
||||
```
|
||||
|
||||
## Result closing (Enskild firma, profit)
|
||||
```
|
||||
Debit 8999 Årets resultat
|
||||
Credit 2019 Årets resultat, delägare 1
|
||||
```
|
||||
|
||||
## New year opening: carry forward previous year result (AB)
|
||||
```
|
||||
Debit 2099 Årets resultat (previous year)
|
||||
Credit 2098 Vinst/förlust från föregående år
|
||||
```
|
||||
After bolagsstämma:
|
||||
```
|
||||
Debit 2098 Vinst/förlust från föregående år
|
||||
Credit 2091 Balanserad vinst eller förlust
|
||||
```
|
||||
|
||||
## New year opening: consolidate equity (Enskild firma)
|
||||
Zero all of 2011–2019 into 2010:
|
||||
```
|
||||
Debit 2010 Eget kapital (for net credits from sub-accounts)
|
||||
Credit 2011 Egna varuuttag
|
||||
Credit 2012 Avräkning skatter och avgifter
|
||||
Credit 2013 Övriga egna uttag
|
||||
Debit 2017 Årets kapitaltillskott (reverse into 2010)
|
||||
Debit 2018 Övriga egna insättningar (reverse into 2010)
|
||||
Debit 2019 Årets resultat (reverse into 2010)
|
||||
```
|
||||
(Direction depends on whether sub-accounts have debit or credit balances.)
|
||||
|
||||
## Särskild löneskatt on pension provisions
|
||||
```
|
||||
Debit 7533 Särskild löneskatt
|
||||
Credit 2514 Beräknad särskild löneskatt
|
||||
```
|
||||
Rate: **24.26%** on pension costs.
|
||||
@@ -0,0 +1,86 @@
|
||||
# K2 vs K3: Implementation Differences for Year-End Closing
|
||||
|
||||
## Component depreciation
|
||||
|
||||
**K3**: Mandatory. Assets with significant components having materially different useful lives must be split and depreciated separately. Buildings decomposed into roof, HVAC, facade, frame, etc. Software must support multiple components per asset with independent useful lives, residual values, and schedules.
|
||||
|
||||
**K2**: Forbidden. Each asset treated as single unit.
|
||||
|
||||
## Deferred tax (uppskjuten skatt)
|
||||
|
||||
**K3**: Required using balance sheet approach on all temporary differences.
|
||||
- **1370** Uppskjuten skattefordran
|
||||
- **2240** Avsättningar för uppskjutna skatter
|
||||
- **8940** Uppskjuten skatt
|
||||
- All marked **[Ej K2]** in BAS kontoplan
|
||||
- Obeskattade reserver analytically split: 79.4% equity / 20.6% latent skatteskuld
|
||||
|
||||
**K2**: Never recognized. These accounts must be hidden/disabled.
|
||||
|
||||
## Internally developed intangible assets
|
||||
|
||||
**K3**: May be capitalized using **1010–1019** (Utvecklingsutgifter), also [Ej K2].
|
||||
|
||||
**K2**: All development costs must be expensed immediately. Only acquired intangibles may be recognized.
|
||||
|
||||
## Fair value measurement
|
||||
|
||||
**K3**: Available for certain financial instruments and investment properties.
|
||||
|
||||
**K2**: Only historical cost (anskaffningsvärde). Försiktighetsprincip enforced strictly.
|
||||
|
||||
## Leasing classification
|
||||
|
||||
**K3**: Distinguishes financial and operational leases. Financial leases capitalized:
|
||||
- **1260** Leasade tillgångar [Ej K2]
|
||||
- **1269** Ack. avskrivningar [Ej K2]
|
||||
|
||||
**K2**: All leases treated as operational.
|
||||
|
||||
## Income statement format
|
||||
|
||||
**K2**: Kostnadsslagsindelad only.
|
||||
**K3**: Both kostnadsslagsindelad and funktionsindelad allowed.
|
||||
|
||||
## Depreciation simplifications
|
||||
|
||||
**K2**: May always set inventarier useful life to 5 years. May use tax depreciation rates directly for buildings, potentially avoiding separate bokslutsdispositioner for överavskrivningar.
|
||||
|
||||
**K3**: Individually assessed useful lives and residual values required for every asset.
|
||||
|
||||
## Accrual threshold
|
||||
|
||||
**K2**: Individual recurring costs below 5,000 SEK (not fluctuating >20%) need not be accrued (except personnel costs).
|
||||
|
||||
**K3**: No blanket threshold. Individual materiality assessment.
|
||||
|
||||
## Notes requirements
|
||||
|
||||
**K2**: Simplified, template-based. Sufficient to state framework applied, depreciation periods, employees, pledges, contingencies.
|
||||
|
||||
**K3**: Extensive: deferred tax analysis, critical judgments, estimation uncertainty, component depreciation details, segment reporting (if applicable).
|
||||
|
||||
## Accounts to hide/disable in K2 mode
|
||||
|
||||
The following BAS accounts are marked [Ej K2] and should be hidden or disabled:
|
||||
- **1010–1019** (Utvecklingsutgifter)
|
||||
- **1081** (Pågående projekt, immateriella)
|
||||
- **1260** (Leasade tillgångar)
|
||||
- **1269** (Ack. avskrivningar leasade tillgångar)
|
||||
- **1370** (Uppskjuten skattefordran)
|
||||
- **2240** (Avsättningar för uppskjutna skatter)
|
||||
- **8940** (Uppskjuten skatt)
|
||||
|
||||
## Summary table
|
||||
|
||||
| Feature | K2 | K3 |
|
||||
|---------|-----|-----|
|
||||
| Component depreciation | Forbidden | Mandatory |
|
||||
| Deferred tax | Forbidden | Required |
|
||||
| Capitalize dev costs | Forbidden | Allowed |
|
||||
| Fair value | Forbidden | Allowed |
|
||||
| Financial leases | Not recognized | Capitalized |
|
||||
| RR format | Kostnadsslagsindelad only | Both |
|
||||
| Depreciation | Schablonmässig OK | Individual assessment |
|
||||
| Accrual threshold | 5,000 SEK | No threshold |
|
||||
| Notes | Simplified | Extensive |
|
||||
@@ -0,0 +1,59 @@
|
||||
# Legal Framework for Swedish Year-End Closing
|
||||
|
||||
## Primary laws
|
||||
|
||||
- **Bokföringslagen (BFL, SFS 1999:1078)** — who must keep accounts and how they close
|
||||
- **Årsredovisningslagen (ÅRL, SFS 1995:1554)** — content and format of årsredovisning
|
||||
|
||||
## Who does what (BFL Chapter 6)
|
||||
|
||||
### Must prepare årsredovisning (§1):
|
||||
- All aktiebolag (regardless of size)
|
||||
- All ekonomiska föreningar
|
||||
- Handelsbolag with at least one juridisk person as partner
|
||||
- Bookkeeping-obligated stiftelser
|
||||
- Any enterprise meeting "större företag" criteria
|
||||
|
||||
### Prepare årsbokslut (§3):
|
||||
- All other bookkeeping-obligated entities (including most enskilda firmor)
|
||||
- Consists of: resultaträkning, balansräkning, noter (no förvaltningsberättelse)
|
||||
|
||||
### Förenklat årsbokslut (§6):
|
||||
- Enterprises with nettoomsättning normally ≤ 3 MSEK
|
||||
- Only resultaträkning and balansräkning, no notes
|
||||
|
||||
## K-framework mapping
|
||||
|
||||
| Framework | Full name | Applies to |
|
||||
|-----------|-----------|------------|
|
||||
| K1 (BFNAR 2006:1) | Enskilda näringsidkare, förenklat årsbokslut | Sole traders with revenue ≤ 3 MSEK |
|
||||
| BFNAR 2017:3 | Årsbokslut | Entities preparing full årsbokslut (not årsredovisning) |
|
||||
| K2 (BFNAR 2016:10) | Årsredovisning i mindre företag | Smaller AB/EK föreningar choosing simplified rules |
|
||||
| K3 (BFNAR 2012:1) | Årsredovisning och koncernredovisning | Default/mandatory for all årsredovisning preparers; required for större företag |
|
||||
|
||||
## Större företag definition (ÅRL 1 kap 3§)
|
||||
|
||||
Exceeds more than one of three thresholds for each of the two most recent fiscal years:
|
||||
- **>50 average employees**
|
||||
- **>40 MSEK total assets**
|
||||
- **>80 MSEK net revenue**
|
||||
- Or any entity with listed securities
|
||||
|
||||
Större företag must use K3, prepare kassaflödesanalys, and meet additional disclosure requirements.
|
||||
|
||||
## Decision tree for developers
|
||||
|
||||
```
|
||||
AB → always årsredovisning → K2 (if mindre and eligible) or K3
|
||||
Enskild firma, revenue ≤ 3 MSEK → K1 förenklat årsbokslut
|
||||
Enskild firma, revenue > 3 MSEK → full årsbokslut per BFNAR 2017:3
|
||||
Enskild firma meeting större criteria (extremely rare) → årsredovisning under K3
|
||||
```
|
||||
|
||||
## 2025/2026 K2 changes
|
||||
|
||||
From fiscal years starting after December 31, 2025, K2 can no longer be used by:
|
||||
- Bostadsrättsföreningar
|
||||
- Companies with foreign branches
|
||||
- Companies holding crypto assets
|
||||
- Those issuing share-based payments
|
||||
@@ -0,0 +1,77 @@
|
||||
# Common Mistakes, Compliance Pitfalls, and Reference Rates
|
||||
|
||||
## Common mistakes
|
||||
|
||||
### 1. Failure to reverse prior-year accruals
|
||||
Interimsposter booked at year-end must be reversed on day 1 of new fiscal year. Without auto-reversal, costs/revenues are double-counted. Software should auto-generate reversal entries.
|
||||
|
||||
### 2. Incorrect periodiseringsfond calculations
|
||||
- Wrong percentage: 25% for AB, 30% for EF
|
||||
- Missing mandatory 6-year reversal
|
||||
- Forgetting **gross-up rule** for funds created before 2019 (at old 22% rate, requiring 103–106% uppräkning on reversal)
|
||||
|
||||
### 3. Missing schablonintäkt on periodiseringsfonder
|
||||
Affects AB companies. SLR × total funds at year-start. Is a skattemässig justering, NEVER booked. Many systems incorrectly try to record it as journal entry.
|
||||
|
||||
### 4. Confusing bokföringsmässigt and skattemässigt resultat
|
||||
The reconciliation from book result to taxable result (INK2S logic) must be implemented as separate calculation layer.
|
||||
|
||||
### 5. Failing to close 2099 to 2091 correctly
|
||||
- **2099** (Årets resultat) must move to **2098** at year-start
|
||||
- Then to **2091** after bolagsstämma decides on disposition
|
||||
- 2099 should ONLY ever contain current year's result
|
||||
|
||||
### 6. Kontrollbalansräkning requirement
|
||||
Triggers when eget kapital falls below **50% of registrerat aktiekapital** (ABL 25 kap).
|
||||
|
||||
In kontrollbalansräkning:
|
||||
- Obeskattade reserver split: 79.4% equity / 20.6% tax
|
||||
- Assets may be revalued to net realizable value
|
||||
|
||||
If confirmed:
|
||||
1. Board calls first kontrollstämma
|
||||
2. Must restore full aktiekapital within 8 months
|
||||
3. Board members face **personal liability** if not followed
|
||||
|
||||
Software should flag when equity approaches this threshold.
|
||||
|
||||
### 7. Forgetting SLP on pension provisions
|
||||
Särskild löneskatt 24.26% on pension costs must be booked same period:
|
||||
Debit 7533 / Credit 2514. Frequently missed.
|
||||
|
||||
### 8. Incorrect inventory valuation
|
||||
- Forgetting 3% inkuransavdrag (97% schablon rule)
|
||||
- Applying it to excluded types (real estate, securities)
|
||||
- Affects both balance sheet and tax calculation
|
||||
|
||||
### 9. Not reconciling överavskrivningar
|
||||
Mismatch between plan depreciation (78xx) and tax depreciation creates incorrect obeskattade reserver (2150 series), cascading into wrong tax and potential kontrollbalansräkning trigger.
|
||||
|
||||
### 10. Booking items that should only exist in declaration
|
||||
For enskild firma: periodiseringsfond, expansionsfond, räntefördelning, egenavgifter schablonavdrag should NEVER appear as journal entries.
|
||||
|
||||
---
|
||||
|
||||
## Key reference rates and thresholds
|
||||
|
||||
| Parameter | 2025 | 2026 |
|
||||
|-----------|------|------|
|
||||
| Bolagsskatt | 20.6% | 20.6% |
|
||||
| Statslåneränta (SLR, Nov 30 prior year) | 1.96% | 2.55% |
|
||||
| Schablonintäkt periodiseringsfond (AB) | 1.96% (floor 0.5%) | 2.55% (floor 0.5%) |
|
||||
| Positiv räntefördelning | 7.96% (SLR+6) | 8.55% (SLR+6) |
|
||||
| Negativ räntefördelning | 2.96% (SLR+1) | 3.55% (SLR+1) |
|
||||
| Egenavgifter (full, 7 karensdagar) | 28.97% | 28.97% |
|
||||
| Schablonavdrag egenavgifter (active) | 25% | 25% |
|
||||
| Särskild löneskatt (SLP) | 24.26% | 24.26% |
|
||||
| Expansionsfondsskatt | 20.6% | 20.6% |
|
||||
| Expansionsfond max | 125.94% of kapitalunderlag | 125.94% |
|
||||
| Periodiseringsfond AB max | 25% of skattemässigt resultat | 25% |
|
||||
| Periodiseringsfond EF max | 30% of result | 30% |
|
||||
| Arbetsgivaravgifter (standard) | 31.42% | 31.42% |
|
||||
| Prisbasbelopp | 58,800 SEK | 59,200 SEK |
|
||||
| K2 accrual threshold | 5,000 SEK/item | 5,000 SEK/item |
|
||||
| Inkuransavdrag (inventory) | 3% (97% rule) | 3% (97% rule) |
|
||||
| Kontrollbalansräkning trigger | <50% of aktiekapital | <50% |
|
||||
| Neg. räntefördelning threshold | -500,000 SEK | -500,000 SEK |
|
||||
| Revisor opt-out thresholds | >3 emp, >1.5M BS, >3M rev (2/3) | Same |
|
||||
@@ -0,0 +1,158 @@
|
||||
# Reporting Obligations, Filing, and SIE4
|
||||
|
||||
## Årsredovisning for AB
|
||||
|
||||
Must contain, in order:
|
||||
1. **Förvaltningsberättelse**
|
||||
2. **Resultaträkning**
|
||||
3. **Balansräkning**
|
||||
4. **Noter (tilläggsupplysningar)**
|
||||
5. **Kassaflödesanalys** (större företag only)
|
||||
|
||||
Every page: company name, organisationsnummer, registered office.
|
||||
|
||||
### Förvaltningsberättelse (K2 minimum)
|
||||
- Description of business activities and säte
|
||||
- Important events during the year
|
||||
- Whether kontrollbalansräkning has been prepared
|
||||
- **Flerårsöversikt**: nettoomsättning, resultat efter finansiella poster, soliditet for current year + 3 preceding years
|
||||
- Specification of changes in eget kapital
|
||||
- Board's proposed **resultatdisposition**
|
||||
|
||||
### Resultaträkning
|
||||
- K2: **kostnadsslagsindelad** format ONLY
|
||||
- K3: both kostnadsslagsindelad and funktionsindelad allowed
|
||||
- Key lines: nettoomsättning, lagerförändring, övriga rörelseintäkter, personalkostnader, avskrivningar, finansiella poster, bokslutsdispositioner, skatt på årets resultat, årets resultat
|
||||
|
||||
### Balansräkning
|
||||
- Tillgångar: anläggningstillgångar (immateriella/materiella/finansiella), omsättningstillgångar (varulager/fordringar/kassa)
|
||||
- Eget kapital och skulder: bundet/fritt eget kapital, obeskattade reserver, avsättningar, långfristiga/kortfristiga skulder
|
||||
- K2: rigid template, cannot add/merge line items beyond template
|
||||
|
||||
### Minimum notes (K2)
|
||||
- Applied redovisningsprinciper ("upprättad i enlighet med ÅRL och BFNAR 2016:10")
|
||||
- Avskrivningstider per asset category
|
||||
- Medelantal anställda
|
||||
- Ställda säkerheter
|
||||
- Eventualförpliktelser
|
||||
|
||||
K3 requires significantly more: deferred tax analysis, critical judgments, estimation uncertainty.
|
||||
|
||||
### Signing
|
||||
All board members + VD (if appointed) must sign. Revisionsberättelse appended if company has revisor.
|
||||
|
||||
---
|
||||
|
||||
## NE-bilaga for Enskild firma
|
||||
|
||||
Form: blankett 2161, filed as part of Inkomstdeklaration 1.
|
||||
|
||||
### Page 1: Räkenskapsschema
|
||||
- R1–R4: Intäkter (momspliktiga, momsfria, egna uttag, övriga)
|
||||
- R5–R13: Kostnader (varuinköp, externa kostnader, löner, arbetsgivaravgifter, avskrivningar)
|
||||
- R14: Bokfört resultat
|
||||
- B1–B22: Condensed balansräkning
|
||||
|
||||
### Page 2: Skattemässiga justeringar
|
||||
- R15–R46: räntefördelning, periodiseringsfond, expansionsfond, egenavgifter schablonavdrag
|
||||
- NE surplus/deficit flows into INK1 under "Inkomst av näringsverksamhet"
|
||||
|
||||
---
|
||||
|
||||
## Audit thresholds
|
||||
|
||||
Private AB may opt out of revisor if NOT exceeding 2 of 3 thresholds for 2 consecutive years:
|
||||
- **>3 average employees**
|
||||
- **>1.5 MSEK total assets**
|
||||
- **>3 MSEK net revenue**
|
||||
|
||||
Exceeding → must have registered revisor, revisionsberättelse becomes part of årsredovisning.
|
||||
|
||||
---
|
||||
|
||||
## Filing deadlines
|
||||
|
||||
### AB with calendar year (Jan–Dec)
|
||||
|
||||
| Milestone | Deadline | Penalty |
|
||||
|-----------|----------|---------|
|
||||
| Årsredovisning prepared | June 30 (6 months) | Bokföringsbrott risk |
|
||||
| Bolagsstämma (AGM) | June 30 (6 months) | Must adopt årsredovisning |
|
||||
| Årsredovisning to Bolagsverket | July 31 (7 months) | Förseningsavgift 5,000 SEK |
|
||||
| Inkomstdeklaration 2 to Skatteverket | August 1 | Förseningsavgift 6,250 SEK |
|
||||
|
||||
For brutet räkenskapsår: same intervals relative to fiscal year-end.
|
||||
|
||||
### Late filing penalties (Bolagsverket)
|
||||
- After 7 months: **5,000 SEK**
|
||||
- After 9 months: additional **5,000 SEK**
|
||||
- After 11 months: additional **10,000 SEK**
|
||||
- Total maximum: **20,000 SEK** (private AB)
|
||||
- After 11 months: Bolagsverket initiates **tvångslikvidation**
|
||||
- Penalties are NOT tax-deductible
|
||||
|
||||
### Filing formats
|
||||
- Bolagsverket: **iXBRL** via API, or paper/PDF + fastställelseintyg (signed with BankID)
|
||||
- Skatteverket INK2: main form (INK2), räkenskapsschema (INK2R), skattemässiga justeringar (INK2S). INK2R/INK2S uploadable as **SRU-filer** via filöverföring.
|
||||
|
||||
### Enskild firma
|
||||
- **Inkomstdeklaration 1 + NE-bilaga** by **May 2** (or nearest weekday)
|
||||
- 2026: May 4 for fiscal year 2025
|
||||
- No filing with Bolagsverket
|
||||
|
||||
---
|
||||
|
||||
## SIE4 Export
|
||||
|
||||
SIE (Standard Import Export) is the Swedish open standard. SIE 4 provides full transaction-level data.
|
||||
|
||||
### File format
|
||||
- Plain text, **CP437 encoding**
|
||||
- Hash-prefixed tags
|
||||
|
||||
### Critical tags for year-end closing
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `#SIETYP` | Always "4" |
|
||||
| `#FNAMN` | Company name |
|
||||
| `#ORGNR` | Organisationsnummer |
|
||||
| `#RAR` | Fiscal year definition (0 = current, -1 = previous) |
|
||||
| `#KPTYP` | Chart of accounts (e.g. "BAS2025") |
|
||||
| `#KONTO` | Account number and name |
|
||||
| `#SRU` | Maps account to Skatteverket field codes |
|
||||
| `#IB` / `#UB` | Opening/closing balances (balance sheet accounts) |
|
||||
| `#VER` | Verification header (series, number, date, description) |
|
||||
| `#TRANS` | Transaction lines within verification |
|
||||
|
||||
### Bokslutstransaktioner conventions
|
||||
- Use dedicated **verifikationsserie** (e.g. "I" or "B")
|
||||
- Dated on balance sheet date
|
||||
- Many systems treat as **period 13** (boksluts period)
|
||||
- `#SRU` tag enables automated INK2R/INK2S generation
|
||||
|
||||
### File extensions
|
||||
- `.se` — complete export with balances
|
||||
- `.si` — transaction-only import
|
||||
|
||||
### Example SIE4 snippet
|
||||
```
|
||||
#FLAGGA 0
|
||||
#FORMAT PC8
|
||||
#SIETYP 4
|
||||
#PROGRAM "AccountingSoft" 2.0
|
||||
#GEN 20260401
|
||||
#FNAMN "Exempel AB"
|
||||
#ORGNR 556123-4567
|
||||
#RAR 0 20250101 20251231
|
||||
#KPTYP BAS2025
|
||||
#KONTO 8910 "Skatt på årets resultat"
|
||||
#SRU 8910 7650
|
||||
#IB 0 2091 -500000.00
|
||||
#UB 0 2091 -897000.00
|
||||
#VER "I" 1 20251231 "Bokslut - skatteberäkning"
|
||||
{
|
||||
#TRANS 8910 {} 103000.00
|
||||
#TRANS 2512 {} -103000.00
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# Tax Calculations: AB vs Enskild Firma
|
||||
|
||||
## Bolagsskatt for AB
|
||||
|
||||
Rate: **20.6%** (since January 1, 2021).
|
||||
|
||||
### Skattemässigt resultat calculation
|
||||
|
||||
Start from bokfört resultat, then adjust:
|
||||
|
||||
**Add back (ej avdragsgilla kostnader):**
|
||||
- Representation (over deductible limits)
|
||||
- Böter, skattetillägg
|
||||
- Kostnadsränta on skattekonto
|
||||
- Gåvor
|
||||
- Bolagsbildningskostnader
|
||||
|
||||
**Subtract (ej skattepliktiga intäkter):**
|
||||
- Utdelning from näringsbetingade andelar
|
||||
- Intäktsränta on skattekonto
|
||||
|
||||
**Add:**
|
||||
- Schablonintäkt on periodiseringsfonder
|
||||
- Reversal of overused tax depreciation
|
||||
|
||||
**Subtract:**
|
||||
- Prior-year losses (underskottsavdrag)
|
||||
|
||||
Round down to nearest 10 SEK before applying 20.6%.
|
||||
|
||||
### Schablonintäkt on periodiseringsfonder (AB)
|
||||
|
||||
Formula: total periodiseringsfonder at year-start × statslåneränta (SLR) from November 30 of preceding year (floor 0.5%).
|
||||
|
||||
- Beskattningsår ending 2025: **1.96%**
|
||||
- Beskattningsår ending 2026: **2.55%**
|
||||
|
||||
This is a skattemässig justering ONLY. NEVER booked in accounting. Reported on INK2S field 4.6a.
|
||||
|
||||
### Transition rule for pre-2019 periodiseringsfonder
|
||||
|
||||
Funds created before 2019 (when rate was 22%) require **gross-up of 103–106%** on reversal to compensate for rate reduction. Handled only in tax calculation.
|
||||
|
||||
---
|
||||
|
||||
## Enskild firma: four unique tax mechanisms
|
||||
|
||||
An enskild firma is NOT a skattesubjekt. Owner pays tax personally via Inkomstdeklaration 1 + NE-bilaga.
|
||||
|
||||
### 1. Egenavgifter
|
||||
|
||||
Replace arbetsgivaravgifter for employees.
|
||||
|
||||
**Full rate (2025/2026):** 28.97% (born 1959 or later, active business, 7 karensdagar)
|
||||
|
||||
**NE-bilaga schablonavdrag logic:**
|
||||
- R43: Schablonavdrag = **25%** of överskott before egenavgifter
|
||||
- R40: Previous year's schablonavdrag added back
|
||||
- R41: Actual egenavgifter charged previous year deducted
|
||||
- Net effect: estimated deduction, reconciled following year
|
||||
|
||||
**Reduced rates:**
|
||||
- Pensioners born 1938–1958: 10.21% rate, 10% schablonavdrag
|
||||
- Passive businesses: SLP 24.26% instead, 20% schablonavdrag
|
||||
|
||||
**Additional nedsättning:** 7.5% (max 15,000 SEK/year) for active businesses with överskott > 40,000 SEK. Calculated automatically by Skatteverket.
|
||||
|
||||
### 2. Räntefördelning
|
||||
|
||||
Shifts calculated return on business capital from inkomstslaget näringsverksamhet (taxed at marginal rates up to ~55% including egenavgifter) to inkomstslaget kapital (flat 30%).
|
||||
|
||||
**Positive räntefördelning (voluntary):**
|
||||
- Rate: SLR + 6 percentage points
|
||||
- 2025: 7.96%, 2026: 8.55%
|
||||
- Applied on positive kapitalunderlag
|
||||
- From 2025: no minimum kapitalunderlag threshold (previously 50,000 SEK)
|
||||
|
||||
**Negative räntefördelning (mandatory):**
|
||||
- Triggers when negative kapitalunderlag exceeds **500,000 SEK**
|
||||
- Rate: SLR + 1 percentage point
|
||||
- 2025: 2.96%, 2026: 3.55%
|
||||
|
||||
**Kapitalunderlag** = adjusted eget kapital in the business at previous year-end.
|
||||
|
||||
Handled in NE-bilaga only, NEVER booked.
|
||||
|
||||
### 3. Expansionsfond
|
||||
|
||||
Gives sole traders equivalent of AB's ability to retain earnings at corporate tax rate.
|
||||
|
||||
- Owner pays **20.6% expansionsfondsskatt** on avsättning
|
||||
- Max avsättning: **125.94%** of kapitalunderlag at year-end
|
||||
- **No mandatory 6-year reversal** (can be held indefinitely)
|
||||
- On reversal: amount added back as NV income, previously paid 20.6% credited against that year's tax
|
||||
|
||||
Handled exclusively in NE-bilaga (R33/R34), NEVER booked.
|
||||
|
||||
### 4. Periodiseringsfond (Enskild firma)
|
||||
|
||||
- Max deferral: **30%** of result (vs 25% for AB)
|
||||
- Same 6-year mandatory reversal
|
||||
- **No schablonintäkt** for fysiska personer
|
||||
- Handled only in NE-bilaga (R29/R30), NEVER booked
|
||||
|
||||
---
|
||||
|
||||
## Critical distinction: booked vs declaration-only
|
||||
|
||||
| Item | AB | Enskild firma |
|
||||
|------|-----|---------------|
|
||||
| Periodiseringsfond | **Booked** (8811/21xx) | Declaration only (NE R29/R30) |
|
||||
| Överavskrivningar | **Booked** (8850/2150) | **Booked** (8850/2150) |
|
||||
| Skatt på årets resultat | **Booked** (8910/2512) | NOT booked (personal tax) |
|
||||
| Schablonintäkt periodiseringsfond | Declaration only (INK2S 4.6a) | N/A |
|
||||
| Räntefördelning | N/A | Declaration only |
|
||||
| Expansionsfond | N/A | Declaration only |
|
||||
| Egenavgifter schablonavdrag | N/A | Declaration only |
|
||||
@@ -11,6 +11,9 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Mail, ArrowLeft, KeyRound } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
import type { BankIdResult } from '@/components/auth/BankIdAuth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -20,9 +23,11 @@ export default function LoginPage() {
|
||||
const [showResetPassword, setShowResetPassword] = useState(false)
|
||||
const [resetCooldownUntil, setResetCooldownUntil] = useState<number | null>(null)
|
||||
const [resetCooldownRemaining, setResetCooldownRemaining] = useState(0)
|
||||
const [bankIdNoAccount, setBankIdNoAccount] = useState<{ givenName?: string; surname?: string } | null>(null)
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
|
||||
// Reset cooldown timer
|
||||
useEffect(() => {
|
||||
@@ -37,6 +42,51 @@ export default function LoginPage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [resetCooldownUntil])
|
||||
|
||||
const handleBankIdComplete = async (result: BankIdResult) => {
|
||||
if (result.error === 'no_account') {
|
||||
setBankIdNoAccount({ givenName: result.givenName, surname: result.surname })
|
||||
return
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
title: 'Inloggning misslyckades',
|
||||
description: 'Kunde inte slutfora BankID-inloggningen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.tokenHash && result.type) {
|
||||
try {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash: result.tokenHash,
|
||||
type: result.type as 'magiclink',
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[login] BankID verifyOtp failed', error)
|
||||
toast({
|
||||
title: 'Inloggning misslyckades',
|
||||
description: 'Kunde inte slutfora BankID-inloggningen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('[login] BankID complete error', error)
|
||||
toast({
|
||||
title: 'Inloggning misslyckades',
|
||||
description: getErrorMessage(error, { context: 'auth' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
@@ -244,6 +294,41 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
{bankIdEnabled && (
|
||||
<>
|
||||
{bankIdNoAccount ? (
|
||||
<div className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
Hej {bankIdNoAccount.givenName}!
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||
Vi hittade inget konto kopplat till ditt BankID. Logga in med e-post nedan och koppla sedan BankID i installningar.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBankIdNoAccount(null)}
|
||||
className="text-xs text-amber-600 underline underline-offset-2 hover:text-amber-800 dark:text-amber-400"
|
||||
>
|
||||
Eller skapa ett nytt konto
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-5">
|
||||
<BankIdAuth mode="login" onComplete={handleBankIdComplete} />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">eller logga in med e-post</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-postadress</Label>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,6 +11,9 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Mail, ArrowLeft } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
import type { BankIdResult } from '@/components/auth/BankIdAuth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
@@ -32,8 +35,13 @@ function RegisterPageContent() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isRegistered, setIsRegistered] = useState(false)
|
||||
const [inviteEmail, setInviteEmail] = useState<string | null>(null)
|
||||
const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null)
|
||||
const [bankIdSessionId, setBankIdSessionId] = useState<string | null>(null)
|
||||
const [bankIdEmail, setBankIdEmail] = useState('')
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
|
||||
// When arriving from an invite link, fetch the invite info to pre-fill
|
||||
// and lock the email field so the user registers with the correct address.
|
||||
@@ -52,6 +60,87 @@ function RegisterPageContent() {
|
||||
.catch(() => {})
|
||||
}, [searchParams])
|
||||
|
||||
const handleBankIdComplete = (result: BankIdResult) => {
|
||||
if (result.error) {
|
||||
toast({
|
||||
title: 'BankID misslyckades',
|
||||
description: 'Kunde inte verifiera din identitet.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// BankID verified — store sessionId and show email form
|
||||
setBankIdUser({ givenName: result.givenName, surname: result.surname })
|
||||
if (result.sessionId) setBankIdSessionId(result.sessionId)
|
||||
}
|
||||
|
||||
const handleBankIdSignup = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailValue = (formData.get('bankid_email') as string) || bankIdEmail
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/tic/bankid/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: bankIdSessionId,
|
||||
mode: 'signup',
|
||||
email: emailValue,
|
||||
}),
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (json.error === 'already_linked') {
|
||||
toast({
|
||||
title: 'BankID redan kopplat',
|
||||
description: 'Detta BankID ar redan kopplat till ett konto. Forsok logga in istallet.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Registrering misslyckades',
|
||||
description: json.message || json.error || 'Ett ovantat fel uppstod.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange token hash for Supabase session
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash: json.data.tokenHash,
|
||||
type: json.data.type as 'magiclink',
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('[register] BankID verifyOtp failed', error)
|
||||
toast({
|
||||
title: 'Kunde inte slutfora registreringen',
|
||||
description: getErrorMessage(error, { context: 'auth' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/onboarding')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('[register] BankID signup error', error)
|
||||
toast({
|
||||
title: 'Registrering misslyckades',
|
||||
description: getErrorMessage(error, { context: 'auth' }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function isStrongPassword(pw: string): boolean {
|
||||
return pw.length >= 8
|
||||
&& /[a-z]/.test(pw)
|
||||
@@ -245,6 +334,74 @@ function RegisterPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
{bankIdEnabled && !bankIdUser && (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<BankIdAuth mode="signup" onComplete={handleBankIdComplete} />
|
||||
</div>
|
||||
<div className="relative mb-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">eller skapa konto med e-post</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bankIdUser ? (
|
||||
<form onSubmit={handleBankIdSignup} className="space-y-5">
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">
|
||||
{bankIdUser.givenName} {bankIdUser.surname}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Verifierad med BankID
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankid_email">E-postadress</Label>
|
||||
<Input
|
||||
id="bankid_email"
|
||||
name="bankid_email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="namn@exempel.se"
|
||||
value={bankIdEmail}
|
||||
onChange={(e) => setBankIdEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Anvands for inloggning och notifieringar.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar konto...
|
||||
</>
|
||||
) : (
|
||||
'Skapa konto'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground"
|
||||
onClick={() => {
|
||||
setBankIdUser(null)
|
||||
setBankIdSessionId(null)
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-postadress</Label>
|
||||
@@ -310,6 +467,7 @@ function RegisterPageContent() {
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -6,24 +6,20 @@ import Image from 'next/image'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ArrowRight } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
import type { CompanyRole } from '@/extensions/general/tic/lib/bankid-types'
|
||||
|
||||
import Step0RoleChoice from '@/components/onboarding/Step0RoleChoice'
|
||||
import Step1EntityType from '@/components/onboarding/Step1EntityType'
|
||||
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
|
||||
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
||||
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
|
||||
|
||||
type OnboardingMode = 'choice' | 'self' | 'consultant'
|
||||
|
||||
const STEP_INFO = [
|
||||
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
|
||||
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
|
||||
@@ -31,12 +27,13 @@ const STEP_INFO = [
|
||||
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' },
|
||||
]
|
||||
|
||||
const STEP_INFO_CONSULTANT = [
|
||||
{ title: 'Kundföretag', subtitle: 'Välj din kunds företagsform.', label: 'Företagsform' },
|
||||
{ title: 'Kundföretag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
|
||||
{ title: 'F-skatt & räkenskapsår', subtitle: 'Din kunds skatteregistrering och räkenskapsår.', label: 'Skatt' },
|
||||
{ title: 'Moms & bokföring', subtitle: 'Din kunds momsregistrering och bokföringsmetod.', label: 'Moms' },
|
||||
]
|
||||
/** Map TIC legalEntityType to gnubok EntityType */
|
||||
function mapEntityType(ticType: string): EntityType | null {
|
||||
const lower = ticType.toLowerCase()
|
||||
if (lower === 'ab' || lower.includes('aktiebolag')) return 'aktiebolag'
|
||||
if (lower === 'ef' || lower.includes('enskild firma') || lower.includes('enskild')) return 'enskild_firma'
|
||||
return null
|
||||
}
|
||||
|
||||
function translatePeriodError(msg: string): string {
|
||||
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
|
||||
@@ -89,9 +86,8 @@ function OnboardingPageContent() {
|
||||
const [companyId, setCompanyId] = useState<string | null>(null)
|
||||
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
||||
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
|
||||
const [mode, setMode] = useState<OnboardingMode>('choice')
|
||||
const [consultantLanding, setConsultantLanding] = useState(false)
|
||||
const [teamName, setTeamName] = useState('')
|
||||
const [enrichmentCompanies, setEnrichmentCompanies] = useState<CompanyRole[]>([])
|
||||
const [orgNumberLocked, setOrgNumberLocked] = useState(false)
|
||||
|
||||
const totalSteps = 4
|
||||
|
||||
@@ -196,7 +192,6 @@ function OnboardingPageContent() {
|
||||
}
|
||||
|
||||
setCompanyId(membership.company_id)
|
||||
setMode('self') // Resuming — skip role choice
|
||||
|
||||
if (data) {
|
||||
const step = data.onboarding_step || 1
|
||||
@@ -211,6 +206,48 @@ function OnboardingPageContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load BankID enrichment data if available (one-time use)
|
||||
try {
|
||||
const { data: enrichmentRow } = await supabase
|
||||
.from('extension_data')
|
||||
.select('id, value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', 'tic')
|
||||
.eq('key', 'bankid_enrichment')
|
||||
.maybeSingle()
|
||||
|
||||
if (enrichmentRow?.value) {
|
||||
const enrichment = enrichmentRow.value as { spar?: Record<string, string>; companyRoles?: CompanyRole[] }
|
||||
|
||||
// Extract active companies
|
||||
const activeCompanies = (enrichment.companyRoles ?? []).filter(
|
||||
(c: CompanyRole) => c.companyStatus === 'Aktivt' && c.positionEnd === null
|
||||
)
|
||||
if (activeCompanies.length > 0) {
|
||||
setEnrichmentCompanies(activeCompanies)
|
||||
}
|
||||
|
||||
// Pre-fill SPAR address if not already set
|
||||
if (enrichment.spar && !settings.address_line1) {
|
||||
const spar = enrichment.spar
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
address_line1: spar.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 || prev.address_line1,
|
||||
postal_code: spar.Folkbokforingsadress_SvenskAdress_PostNr || prev.postal_code,
|
||||
city: spar.Folkbokforingsadress_SvenskAdress_Postort || prev.city,
|
||||
}))
|
||||
}
|
||||
|
||||
// Delete enrichment data (one-time use)
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('id', enrichmentRow.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(LOG, 'enrichment loading failed (non-blocking)', err)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -563,8 +600,6 @@ function OnboardingPageContent() {
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
} else if (isConsultant) {
|
||||
setConsultantLanding(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,44 +614,20 @@ function OnboardingPageContent() {
|
||||
setCurrentStep(nextStep)
|
||||
}
|
||||
|
||||
const handleConsultantCreateTeam = async () => {
|
||||
if (!teamName.trim()) {
|
||||
toast({ title: 'Ange ett namn', description: 'Ditt team behöver ett namn.', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
/** Handle selecting a company from BankID enrichment */
|
||||
const handleEnrichmentSelect = (company: CompanyRole) => {
|
||||
const entityType = mapEntityType(company.legalEntityType)
|
||||
if (!entityType) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const { data: newTeamId, error: teamError } = await supabase.rpc('create_team_with_owner', {
|
||||
p_name: teamName.trim(),
|
||||
})
|
||||
|
||||
if (teamError || !newTeamId) {
|
||||
logError('consultant team creation failed', { message: teamError?.message })
|
||||
toast({ title: 'Fel', description: 'Kunde inte skapa team. Försök igen.', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
console.log(LOG, 'created team', newTeamId)
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Lägg till ditt första kundföretag för att komma igång.',
|
||||
})
|
||||
// Hard navigation to exit the (onboarding) route group and trigger middleware
|
||||
window.location.href = '/'
|
||||
} catch (err) {
|
||||
logError('consultant team creation threw', { error: String(err) })
|
||||
Sentry.captureException(err)
|
||||
toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
// Auto-set entity type, org number, and company name
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
entity_type: entityType,
|
||||
org_number: company.companyRegistrationNumber,
|
||||
company_name: company.legalName,
|
||||
}))
|
||||
setOrgNumberLocked(true)
|
||||
setEnrichmentCompanies([]) // Dismiss picker
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -627,14 +638,39 @@ function OnboardingPageContent() {
|
||||
)
|
||||
}
|
||||
|
||||
const isConsultant = mode === 'consultant'
|
||||
const stepInfoArr = isConsultant ? STEP_INFO_CONSULTANT : STEP_INFO
|
||||
const stepInfo = stepInfoArr[currentStep - 1]
|
||||
const showRoleChoice = mode === 'choice'
|
||||
const showConsultantLanding = isConsultant && consultantLanding
|
||||
const stepInfo = STEP_INFO[currentStep - 1]
|
||||
|
||||
const renderSteps = () => (
|
||||
<>
|
||||
{currentStep === 1 && enrichmentCompanies.length > 0 && (
|
||||
<div className="mb-6 space-y-3">
|
||||
<p className="text-sm font-medium">Vi hittade dessa foretag kopplade till ditt BankID:</p>
|
||||
{enrichmentCompanies.map((company) => {
|
||||
const entityType = mapEntityType(company.legalEntityType)
|
||||
return (
|
||||
<button
|
||||
key={company.companyRegistrationNumber}
|
||||
onClick={() => handleEnrichmentSelect(company)}
|
||||
className="w-full rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/[0.02]"
|
||||
>
|
||||
<p className="font-medium text-sm">{company.legalName}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{company.companyRegistrationNumber} · {entityType === 'aktiebolag' ? 'Aktiebolag' : entityType === 'enskild_firma' ? 'Enskild firma' : company.legalEntityType}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="relative py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">eller valj foretagsform manuellt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Step1EntityType
|
||||
initialData={{ entity_type: settings.entity_type as EntityType }}
|
||||
@@ -659,6 +695,7 @@ function OnboardingPageContent() {
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
orgNumberLocked={orgNumberLocked}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -693,147 +730,7 @@ function OnboardingPageContent() {
|
||||
</>
|
||||
)
|
||||
|
||||
// ── Role Choice Screen (Step 0) ──
|
||||
if (showRoleChoice) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<header className="relative bg-[#141414] text-white overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
|
||||
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
|
||||
<Image
|
||||
src="/gnubokiceon-removebg-preview.png"
|
||||
alt="Gnubok"
|
||||
width={30}
|
||||
height={30}
|
||||
className="invert opacity-90"
|
||||
/>
|
||||
<span className="font-display text-base tracking-tight">gnubok</span>
|
||||
</div>
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
|
||||
Välkommen till gnubok
|
||||
</h1>
|
||||
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
|
||||
Hur vill du använda gnubok?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
|
||||
<div className="animate-slide-up">
|
||||
<Step0RoleChoice
|
||||
onChooseSelf={() => setMode('self')}
|
||||
onChooseConsultant={() => {
|
||||
setMode('consultant')
|
||||
setConsultantLanding(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Consultant Landing Screen ──
|
||||
if (showConsultantLanding) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<header className="relative bg-[#141414] text-white overflow-hidden">
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
|
||||
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
|
||||
<Image
|
||||
src="/gnubokiceon-removebg-preview.png"
|
||||
alt="Gnubok"
|
||||
width={30}
|
||||
height={30}
|
||||
className="invert opacity-90"
|
||||
/>
|
||||
<span className="font-display text-base tracking-tight">gnubok</span>
|
||||
</div>
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
|
||||
Namnge ditt team
|
||||
</h1>
|
||||
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
|
||||
Skapa ett team som samlar dig och dina kollegor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
|
||||
<div className="animate-slide-up space-y-5">
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="team-name">Teamnamn</Label>
|
||||
<Input
|
||||
id="team-name"
|
||||
placeholder="T.ex. Redovisningsbyrån AB"
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="h-11"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Du kan ändra namnet senare i inställningar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={handleConsultantCreateTeam}
|
||||
disabled={isSaving || !teamName.trim()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar team...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Skapa team
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode('choice')
|
||||
setConsultantLanding(false)
|
||||
}}
|
||||
className="block mx-auto text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
Tillbaka
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Steps 1–4 (self or consultant adding company) ──
|
||||
// ── Steps 1–4 ──
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
{/* ── Branded Header ── */}
|
||||
@@ -866,7 +763,7 @@ function OnboardingPageContent() {
|
||||
</div>
|
||||
{/* Step indicator — inline with logo row */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{stepInfoArr.map((_, i) => {
|
||||
{STEP_INFO.map((_, i) => {
|
||||
const num = i + 1
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { BankIdQrCode } from './BankIdQrCode'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Smartphone, Monitor } from 'lucide-react'
|
||||
|
||||
type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account'
|
||||
|
||||
interface BankIdSession {
|
||||
sessionId: string
|
||||
autoStartToken: string
|
||||
qrStartToken: string
|
||||
qrStartSecret: string
|
||||
}
|
||||
|
||||
export interface BankIdResult {
|
||||
tokenHash?: string
|
||||
type?: string
|
||||
isNewUser?: boolean
|
||||
error?: 'no_account' | 'already_linked' | 'session_invalid'
|
||||
givenName?: string
|
||||
surname?: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
interface BankIdAuthProps {
|
||||
mode: 'login' | 'signup' | 'link'
|
||||
onComplete: (result: BankIdResult) => void
|
||||
}
|
||||
|
||||
const API_BASE = '/api/extensions/ext/tic/bankid'
|
||||
|
||||
function isMobile(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* BankID authentication flow component.
|
||||
* Handles QR code display (desktop) or app deep link (mobile),
|
||||
* polling, and result handling.
|
||||
*/
|
||||
export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
const [status, setStatus] = useState<BankIdStatus>('idle')
|
||||
const [session, setSession] = useState<BankIdSession | null>(null)
|
||||
const [hintMessage, setHintMessage] = useState<string>('')
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const lastStartRef = useRef<number>(0)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort()
|
||||
abortRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cleanup, [cleanup])
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
// Prevent rapid restarts (each start = billable TIC session)
|
||||
const now = Date.now()
|
||||
if (now - lastStartRef.current < 5000) return
|
||||
lastStartRef.current = now
|
||||
|
||||
cleanup()
|
||||
setStatus('scanning')
|
||||
setHintMessage('Starta BankID-appen')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/start`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to start BankID')
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
const newSession: BankIdSession = data
|
||||
setSession(newSession)
|
||||
|
||||
// On mobile, open BankID app
|
||||
if (isMobile()) {
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=${returnUrl}`
|
||||
}
|
||||
|
||||
// Start polling
|
||||
abortRef.current = new AbortController()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(`${API_BASE}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
signal: abortRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!pollRes.ok) return
|
||||
|
||||
const pollJson = await pollRes.json()
|
||||
const pollData = pollJson.data
|
||||
|
||||
if (!pollData) {
|
||||
console.warn('[bankid] poll returned no data:', pollJson)
|
||||
return
|
||||
}
|
||||
|
||||
// Update hint message from TIC API
|
||||
if (pollData.message) {
|
||||
setHintMessage(pollData.message)
|
||||
}
|
||||
|
||||
// Handle token refresh (order regeneration ~25s)
|
||||
if (pollData.qrStartToken && pollData.qrStartSecret) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
|
||||
if (pollData.status === 'complete') {
|
||||
cleanup()
|
||||
setStatus('complete')
|
||||
|
||||
if (mode === 'login') {
|
||||
// For login, call /complete to exchange for Supabase session
|
||||
try {
|
||||
const completeRes = await fetch(`${API_BASE}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: newSession.sessionId,
|
||||
mode: 'login',
|
||||
}),
|
||||
})
|
||||
const completeJson = await completeRes.json()
|
||||
|
||||
if (!completeRes.ok) {
|
||||
onCompleteRef.current({
|
||||
error: completeJson.error,
|
||||
givenName: completeJson.givenName,
|
||||
surname: completeJson.surname,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({
|
||||
tokenHash: completeJson.data.tokenHash,
|
||||
type: completeJson.data.type,
|
||||
isNewUser: completeJson.data.isNewUser,
|
||||
})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// For link, call /link to associate BankID with current user
|
||||
try {
|
||||
const linkRes = await fetch(`${API_BASE}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
})
|
||||
const linkJson = await linkRes.json()
|
||||
|
||||
if (!linkRes.ok) {
|
||||
onCompleteRef.current({ error: linkJson.error })
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else {
|
||||
// For signup, return user data + sessionId so parent can collect email
|
||||
onCompleteRef.current({
|
||||
givenName: pollData.user?.givenName,
|
||||
surname: pollData.user?.surname,
|
||||
sessionId: newSession.sessionId,
|
||||
})
|
||||
}
|
||||
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
|
||||
cleanup()
|
||||
setStatus('failed')
|
||||
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
// Polling error — will retry next tick
|
||||
}
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
setStatus('failed')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
|
||||
}
|
||||
}, [cleanup, mode])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (session) {
|
||||
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
|
||||
}
|
||||
cleanup()
|
||||
setStatus('idle')
|
||||
setSession(null)
|
||||
}, [session, cleanup])
|
||||
|
||||
if (status === 'idle') {
|
||||
const label = mode === 'login'
|
||||
? 'Logga in med BankID'
|
||||
: mode === 'link'
|
||||
? 'Koppla BankID'
|
||||
: 'Skapa konto med BankID'
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={startSession}
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-[1.5px] py-6 text-base"
|
||||
>
|
||||
<BankIdIcon />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
<Button onClick={startSession} variant="outline" className="gap-2">
|
||||
<BankIdIcon />
|
||||
Forsok igen
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const openBankIdOnDevice = () => {
|
||||
if (!session) return
|
||||
// Open BankID app on the same device via deep link
|
||||
// redirect=null tells BankID not to redirect after completion
|
||||
window.location.href = `bankid:///?autostarttoken=${session.autoStartToken}&redirect=null`
|
||||
}
|
||||
|
||||
// Scanning / waiting for user
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{session && !isMobile() && (
|
||||
<>
|
||||
<BankIdQrCode
|
||||
qrStartToken={session.qrStartToken}
|
||||
qrStartSecret={session.qrStartSecret}
|
||||
/>
|
||||
<Button
|
||||
onClick={openBankIdOnDevice}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
BankID pa den har enheten
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile() && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Smartphone className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Oppnar BankID-appen...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">{hintMessage}</p>
|
||||
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BankIdIcon() {
|
||||
return (
|
||||
<Image
|
||||
src="/logos/bankid-seeklogo.svg"
|
||||
alt="BankID"
|
||||
width={20}
|
||||
height={20}
|
||||
className="dark:invert"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
interface BankIdQrCodeProps {
|
||||
qrStartToken: string
|
||||
qrStartSecret: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated BankID QR code component.
|
||||
* Computes HMAC-SHA256 every second per BankID spec:
|
||||
* bankid.{qrStartToken}.{time}.{hmac_sha256(qrStartSecret, time)}
|
||||
*/
|
||||
export function BankIdQrCode({ qrStartToken, qrStartSecret }: BankIdQrCodeProps) {
|
||||
const [svgData, setSvgData] = useState<string>('')
|
||||
const elapsedRef = useRef(0)
|
||||
const tokenRef = useRef(qrStartToken)
|
||||
const secretRef = useRef(qrStartSecret)
|
||||
|
||||
// Update refs when tokens change (order regeneration)
|
||||
useEffect(() => {
|
||||
tokenRef.current = qrStartToken
|
||||
secretRef.current = qrStartSecret
|
||||
elapsedRef.current = 0
|
||||
}, [qrStartToken, qrStartSecret])
|
||||
|
||||
const generateQr = useCallback(async () => {
|
||||
const token = tokenRef.current
|
||||
const secret = secretRef.current
|
||||
const time = elapsedRef.current
|
||||
|
||||
try {
|
||||
// Compute HMAC-SHA256 using Web Crypto API
|
||||
const encoder = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(time.toString()))
|
||||
const qrAuthCode = Array.from(new Uint8Array(signature))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
const qrData = `bankid.${token}.${time}.${qrAuthCode}`
|
||||
|
||||
const svg = await QRCode.toString(qrData, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
width: 200,
|
||||
color: { dark: '#141414', light: '#ffffff' },
|
||||
})
|
||||
setSvgData(svg)
|
||||
} catch {
|
||||
// Silently fail — next tick will retry
|
||||
}
|
||||
|
||||
elapsedRef.current++
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Generate immediately, then every second
|
||||
generateQr()
|
||||
const interval = setInterval(generateQr, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [generateQr])
|
||||
|
||||
if (!svgData) {
|
||||
return (
|
||||
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-lg border bg-white">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-lg border bg-white p-2"
|
||||
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { User, Users } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Step0Props {
|
||||
onChooseSelf: () => void
|
||||
onChooseConsultant: () => void
|
||||
}
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'self',
|
||||
label: 'Jag bokför själv',
|
||||
description: 'Du driver ditt eget företag och vill hantera bokföringen.',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
key: 'consultant',
|
||||
label: 'Jag bokför åt någon annan',
|
||||
description: 'Du är konsult eller byrå och bokför åt dina kunder.',
|
||||
icon: Users,
|
||||
},
|
||||
] as const
|
||||
|
||||
export default function Step0RoleChoice({ onChooseSelf, onChooseConsultant }: Step0Props) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-3">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon
|
||||
const handler = option.key === 'self' ? onChooseSelf : onChooseConsultant
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={handler}
|
||||
className="text-left w-full"
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative p-5 transition-all cursor-pointer',
|
||||
'hover:border-primary/50 hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted/50">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ interface Step2Props {
|
||||
onNext: (data: FormData) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
orgNumberLocked?: boolean
|
||||
}
|
||||
|
||||
export default function Step2CompanyDetails({
|
||||
@@ -44,6 +45,7 @@ export default function Step2CompanyDetails({
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
orgNumberLocked,
|
||||
}: Step2Props) {
|
||||
const {
|
||||
register,
|
||||
@@ -167,6 +169,8 @@ export default function Step2CompanyDetails({
|
||||
id="org_number"
|
||||
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
|
||||
{...register('org_number')}
|
||||
readOnly={orgNumberLocked}
|
||||
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
|
||||
/>
|
||||
{errors.org_number && (
|
||||
<p className="text-sm text-destructive">{errors.org_number.message}</p>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
import type { BankIdResult } from '@/components/auth/BankIdAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface BankIdIdentity {
|
||||
given_name: string | null
|
||||
surname: string | null
|
||||
linked_at: string
|
||||
}
|
||||
|
||||
export function BankIdSettings() {
|
||||
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLinking, setIsLinking] = useState(false)
|
||||
const [isUnlinking, setIsUnlinking] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchIdentity = useCallback(async () => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) { setIsLoading(false); return }
|
||||
|
||||
const { data } = await supabase
|
||||
.from('bankid_identities')
|
||||
.select('given_name, surname, linked_at')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
setIdentity(data)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentity()
|
||||
}, [fetchIdentity])
|
||||
|
||||
const handleLinkComplete = async (result: BankIdResult) => {
|
||||
if (result.error) {
|
||||
const message = result.error === 'already_linked'
|
||||
? 'Detta BankID ar redan kopplat till ett annat konto.'
|
||||
: 'Kunde inte koppla BankID.'
|
||||
toast({ title: message, variant: 'destructive' })
|
||||
setIsLinking(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'BankID kopplat till ditt konto' })
|
||||
setIsLinking(false)
|
||||
fetchIdentity()
|
||||
}
|
||||
|
||||
const handleUnlink = async () => {
|
||||
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
|
||||
|
||||
setIsUnlinking(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/tic/bankid/unlink', { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Unlink failed')
|
||||
|
||||
setIdentity(null)
|
||||
toast({ title: 'BankID bortkopplat' })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsUnlinking(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLinking) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Koppla BankID</CardTitle>
|
||||
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{identity ? (
|
||||
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
BankID
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{identity
|
||||
? 'Ditt konto ar kopplat till BankID.'
|
||||
: 'Koppla BankID for sakrare inloggning.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{identity ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{identity.given_name} {identity.surname}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
Kopplat {new Date(identity.linked_at).toLocaleDateString('sv-SE')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsLinking(true)}
|
||||
>
|
||||
Koppla BankID
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react'
|
||||
import { isMfaRequired } from '@/lib/auth/mfa'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { BankIdSettings } from '@/components/settings/BankIdSettings'
|
||||
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
const mfaRequired = isMfaRequired()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
|
||||
export function SecuritySettings() {
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -132,6 +135,7 @@ export function SecuritySettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{bankIdEnabled && <BankIdSettings />}
|
||||
{/* Change password */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
startBankIdAuth,
|
||||
pollBankIdSession,
|
||||
collectBankIdResult,
|
||||
cancelBankIdSession,
|
||||
} from '../lib/bankid-client'
|
||||
import { TICAPIError } from '../lib/tic-types'
|
||||
|
||||
const API_KEY = 'test-api-key'
|
||||
const BASE_URL = 'https://id.tic.io/api/v1'
|
||||
|
||||
describe('bankid-client', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
vi.stubEnv('TIC_IDENTITY_API_KEY', API_KEY)
|
||||
vi.stubEnv('TIC_IDENTITY_API_URL', BASE_URL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('startBankIdAuth', () => {
|
||||
it('calls POST /auth/bankid/start with correct params', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
autoStartToken: 'auto-token',
|
||||
qrStartToken: 'qr-token',
|
||||
qrStartSecret: 'qr-secret',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await startBankIdAuth('192.168.1.1', 'TestAgent')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/bankid/start`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'X-Api-Key': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const body = JSON.parse(
|
||||
(vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string
|
||||
)
|
||||
expect(body.endUserIp).toBe('192.168.1.1')
|
||||
expect(body.userAgent).toBe('TestAgent')
|
||||
expect(result.sessionId).toBe('test-session')
|
||||
})
|
||||
|
||||
it('throws NOT_CONFIGURED when API key is missing', async () => {
|
||||
vi.stubEnv('TIC_IDENTITY_API_KEY', '')
|
||||
|
||||
await expect(startBankIdAuth('1.1.1.1')).rejects.toThrow(TICAPIError)
|
||||
await expect(startBankIdAuth('1.1.1.1')).rejects.toMatchObject({
|
||||
code: 'NOT_CONFIGURED',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws RATE_LIMIT_EXCEEDED on 429', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response('Too many requests', { status: 429 })
|
||||
)
|
||||
|
||||
await expect(startBankIdAuth('1.1.1.1')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollBankIdSession', () => {
|
||||
it('calls POST /auth/{sessionId}/poll', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'pending',
|
||||
hintCode: 'outstandingTransaction',
|
||||
message: 'Starta BankID-appen',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await pollBankIdSession('test-session')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/test-session/poll`,
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
expect(result.status).toBe('pending')
|
||||
expect(result.message).toBe('Starta BankID-appen')
|
||||
})
|
||||
|
||||
it('returns complete status with user data', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'complete',
|
||||
user: {
|
||||
personalNumber: '199001011234',
|
||||
givenName: 'Test',
|
||||
surname: 'Testsson',
|
||||
name: 'Test Testsson',
|
||||
},
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await pollBankIdSession('test-session')
|
||||
expect(result.status).toBe('complete')
|
||||
expect(result.user?.personalNumber).toBe('199001011234')
|
||||
})
|
||||
|
||||
it('returns failed status on 410 Gone', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'failed',
|
||||
hintCode: 'userCancel',
|
||||
error: 'authentication_failed',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 410 })
|
||||
)
|
||||
|
||||
const result = await pollBankIdSession('test-session')
|
||||
expect(result.status).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectBankIdResult', () => {
|
||||
it('calls GET /auth/{sessionId}/collect', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'complete',
|
||||
user: {
|
||||
personalNumber: '199001011234',
|
||||
givenName: 'Test',
|
||||
surname: 'Testsson',
|
||||
name: 'Test Testsson',
|
||||
},
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await collectBankIdResult('test-session')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/test-session/collect`,
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
expect(result.user?.name).toBe('Test Testsson')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelBankIdSession', () => {
|
||||
it('calls DELETE /auth/{sessionId}', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 }))
|
||||
|
||||
await cancelBankIdSession('test-session')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/test-session`,
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,25 @@ import {
|
||||
getCompanyPurpose,
|
||||
getFinancialReportSummaries,
|
||||
} from './lib/tic-client'
|
||||
import {
|
||||
startBankIdAuth,
|
||||
pollBankIdSession,
|
||||
collectBankIdResult,
|
||||
cancelBankIdSession,
|
||||
requestEnrichment,
|
||||
fetchEnrichmentData,
|
||||
} from './lib/bankid-client'
|
||||
import { TICAPIError } from './lib/tic-types'
|
||||
import type { TICCompanyProfile } from './lib/tic-types'
|
||||
import type { BankIdCompleteRequest } from './lib/bankid-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// Server-side per-IP rate limit for /bankid/start (each call = billable TIC session)
|
||||
const bankIdStartCooldowns = new Map<string, number>()
|
||||
const BANKID_START_COOLDOWN_MS = 5_000
|
||||
|
||||
/** Map TIC bankAccountType enum to human-readable string */
|
||||
function bankAccountTypeLabel(type?: number): string {
|
||||
@@ -340,6 +356,421 @@ export const ticExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
// ── BankID Authentication ──────────────────────────────────────
|
||||
// Routes for BankID login/signup via TIC Identity API.
|
||||
// skipAuth: true on auth routes (user has no Supabase session yet).
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/start',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| '127.0.0.1'
|
||||
|
||||
// Per-IP rate limit (each start = billable TIC session)
|
||||
const now = Date.now()
|
||||
const lastStart = bankIdStartCooldowns.get(ip) ?? 0
|
||||
if (now - lastStart < BANKID_START_COOLDOWN_MS) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
bankIdStartCooldowns.set(ip, now)
|
||||
|
||||
// Prevent map from growing unbounded
|
||||
if (bankIdStartCooldowns.size > 10_000) {
|
||||
const cutoff = now - BANKID_START_COOLDOWN_MS
|
||||
for (const [k, v] of bankIdStartCooldowns) {
|
||||
if (v < cutoff) bankIdStartCooldowns.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get('user-agent') || undefined
|
||||
|
||||
const session = await startBankIdAuth(ip, userAgent)
|
||||
return NextResponse.json({ data: session })
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
if (error.code === 'NOT_CONFIGURED') {
|
||||
return NextResponse.json({ error: 'BankID is not configured' }, { status: 503 })
|
||||
}
|
||||
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
||||
}
|
||||
}
|
||||
console.error('[tic/bankid] start failed', error)
|
||||
return NextResponse.json({ error: 'Failed to start BankID session' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/poll',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const sessionId = body?.sessionId
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await pollBankIdSession(sessionId)
|
||||
if (result.status !== 'pending') {
|
||||
console.log('[tic/bankid] poll status:', result.status, result.hintCode, result.user?.personalNumber ? 'has-user' : 'no-user')
|
||||
}
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
||||
}
|
||||
}
|
||||
console.error('[tic/bankid] poll failed', error)
|
||||
return NextResponse.json({ error: 'Failed to poll BankID session' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/complete',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const body: BankIdCompleteRequest = await request.json()
|
||||
const { sessionId, mode, email } = body
|
||||
|
||||
if (!sessionId || !mode) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId and mode are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'signup' && !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'email is required for signup' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify BankID session is complete
|
||||
const session = await collectBankIdResult(sessionId)
|
||||
if (session.status !== 'complete' || !session.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session_invalid', message: 'BankID session is not complete' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { personalNumber, givenName, surname, name } = session.user
|
||||
const pnrHash = hashPersonalNumber(personalNumber)
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// Look up existing BankID identity
|
||||
const { data: existing } = await supabase
|
||||
.from('bankid_identities')
|
||||
.select('user_id')
|
||||
.eq('personal_number_hash', pnrHash)
|
||||
.single()
|
||||
|
||||
if (mode === 'login') {
|
||||
if (!existing) {
|
||||
return NextResponse.json({
|
||||
error: 'no_account',
|
||||
givenName,
|
||||
surname,
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
// Returning user — generate magic link
|
||||
const { data: userData } = await supabase.auth.admin.getUserById(existing.user_id)
|
||||
if (!userData?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session_invalid', message: 'User account not found' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: userData.user.email,
|
||||
})
|
||||
|
||||
if (linkError || !link?.properties?.hashed_token) {
|
||||
console.error('[tic/bankid] generateLink failed', linkError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
tokenHash: link.properties.hashed_token,
|
||||
type: 'magiclink',
|
||||
isNewUser: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mode === 'signup'
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'already_linked', message: 'This BankID is already linked to an account' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new Supabase user
|
||||
const randomPassword = crypto.randomBytes(32).toString('base64url')
|
||||
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
|
||||
email: email!,
|
||||
email_confirm: true,
|
||||
password: randomPassword,
|
||||
user_metadata: { full_name: name },
|
||||
})
|
||||
|
||||
if (createError || !newUser?.user) {
|
||||
console.error('[tic/bankid] createUser failed', createError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create account', message: createError?.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = newUser.user.id
|
||||
|
||||
// Mark user as BankID-linked (skips TOTP MFA)
|
||||
await supabase.auth.admin.updateUserById(userId, {
|
||||
app_metadata: { bankid_linked: true },
|
||||
})
|
||||
|
||||
// Store BankID identity
|
||||
const { error: insertError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
personal_number_hash: pnrHash,
|
||||
personal_number_enc: encryptPersonalNumber(personalNumber),
|
||||
given_name: givenName,
|
||||
surname,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
console.error('[tic/bankid] insert bankid_identities failed', insertError)
|
||||
// Clean up the created user if identity linking fails
|
||||
await supabase.auth.admin.deleteUser(userId)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link BankID identity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate magic link for session
|
||||
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: email!,
|
||||
})
|
||||
|
||||
if (linkError || !link?.properties?.hashed_token) {
|
||||
console.error('[tic/bankid] generateLink failed for new user', linkError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Account created but failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Attempt enrichment and store for onboarding pre-fill
|
||||
try {
|
||||
const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles'])
|
||||
if (enrichment.status === 'Completed' && enrichment.secureUrl) {
|
||||
const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl)
|
||||
console.log('[tic/bankid] enrichment success', {
|
||||
hasSpar: !!enrichmentData.spar,
|
||||
companyCount: enrichmentData.companyRoles?.length ?? 0,
|
||||
})
|
||||
|
||||
// Store enrichment data in extension_data for the onboarding page to read
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
extension_id: 'tic',
|
||||
key: 'bankid_enrichment',
|
||||
value: enrichmentData,
|
||||
}, { onConflict: 'user_id,extension_id,key' })
|
||||
}
|
||||
} catch (enrichError) {
|
||||
// Enrichment is optional — don't fail signup
|
||||
console.warn('[tic/bankid] enrichment failed (non-blocking)', enrichError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
tokenHash: link.properties.hashed_token,
|
||||
type: 'magiclink',
|
||||
isNewUser: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
console.error('[tic/bankid] complete TIC error', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'BankID verification failed' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
console.error('[tic/bankid] complete unexpected error', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to complete BankID authentication' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/bankid/:sessionId',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const sessionId = url.searchParams.get('_sessionId')
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await cancelBankIdSession(sessionId)
|
||||
return NextResponse.json({ data: { cancelled: true } })
|
||||
} catch (error) {
|
||||
console.error('[tic/bankid] cancel failed', error)
|
||||
return NextResponse.json({ error: 'Failed to cancel session' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/link',
|
||||
// skipAuth: false — requires existing Supabase session
|
||||
handler: async (request: Request, ctx?) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId } = body
|
||||
|
||||
if (!sessionId || !ctx?.userId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify BankID session
|
||||
const session = await collectBankIdResult(sessionId)
|
||||
if (session.status !== 'complete' || !session.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session_invalid', message: 'BankID session is not complete' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { personalNumber, givenName, surname } = session.user
|
||||
const pnrHash = hashPersonalNumber(personalNumber)
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// Check personnummer not already linked to another user
|
||||
const { data: existing } = await supabase
|
||||
.from('bankid_identities')
|
||||
.select('user_id')
|
||||
.eq('personal_number_hash', pnrHash)
|
||||
.single()
|
||||
|
||||
if (existing && existing.user_id !== ctx.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'already_linked', message: 'This BankID is already linked to another account' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existing && existing.user_id === ctx.userId) {
|
||||
return NextResponse.json({ data: { linked: true, alreadyLinked: true } })
|
||||
}
|
||||
|
||||
// Link BankID to current user
|
||||
const { error: insertError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
personal_number_hash: pnrHash,
|
||||
personal_number_enc: encryptPersonalNumber(personalNumber),
|
||||
given_name: givenName,
|
||||
surname,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
console.error('[tic/bankid] link insert failed', insertError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link BankID' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Mark user as BankID-linked (skips TOTP MFA)
|
||||
await supabase.auth.admin.updateUserById(ctx.userId, {
|
||||
app_metadata: { bankid_linked: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { linked: true } })
|
||||
} catch (error) {
|
||||
console.error('[tic/bankid] link failed', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link BankID' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/unlink',
|
||||
// skipAuth: false — requires existing Supabase session
|
||||
handler: async (_request: Request, ctx?) => {
|
||||
try {
|
||||
if (!ctx?.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// Delete bankid_identities row
|
||||
const { error: deleteError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.delete()
|
||||
.eq('user_id', ctx.userId)
|
||||
|
||||
if (deleteError) {
|
||||
console.error('[tic/bankid] unlink delete failed', deleteError)
|
||||
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Clear app_metadata.bankid_linked so MFA enforcement resumes
|
||||
await supabase.auth.admin.updateUserById(ctx.userId, {
|
||||
app_metadata: { bankid_linked: false },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { unlinked: true } })
|
||||
} catch (error) {
|
||||
console.error('[tic/bankid] unlink failed', error)
|
||||
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* TIC Identity API client for BankID authentication.
|
||||
*
|
||||
* Direct calls to https://id.tic.io/api/v1/ with X-Api-Key header.
|
||||
* This is a separate API surface from the company lookup proxy (TIC_API_PROXY_URL).
|
||||
*/
|
||||
|
||||
import { TICAPIError } from './tic-types'
|
||||
import type {
|
||||
BankIdStartRequest,
|
||||
BankIdStartResponse,
|
||||
BankIdPollResponse,
|
||||
BankIdCollectResponse,
|
||||
EnrichmentRequest,
|
||||
EnrichmentResponse,
|
||||
EnrichmentData,
|
||||
} from './bankid-types'
|
||||
|
||||
const API_TIMEOUT = 15_000
|
||||
|
||||
function getConfig(): { baseUrl: string; apiKey: string } {
|
||||
const baseUrl = process.env.TIC_IDENTITY_API_URL || 'https://id.tic.io/api/v1'
|
||||
const apiKey = process.env.TIC_IDENTITY_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new TICAPIError('TIC_IDENTITY_API_KEY is not configured', undefined, 'NOT_CONFIGURED')
|
||||
}
|
||||
return { baseUrl, apiKey }
|
||||
}
|
||||
|
||||
async function identityFetch<T>(
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const { baseUrl, apiKey } = getConfig()
|
||||
const url = `${baseUrl}${path}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Api-Key': apiKey,
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(API_TIMEOUT),
|
||||
})
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
if (response.status === 410) {
|
||||
// Session failed/expired — return the error body
|
||||
const data = await response.json()
|
||||
return data as T
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => response.statusText)
|
||||
throw new TICAPIError(`TIC Identity API error: ${errorBody}`, response.status)
|
||||
}
|
||||
|
||||
// DELETE returns no body
|
||||
if (response.status === 204 || method === 'DELETE') {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TICAPIError) throw error
|
||||
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
||||
throw new TICAPIError('Request timeout', undefined, 'TIMEOUT')
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new TICAPIError(`TIC Identity API request failed: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Start a new BankID authentication session. */
|
||||
export async function startBankIdAuth(
|
||||
endUserIp: string,
|
||||
userAgent?: string
|
||||
): Promise<BankIdStartResponse> {
|
||||
const body: BankIdStartRequest = { endUserIp }
|
||||
if (userAgent) body.userAgent = userAgent
|
||||
|
||||
return identityFetch<BankIdStartResponse>('POST', '/auth/bankid/start', body)
|
||||
}
|
||||
|
||||
/** Poll a BankID session for status updates. Call every 2 seconds. */
|
||||
export async function pollBankIdSession(sessionId: string): Promise<BankIdPollResponse> {
|
||||
return identityFetch<BankIdPollResponse>('POST', `/auth/${sessionId}/poll`)
|
||||
}
|
||||
|
||||
/** Fetch cached session data (after webhook/callback, not for polling). */
|
||||
export async function collectBankIdResult(sessionId: string): Promise<BankIdCollectResponse> {
|
||||
return identityFetch<BankIdCollectResponse>('GET', `/auth/${sessionId}/collect`)
|
||||
}
|
||||
|
||||
/** Cancel an active BankID session. */
|
||||
export async function cancelBankIdSession(sessionId: string): Promise<void> {
|
||||
return identityFetch<void>('DELETE', `/auth/${sessionId}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrichment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Request enrichment data for a completed session. Must be called within 30 min. */
|
||||
export async function requestEnrichment(
|
||||
sessionId: string,
|
||||
types: EnrichmentRequest['types']
|
||||
): Promise<EnrichmentResponse> {
|
||||
const body: EnrichmentRequest = { sessionId, types }
|
||||
return identityFetch<EnrichmentResponse>('POST', '/enrichment', body)
|
||||
}
|
||||
|
||||
/** Fetch enrichment data from the one-time secure URL. No API key needed for this call. */
|
||||
export async function fetchEnrichmentData(secureUrl: string): Promise<EnrichmentData> {
|
||||
const { baseUrl } = getConfig()
|
||||
// secureUrl is a relative path like /api/v1/enrichment/data/{token}
|
||||
const url = secureUrl.startsWith('http') ? secureUrl : `${baseUrl.replace('/api/v1', '')}${secureUrl}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(API_TIMEOUT),
|
||||
})
|
||||
|
||||
if (response.status === 410) {
|
||||
throw new TICAPIError('Enrichment data expired or already fetched', 410, 'TOKEN_EXPIRED')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TICAPIError(`Failed to fetch enrichment data: ${response.statusText}`, response.status)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* TIC Identity API types for BankID authentication.
|
||||
* API reference: https://id.tic.io/api/v1
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BankIdStartRequest {
|
||||
endUserIp: string
|
||||
userAgent?: string
|
||||
personalNumber?: string
|
||||
callbackUrl?: string
|
||||
webhookUrl?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface BankIdStartResponse {
|
||||
sessionId: string
|
||||
provider: string
|
||||
orderRef: string
|
||||
autoStartToken: string
|
||||
qrStartToken: string
|
||||
qrStartSecret: string
|
||||
subscriptionToken: string
|
||||
sessionExpiresAt: string
|
||||
endUserIp: string
|
||||
}
|
||||
|
||||
export interface BankIdUser {
|
||||
personalNumber: string
|
||||
givenName: string
|
||||
surname: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface BankIdPollResponse {
|
||||
sessionId: string
|
||||
status: 'pending' | 'complete' | 'failed' | 'cancelled'
|
||||
hintCode?: string
|
||||
message?: string
|
||||
messageEn?: string
|
||||
orderCount?: number
|
||||
maxOrders?: number
|
||||
sessionExpiresInSeconds?: number
|
||||
/** Present when status is 'complete' */
|
||||
user?: BankIdUser
|
||||
completedAt?: string
|
||||
/** Updated tokens on order regeneration (~25s) */
|
||||
qrStartToken?: string
|
||||
qrStartSecret?: string
|
||||
/** Present when status is 'failed' */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BankIdCollectResponse {
|
||||
sessionId: string
|
||||
status: 'pending' | 'complete' | 'failed' | 'cancelled'
|
||||
hintCode?: string
|
||||
user?: BankIdUser
|
||||
completedAt?: string
|
||||
orderCount?: number
|
||||
maxOrders?: number
|
||||
sessionExpiresInSeconds?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrichment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EnrichmentType =
|
||||
| 'SPAR'
|
||||
| 'CompanyRoles'
|
||||
| 'PropertyOwnership'
|
||||
| 'Income'
|
||||
| 'IpIntelligence'
|
||||
| 'Full'
|
||||
|
||||
export interface EnrichmentRequest {
|
||||
sessionId: string
|
||||
types: EnrichmentType[]
|
||||
webhookUrl?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface EnrichmentResponse {
|
||||
enrichmentId: string
|
||||
sessionId: string
|
||||
status: 'Pending' | 'Processing' | 'Completed' | 'PartiallyCompleted' | 'Failed'
|
||||
requestedTypes: EnrichmentType[]
|
||||
completedTypes: EnrichmentType[]
|
||||
secureUrl: string
|
||||
secureUrlExpiresAtUtc: string
|
||||
}
|
||||
|
||||
export interface SparData {
|
||||
Person_IdNummer: string
|
||||
Person_PersonIdTyp: string
|
||||
Skydd_Sekretessmarkering: boolean
|
||||
Skydd_SkyddadFolkbokforing: boolean
|
||||
Namn_Fornamn: string
|
||||
Namn_Mellannamn?: string
|
||||
Namn_Efternamn: string
|
||||
Namn_Tilltalsnamn?: string
|
||||
PersonDetaljer_Kon: string
|
||||
PersonDetaljer_Fodelsedatum: string
|
||||
Folkbokforing_FolkbokfordLanKod?: string
|
||||
Folkbokforing_FolkbokfordKommunKod?: string
|
||||
Folkbokforingsadress_SvenskAdress_CareOf?: string
|
||||
Folkbokforingsadress_SvenskAdress_Utdelningsadress1?: string
|
||||
Folkbokforingsadress_SvenskAdress_Utdelningsadress2?: string
|
||||
Folkbokforingsadress_SvenskAdress_PostNr?: string
|
||||
Folkbokforingsadress_SvenskAdress_Postort?: string
|
||||
}
|
||||
|
||||
export interface CompanyRole {
|
||||
companyId: number
|
||||
companyRegistrationNumber: string
|
||||
legalName: string
|
||||
legalEntityType: string
|
||||
positionTypes: string[]
|
||||
positionDescriptions: string[]
|
||||
positionStart: string
|
||||
positionEnd: string | null
|
||||
companyStatus: string
|
||||
signatureDescription?: string
|
||||
}
|
||||
|
||||
export interface EnrichmentData {
|
||||
personalNumber: string
|
||||
name: string
|
||||
enrichedAtUtc: string
|
||||
spar?: SparData
|
||||
companyRoles?: CompanyRole[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Complete endpoint request/response (gnubok internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BankIdCompleteRequest {
|
||||
sessionId: string
|
||||
mode: 'login' | 'signup'
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface BankIdCompleteResponse {
|
||||
tokenHash: string
|
||||
type: string
|
||||
isNewUser: boolean
|
||||
enrichmentData?: EnrichmentData
|
||||
}
|
||||
|
||||
export interface BankIdCompleteErrorResponse {
|
||||
error: 'no_account' | 'already_linked' | 'session_invalid' | 'session_expired'
|
||||
givenName?: string
|
||||
surname?: string
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
isBankIdEnabled,
|
||||
hashPersonalNumber,
|
||||
encryptPersonalNumber,
|
||||
decryptPersonalNumber,
|
||||
maskPersonalNumber,
|
||||
} from '../bankid'
|
||||
|
||||
// Generate a valid 32-byte hex key for tests
|
||||
const TEST_KEY = 'a'.repeat(64) // 32 bytes in hex
|
||||
|
||||
describe('bankid helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('isBankIdEnabled', () => {
|
||||
it('returns false when NEXT_PUBLIC_SELF_HOSTED is true', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_BANKID_ENABLED', 'true')
|
||||
expect(isBankIdEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when BANKID_ENABLED is true and not self-hosted', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_BANKID_ENABLED', 'true')
|
||||
expect(isBankIdEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when BANKID_ENABLED is not set', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_BANKID_ENABLED', '')
|
||||
expect(isBankIdEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hashPersonalNumber', () => {
|
||||
it('returns a consistent SHA-256 hex hash', () => {
|
||||
const hash1 = hashPersonalNumber('199001011234')
|
||||
const hash2 = hashPersonalNumber('199001011234')
|
||||
expect(hash1).toBe(hash2)
|
||||
expect(hash1).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
it('returns different hashes for different numbers', () => {
|
||||
const hash1 = hashPersonalNumber('199001011234')
|
||||
const hash2 = hashPersonalNumber('199001015678')
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('encrypt/decrypt round-trip', () => {
|
||||
it('encrypts and decrypts a personnummer', () => {
|
||||
const pnr = '199001011234'
|
||||
const encrypted = encryptPersonalNumber(pnr)
|
||||
expect(encrypted).toBeInstanceOf(Buffer)
|
||||
// iv (12) + tag (16) + ciphertext (at least 1 byte)
|
||||
expect(encrypted.length).toBeGreaterThan(28)
|
||||
|
||||
const decrypted = decryptPersonalNumber(encrypted)
|
||||
expect(decrypted).toBe(pnr)
|
||||
})
|
||||
|
||||
it('produces different ciphertext each time (random IV)', () => {
|
||||
const pnr = '199001011234'
|
||||
const enc1 = encryptPersonalNumber(pnr)
|
||||
const enc2 = encryptPersonalNumber(pnr)
|
||||
expect(enc1.equals(enc2)).toBe(false)
|
||||
})
|
||||
|
||||
it('throws when BANKID_ENCRYPTION_KEY is missing', () => {
|
||||
vi.stubEnv('BANKID_ENCRYPTION_KEY', '')
|
||||
expect(() => encryptPersonalNumber('199001011234')).toThrow('BANKID_ENCRYPTION_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
describe('maskPersonalNumber', () => {
|
||||
it('masks a 12-digit personnummer', () => {
|
||||
expect(maskPersonalNumber('199001011234')).toBe('XXXXXXXX-1234')
|
||||
})
|
||||
|
||||
it('masks a 10-digit personnummer', () => {
|
||||
expect(maskPersonalNumber('9001011234')).toBe('XXXXXX-1234')
|
||||
})
|
||||
|
||||
it('handles short input gracefully', () => {
|
||||
expect(maskPersonalNumber('12')).toBe('****')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { isMfaRequired, shouldEnforceMfa } from '../mfa'
|
||||
|
||||
describe('mfa helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('isMfaRequired', () => {
|
||||
it('returns false when self-hosted', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(isMfaRequired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when hosted and MFA required', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(isMfaRequired()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldEnforceMfa', () => {
|
||||
it('returns false when MFA is not required', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
expect(shouldEnforceMfa({ app_metadata: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when user has bankid_linked', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(shouldEnforceMfa({ app_metadata: { bankid_linked: true } })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when MFA required and no bankid', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(shouldEnforceMfa({ app_metadata: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when app_metadata is undefined', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(shouldEnforceMfa({})).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* BankID authentication helpers.
|
||||
*
|
||||
* BankID is only available on the hosted deployment (requires TIC Identity API).
|
||||
* Self-hosted deployments never show the BankID option.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isBankIdEnabled(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
return process.env.NEXT_PUBLIC_BANKID_ENABLED === 'true'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personnummer hashing (for lookup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** SHA-256 hash of a personnummer for fast DB lookup. */
|
||||
export function hashPersonalNumber(personalNumber: string): string {
|
||||
return crypto.createHash('sha256').update(personalNumber).digest('hex')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personnummer encryption (for display in settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.BANKID_ENCRYPTION_KEY
|
||||
if (!key) throw new Error('BANKID_ENCRYPTION_KEY is required for BankID operations')
|
||||
return Buffer.from(key, 'hex')
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt a personnummer for storage. */
|
||||
export function encryptPersonalNumber(personalNumber: string): Buffer {
|
||||
const key = getEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(personalNumber, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
|
||||
// Format: iv (12) + tag (16) + ciphertext
|
||||
return Buffer.concat([iv, tag, encrypted])
|
||||
}
|
||||
|
||||
/** AES-256-GCM decrypt a stored personnummer. */
|
||||
export function decryptPersonalNumber(data: Buffer): string {
|
||||
const key = getEncryptionKey()
|
||||
|
||||
const iv = data.subarray(0, 12)
|
||||
const tag = data.subarray(12, 28)
|
||||
const encrypted = data.subarray(28)
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mask a personnummer for display: "XXXXXXXX-1234" */
|
||||
export function maskPersonalNumber(personalNumber: string): string {
|
||||
if (personalNumber.length < 4) return '****'
|
||||
const last4 = personalNumber.slice(-4)
|
||||
const masked = personalNumber.length === 12 ? 'XXXXXXXX' : 'XXXXXX'
|
||||
return `${masked}-${last4}`
|
||||
}
|
||||
@@ -9,3 +9,13 @@ export function isMfaRequired(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
return process.env.NEXT_PUBLIC_REQUIRE_MFA === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MFA should be enforced for a specific user.
|
||||
* BankID-linked users skip TOTP because BankID is inherently 2FA.
|
||||
*/
|
||||
export function shouldEnforceMfa(user: { app_metadata?: Record<string, unknown> }): boolean {
|
||||
if (!isMfaRequired()) return false
|
||||
if (user.app_metadata?.bankid_linked) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isMfaRequired } from './mfa'
|
||||
import { shouldEnforceMfa } from './mfa'
|
||||
import type { User, SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
type AuthResult =
|
||||
@@ -25,7 +25,7 @@ export async function requireAuth(): Promise<AuthResult> {
|
||||
}
|
||||
}
|
||||
|
||||
if (isMfaRequired()) {
|
||||
if (shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { isMfaRequired } from '@/lib/auth/mfa'
|
||||
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
||||
|
||||
export async function updateSession(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({
|
||||
@@ -93,7 +93,7 @@ export async function updateSession(request: NextRequest) {
|
||||
}
|
||||
|
||||
// MFA enforcement (application-side only, not RLS)
|
||||
if (isMfaRequired()) {
|
||||
if (shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
|
||||
// User has MFA enrolled but hasn't verified this session → redirect to verify
|
||||
|
||||
Generated
+274
-2
@@ -27,6 +27,7 @@
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.93.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
@@ -41,6 +42,7 @@
|
||||
"next": "16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
@@ -6822,6 +6824,15 @@
|
||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||
@@ -7901,11 +7912,19 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -8396,6 +8415,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001766",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||
@@ -8527,6 +8555,17 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
@@ -8549,7 +8588,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -8877,6 +8915,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
@@ -8991,6 +9038,12 @@
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -10210,6 +10263,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -11019,6 +11081,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -13089,6 +13160,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
@@ -13296,6 +13376,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -13470,6 +13559,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
@@ -13830,6 +13936,15 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -13852,6 +13967,12 @@
|
||||
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
@@ -14202,6 +14323,12 @@
|
||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -14543,6 +14670,26 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -14670,6 +14817,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -15982,6 +16141,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.20",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||
@@ -16031,6 +16196,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
@@ -16061,12 +16240,105 @@
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@supabase/ssr": "^0.8.0",
|
||||
"@supabase/supabase-js": "^2.93.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
@@ -46,6 +47,7 @@
|
||||
"next": "16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 13 13" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M4.169,6.789l0.533,-3.354l-0.569,0c-0.266,0 -0.609,-0.149 -0.709,-0.423c-0.033,-0.093 -0.109,-0.412 0.33,-0.722c0.157,-0.109 0.258,-0.23 0.278,-0.323c0.021,-0.096 -0.004,-0.181 -0.072,-0.245c-0.097,-0.093 -0.286,-0.146 -0.528,-0.146c-0.407,0 -0.694,0.234 -0.722,0.404c-0.02,0.125 0.077,0.225 0.161,0.29c0.254,0.189 0.315,0.463 0.158,0.722c-0.162,0.266 -0.512,0.439 -0.887,0.443l-0.581,0c-0.048,0.327 -0.839,5.334 -0.899,5.729l3.149,0c0.028,-0.178 0.173,-1.125 0.371,-2.375l-0.013,0Z" style="fill-rule:nonzero;"/><path d="M8.387,0.5l-3.206,0l-0.427,2.713l0.544,0c0.299,0 0.581,-0.137 0.702,-0.334c0.04,-0.065 0.056,-0.121 0.056,-0.174c0,-0.113 -0.076,-0.197 -0.153,-0.254c-0.21,-0.157 -0.254,-0.322 -0.254,-0.439c0,-0.024 0,-0.045 0.004,-0.065c0.044,-0.286 0.431,-0.596 0.943,-0.596c0.307,0 0.541,0.072 0.682,0.205c0.125,0.117 0.173,0.282 0.137,0.456c-0.044,0.205 -0.25,0.375 -0.367,0.459c-0.31,0.218 -0.27,0.408 -0.25,0.464c0.065,0.169 0.311,0.278 0.5,0.278l0.826,0l0,0.004c1.129,0.008 1.734,0.528 1.545,1.738c-0.178,1.125 -1.041,1.608 -2.069,1.617l-0.407,2.596l0.601,0c2.536,0 4.608,-1.629 5.015,-4.201c0.504,-3.193 -1.52,-4.467 -4.422,-4.467Z" style="fill-rule:nonzero;"/><path d="M8.387,0.5l-3.206,0l-0.427,2.713l0.544,0c0.299,0 0.581,-0.137 0.702,-0.334c0.04,-0.065 0.056,-0.121 0.056,-0.174c0,-0.113 -0.076,-0.197 -0.153,-0.254c-0.21,-0.157 -0.254,-0.322 -0.254,-0.439c0,-0.024 0,-0.045 0.004,-0.065c0.044,-0.286 0.431,-0.596 0.943,-0.596c0.307,0 0.541,0.072 0.682,0.205c0.125,0.117 0.173,0.282 0.137,0.456c-0.044,0.205 -0.25,0.375 -0.367,0.459c-0.31,0.218 -0.27,0.408 -0.25,0.464c0.065,0.169 0.311,0.278 0.5,0.278l0.826,0l0,0.004c1.129,0.008 1.734,0.528 1.545,1.738c-0.178,1.125 -1.041,1.608 -2.069,1.617l-0.407,2.596l0.601,0c2.536,0 4.608,-1.629 5.015,-4.201c0.504,-3.193 -1.52,-4.467 -4.422,-4.467Z" style="fill-rule:nonzero;"/><path d="M0.493,10.321l1.286,0c0.548,0 0.681,0.278 0.641,0.532c-0.032,0.206 -0.174,0.359 -0.415,0.46c0.306,0.117 0.427,0.298 0.383,0.584c-0.057,0.359 -0.367,0.625 -0.775,0.625l-1.463,0l0.343,-2.201Zm0.85,0.911c0.25,0 0.367,-0.133 0.391,-0.29c0.025,-0.17 -0.052,-0.286 -0.302,-0.286l-0.222,0l-0.088,0.576l0.221,0Zm-0.137,0.952c0.258,0 0.407,-0.105 0.444,-0.319c0.028,-0.185 -0.077,-0.294 -0.327,-0.294l-0.254,0l-0.097,0.617l0.234,0l0,-0.004Z" style="fill-rule:nonzero;"/><path d="M4.19,12.538c-0.335,0.024 -0.496,-0.012 -0.577,-0.157c-0.177,0.109 -0.375,0.165 -0.584,0.165c-0.379,0 -0.512,-0.197 -0.476,-0.415c0.016,-0.105 0.076,-0.206 0.173,-0.29c0.21,-0.182 0.726,-0.206 0.927,-0.343c0.017,-0.153 -0.044,-0.209 -0.233,-0.209c-0.222,0 -0.408,0.072 -0.726,0.29l0.077,-0.5c0.274,-0.198 0.54,-0.29 0.846,-0.29c0.391,0 0.738,0.161 0.673,0.588l-0.076,0.484c-0.028,0.169 -0.02,0.222 0.169,0.226l-0.193,0.451Zm-0.581,-0.758c-0.177,0.113 -0.508,0.093 -0.544,0.327c-0.016,0.109 0.052,0.189 0.161,0.189c0.105,0 0.234,-0.044 0.339,-0.117c-0.008,-0.04 -0.004,-0.08 0.008,-0.157l0.036,-0.242Z" style="fill-rule:nonzero;"/><path d="M4.815,10.817l0.669,0l-0.037,0.222c0.214,-0.182 0.375,-0.25 0.585,-0.25c0.375,0 0.548,0.229 0.488,0.604l-0.173,1.125l-0.67,0l0.145,-0.931c0.029,-0.169 -0.024,-0.25 -0.153,-0.25c-0.105,0 -0.201,0.056 -0.294,0.181l-0.153,0.996l-0.67,0l0.263,-1.697Z" style="fill-rule:nonzero;"/><path d="M7.04,10.321l0.669,0l-0.169,1.08l0.641,-0.584l0.826,0l-0.822,0.725l0.661,0.976l-0.842,0l-0.508,-0.786l-0.009,0l-0.12,0.786l-0.67,0l0.343,-2.197Z" style="fill-rule:nonzero;"/><path d="M9.411,10.321l0.77,0l-0.339,2.197l-0.77,0l0.339,-2.197Z" style="fill-rule:nonzero;"/><path d="M10.556,10.321l1.1,0c0.851,0 1.097,0.617 1.016,1.129c-0.077,0.5 -0.472,1.068 -1.217,1.068l-1.242,0l0.343,-2.197Zm0.713,1.673c0.375,0 0.581,-0.185 0.641,-0.576c0.044,-0.291 -0.044,-0.577 -0.459,-0.577l-0.206,0l-0.177,1.153l0.201,0Z" style="fill-rule:nonzero;"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,40 @@
|
||||
-- BankID identity linking table
|
||||
-- Maps Supabase auth users to Swedish personnummer for BankID login.
|
||||
-- Personnummer stored as SHA-256 hash (lookup) + AES-256-GCM encrypted (display).
|
||||
|
||||
create table public.bankid_identities (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
user_id uuid references auth.users on delete cascade unique not null,
|
||||
personal_number_hash text not null,
|
||||
personal_number_enc bytea not null,
|
||||
given_name text,
|
||||
surname text,
|
||||
linked_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.bankid_identities enable row level security;
|
||||
|
||||
-- Users can view their own BankID identity
|
||||
create policy "bankid_identities_select" on public.bankid_identities
|
||||
for select using (auth.uid() = user_id);
|
||||
|
||||
-- Users can link BankID to their own account
|
||||
create policy "bankid_identities_insert" on public.bankid_identities
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
-- Users can unlink BankID from their account
|
||||
create policy "bankid_identities_delete" on public.bankid_identities
|
||||
for delete using (auth.uid() = user_id);
|
||||
|
||||
-- Fast lookup of returning BankID users by personnummer hash
|
||||
create unique index idx_bankid_identities_pnr_hash
|
||||
on public.bankid_identities (personal_number_hash);
|
||||
|
||||
create index idx_bankid_identities_user_id
|
||||
on public.bankid_identities (user_id);
|
||||
|
||||
create trigger bankid_identities_updated_at
|
||||
before update on public.bankid_identities
|
||||
for each row execute function public.update_updated_at_column();
|
||||
Reference in New Issue
Block a user